diff --git a/prisma/migrations/20251219134248_add_doll_model/migration.sql b/prisma/migrations/20251219134248_add_doll_model/migration.sql new file mode 100644 index 0000000..bacc51a --- /dev/null +++ b/prisma/migrations/20251219134248_add_doll_model/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "dolls" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "configuration" JSONB NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "dolls_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "dolls" ADD CONSTRAINT "dolls_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 802ca45..b5e5fd7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -47,6 +47,7 @@ model User { receivedFriendRequests FriendRequest[] @relation("ReceivedFriendRequests") userFriendships Friendship[] @relation("UserFriendships") friendFriendships Friendship[] @relation("FriendFriendships") + dolls Doll[] @@map("users") } @@ -79,6 +80,20 @@ model Friendship { @@map("friendships") } +model Doll { + id String @id @default(uuid()) + name String + configuration Json + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("dolls") +} + enum FriendRequestStatus { PENDING ACCEPTED diff --git a/src/app.module.ts b/src/app.module.ts index 2b13058..cb6a608 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { DatabaseModule } from './database/database.module'; import { RedisModule } from './database/redis.module'; import { WsModule } from './ws/ws.module'; import { FriendsModule } from './friends/friends.module'; +import { DollsModule } from './dolls/dolls.module'; /** * Validates required environment variables. @@ -69,6 +70,7 @@ function validateEnvironment(config: Record): Record { AuthModule, WsModule, FriendsModule, + DollsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/dolls/dolls.controller.ts b/src/dolls/dolls.controller.ts new file mode 100644 index 0000000..79f70f2 --- /dev/null +++ b/src/dolls/dolls.controller.ts @@ -0,0 +1,134 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseGuards, + HttpCode, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { DollsService } from './dolls.service'; +import { CreateDollDto } from './dto/create-doll.dto'; +import { UpdateDollDto } from './dto/update-doll.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CurrentUser, + type AuthenticatedUser, +} from '../auth/decorators/current-user.decorator'; +import { AuthService } from '../auth/auth.service'; + +@ApiTags('dolls') +@Controller('dolls') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class DollsController { + constructor( + private readonly dollsService: DollsService, + private readonly authService: AuthService, + ) {} + + @Post() + @ApiOperation({ + summary: 'Create a new doll', + description: + 'Creates a new doll with the specified name and optional configuration. Defaults to black outline and white body if no configuration provided.', + }) + @ApiResponse({ + status: 201, + description: 'The doll has been successfully created.', + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async create( + @CurrentUser() authUser: AuthenticatedUser, + @Body() createDollDto: CreateDollDto, + ) { + const user = await this.authService.ensureUserExists(authUser); + return this.dollsService.create(user.id, createDollDto); + } + + @Get() + @ApiOperation({ + summary: 'Get all dolls', + description: 'Retrieves all dolls belonging to the authenticated user.', + }) + @ApiResponse({ + status: 200, + description: 'Return all dolls.', + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async findAll(@CurrentUser() authUser: AuthenticatedUser) { + const user = await this.authService.ensureUserExists(authUser); + return this.dollsService.findAll(user.id); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get a doll by ID', + description: 'Retrieves a specific doll by its ID.', + }) + @ApiResponse({ + status: 200, + description: 'Return the doll.', + }) + @ApiResponse({ status: 404, description: 'Doll not found' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async findOne( + @CurrentUser() authUser: AuthenticatedUser, + @Param('id') id: string, + ) { + const user = await this.authService.ensureUserExists(authUser); + return this.dollsService.findOne(id, user.id); + } + + @Patch(':id') + @ApiOperation({ + summary: 'Update a doll', + description: "Updates a doll's name or configuration.", + }) + @ApiResponse({ + status: 200, + description: 'The doll has been successfully updated.', + }) + @ApiResponse({ status: 404, description: 'Doll not found' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async update( + @CurrentUser() authUser: AuthenticatedUser, + @Param('id') id: string, + @Body() updateDollDto: UpdateDollDto, + ) { + const user = await this.authService.ensureUserExists(authUser); + return this.dollsService.update(id, user.id, updateDollDto); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ + summary: 'Delete a doll', + description: 'Soft deletes a doll.', + }) + @ApiResponse({ + status: 204, + description: 'The doll has been successfully deleted.', + }) + @ApiResponse({ status: 404, description: 'Doll not found' }) + @ApiResponse({ status: 403, description: 'Forbidden' }) + @ApiUnauthorizedResponse({ description: 'Unauthorized' }) + async remove( + @CurrentUser() authUser: AuthenticatedUser, + @Param('id') id: string, + ) { + const user = await this.authService.ensureUserExists(authUser); + return this.dollsService.remove(id, user.id); + } +} diff --git a/src/dolls/dolls.module.ts b/src/dolls/dolls.module.ts new file mode 100644 index 0000000..80f2f54 --- /dev/null +++ b/src/dolls/dolls.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DollsService } from './dolls.service'; +import { DollsController } from './dolls.controller'; +import { DatabaseModule } from '../database/database.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [DatabaseModule, AuthModule], + controllers: [DollsController], + providers: [DollsService], + exports: [DollsService], +}) +export class DollsModule {} diff --git a/src/dolls/dolls.service.spec.ts b/src/dolls/dolls.service.spec.ts new file mode 100644 index 0000000..09aa432 --- /dev/null +++ b/src/dolls/dolls.service.spec.ts @@ -0,0 +1,142 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DollsService } from './dolls.service'; +import { PrismaService } from '../database/prisma.service'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; +import { Doll } from '@prisma/client'; + +describe('DollsService', () => { + let service: DollsService; + let prismaService: PrismaService; + + const mockDoll: Doll = { + id: 'doll-1', + name: 'Test Doll', + configuration: { + colorScheme: { + outline: '#000000', + body: '#FFFFFF', + }, + }, + userId: 'user-1', + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }; + + const mockPrismaService = { + doll: { + create: jest.fn().mockResolvedValue(mockDoll), + findMany: jest.fn().mockResolvedValue([mockDoll]), + findFirst: jest.fn().mockResolvedValue(mockDoll), + update: jest.fn().mockResolvedValue(mockDoll), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DollsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(DollsService); + prismaService = module.get(PrismaService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a doll with default configuration', async () => { + const createDto = { name: 'New Doll' }; + const userId = 'user-1'; + + await service.create(userId, createDto); + + expect(prismaService.doll.create).toHaveBeenCalledWith({ + data: { + name: createDto.name, + configuration: { + colorScheme: { + outline: '#000000', + body: '#FFFFFF', + }, + }, + userId, + }, + }); + }); + }); + + describe('findAll', () => { + it('should return an array of dolls', async () => { + const userId = 'user-1'; + await service.findAll(userId); + + expect(prismaService.doll.findMany).toHaveBeenCalledWith({ + where: { + userId, + deletedAt: null, + }, + orderBy: { + createdAt: 'asc', + }, + }); + }); + }); + + describe('findOne', () => { + it('should return a doll if found and owned by user', async () => { + const userId = 'user-1'; + const dollId = 'doll-1'; + + const result = await service.findOne(dollId, userId); + expect(result).toEqual(mockDoll); + }); + + it('should throw NotFoundException if doll not found', async () => { + jest.spyOn(prismaService.doll, 'findFirst').mockResolvedValueOnce(null); + + await expect(service.findOne('doll-1', 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw ForbiddenException if doll belongs to another user', async () => { + jest + .spyOn(prismaService.doll, 'findFirst') + .mockResolvedValueOnce({ ...mockDoll, userId: 'user-2' }); + + await expect(service.findOne('doll-1', 'user-1')).rejects.toThrow( + ForbiddenException, + ); + }); + }); + + describe('update', () => { + it('should update a doll', async () => { + const updateDto = { name: 'Updated Doll' }; + await service.update('doll-1', 'user-1', updateDto); + + expect(prismaService.doll.update).toHaveBeenCalled(); + }); + }); + + describe('remove', () => { + it('should soft delete a doll', async () => { + await service.remove('doll-1', 'user-1'); + + expect(prismaService.doll.update).toHaveBeenCalledWith({ + where: { id: 'doll-1' }, + data: { + deletedAt: expect.any(Date), + }, + }); + }); + }); +}); diff --git a/src/dolls/dolls.service.ts b/src/dolls/dolls.service.ts new file mode 100644 index 0000000..ec86684 --- /dev/null +++ b/src/dolls/dolls.service.ts @@ -0,0 +1,125 @@ +import { + Injectable, + NotFoundException, + ForbiddenException, + Logger, +} from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { CreateDollDto, DollConfigurationDto } from './dto/create-doll.dto'; +import { UpdateDollDto } from './dto/update-doll.dto'; +import { Doll, Prisma } from '@prisma/client'; + +@Injectable() +export class DollsService { + private readonly logger = new Logger(DollsService.name); + + constructor(private readonly prisma: PrismaService) {} + + async create(userId: string, createDollDto: CreateDollDto): Promise { + const defaultConfiguration: DollConfigurationDto = { + colorScheme: { + outline: '#000000', + body: '#FFFFFF', + }, + }; + + // Merge default configuration with provided configuration + // If configuration or colorScheme is not provided, use defaults + const configuration: DollConfigurationDto = { + ...defaultConfiguration, + ...(createDollDto.configuration || {}), + colorScheme: { + ...defaultConfiguration.colorScheme!, + ...(createDollDto.configuration?.colorScheme || {}), + }, + }; + + return this.prisma.doll.create({ + data: { + name: createDollDto.name, + configuration: configuration as unknown as Prisma.InputJsonValue, + userId, + }, + }); + } + + async findAll(userId: string): Promise { + return this.prisma.doll.findMany({ + where: { + userId, + deletedAt: null, + }, + orderBy: { + createdAt: 'asc', + }, + }); + } + + async findOne(id: string, userId: string): Promise { + const doll = await this.prisma.doll.findFirst({ + where: { + id, + deletedAt: null, + }, + }); + + if (!doll) { + throw new NotFoundException(`Doll with ID ${id} not found`); + } + + if (doll.userId !== userId) { + throw new ForbiddenException('You do not have access to this doll'); + } + + return doll; + } + + async update( + id: string, + userId: string, + updateDollDto: UpdateDollDto, + ): Promise { + const doll = await this.findOne(id, userId); + + let configuration = doll.configuration as unknown as DollConfigurationDto; + + if (updateDollDto.configuration) { + // Deep merge configuration if provided + configuration = { + ...configuration, + ...updateDollDto.configuration, + colorScheme: { + outline: + updateDollDto.configuration.colorScheme?.outline || + configuration.colorScheme?.outline || + '#000000', + body: + updateDollDto.configuration.colorScheme?.body || + configuration.colorScheme?.body || + '#FFFFFF', + }, + }; + } + + return this.prisma.doll.update({ + where: { id }, + data: { + name: updateDollDto.name, + configuration: configuration as unknown as Prisma.InputJsonValue, + }, + }); + } + + async remove(id: string, userId: string): Promise { + // Check existence and ownership + await this.findOne(id, userId); + + // Soft delete + await this.prisma.doll.update({ + where: { id }, + data: { + deletedAt: new Date(), + }, + }); + } +} diff --git a/src/dolls/dto/create-doll.dto.ts b/src/dolls/dto/create-doll.dto.ts new file mode 100644 index 0000000..d03f417 --- /dev/null +++ b/src/dolls/dto/create-doll.dto.ts @@ -0,0 +1,57 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + ValidateNested, + IsHexColor, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class DollColorSchemeDto { + @ApiProperty({ + description: 'Outline color in HEX code (e.g. #000000)', + example: '#000000', + }) + @IsString() + @IsHexColor() + outline: string; + + @ApiProperty({ + description: 'Body fill color in HEX code (e.g. #FFFFFF)', + example: '#FFFFFF', + }) + @IsString() + @IsHexColor() + body: string; +} + +export class DollConfigurationDto { + @ApiPropertyOptional({ + description: 'Color scheme for the doll', + type: DollColorSchemeDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => DollColorSchemeDto) + colorScheme?: DollColorSchemeDto; +} + +export class CreateDollDto { + @ApiProperty({ + description: 'Display name of the doll', + example: 'My First Doll', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ + description: 'Configuration for the doll', + type: DollConfigurationDto, + }) + @IsOptional() + @ValidateNested() + @Type(() => DollConfigurationDto) + configuration?: DollConfigurationDto; +} diff --git a/src/dolls/dto/update-doll.dto.ts b/src/dolls/dto/update-doll.dto.ts new file mode 100644 index 0000000..e457eb2 --- /dev/null +++ b/src/dolls/dto/update-doll.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateDollDto } from './create-doll.dto'; + +export class UpdateDollDto extends PartialType(CreateDollDto) {}