Dolls
This commit is contained in:
@@ -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<string, any>): Record<string, any> {
|
||||
AuthModule,
|
||||
WsModule,
|
||||
FriendsModule,
|
||||
DollsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
134
src/dolls/dolls.controller.ts
Normal file
134
src/dolls/dolls.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
13
src/dolls/dolls.module.ts
Normal file
13
src/dolls/dolls.module.ts
Normal file
@@ -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 {}
|
||||
142
src/dolls/dolls.service.spec.ts
Normal file
142
src/dolls/dolls.service.spec.ts
Normal file
@@ -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>(DollsService);
|
||||
prismaService = module.get<PrismaService>(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),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
125
src/dolls/dolls.service.ts
Normal file
125
src/dolls/dolls.service.ts
Normal file
@@ -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<Doll> {
|
||||
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<Doll[]> {
|
||||
return this.prisma.doll.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string): Promise<Doll> {
|
||||
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<Doll> {
|
||||
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<void> {
|
||||
// Check existence and ownership
|
||||
await this.findOne(id, userId);
|
||||
|
||||
// Soft delete
|
||||
await this.prisma.doll.update({
|
||||
where: { id },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
57
src/dolls/dto/create-doll.dto.ts
Normal file
57
src/dolls/dto/create-doll.dto.ts
Normal file
@@ -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;
|
||||
}
|
||||
4
src/dolls/dto/update-doll.dto.ts
Normal file
4
src/dolls/dto/update-doll.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateDollDto } from './create-doll.dto';
|
||||
|
||||
export class UpdateDollDto extends PartialType(CreateDollDto) {}
|
||||
Reference in New Issue
Block a user