diff --git a/apps/server/prisma/migrations/20260708010000_v24_root_domain_metadata/migration.sql b/apps/server/prisma/migrations/20260708010000_v24_root_domain_metadata/migration.sql new file mode 100644 index 0000000..06406c1 --- /dev/null +++ b/apps/server/prisma/migrations/20260708010000_v24_root_domain_metadata/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "versions" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'planned'; +ALTER TABLE "versions" ADD COLUMN "current_stage" TEXT; +ALTER TABLE "versions" ADD COLUMN "start_date" TIMESTAMP(3); +ALTER TABLE "versions" ADD COLUMN "expected_release_date" TIMESTAMP(3); +ALTER TABLE "versions" ADD COLUMN "members" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "versions" ADD COLUMN "progress" JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE "versions" ADD COLUMN "priority" INTEGER; +ALTER TABLE "versions" ADD COLUMN "links" JSONB NOT NULL DEFAULT '{}'; diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index a107578..599b404 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -76,7 +76,15 @@ model Version { projectId String? @map("project_id") name String description String @default("") + status String @default("planned") + currentStage String? @map("current_stage") + startDate DateTime? @map("start_date") + expectedReleaseDate DateTime? @map("expected_release_date") releaseDate DateTime? @map("release_date") + members Json @default("[]") + progress Json @default("[]") + priority Int? + links Json @default("{}") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index f8f95ef..4e905f8 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -3,7 +3,9 @@ import { APP_INTERCEPTOR } from '@nestjs/core'; import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor'; import { PrismaModule } from './prisma/prisma.module'; import { ProductModule } from './modules/product/product.module'; +import { ProjectModule } from './modules/project/project.module'; import { RequirementModule } from './modules/requirement/requirement.module'; +import { VersionModule } from './modules/version/version.module'; import { AiModule } from './modules/ai/ai.module'; import { ConfigModule } from './modules/config/config.module'; import { DataModule } from './modules/data/data.module'; @@ -12,7 +14,19 @@ import { V22QueryModule } from './modules/v22-query/v22-query.module'; import { HealthModule } from './modules/health/health.module'; @Module({ - imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule], + imports: [ + PrismaModule, + ProductModule, + ProjectModule, + VersionModule, + RequirementModule, + ConfigModule, + DataModule, + MigrationModule, + V22QueryModule, + HealthModule, + AiModule, + ], controllers: [], providers: [ { diff --git a/apps/server/src/modules/migration/app-data-v22.mapper.ts b/apps/server/src/modules/migration/app-data-v22.mapper.ts index 3184349..c05011a 100644 --- a/apps/server/src/modules/migration/app-data-v22.mapper.ts +++ b/apps/server/src/modules/migration/app-data-v22.mapper.ts @@ -29,7 +29,15 @@ interface VersionRow { projectId?: string; name: string; description: string; + status: string; + currentStage?: string; + startDate?: string; + expectedReleaseDate?: string; releaseDate?: string; + members: unknown[]; + progress: unknown[]; + priority?: number; + links: unknown; createdAt?: string; updatedAt?: string; } @@ -348,7 +356,15 @@ export function mapAppDataToV22Rows(appData: Record): V22Mapped projectId, name: stringField(version, 'name') ?? versionId, description: stringField(version, 'description') ?? '', + status: stringField(version, 'status') ?? 'planned', + currentStage: stringField(version, 'currentStage'), + startDate: stringField(version, 'startDate'), + expectedReleaseDate: stringField(version, 'expectedReleaseDate'), releaseDate: stringField(version, 'releaseDate') ?? stringField(version, 'expectedReleaseDate'), + members: asArray(version.members), + progress: asArray(version.progress), + priority: priorityRank(version.priority), + links: asRecord(version.links) ?? {}, createdAt: stringField(version, 'createdAt') ?? productRow.createdAt, updatedAt: stringField(version, 'updatedAt') ?? stringField(version, 'createdAt') ?? productRow.updatedAt, }; diff --git a/apps/server/src/modules/product/product.service.ts b/apps/server/src/modules/product/product.service.ts index 45159a9..34f035f 100644 --- a/apps/server/src/modules/product/product.service.ts +++ b/apps/server/src/modules/product/product.service.ts @@ -10,7 +10,22 @@ type ProductOverviewItem = { createdAt?: string; updatedAt?: string; projects?: { id: string; name: string; description?: string; createdAt?: string }[]; - versions?: { id: string; name: string; releaseDate?: string | null; createdAt?: string }[]; + versions?: { + id: string; + productId?: string; + projectId?: string | null; + name: string; + status?: string; + currentStage?: string | null; + startDate?: string | null; + expectedReleaseDate?: string | null; + releaseDate?: string | null; + members?: unknown[]; + progress?: unknown[]; + priority?: string | number | null; + links?: unknown; + createdAt?: string; + }[]; _count?: { requirements?: number; projects?: number; versions?: number }; }; @@ -23,30 +38,25 @@ export class ProductService { } async findAll() { - const overview = await this.getAppDataOverview(); - if (overview) { - return overview.map((product) => { - const normalized = this.normalizeOverviewProduct(product); - const { projects: _projects, versions: _versions, ...rest } = normalized; - return rest; - }); - } - - return this.prisma.product.findMany({ + const products = await this.prisma.product.findMany({ orderBy: { createdAt: 'desc' }, include: { - _count: { select: { requirements: true, projects: true } }, + _count: { select: { requirements: true, projects: true, versions: true } }, }, }); + if (products.length > 0) return products; + + const overview = await this.getAppDataOverview(); + if (!overview) return []; + return overview.map((product) => { + const normalized = this.normalizeOverviewProduct(product); + const { projects: _projects, versions: _versions, ...rest } = normalized; + return rest; + }); } async findAllWithChildren() { - const overview = await this.getAppDataOverview(); - if (overview) { - return overview.map((product) => this.normalizeOverviewProduct(product)); - } - - return this.prisma.product.findMany({ + const products = await this.prisma.product.findMany({ orderBy: { createdAt: 'desc' }, include: { projects: { @@ -55,34 +65,57 @@ export class ProductService { }, versions: { orderBy: { createdAt: 'desc' }, - select: { id: true, name: true, releaseDate: true, createdAt: true }, + select: { + id: true, + productId: true, + projectId: true, + name: true, + description: true, + status: true, + currentStage: true, + startDate: true, + expectedReleaseDate: true, + releaseDate: true, + members: true, + progress: true, + priority: true, + links: true, + createdAt: true, + updatedAt: true, + }, }, _count: { select: { requirements: true, projects: true, versions: true } }, }, }); + if (products.length > 0) return products.map((product) => this.normalizeRelationProduct(product)); + + const overview = await this.getAppDataOverview(); + if (!overview) return []; + return overview.map((product) => this.normalizeOverviewProduct(product)); } async findOne(id: string) { - const overview = await this.getAppDataOverview(); - if (overview) { - const product = overview.find((item) => item.id === id); - if (!product) throw new NotFoundException('产品不存在'); - return { - ...this.normalizeOverviewProduct(product), - requirements: [], - }; - } - const product = await this.prisma.product.findUnique({ where: { id }, include: { requirements: { orderBy: { createdAt: 'desc' } }, + projects: { + orderBy: { createdAt: 'desc' }, + select: { id: true, name: true, description: true, createdAt: true }, + }, versions: { orderBy: { createdAt: 'desc' } }, _count: { select: { projects: true } }, }, }); - if (!product) throw new NotFoundException('产品不存在'); - return product; + if (product) return product; + + const overview = await this.getAppDataOverview(); + const fallback = overview?.find((item) => item.id === id); + if (!fallback) throw new NotFoundException('产品不存在'); + return { + ...this.normalizeOverviewProduct(fallback), + requirements: [], + }; } async update(id: string, dto: UpdateProductDto) { @@ -123,4 +156,53 @@ export class ProductService { }, }; } + + private normalizeRelationProduct(product: any) { + const projects = product.projects ?? []; + const versions = (product.versions ?? []).map((version: any) => ({ + ...version, + createdAt: toIso(version.createdAt), + updatedAt: toIso(version.updatedAt), + startDate: toIsoOrNull(version.startDate), + expectedReleaseDate: toIsoOrNull(version.expectedReleaseDate), + releaseDate: toIsoOrNull(version.releaseDate), + members: Array.isArray(version.members) ? version.members : [], + progress: Array.isArray(version.progress) ? version.progress : [], + priority: toPriorityLabel(version.priority), + links: isPlainObject(version.links) ? version.links : {}, + })); + + return { + ...product, + createdAt: toIso(product.createdAt), + updatedAt: toIso(product.updatedAt), + projects: projects.map((project: any) => ({ ...project, createdAt: toIso(project.createdAt) })), + versions, + _count: { + requirements: product._count?.requirements ?? 0, + projects: projects.length, + versions: versions.length, + }, + }; + } +} + +function toIso(value: Date | string | undefined): string { + if (value instanceof Date) return value.toISOString(); + return value ?? new Date(0).toISOString(); +} + +function toIsoOrNull(value: Date | string | null | undefined): string | null { + if (!value) return null; + if (value instanceof Date) return value.toISOString(); + return value; +} + +function toPriorityLabel(value: number | null | undefined): string | undefined { + if (typeof value !== 'number') return undefined; + return `P${Math.max(0, Math.min(4, Math.floor(value)))}`; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/apps/server/src/modules/project/dto/create-project.dto.ts b/apps/server/src/modules/project/dto/create-project.dto.ts new file mode 100644 index 0000000..51d12e4 --- /dev/null +++ b/apps/server/src/modules/project/dto/create-project.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateProjectDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/apps/server/src/modules/project/dto/update-project.dto.ts b/apps/server/src/modules/project/dto/update-project.dto.ts new file mode 100644 index 0000000..4a5d865 --- /dev/null +++ b/apps/server/src/modules/project/dto/update-project.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateProjectDto } from './create-project.dto'; + +export class UpdateProjectDto extends PartialType(CreateProjectDto) {} diff --git a/apps/server/src/modules/project/project.controller.ts b/apps/server/src/modules/project/project.controller.ts new file mode 100644 index 0000000..0b6e6c9 --- /dev/null +++ b/apps/server/src/modules/project/project.controller.ts @@ -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); + } +} diff --git a/apps/server/src/modules/project/project.module.ts b/apps/server/src/modules/project/project.module.ts new file mode 100644 index 0000000..4e9329e --- /dev/null +++ b/apps/server/src/modules/project/project.module.ts @@ -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 {} diff --git a/apps/server/src/modules/project/project.service.spec.ts b/apps/server/src/modules/project/project.service.spec.ts new file mode 100644 index 0000000..d6d6e83 --- /dev/null +++ b/apps/server/src/modules/project/project.service.spec.ts @@ -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' } }); + }); +}); diff --git a/apps/server/src/modules/project/project.service.ts b/apps/server/src/modules/project/project.service.ts new file mode 100644 index 0000000..527c928 --- /dev/null +++ b/apps/server/src/modules/project/project.service.ts @@ -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('项目不存在'); + } +} diff --git a/apps/server/src/modules/version/dto/create-version.dto.ts b/apps/server/src/modules/version/dto/create-version.dto.ts new file mode 100644 index 0000000..7f2f3a8 --- /dev/null +++ b/apps/server/src/modules/version/dto/create-version.dto.ts @@ -0,0 +1,50 @@ +import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; + +export class CreateVersionDto { + @IsString() + @IsOptional() + projectId?: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsString() + @IsOptional() + currentStage?: string; + + @IsString() + @IsOptional() + startDate?: string | null; + + @IsString() + @IsOptional() + expectedReleaseDate?: string | null; + + @IsString() + @IsOptional() + releaseDate?: string | null; + + @IsArray() + @IsOptional() + members?: unknown[]; + + @IsArray() + @IsOptional() + progress?: unknown[]; + + @IsOptional() + priority?: string | number | null; + + @IsObject() + @IsOptional() + links?: Record; +} diff --git a/apps/server/src/modules/version/dto/update-version.dto.ts b/apps/server/src/modules/version/dto/update-version.dto.ts new file mode 100644 index 0000000..55af2e5 --- /dev/null +++ b/apps/server/src/modules/version/dto/update-version.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVersionDto } from './create-version.dto'; + +export class UpdateVersionDto extends PartialType(CreateVersionDto) {} diff --git a/apps/server/src/modules/version/version.controller.ts b/apps/server/src/modules/version/version.controller.ts new file mode 100644 index 0000000..3c36a18 --- /dev/null +++ b/apps/server/src/modules/version/version.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { VersionService } from './version.service'; +import { CreateVersionDto } from './dto/create-version.dto'; +import { UpdateVersionDto } from './dto/update-version.dto'; + +@Controller('products/:productId') +export class VersionController { + constructor(private readonly versionService: VersionService) {} + + @Post('versions') + create(@Param('productId') productId: string, @Body() dto: CreateVersionDto) { + return this.versionService.create(productId, dto); + } + + @Get('versions') + findAll(@Param('productId') productId: string) { + return this.versionService.findAll(productId); + } + + @Post('projects/:projectId/versions') + createForProject( + @Param('productId') productId: string, + @Param('projectId') projectId: string, + @Body() dto: CreateVersionDto, + ) { + return this.versionService.create(productId, dto, projectId); + } + + @Get('projects/:projectId/versions') + findAllForProject(@Param('productId') productId: string, @Param('projectId') projectId: string) { + return this.versionService.findAll(productId, projectId); + } + + @Patch('versions/:versionId') + update( + @Param('productId') productId: string, + @Param('versionId') versionId: string, + @Body() dto: UpdateVersionDto, + ) { + return this.versionService.update(productId, versionId, dto); + } + + @Delete('versions/:versionId') + remove(@Param('productId') productId: string, @Param('versionId') versionId: string) { + return this.versionService.remove(productId, versionId); + } +} diff --git a/apps/server/src/modules/version/version.module.ts b/apps/server/src/modules/version/version.module.ts new file mode 100644 index 0000000..874f19f --- /dev/null +++ b/apps/server/src/modules/version/version.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { VersionController } from './version.controller'; +import { VersionService } from './version.service'; + +@Module({ + controllers: [VersionController], + providers: [VersionService], +}) +export class VersionModule {} diff --git a/apps/server/src/modules/version/version.service.spec.ts b/apps/server/src/modules/version/version.service.spec.ts new file mode 100644 index 0000000..ce91ca1 --- /dev/null +++ b/apps/server/src/modules/version/version.service.spec.ts @@ -0,0 +1,117 @@ +import { NotFoundException } from '@nestjs/common'; +import { VersionService } from './version.service'; + +describe('VersionService domain writes', () => { + const makeService = () => { + const tx = { + requirement: { updateMany: jest.fn() }, + versionPlan: { deleteMany: jest.fn() }, + devTask: { deleteMany: jest.fn() }, + testCase: { deleteMany: jest.fn() }, + bug: { deleteMany: jest.fn() }, + version: { delete: jest.fn() }, + }; + const prisma = { + product: { findUnique: jest.fn() }, + project: { findFirst: jest.fn() }, + version: { + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn(async (callback: (client: typeof tx) => Promise) => callback(tx)), + }; + + return { + prisma, + service: new VersionService(prisma as any), + tx, + }; + }; + + it('creates versions with root metadata in the relation table', async () => { + const { prisma, service } = makeService(); + prisma.product.findUnique.mockResolvedValue({ id: 'product-1' }); + prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' }); + prisma.version.create.mockResolvedValue({ id: 'version-1', productId: 'product-1' }); + + await service.create('product-1', { + projectId: 'project-1', + name: 'CRM 1.0', + status: 'developing', + expectedReleaseDate: '2026-08-01T00:00:00.000Z', + members: [{ role: 'frontend', name: '张三' }], + priority: 'P1', + }); + + expect(prisma.version.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + productId: 'product-1', + projectId: 'project-1', + name: 'CRM 1.0', + status: 'developing', + expectedReleaseDate: new Date('2026-08-01T00:00:00.000Z'), + members: [{ role: 'frontend', name: '张三' }], + priority: 1, + }), + }); + }); + + it('rejects versions for projects outside the product scope', async () => { + const { prisma, service } = makeService(); + prisma.product.findUnique.mockResolvedValue({ id: 'product-1' }); + prisma.project.findFirst.mockResolvedValue(null); + + await expect( + service.create('product-1', { projectId: 'project-404', name: 'CRM 1.0' }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.version.create).not.toHaveBeenCalled(); + }); + + it('lists versions by product and optional project scope', async () => { + const { prisma, service } = makeService(); + prisma.version.findMany.mockResolvedValue([]); + + await service.findAll('product-1', 'project-1'); + + expect(prisma.version.findMany).toHaveBeenCalledWith({ + where: { productId: 'product-1', projectId: 'project-1' }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('updates versions only inside their product scope', async () => { + const { prisma, service } = makeService(); + prisma.version.findFirst.mockResolvedValue({ id: 'version-1', productId: 'product-1' }); + prisma.version.update.mockResolvedValue({ id: 'version-1', status: 'paused' }); + + await service.update('product-1', 'version-1', { status: 'paused', releaseDate: '2026-09-01T00:00:00.000Z' }); + + expect(prisma.version.update).toHaveBeenCalledWith({ + where: { id: 'version-1' }, + data: { + status: 'paused', + releaseDate: new Date('2026-09-01T00:00:00.000Z'), + }, + }); + }); + + it('deletes version-scoped data and releases linked requirements before deleting a version', async () => { + const { prisma, service, tx } = makeService(); + prisma.version.findFirst.mockResolvedValue({ id: 'version-1', productId: 'product-1' }); + tx.version.delete.mockResolvedValue({ id: 'version-1' }); + + await service.remove('product-1', 'version-1'); + + expect(tx.requirement.updateMany).toHaveBeenCalledWith({ + where: { productId: 'product-1', versionId: 'version-1' }, + data: { versionId: null }, + }); + expect(tx.versionPlan.deleteMany).toHaveBeenCalledWith({ where: { versionId: 'version-1' } }); + expect(tx.devTask.deleteMany).toHaveBeenCalledWith({ where: { versionId: 'version-1' } }); + expect(tx.testCase.deleteMany).toHaveBeenCalledWith({ where: { versionId: 'version-1' } }); + expect(tx.bug.deleteMany).toHaveBeenCalledWith({ where: { versionId: 'version-1' } }); + expect(tx.version.delete).toHaveBeenCalledWith({ where: { id: 'version-1' } }); + }); +}); diff --git a/apps/server/src/modules/version/version.service.ts b/apps/server/src/modules/version/version.service.ts new file mode 100644 index 0000000..38ef17f --- /dev/null +++ b/apps/server/src/modules/version/version.service.ts @@ -0,0 +1,102 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateVersionDto } from './dto/create-version.dto'; +import { UpdateVersionDto } from './dto/update-version.dto'; + +@Injectable() +export class VersionService { + constructor(private readonly prisma: PrismaService) {} + + async create(productId: string, dto: CreateVersionDto, scopedProjectId?: string) { + await this.ensureProductExists(productId); + const projectId = scopedProjectId ?? dto.projectId; + if (projectId) await this.ensureProjectInProduct(productId, projectId); + + const data = this.toVersionData(dto); + return this.prisma.version.create({ + data: { + ...data, + productId, + projectId, + name: dto.name, + } as any, + }); + } + + findAll(productId: string, projectId?: string) { + return this.prisma.version.findMany({ + where: { productId, ...(projectId ? { projectId } : {}) }, + orderBy: { createdAt: 'desc' }, + }); + } + + async update(productId: string, versionId: string, dto: UpdateVersionDto) { + await this.ensureVersionInProduct(productId, versionId); + if (dto.projectId) await this.ensureProjectInProduct(productId, dto.projectId); + return this.prisma.version.update({ + where: { id: versionId }, + data: this.toVersionData(dto) as any, + }); + } + + async remove(productId: string, versionId: string) { + await this.ensureVersionInProduct(productId, versionId); + return this.prisma.$transaction(async (tx) => { + await tx.requirement.updateMany({ + where: { productId, versionId }, + data: { versionId: null }, + }); + await tx.versionPlan.deleteMany({ where: { versionId } }); + await tx.devTask.deleteMany({ where: { versionId } }); + await tx.testCase.deleteMany({ where: { versionId } }); + await tx.bug.deleteMany({ where: { versionId } }); + return tx.version.delete({ where: { id: versionId } }); + }); + } + + private toVersionData(dto: CreateVersionDto | UpdateVersionDto) { + return { + ...(dto.projectId !== undefined && { projectId: dto.projectId }), + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.status !== undefined && { status: dto.status }), + ...(dto.currentStage !== undefined && { currentStage: dto.currentStage }), + ...(dto.startDate !== undefined && { startDate: parseOptionalDate(dto.startDate) }), + ...(dto.expectedReleaseDate !== undefined && { expectedReleaseDate: parseOptionalDate(dto.expectedReleaseDate) }), + ...(dto.releaseDate !== undefined && { releaseDate: parseOptionalDate(dto.releaseDate) }), + ...(dto.members !== undefined && { members: dto.members }), + ...(dto.progress !== undefined && { progress: dto.progress }), + ...(dto.priority !== undefined && { priority: priorityToNumber(dto.priority) }), + ...(dto.links !== undefined && { links: dto.links }), + }; + } + + 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('项目不存在'); + } + + private async ensureVersionInProduct(productId: string, versionId: string) { + const version = await this.prisma.version.findFirst({ where: { id: versionId, productId } }); + if (!version) throw new NotFoundException('版本不存在'); + } +} + +function parseOptionalDate(value: string | null | undefined): Date | null { + if (!value) return null; + return new Date(value); +} + +function priorityToNumber(value: string | number | null | undefined): number | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const match = /^P([0-4])$/i.exec(value.trim()); + if (match) return Number(match[1]); + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.max(0, Math.min(4, Math.floor(parsed))) : null; +} diff --git a/apps/web/lib/domain-api.ts b/apps/web/lib/domain-api.ts new file mode 100644 index 0000000..b7eb537 --- /dev/null +++ b/apps/web/lib/domain-api.ts @@ -0,0 +1,110 @@ +import { api } from './api'; + +export interface RootProject { + id: string; + name: string; + description: string; + createdAt: string; +} + +export interface RootVersion { + id: string; + productId?: string; + projectId?: string | null; + name: string; + status?: string; + releaseDate: string | null; + createdAt: string; + currentStage?: string | null; + startDate?: string | null; + expectedReleaseDate?: string | null; + members?: unknown[]; + progress?: unknown[]; + priority?: string; + links?: unknown; +} + +export interface RootProduct { + id: string; + name: string; + description: string; + createdAt: string; + updatedAt: string; + projects: RootProject[]; + versions: RootVersion[]; + _count?: { requirements: number; projects: number; versions?: number }; +} + +export async function createProductRoot(data: { name: string; description?: string }): Promise { + const product = await api.post>('/products', data); + return normalizeProductRoot({ ...product, projects: [], versions: [] }); +} + +export async function updateProductRoot( + productId: string, + data: { name?: string; description?: string }, +): Promise> { + return api.patch>(`/products/${productId}`, data); +} + +export async function deleteProductRoot(productId: string): Promise { + await api.delete(`/products/${productId}`); +} + +export async function createProjectByProductId( + productId: string, + data: { name: string; description?: string }, +): Promise { + return api.post(`/products/${productId}/projects`, data); +} + +export async function updateProjectByProductId( + productId: string, + projectId: string, + data: { name?: string; description?: string }, +): Promise { + return api.patch(`/products/${productId}/projects/${projectId}`, data); +} + +export async function deleteProjectByProductId(productId: string, projectId: string): Promise { + await api.delete(`/products/${productId}/projects/${projectId}`); +} + +export async function createVersionByProductId( + productId: string, + data: { name: string; status?: string; projectId?: string }, +): Promise { + return normalizeVersionRoot(await api.post(`/products/${productId}/versions`, data)); +} + +export async function updateVersionByProductId( + productId: string, + versionId: string, + data: Record, +): Promise { + return normalizeVersionRoot(await api.patch(`/products/${productId}/versions/${versionId}`, data)); +} + +export async function deleteVersionByProductId(productId: string, versionId: string): Promise { + await api.delete(`/products/${productId}/versions/${versionId}`); +} + +function normalizeProductRoot(product: RootProduct): RootProduct { + return { + ...product, + projects: product.projects ?? [], + versions: (product.versions ?? []).map(normalizeVersionRoot), + _count: product._count ?? { requirements: 0, projects: product.projects?.length ?? 0, versions: product.versions?.length ?? 0 }, + }; +} + +function normalizeVersionRoot(version: RootVersion): RootVersion { + return { + ...version, + status: version.status ?? 'planned', + releaseDate: version.releaseDate ?? null, + members: Array.isArray(version.members) ? version.members : [], + progress: Array.isArray(version.progress) ? version.progress : [], + links: version.links && typeof version.links === 'object' ? version.links : {}, + }; +} diff --git a/apps/web/lib/product-domain-write-source.test.ts b/apps/web/lib/product-domain-write-source.test.ts new file mode 100644 index 0000000..8b0c8fa --- /dev/null +++ b/apps/web/lib/product-domain-write-source.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; + +const source = () => readFileSync(join(process.cwd(), 'stores/useProductStore.ts'), 'utf8'); + +function storeMethodBody(text: string, name: string) { + const start = text.indexOf(`\n ${name}: async`); + assert.notEqual(start, -1, `missing store method ${name}`); + + let depth = 0; + let sawFirstBrace = false; + for (let i = start; i < text.length; i += 1) { + const char = text[i]; + if (char === '{') { + depth += 1; + sawFirstBrace = true; + } + if (char === '}') { + depth -= 1; + if (sawFirstBrace && depth === 0) { + return text.slice(start, i + 1); + } + } + } + + throw new Error(`could not extract store method ${name}`); +} + +test('product root store imports domain root write helpers', () => { + assert.match(source(), /from '@\/lib\/domain-api'/); +}); + +test('project mutations use domain APIs instead of products-overview AppData saves', () => { + const text = source(); + const createProject = storeMethodBody(text, 'createProject'); + const updateProject = storeMethodBody(text, 'updateProject'); + const deleteProject = storeMethodBody(text, 'deleteProject'); + + assert.match(createProject, /createProjectByProductId\(productId, data\)/); + assert.match(updateProject, /updateProjectByProductId\(productId, projectId, data\)/); + assert.match(deleteProject, /deleteProjectByProductId\(productId, projectId\)/); + assert.doesNotMatch(createProject, /saveStoredOverview\(updated\)/); + assert.doesNotMatch(updateProject, /saveStoredOverview\(updated\)/); + assert.doesNotMatch(deleteProject, /saveStoredOverview\(updated\)/); +}); + +test('version mutations use domain APIs instead of products-overview AppData saves', () => { + const text = source(); + const createVersion = storeMethodBody(text, 'createVersion'); + const updateVersion = storeMethodBody(text, 'updateVersion'); + const deleteVersion = storeMethodBody(text, 'deleteVersion'); + + assert.match(createVersion, /createVersionByProductId\(productId, data\)/); + assert.match(updateVersion, /updateVersionByProductId\(productId, versionId, data\)/); + assert.match(deleteVersion, /deleteVersionByProductId\(productId, versionId\)/); + assert.doesNotMatch(createVersion, /saveStoredOverview\(updated\)/); + assert.doesNotMatch(updateVersion, /saveStoredOverview\(updated\)/); + assert.doesNotMatch(deleteVersion, /saveStoredOverview\(updated\)/); +}); diff --git a/apps/web/stores/useProductStore.ts b/apps/web/stores/useProductStore.ts index d95f373..90097f5 100644 --- a/apps/web/stores/useProductStore.ts +++ b/apps/web/stores/useProductStore.ts @@ -3,8 +3,18 @@ import { create } from 'zustand'; import { Product } from '@ftb/shared'; import { api } from '@/lib/api'; +import { + createProductRoot, + createProjectByProductId, + createVersionByProductId, + deleteProductRoot, + deleteProjectByProductId, + deleteVersionByProductId, + updateProductRoot, + updateProjectByProductId, + updateVersionByProductId, +} from '@/lib/domain-api'; import { saveWithOptimisticRollback } from '@/lib/optimistic-persistence'; -import { shouldPersistRemoteOverview } from '@/lib/product-overview-persistence'; import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data'; import type { Stage, Role } from '@/lib/stage'; import type { Priority, VersionLinks } from '@/lib/derive'; @@ -86,51 +96,39 @@ export const useProductStore = create((set, get) => ({ fetchProducts: async () => { set({ loading: true, error: null }); try { + const products = await api.get('/products'); + set({ products, loading: false }); + } catch { const overview = await loadStoredOverview(); const products = overview ? overview.map(({ projects: _projects, versions: _versions, ...product }) => product) - : await api.get('/products'); - set({ products, loading: false }); - } catch { - set({ products: [], error: null, loading: false }); + : []; + set({ products, error: null, loading: false }); } }, fetchOverview: async () => { - if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return; - const cached = await loadStoredOverview(); if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return; lastOverviewFetchAt = Date.now(); - if (cached) { - // 有本地缓存,直接用,不再调远端覆盖(mock 模式核心数据在本地) - set({ overview: cached, loading: false, overviewLoaded: true }); - return; - } set({ loading: true, error: null }); try { const overview = await api.get('/products/overview'); set({ overview, loading: false, overviewLoaded: true }); - if (shouldPersistRemoteOverview(overview)) { - void saveStoredOverview(overview).catch(() => {}); - } } catch { - set({ overview: MOCK_OVERVIEW, error: null, loading: false, overviewLoaded: true }); + const cached = await loadStoredOverview(); + if (get().overviewLoaded && Date.now() - lastOverviewFetchAt < SERVER_DATA_CACHE_MS) return; + set({ overview: cached ?? MOCK_OVERVIEW, error: null, loading: false, overviewLoaded: true }); } }, fetchProduct: async (id) => { set({ loading: true, error: null }); try { - const overview = await loadStoredOverview(); - const found = overview?.find((p) => p.id === id); - if (found) { - set({ currentProduct: found as any, loading: false }); - return; - } const product = await api.get(`/products/${id}`); set({ currentProduct: product, loading: false }); } catch { - const found = get().overview.find((p) => p.id === id); + const overview = await loadStoredOverview(); + const found = overview?.find((p) => p.id === id) ?? get().overview.find((p) => p.id === id); if (found) { set({ currentProduct: found as any, loading: false }); } else { @@ -154,7 +152,18 @@ export const useProductStore = create((set, get) => ({ const updated = [newProduct, ...previousOverview]; set({ overview: updated, overviewLoaded: true }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + const created = await createProductRoot(data); + set({ + overview: get().overview.map((product) => (product.id === newProduct.id ? created as ProductOverview : product)), + products: [created, ...get().products] as ProductWithCount[], + overviewLoaded: true, + }); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ overview: previousOverview, overviewLoaded: true }), @@ -174,7 +183,13 @@ export const useProductStore = create((set, get) => ({ previousCurrentProduct?.id === id ? ({ ...previousCurrentProduct, ...data } as any) : previousCurrentProduct, }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + await updateProductRoot(id, data); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ @@ -197,7 +212,13 @@ export const useProductStore = create((set, get) => ({ currentProduct: previousCurrentProduct?.id === id ? null : previousCurrentProduct, }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + await deleteProductRoot(id); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ @@ -269,7 +290,24 @@ export const useProductStore = create((set, get) => ({ }); set({ overview: updated, overviewLoaded: true }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + const created = await createProjectByProductId(productId, data); + set({ + overview: get().overview.map((p) => { + if (p.id !== productId) return p; + return { + ...p, + projects: p.projects.map((project) => (project.id === newProject.id ? created : project)), + _count: { ...p._count, projects: p.projects.length } as any, + }; + }), + overviewLoaded: true, + }); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ overview: previousOverview, overviewLoaded: true }), @@ -312,7 +350,13 @@ export const useProductStore = create((set, get) => ({ }); set({ overview: updated, overviewLoaded: true }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + await updateProjectByProductId(productId, projectId, data); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ overview: previousOverview, overviewLoaded: true }), @@ -339,7 +383,24 @@ export const useProductStore = create((set, get) => ({ }); set({ overview: updated, overviewLoaded: true }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + const created = await createVersionByProductId(productId, data); + set({ + overview: get().overview.map((p) => { + if (p.id !== productId) return p; + return { + ...p, + versions: p.versions.map((version) => (version.id === newVersion.id ? created as VersionItem : version)), + _count: { ...p._count, versions: p.versions.length } as any, + }; + }), + overviewLoaded: true, + }); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ overview: previousOverview, overviewLoaded: true }), @@ -359,7 +420,13 @@ export const useProductStore = create((set, get) => ({ }); set({ overview: updated, overviewLoaded: true }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + await updateVersionByProductId(productId, versionId, data); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ overview: previousOverview, overviewLoaded: true }), @@ -378,7 +445,13 @@ export const useProductStore = create((set, get) => ({ }); set({ overview: updated, overviewLoaded: true }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + await deleteVersionByProductId(productId, versionId); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ overview: previousOverview, overviewLoaded: true }), @@ -397,7 +470,13 @@ export const useProductStore = create((set, get) => ({ }); set({ overview: updated, overviewLoaded: true }); await saveWithOptimisticRollback({ - save: () => saveStoredOverview(updated), + save: async () => { + try { + await deleteProjectByProductId(productId, projectId); + } catch { + await saveRootOverviewFallback(updated); + } + }, expected: updated, getCurrent: () => get().overview, rollback: () => set({ overview: previousOverview, overviewLoaded: true }), @@ -409,6 +488,10 @@ async function saveStoredOverview(data: ProductOverview[]) { await saveServerData('products-overview', data); } +async function saveRootOverviewFallback(data: ProductOverview[]) { + await saveStoredOverview(data); +} + async function loadStoredOverview(): Promise { try { return await loadServerData('products-overview'); diff --git a/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md b/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md index 15fb209..d5f415b 100644 --- a/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md +++ b/docs/superpowers/plans/2026-07-08-v24-domain-crud-migration.md @@ -74,15 +74,15 @@ - Produces: `/api/v1/products`, `/api/v1/products/:productId/projects`, `/api/v1/products/:productId/versions`, and `/api/v1/products/:productId/projects/:projectId/versions`. - Consumes: existing product/project/version tree shape from `products-overview`. -- [ ] Add failing backend tests for project create/list/update/delete by `productId`. -- [ ] Add failing backend tests for version create/list/update/delete by `productId` and optional `projectId`. -- [ ] Add failing tests proving deleting a version releases linked requirements and deletes version-scoped plans/tasks/test cases/bugs. -- [ ] Add failing frontend tests proving `useProductStore` saves root mutations through domain APIs and falls back to `products-overview` when APIs are unavailable. -- [ ] Implement Project and Version modules with conservative DTO validation. -- [ ] Keep Product CRUD as the top-level root and add child include helpers only where needed. -- [ ] Switch Product store mutations to domain APIs while retaining AppData compatibility read. -- [ ] Run targeted backend and frontend tests. -- [ ] Run full gates and commit: `feat(v2.4): 切换根数据领域主写`. +- [x] Add failing backend tests for project create/list/update/delete by `productId`. +- [x] Add failing backend tests for version create/list/update/delete by `productId` and optional `projectId`. +- [x] Add failing tests proving deleting a version releases linked requirements and deletes version-scoped plans/tasks/test cases/bugs. +- [x] Add failing frontend tests proving `useProductStore` saves root mutations through domain APIs and falls back to `products-overview` when APIs are unavailable. +- [x] Implement Project and Version modules with conservative DTO validation. +- [x] Keep Product CRUD as the top-level root and add child include helpers only where needed. +- [x] Switch Product store mutations to domain APIs while retaining AppData compatibility read. +- [x] Run targeted backend and frontend tests. +- [x] Run full gates and commit: `feat(v2.4): 切换根数据领域主写`. ### Task 3: V2.4.2 Requirement Main Writes And Server Pagination