From 5f5a211c99f28cf0e86e62a9205a848307938f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 12:18:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2.4):=20=E5=88=87=E6=8D=A2=E9=9C=80?= =?UTF-8?q?=E6=B1=82=E6=B1=A0=E9=A2=86=E5=9F=9F=E4=B8=BB=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../requirement/dto/create-requirement.dto.ts | 48 +++++- .../requirement/requirement.controller.ts | 20 ++- .../requirement/requirement.service.spec.ts | 111 +++++++++++++ .../requirement/requirement.service.ts | 146 ++++++++++++++++-- apps/web/app/requirements/page.tsx | 19 ++- apps/web/app/versions/[id]/page.tsx | 29 +++- apps/web/app/versions/page.tsx | 4 +- apps/web/lib/domain-api.ts | 129 ++++++++++++++++ .../requirement-domain-write-source.test.ts | 67 ++++++++ apps/web/stores/useRequirementStore.ts | 69 ++++++++- .../2026-07-08-v24-domain-crud-migration.md | 16 +- 11 files changed, 602 insertions(+), 56 deletions(-) create mode 100644 apps/web/lib/requirement-domain-write-source.test.ts diff --git a/apps/server/src/modules/requirement/dto/create-requirement.dto.ts b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts index c83c4b9..04eb742 100644 --- a/apps/server/src/modules/requirement/dto/create-requirement.dto.ts +++ b/apps/server/src/modules/requirement/dto/create-requirement.dto.ts @@ -1,4 +1,5 @@ -import { IsString, IsNotEmpty, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { RequirementStatus } from '@ftb/shared'; +import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class CreateRequirementDto { @IsString() @@ -13,13 +14,46 @@ export class CreateRequirementDto { @IsOptional() description?: string; - @IsInt() - @Min(0) - @Max(4) @IsOptional() - priority?: number; + priority?: string | number; @IsString() - @IsNotEmpty() - creatorId!: string; + @IsOptional() + projectId?: string; + + @IsString() + @IsOptional() + versionId?: string; + + @IsString() + @IsOptional() + type?: string; + + @IsString() + @IsOptional() + typeId?: string; + + @IsString() + @IsOptional() + sourceType?: string; + + @IsString() + @IsOptional() + sourceTarget?: string; + + @IsString() + @IsOptional() + platform?: string; + + @IsArray() + @IsOptional() + platforms?: string[]; + + @IsIn(Object.values(RequirementStatus)) + @IsOptional() + status?: RequirementStatus; + + @IsString() + @IsOptional() + creatorId?: string; } diff --git a/apps/server/src/modules/requirement/requirement.controller.ts b/apps/server/src/modules/requirement/requirement.controller.ts index 3a7ea75..e022fd1 100644 --- a/apps/server/src/modules/requirement/requirement.controller.ts +++ b/apps/server/src/modules/requirement/requirement.controller.ts @@ -20,8 +20,26 @@ export class RequirementController { findAll( @Param('productId') productId: string, @Query('status') status?: string, + @Query('projectId') projectId?: string, + @Query('versionId') versionId?: string, + @Query('priority') priority?: string, + @Query('type') type?: string, + @Query('q') q?: string, + @Query('sort') sort?: string, + @Query('cursor') cursor?: string, + @Query('limit') limit?: string, ) { - return this.requirementService.findAll(productId, status); + return this.requirementService.findAll(productId, { + projectId, + versionId, + status, + priority, + type, + q, + sort, + cursor, + limit, + }); } @Get(':id') diff --git a/apps/server/src/modules/requirement/requirement.service.spec.ts b/apps/server/src/modules/requirement/requirement.service.spec.ts index 5a59e00..7523a77 100644 --- a/apps/server/src/modules/requirement/requirement.service.spec.ts +++ b/apps/server/src/modules/requirement/requirement.service.spec.ts @@ -37,6 +37,84 @@ describe('RequirementService with V2.2 composite requirement key', () => { }); }); + it('creates requirements with frontend pool fields in relation columns', async () => { + const { prisma, service } = makeService(); + prisma.requirement.create.mockResolvedValue({ id: 'req-1', productId: 'product-1', code: 'REQ-001' }); + + await service.create('product-1', { + code: 'REQ-001', + title: 'Payment', + projectId: 'project-1', + versionId: 'version-1', + type: 'feature', + sourceType: 'customer', + sourceTarget: 'ACME', + platform: 'web,ios', + priority: 1, + } as any); + + expect(prisma.requirement.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + type: 'feature', + sourceType: 'customer', + sourceTarget: 'ACME', + platform: 'web,ios', + }), + }); + }); + + it('paginates requirement pool queries by product partition key and composite cursor', async () => { + const { prisma, service } = makeService(); + prisma.requirement.findMany.mockResolvedValue([{ id: 'req-2' }, { id: 'req-1' }]); + + const result = await service.findAll('product-1', { + projectId: 'project-1', + versionId: 'version-1', + status: 'adopted', + priority: 'P1', + type: 'feature', + q: 'login', + sort: 'created_at_asc', + cursor: 'req-3', + limit: '1', + } as any); + + expect(result).toEqual({ items: [{ id: 'req-2' }], nextCursor: 'req-1' }); + expect(prisma.requirement.findMany).toHaveBeenCalledWith({ + where: { + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + status: 'adopted', + priority: 1, + type: 'feature', + OR: [ + { code: { contains: 'login', mode: 'insensitive' } }, + { title: { contains: 'login', mode: 'insensitive' } }, + ], + }, + include: { creator: { select: { id: true, name: true } } }, + orderBy: { createdAt: 'asc' }, + take: 2, + cursor: { id_productId: { id: 'req-3', productId: 'product-1' } }, + skip: 1, + }); + }); + + it('uses only approved requirement sort keys', async () => { + const { prisma, service } = makeService(); + prisma.requirement.findMany.mockResolvedValue([]); + + await service.findAll('product-1', { sort: 'unsafe_sql_asc', limit: '20' } as any); + + expect(prisma.requirement.findMany).toHaveBeenCalledWith(expect.objectContaining({ + orderBy: { createdAt: 'desc' }, + })); + }); + it('updates requirements by id plus product id', async () => { const { prisma, service } = makeService(); prisma.requirement.findFirst.mockResolvedValue({ @@ -54,6 +132,37 @@ describe('RequirementService with V2.2 composite requirement key', () => { }); }); + it('updates frontend pool fields by id plus product id', async () => { + const { prisma, service } = makeService(); + prisma.requirement.findFirst.mockResolvedValue({ + id: 'req-1', + productId: 'product-1', + status: 'pending_review', + }); + prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' }); + + await service.update('product-1', 'req-1', { + projectId: 'project-2', + versionId: 'version-2', + type: 'optimization', + sourceType: 'internal', + sourceTarget: 'Product', + platform: 'web,h5', + } as any); + + expect(prisma.requirement.update).toHaveBeenCalledWith({ + where: { id_productId: { id: 'req-1', productId: 'product-1' } }, + data: { + projectId: 'project-2', + versionId: 'version-2', + type: 'optimization', + sourceType: 'internal', + sourceTarget: 'Product', + platform: 'web,h5', + }, + }); + }); + it('deletes requirements by id plus product id', async () => { const { prisma, service } = makeService(); prisma.requirement.findFirst.mockResolvedValue({ @@ -75,7 +184,9 @@ describe('RequirementService with V2.2 composite requirement key', () => { ['pending_review', 'rejected'], ['rejected', 'pending_review'], ['adopted', 'planned'], + ['adopted', 'closed'], ['planned', 'developing'], + ['planned', 'closed'], ['developing', 'testing'], ['testing', 'released'], ['released', 'closed'], diff --git a/apps/server/src/modules/requirement/requirement.service.ts b/apps/server/src/modules/requirement/requirement.service.ts index f6e8b1c..a072d72 100644 --- a/apps/server/src/modules/requirement/requirement.service.ts +++ b/apps/server/src/modules/requirement/requirement.service.ts @@ -7,14 +7,26 @@ import { UpdateRequirementDto } from './dto/update-requirement.dto'; const VALID_TRANSITIONS: Record = { [RequirementStatus.PENDING_REVIEW]: [RequirementStatus.ADOPTED, RequirementStatus.REJECTED], [RequirementStatus.REJECTED]: [RequirementStatus.PENDING_REVIEW], - [RequirementStatus.ADOPTED]: [RequirementStatus.PLANNED], - [RequirementStatus.PLANNED]: [RequirementStatus.DEVELOPING], + [RequirementStatus.ADOPTED]: [RequirementStatus.PLANNED, RequirementStatus.CLOSED], + [RequirementStatus.PLANNED]: [RequirementStatus.DEVELOPING, RequirementStatus.CLOSED], [RequirementStatus.DEVELOPING]: [RequirementStatus.TESTING], [RequirementStatus.TESTING]: [RequirementStatus.RELEASED], [RequirementStatus.RELEASED]: [RequirementStatus.CLOSED], [RequirementStatus.CLOSED]: [], }; +export interface RequirementListQuery { + projectId?: string; + versionId?: string; + status?: string; + priority?: string; + type?: string; + q?: string; + sort?: string; + cursor?: string; + limit?: string | number; +} + function createFallbackRequirementCode() { return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random() .toString(36) @@ -27,28 +39,62 @@ export class RequirementService { constructor(private prisma: PrismaService) {} create(productId: string, dto: CreateRequirementDto) { + const data = this.toRequirementData(dto); return this.prisma.requirement.create({ data: { + ...data, productId, code: dto.code?.trim() || createFallbackRequirementCode(), title: dto.title, - description: dto.description || '', - status: RequirementStatus.PENDING_REVIEW, - priority: dto.priority ?? 0, - creatorId: dto.creatorId, + description: dto.description ?? '', + status: dto.status ?? RequirementStatus.PENDING_REVIEW, + priority: parsePriorityValue(dto.priority) ?? 0, }, }); } - findAll(productId: string, status?: string) { - return this.prisma.requirement.findMany({ + async findAll(productId: string, query: RequirementListQuery = {}) { + const normalizedProductId = productId?.trim(); + if (!normalizedProductId) { + throw new BadRequestException('productId is required for requirement pool queries'); + } + + const limit = parseLimit(query.limit); + const priority = parsePriorityValue(query.priority); + const search = query.q?.trim(); + const rows = await this.prisma.requirement.findMany({ where: { - productId, - ...(status ? { status } : {}), + productId: normalizedProductId, + ...(query.projectId?.trim() ? { projectId: query.projectId.trim() } : {}), + ...(query.versionId?.trim() ? { versionId: query.versionId.trim() } : {}), + ...(query.status?.trim() ? { status: query.status.trim() } : {}), + ...(priority !== undefined ? { priority } : {}), + ...(query.type?.trim() ? { type: query.type.trim() } : {}), + ...(search + ? { + OR: [ + { code: { contains: search, mode: 'insensitive' as const } }, + { title: { contains: search, mode: 'insensitive' as const } }, + ], + } + : {}), }, - orderBy: { createdAt: 'desc' }, include: { creator: { select: { id: true, name: true } } }, + orderBy: parseRequirementSort(query.sort), + take: limit + 1, + ...(query.cursor + ? { + cursor: { id_productId: { id: query.cursor, productId: normalizedProductId } }, + skip: 1, + } + : {}), }); + + const hasNext = rows.length > limit; + return { + items: hasNext ? rows.slice(0, limit) : rows, + nextCursor: hasNext ? rows[limit]?.id : undefined, + }; } async findOne(productId: string, id: string) { @@ -64,12 +110,7 @@ export class RequirementService { await this.findOne(productId, id); return this.prisma.requirement.update({ where: { id_productId: { id, productId } }, - data: { - ...(dto.code !== undefined && { code: dto.code }), - ...(dto.title !== undefined && { title: dto.title }), - ...(dto.description !== undefined && { description: dto.description }), - ...(dto.priority !== undefined && { priority: dto.priority }), - }, + data: this.toRequirementData(dto), }); } @@ -91,4 +132,75 @@ export class RequirementService { await this.findOne(productId, id); return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } }); } + + private toRequirementData(dto: CreateRequirementDto | UpdateRequirementDto) { + const type = dto.type ?? dto.typeId; + const platform = dto.platform ?? arrayToCsv(dto.platforms); + const priority = parsePriorityValue(dto.priority); + + return { + ...(dto.code !== undefined && { code: dto.code }), + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.projectId !== undefined && { projectId: emptyToNull(dto.projectId) }), + ...(dto.versionId !== undefined && { versionId: emptyToNull(dto.versionId) }), + ...(type !== undefined && { type: emptyToNull(type) }), + ...(dto.sourceType !== undefined && { sourceType: emptyToNull(dto.sourceType) }), + ...(dto.sourceTarget !== undefined && { sourceTarget: emptyToNull(dto.sourceTarget) }), + ...(platform !== undefined && { platform: emptyToNull(platform) }), + ...(dto.creatorId !== undefined && { creatorId: emptyToNull(dto.creatorId) }), + ...(priority !== undefined && { priority }), + }; + } +} + +function emptyToNull(value: string | null | undefined): string | null { + if (value === null) return null; + if (value === undefined) return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function arrayToCsv(value: string[] | undefined): string | undefined { + if (!value) return undefined; + return value.map((item) => item.trim()).filter(Boolean).join(','); +} + +function parseLimit(raw?: string | number): number { + const parsed = raw === undefined ? 50 : Number(raw); + if (!Number.isFinite(parsed)) return 50; + return Math.max(1, Math.min(200, Math.floor(parsed))); +} + +function parsePriorityValue(raw?: string | number | null): number | undefined { + if (raw === null || raw === undefined || raw === '') return undefined; + if (typeof raw === 'number') return Number.isFinite(raw) ? Math.max(0, Math.min(4, Math.floor(raw))) : undefined; + const normalized = raw.trim().toUpperCase(); + const prefixed = /^P([0-4])$/.exec(normalized); + if (prefixed) return Number(prefixed[1]); + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) return undefined; + return Math.max(0, Math.min(4, Math.floor(parsed))); +} + +function parseRequirementSort(sort?: string) { + switch (sort) { + case 'created_at_asc': + return { createdAt: 'asc' as const }; + case 'priority_asc': + return { priority: 'asc' as const }; + case 'priority_desc': + return { priority: 'desc' as const }; + case 'code_asc': + return { code: 'asc' as const }; + case 'code_desc': + return { code: 'desc' as const }; + case 'updated_at_asc': + return { updatedAt: 'asc' as const }; + case 'updated_at_desc': + return { updatedAt: 'desc' as const }; + case 'created_at_desc': + default: + return { createdAt: 'desc' as const }; + } } diff --git a/apps/web/app/requirements/page.tsx b/apps/web/app/requirements/page.tsx index 70f0f02..e222fe3 100644 --- a/apps/web/app/requirements/page.tsx +++ b/apps/web/app/requirements/page.tsx @@ -432,7 +432,6 @@ function RequirementsPageContent() { }, [scopedRequirements, search, statusFilter, priorityFilter, typeFilter, versionFilter, dateSort, requirementDevStatusMap]); const appDataRequirementById = useMemo(() => new Map(requirements.map((item) => [item.id, item])), [requirements]); - const appDataRequirementIds = useMemo(() => new Set(appDataRequirementById.keys()), [appDataRequirementById]); const usingV22Requirements = Boolean(v22RequirementQuery) && !v22RequirementsFailed; const appTotalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const appPage = Math.min(page, appTotalPages); @@ -450,7 +449,7 @@ function RequirementsPageContent() { : filtered.length; const displayPage = usingV22Requirements ? page : appPage; const displayedScopeCount = usingV22Requirements ? total : scopedRequirements.length; - const canMutateRequirement = (req: Requirement) => requirementsLoaded && appDataRequirementIds.has(req.id); + const canMutateRequirement = (req: Requirement) => Boolean(req.productId); const handlePageChange = (nextPage: number) => { if (usingV22Requirements && nextPage > page && !v22CursorByPage[nextPage]) return; setPage(nextPage); @@ -789,7 +788,7 @@ function RequirementsPageContent() { {canMutateRequirement(req) && req.status === 'pending_review' && ( <>