This commit is contained in:
2025-12-20 00:40:15 +08:00
parent c482d1fde1
commit 94b87550a9
9 changed files with 507 additions and 0 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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],

View 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
View 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 {}

View 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
View 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(),
},
});
}
}

View 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;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDollDto } from './create-doll.dto';
export class UpdateDollDto extends PartialType(CreateDollDto) {}