feat(v2.4): 切换根数据领域主写
This commit is contained in:
11
apps/server/src/modules/project/dto/create-project.dto.ts
Normal file
11
apps/server/src/modules/project/dto/create-project.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class CreateProjectDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateProjectDto } from './create-project.dto';
|
||||
|
||||
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}
|
||||
33
apps/server/src/modules/project/project.controller.ts
Normal file
33
apps/server/src/modules/project/project.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { ProjectService } from './project.service';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
|
||||
@Controller('products/:productId/projects')
|
||||
export class ProjectController {
|
||||
constructor(private readonly projectService: ProjectService) {}
|
||||
|
||||
@Post()
|
||||
create(@Param('productId') productId: string, @Body() dto: CreateProjectDto) {
|
||||
return this.projectService.create(productId, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll(@Param('productId') productId: string) {
|
||||
return this.projectService.findAll(productId);
|
||||
}
|
||||
|
||||
@Patch(':projectId')
|
||||
update(
|
||||
@Param('productId') productId: string,
|
||||
@Param('projectId') projectId: string,
|
||||
@Body() dto: UpdateProjectDto,
|
||||
) {
|
||||
return this.projectService.update(productId, projectId, dto);
|
||||
}
|
||||
|
||||
@Delete(':projectId')
|
||||
remove(@Param('productId') productId: string, @Param('projectId') projectId: string) {
|
||||
return this.projectService.remove(productId, projectId);
|
||||
}
|
||||
}
|
||||
9
apps/server/src/modules/project/project.module.ts
Normal file
9
apps/server/src/modules/project/project.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProjectController } from './project.controller';
|
||||
import { ProjectService } from './project.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProjectController],
|
||||
providers: [ProjectService],
|
||||
})
|
||||
export class ProjectModule {}
|
||||
83
apps/server/src/modules/project/project.service.spec.ts
Normal file
83
apps/server/src/modules/project/project.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ProjectService } from './project.service';
|
||||
|
||||
describe('ProjectService domain writes', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
product: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
project: {
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
prisma,
|
||||
service: new ProjectService(prisma as any),
|
||||
};
|
||||
};
|
||||
|
||||
it('creates projects directly under a product relation row', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.product.findUnique.mockResolvedValue({ id: 'product-1' });
|
||||
prisma.project.create.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
|
||||
|
||||
await service.create('product-1', { name: 'CRM', description: 'Customer system' });
|
||||
|
||||
expect(prisma.project.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
productId: 'product-1',
|
||||
name: 'CRM',
|
||||
description: 'Customer system',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('lists projects by product id', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.project.findMany.mockResolvedValue([]);
|
||||
|
||||
await service.findAll('product-1');
|
||||
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: { productId: 'product-1' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates projects only inside their product scope', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
|
||||
prisma.project.update.mockResolvedValue({ id: 'project-1', name: 'CRM v2' });
|
||||
|
||||
await service.update('product-1', 'project-1', { name: 'CRM v2' });
|
||||
|
||||
expect(prisma.project.update).toHaveBeenCalledWith({
|
||||
where: { id: 'project-1' },
|
||||
data: { name: 'CRM v2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when updating a project outside the product scope', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.project.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update('product-1', 'project-404', { name: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.project.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes projects only inside their product scope', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
|
||||
prisma.project.delete.mockResolvedValue({ id: 'project-1' });
|
||||
|
||||
await service.remove('product-1', 'project-1');
|
||||
|
||||
expect(prisma.project.delete).toHaveBeenCalledWith({ where: { id: 'project-1' } });
|
||||
});
|
||||
});
|
||||
53
apps/server/src/modules/project/project.service.ts
Normal file
53
apps/server/src/modules/project/project.service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProjectService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(productId: string, dto: CreateProjectDto) {
|
||||
await this.ensureProductExists(productId);
|
||||
return this.prisma.project.create({
|
||||
data: {
|
||||
productId,
|
||||
name: dto.name,
|
||||
description: dto.description ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findAll(productId: string) {
|
||||
return this.prisma.project.findMany({
|
||||
where: { productId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async update(productId: string, projectId: string, dto: UpdateProjectDto) {
|
||||
await this.ensureProjectInProduct(productId, projectId);
|
||||
return this.prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(productId: string, projectId: string) {
|
||||
await this.ensureProjectInProduct(productId, projectId);
|
||||
return this.prisma.project.delete({ where: { id: projectId } });
|
||||
}
|
||||
|
||||
private async ensureProductExists(productId: string) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
||||
if (!product) throw new NotFoundException('产品不存在');
|
||||
}
|
||||
|
||||
private async ensureProjectInProduct(productId: string, projectId: string) {
|
||||
const project = await this.prisma.project.findFirst({ where: { id: projectId, productId } });
|
||||
if (!project) throw new NotFoundException('项目不存在');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user