diff --git a/.gitignore b/.gitignore index 1eb6fa2..a981be7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ next-env.d.ts *.tsbuildinfo apps/server/data/ .worktrees/ +appdata-archive-*.json diff --git a/apps/server/prisma/migrations/20260708000000_v24_domain_contracts/migration.sql b/apps/server/prisma/migrations/20260708000000_v24_domain_contracts/migration.sql new file mode 100644 index 0000000..b5cc3bf --- /dev/null +++ b/apps/server/prisma/migrations/20260708000000_v24_domain_contracts/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "requirements" ALTER COLUMN "status" SET DEFAULT 'pending_review'; 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/migrations/20260708020000_v24_member_domain_fields/migration.sql b/apps/server/prisma/migrations/20260708020000_v24_member_domain_fields/migration.sql new file mode 100644 index 0000000..65bb641 --- /dev/null +++ b/apps/server/prisma/migrations/20260708020000_v24_member_domain_fields/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "username" TEXT, + ADD COLUMN IF NOT EXISTS "department_id" TEXT, + ADD COLUMN IF NOT EXISTS "role_id" TEXT NOT NULL DEFAULT 'member', + ADD COLUMN IF NOT EXISTS "phone" TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "password" TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "is_system" BOOLEAN NOT NULL DEFAULT false; + +CREATE UNIQUE INDEX IF NOT EXISTS "users_username_key" ON "users"("username"); diff --git a/apps/server/prisma/migrations/20260708030000_v25_audit_events/migration.sql b/apps/server/prisma/migrations/20260708030000_v25_audit_events/migration.sql new file mode 100644 index 0000000..eda85c1 --- /dev/null +++ b/apps/server/prisma/migrations/20260708030000_v25_audit_events/migration.sql @@ -0,0 +1,25 @@ +CREATE TABLE "audit_events" ( + "id" TEXT NOT NULL, + "actor_id" TEXT, + "actor_name" TEXT NOT NULL DEFAULT '', + "action" TEXT NOT NULL, + "entity_type" TEXT NOT NULL, + "entity_id" TEXT NOT NULL, + "product_id" TEXT, + "project_id" TEXT, + "version_id" TEXT, + "scope" JSONB NOT NULL DEFAULT '{}', + "before" JSONB, + "after" JSONB, + "metadata" JSONB NOT NULL DEFAULT '{}', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id", "created_at") +) PARTITION BY RANGE ("created_at"); + +CREATE TABLE "audit_events_default" PARTITION OF "audit_events" DEFAULT; +CREATE INDEX "audit_events_actor_created_at_idx" ON "audit_events"("actor_id", "created_at" DESC); +CREATE INDEX "audit_events_entity_created_at_idx" ON "audit_events"("entity_type", "entity_id", "created_at" DESC); +CREATE INDEX "audit_events_product_created_at_idx" ON "audit_events"("product_id", "created_at" DESC); +CREATE INDEX "audit_events_project_created_at_idx" ON "audit_events"("project_id", "created_at" DESC); +CREATE INDEX "audit_events_version_created_at_idx" ON "audit_events"("version_id", "created_at" DESC); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index c9060f0..d6c8033 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -8,12 +8,18 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - name String - avatar String? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id String @id @default(cuid()) + email String @unique + name String + avatar String? + username String? @unique + departmentId String? @map("department_id") + roleId String @default("member") @map("role_id") + phone String @default("") + password String @default("") + isSystem Boolean @default(false) @map("is_system") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") createdTasks Task[] @relation("TaskCreator") assignedTasks Task[] @relation("TaskAssignee") @@ -76,7 +82,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") @@ -95,7 +109,7 @@ model Requirement { code String title String description String @default("") - status String @default("draft") + status String @default("pending_review") priority Int @default(0) type String? sourceType String? @map("source_type") @@ -409,6 +423,26 @@ model AiLog { @@map("ai_logs") } +model AuditEvent { + id String @default(cuid()) + actorId String? @map("actor_id") + actorName String @default("") @map("actor_name") + action String + entityType String @map("entity_type") + entityId String @map("entity_id") + productId String? @map("product_id") + projectId String? @map("project_id") + versionId String? @map("version_id") + scope Json @default("{}") + before Json? + after Json? + metadata Json @default("{}") + createdAt DateTime @default(now()) @map("created_at") + + @@id([id, createdAt]) + @@map("audit_events") +} + model XiaobaoRiskSummary { versionId String @id @map("version_id") riskLevel String @map("risk_level") diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index f8f95ef..ac73119 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -1,18 +1,54 @@ import { Module } from '@nestjs/common'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor'; +import { AuthModule } from './common/auth/auth.module'; import { PrismaModule } from './prisma/prisma.module'; +import { AuditModule } from './modules/audit/audit.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 { VersionPlanModule } from './modules/version-plan/version-plan.module'; +import { DevTaskModule } from './modules/dev-task/dev-task.module'; +import { TestCaseModule } from './modules/test-case/test-case.module'; +import { BugModule } from './modules/bug/bug.module'; +import { MemberModule } from './modules/member/member.module'; +import { TaskCategoryModule } from './modules/task-category/task-category.module'; +import { TaskWorklogModule } from './modules/task-worklog/task-worklog.module'; +import { OvertimeModule } from './modules/overtime/overtime.module'; import { AiModule } from './modules/ai/ai.module'; import { ConfigModule } from './modules/config/config.module'; import { DataModule } from './modules/data/data.module'; import { MigrationModule } from './modules/migration/migration.module'; import { V22QueryModule } from './modules/v22-query/v22-query.module'; import { HealthModule } from './modules/health/health.module'; +import { ConsistencyModule } from './modules/consistency/consistency.module'; @Module({ - imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule], + imports: [ + PrismaModule, + AuthModule, + AuditModule, + ProductModule, + ProjectModule, + VersionModule, + VersionPlanModule, + DevTaskModule, + TestCaseModule, + BugModule, + MemberModule, + TaskCategoryModule, + TaskWorklogModule, + OvertimeModule, + RequirementModule, + ConfigModule, + DataModule, + MigrationModule, + V22QueryModule, + ConsistencyModule, + HealthModule, + AiModule, + ], controllers: [], providers: [ { diff --git a/apps/server/src/common/audit/audit-mutation.decorator.ts b/apps/server/src/common/audit/audit-mutation.decorator.ts new file mode 100644 index 0000000..15ebe90 --- /dev/null +++ b/apps/server/src/common/audit/audit-mutation.decorator.ts @@ -0,0 +1,14 @@ +import { SetMetadata } from '@nestjs/common'; +import type { PermissionScopeOptions } from '../auth/permission.decorator'; + +export const AUDIT_MUTATION_METADATA_KEY = 'ftb:audit-mutation'; + +export interface AuditMutationMetadata extends PermissionScopeOptions { + action: string; + entityType: string; + entityIdParam?: string; +} + +export function AuditMutation(metadata: AuditMutationMetadata) { + return SetMetadata(AUDIT_MUTATION_METADATA_KEY, metadata); +} diff --git a/apps/server/src/common/audit/audit-mutation.interceptor.spec.ts b/apps/server/src/common/audit/audit-mutation.interceptor.spec.ts new file mode 100644 index 0000000..cc14673 --- /dev/null +++ b/apps/server/src/common/audit/audit-mutation.interceptor.spec.ts @@ -0,0 +1,59 @@ +import { lastValueFrom, of } from 'rxjs'; +import { AuditMutationInterceptor } from './audit-mutation.interceptor'; +import { AuditMutation } from './audit-mutation.decorator'; + +describe('AuditMutationInterceptor', () => { + it('writes an audit event after a successful mutation response', async () => { + const record = jest.fn().mockResolvedValue({ id: 'audit-1' }); + const resolveCurrentUser = jest.fn().mockResolvedValue({ id: 'm-8', name: '超级管理员', roleId: 'role-admin' }); + const interceptor = new AuditMutationInterceptor( + new (jest.requireActual('@nestjs/core').Reflector)(), + { record } as any, + { resolveCurrentUser } as any, + ); + const handler = decorate(() => undefined); + + const result = await lastValueFrom(interceptor.intercept(contextFor(handler), { + handle: () => of({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }), + } as any)); + + expect(result).toEqual({ item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }); + expect(record).toHaveBeenCalledWith({ + actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' }, + action: 'dev_task.update', + entityType: 'dev_task', + entityId: 'task-1', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + scope: { productId: 'product-1', projectId: 'project-1', versionId: 'version-1' }, + after: { item: { id: 'task-1', productId: 'product-1', projectId: 'project-1', versionId: 'version-1' } }, + metadata: { route: 'PATCH /versions/version-1/dev-tasks/task-1' }, + }); + }); +}); + +function decorate(handler: Function) { + AuditMutation({ + action: 'dev_task.update', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + })(handler as any, undefined as any, undefined as any); + return handler; +} + +function contextFor(handler: Function) { + const request = { + method: 'PATCH', + originalUrl: '/versions/version-1/dev-tasks/task-1', + params: { id: 'task-1', versionId: 'version-1' }, + body: {}, + headers: {}, + }; + return { + getHandler: () => handler, + getClass: () => class TestController {}, + switchToHttp: () => ({ getRequest: () => request }), + } as any; +} diff --git a/apps/server/src/common/audit/audit-mutation.interceptor.ts b/apps/server/src/common/audit/audit-mutation.interceptor.ts new file mode 100644 index 0000000..eda5e43 --- /dev/null +++ b/apps/server/src/common/audit/audit-mutation.interceptor.ts @@ -0,0 +1,90 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { mergeMap, Observable } from 'rxjs'; +import { AuthContextService, type AuthenticatedRequest } from '../auth/auth-context.service'; +import { AuditService } from '../../modules/audit/audit.service'; +import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from './audit-mutation.decorator'; + +type MutationRequest = AuthenticatedRequest & { + method?: string; + originalUrl?: string; + url?: string; + params?: Record; + body?: Record; +}; + +@Injectable() +export class AuditMutationInterceptor implements NestInterceptor { + constructor( + private readonly reflector: Reflector, + private readonly audit: AuditService, + private readonly authContext: AuthContextService, + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const metadata = this.reflector.getAllAndOverride(AUDIT_MUTATION_METADATA_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!metadata) return next.handle(); + + const request = context.switchToHttp().getRequest(); + return next.handle().pipe(mergeMap(async (result) => { + const actor = await this.authContext.resolveCurrentUser(request); + const entity = extractEntity(result); + const scope = resolveScope(metadata, request, entity); + await this.audit.record({ + actor, + action: metadata.action, + entityType: metadata.entityType, + entityId: resolveEntityId(metadata, request, entity), + productId: scope.productId, + projectId: scope.projectId, + versionId: scope.versionId, + scope, + after: result, + metadata: { route: `${request.method ?? 'UNKNOWN'} ${request.originalUrl ?? request.url ?? ''}`.trim() }, + }); + return result; + })); + } +} + +function extractEntity(result: unknown): Record | undefined { + if (!result || typeof result !== 'object') return undefined; + const record = result as Record; + if (record.item && typeof record.item === 'object') return record.item as Record; + if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record; + return record; +} + +function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record | undefined) { + const fromParam = metadata.entityIdParam ? request.params?.[metadata.entityIdParam] : undefined; + const fromEntity = entity?.id; + return fromParam ?? (typeof fromEntity === 'string' ? fromEntity : 'unknown'); +} + +function resolveScope(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record | undefined) { + return compact({ + productId: scopedValue(metadata.productIdParam, metadata.productIdBody, 'productId', request, entity), + projectId: scopedValue(metadata.projectIdParam, metadata.projectIdBody, 'projectId', request, entity), + versionId: scopedValue(metadata.versionIdParam, metadata.versionIdBody, 'versionId', request, entity), + }); +} + +function scopedValue( + paramKey: string | undefined, + bodyKey: string | undefined, + resultKey: string, + request: MutationRequest, + entity: Record | undefined, +): string | undefined { + const value = (paramKey ? request.params?.[paramKey] : undefined) + ?? (bodyKey ? request.body?.[bodyKey] : undefined) + ?? entity?.[resultKey]; + return typeof value === 'string' && value ? value : undefined; +} + +function compact>(value: T): { [K in keyof T]?: string } { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string }; +} diff --git a/apps/server/src/common/audit/protected-mutation.decorator.ts b/apps/server/src/common/audit/protected-mutation.decorator.ts new file mode 100644 index 0000000..086566c --- /dev/null +++ b/apps/server/src/common/audit/protected-mutation.decorator.ts @@ -0,0 +1,18 @@ +import { applyDecorators, UseGuards, UseInterceptors } from '@nestjs/common'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission, type PermissionScopeOptions } from '../auth/permission.decorator'; +import { AuditMutation, type AuditMutationMetadata } from './audit-mutation.decorator'; +import { AuditMutationInterceptor } from './audit-mutation.interceptor'; + +export function ProtectedMutation( + permission: string, + scope: PermissionScopeOptions, + audit: AuditMutationMetadata, +) { + return applyDecorators( + UseGuards(PermissionGuard), + RequirePermission(permission, scope), + UseInterceptors(AuditMutationInterceptor), + AuditMutation(audit), + ); +} diff --git a/apps/server/src/common/auth/auth-context.service.ts b/apps/server/src/common/auth/auth-context.service.ts new file mode 100644 index 0000000..861b0b1 --- /dev/null +++ b/apps/server/src/common/auth/auth-context.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface CurrentUser { + id: string; + name?: string; + username?: string; + roleId: string; + email?: string; +} + +export interface AuthenticatedRequest { + headers?: Record; + currentUser?: CurrentUser | null; +} + +@Injectable() +export class AuthContextService { + constructor(private readonly prisma: PrismaService) {} + + async resolveCurrentUser(request: AuthenticatedRequest): Promise { + if (request.currentUser !== undefined) return request.currentUser; + + const userId = headerValue(request, 'x-ftb-user-id') || headerValue(request, 'x-user-id'); + if (!userId) { + request.currentUser = null; + return null; + } + + const headerUser: CurrentUser = { + id: userId, + name: decodeHeader(headerValue(request, 'x-ftb-user-name')), + username: headerValue(request, 'x-ftb-user-username'), + roleId: headerValue(request, 'x-ftb-user-role-id') || '', + email: headerValue(request, 'x-ftb-user-email'), + }; + + if (headerUser.roleId) { + request.currentUser = headerUser; + return headerUser; + } + + const row = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, username: true, roleId: true, email: true }, + }); + request.currentUser = row ? { + id: row.id, + name: row.name, + username: row.username ?? undefined, + roleId: row.roleId || 'member', + email: row.email, + } : null; + return request.currentUser; + } +} + +function headerValue(request: AuthenticatedRequest, name: string): string { + const value = request.headers?.[name] ?? request.headers?.[name.toLowerCase()]; + if (Array.isArray(value)) return value[0] ?? ''; + return value ?? ''; +} + +function decodeHeader(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} diff --git a/apps/server/src/common/auth/auth.module.ts b/apps/server/src/common/auth/auth.module.ts new file mode 100644 index 0000000..346b2ed --- /dev/null +++ b/apps/server/src/common/auth/auth.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common'; +import { AuthContextService } from './auth-context.service'; +import { PermissionGuard } from './permission.guard'; +import { PermissionService } from './permission.service'; + +@Global() +@Module({ + providers: [AuthContextService, PermissionGuard, PermissionService], + exports: [AuthContextService, PermissionGuard, PermissionService], +}) +export class AuthModule {} diff --git a/apps/server/src/common/auth/current-user.decorator.ts b/apps/server/src/common/auth/current-user.decorator.ts new file mode 100644 index 0000000..d54e17b --- /dev/null +++ b/apps/server/src/common/auth/current-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { AuthenticatedRequest, CurrentUser as ResolvedCurrentUser } from './auth-context.service'; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): ResolvedCurrentUser | null => { + const request = ctx.switchToHttp().getRequest(); + return request.currentUser ?? null; + }, +); diff --git a/apps/server/src/common/auth/permission.decorator.ts b/apps/server/src/common/auth/permission.decorator.ts new file mode 100644 index 0000000..9ccf1c0 --- /dev/null +++ b/apps/server/src/common/auth/permission.decorator.ts @@ -0,0 +1,20 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PERMISSION_METADATA_KEY = 'ftb:required-permission'; + +export interface PermissionScopeOptions { + productIdParam?: string; + projectIdParam?: string; + versionIdParam?: string; + productIdBody?: string; + projectIdBody?: string; + versionIdBody?: string; +} + +export interface RequiredPermissionMetadata extends PermissionScopeOptions { + permission: string; +} + +export function RequirePermission(permission: string, scope: PermissionScopeOptions = {}) { + return SetMetadata(PERMISSION_METADATA_KEY, { permission, ...scope }); +} diff --git a/apps/server/src/common/auth/permission.guard.spec.ts b/apps/server/src/common/auth/permission.guard.spec.ts new file mode 100644 index 0000000..59aca73 --- /dev/null +++ b/apps/server/src/common/auth/permission.guard.spec.ts @@ -0,0 +1,74 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthContextService } from './auth-context.service'; +import { PermissionGuard } from './permission.guard'; +import { RequirePermission } from './permission.decorator'; + +describe('PermissionGuard', () => { + it('rejects protected routes when no current user can be resolved', async () => { + const guard = new PermissionGuard( + new Reflector(), + { resolveCurrentUser: jest.fn().mockResolvedValue(null) } as unknown as AuthContextService, + { can: jest.fn() } as any, + ); + const handler = decorate(() => undefined, 'product:create'); + + await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects users without the required permission', async () => { + const guard = new PermissionGuard( + new Reflector(), + { resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any, + { can: jest.fn().mockResolvedValue(false) } as any, + ); + const handler = decorate(() => undefined, 'product:delete'); + + await expect(guard.canActivate(contextFor(handler))).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('passes params-derived resource scope to the permission service', async () => { + const can = jest.fn().mockResolvedValue(true); + const guard = new PermissionGuard( + new Reflector(), + { resolveCurrentUser: jest.fn().mockResolvedValue({ id: 'user-1', roleId: 'role-dev' }) } as any, + { can } as any, + ); + const handler = decorate(() => undefined, 'version.devtask:manage', { + versionIdParam: 'versionId', + projectIdParam: 'projectId', + productIdParam: 'productId', + }); + + await expect(guard.canActivate(contextFor(handler, { + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + }))).resolves.toBe(true); + + expect(can).toHaveBeenCalledWith( + { id: 'user-1', roleId: 'role-dev' }, + 'version.devtask:manage', + { productId: 'product-1', projectId: 'project-1', versionId: 'version-1' }, + ); + }); +}); + +function decorate(handler: Function, permission: string, scope?: { + productIdParam?: string; + projectIdParam?: string; + versionIdParam?: string; +}) { + RequirePermission(permission, scope)(handler as any, undefined as any, undefined as any); + return handler; +} + +function contextFor(handler: Function, params: Record = {}) { + return { + getHandler: () => handler, + getClass: () => class TestController {}, + switchToHttp: () => ({ + getRequest: () => ({ params, headers: {} }), + }), + } as any; +} diff --git a/apps/server/src/common/auth/permission.guard.ts b/apps/server/src/common/auth/permission.guard.ts new file mode 100644 index 0000000..657ce51 --- /dev/null +++ b/apps/server/src/common/auth/permission.guard.ts @@ -0,0 +1,46 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { AuthContextService, type AuthenticatedRequest } from './auth-context.service'; +import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from './permission.decorator'; +import { PermissionService } from './permission.service'; + +@Injectable() +export class PermissionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly authContext: AuthContextService, + private readonly permissions: PermissionService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const metadata = this.reflector.getAllAndOverride(PERMISSION_METADATA_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!metadata) return true; + + const request = context.switchToHttp().getRequest; + body?: Record; + }>(); + const user = await this.authContext.resolveCurrentUser(request); + if (!user) throw new UnauthorizedException('Authentication required'); + + const allowed = await this.permissions.can(user, metadata.permission, { + productId: scopedValue(request, metadata.productIdParam, metadata.productIdBody), + projectId: scopedValue(request, metadata.projectIdParam, metadata.projectIdBody), + versionId: scopedValue(request, metadata.versionIdParam, metadata.versionIdBody), + }); + if (!allowed) throw new ForbiddenException(`Missing permission: ${metadata.permission}`); + return true; + } +} + +function scopedValue( + request: { params?: Record; body?: Record }, + paramKey?: string, + bodyKey?: string, +): string | undefined { + const value = (paramKey ? request.params?.[paramKey] : undefined) ?? (bodyKey ? request.body?.[bodyKey] : undefined); + return typeof value === 'string' && value ? value : undefined; +} diff --git a/apps/server/src/common/auth/permission.service.spec.ts b/apps/server/src/common/auth/permission.service.spec.ts new file mode 100644 index 0000000..95d070c --- /dev/null +++ b/apps/server/src/common/auth/permission.service.spec.ts @@ -0,0 +1,92 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { PermissionService } from './permission.service'; +import type { CurrentUser } from './auth-context.service'; + +describe('PermissionService', () => { + const versionFindUnique = jest.fn(); + const projectMemberFindUnique = jest.fn(); + + const prisma = { + version: { findUnique: versionFindUnique }, + projectMember: { findUnique: projectMemberFindUnique }, + } as any; + + const service = new PermissionService(prisma); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('allows the built-in super admin wildcard for any permission without a scope lookup', async () => { + const user = currentUser({ id: 'm-8', roleId: 'role-admin' }); + + await expect(service.can(user, 'audit:view', { versionId: 'version-1' })).resolves.toBe(true); + + expect(versionFindUnique).not.toHaveBeenCalled(); + expect(projectMemberFindUnique).not.toHaveBeenCalled(); + }); + + it('requires scoped users to belong to the project resolved from a version', async () => { + versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] }); + projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' }); + + await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', { + versionId: 'version-1', + })).resolves.toBe(true); + + expect(versionFindUnique).toHaveBeenCalledWith({ + where: { id: 'version-1' }, + select: { id: true, projectId: true, members: true }, + }); + expect(projectMemberFindUnique).toHaveBeenCalledWith({ + where: { projectId_userId: { projectId: 'project-1', userId: 'dev-1' } }, + }); + }); + + it('allows a version member when no project membership exists', async () => { + versionFindUnique.mockResolvedValue({ + id: 'version-1', + projectId: 'project-1', + members: [{ id: 'tester-1', name: 'Tester One' }], + }); + projectMemberFindUnique.mockResolvedValue(null); + + await expect(service.can(currentUser({ id: 'tester-1', name: 'Tester One', roleId: 'role-test' }), 'version.testcase:manage', { + versionId: 'version-1', + })).resolves.toBe(true); + }); + + it('allows a project member to write version-scoped work activity evidence', async () => { + versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] }); + projectMemberFindUnique.mockResolvedValue({ projectId: 'project-1', userId: 'dev-1', role: 'member' }); + + await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'work-activity:manage', { + versionId: 'version-1', + })).resolves.toBe(true); + }); + + it('denies users who have a global role permission but are outside the resource scope', async () => { + versionFindUnique.mockResolvedValue({ id: 'version-1', projectId: 'project-1', members: [] }); + projectMemberFindUnique.mockResolvedValue(null); + + await expect(service.can(currentUser({ id: 'dev-1', roleId: 'role-dev' }), 'version.devtask:manage', { + versionId: 'version-1', + })).resolves.toBe(false); + }); + + it('throws explicit auth exceptions for assertion callers', async () => { + await expect(service.assertCan(null, 'product:create')).rejects.toBeInstanceOf(UnauthorizedException); + await expect(service.assertCan(currentUser({ roleId: 'role-dev' }), 'product:delete')).rejects.toBeInstanceOf(ForbiddenException); + }); +}); + +function currentUser(overrides: Partial): CurrentUser { + return { + id: 'user-1', + name: 'User One', + username: 'user.one', + roleId: 'role-dev', + email: '', + ...overrides, + }; +} diff --git a/apps/server/src/common/auth/permission.service.ts b/apps/server/src/common/auth/permission.service.ts new file mode 100644 index 0000000..ee0376a --- /dev/null +++ b/apps/server/src/common/auth/permission.service.ts @@ -0,0 +1,167 @@ +import { ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { CurrentUser } from './auth-context.service'; + +export interface ResourceScope { + productId?: string; + projectId?: string; + versionId?: string; +} + +interface ResolvedScope extends ResourceScope { + versionMembers?: unknown; +} + +const VIEW_ONLY = ['product:view', 'project:view', 'version:view', 'requirement:view', 'version.req:view']; + +const SYSTEM_ROLE_PERMISSIONS: Record = { + 'role-admin': ['*'], + 'role-pm': [ + ...std4('product'), + ...std4('project'), + ...std4('version'), + ...std4('requirement'), + 'version.req:view', 'version.req:manage', + 'version.product_plan:view', 'version.product_plan:manage', + 'xiaobao.warning:view', 'xiaobao.warning:manage', + 'overtime:view', 'member:view', 'role:view', + 'version.research:view', 'version.ui_plan:view', 'version.devtask:view', + 'version.testcase:view', 'version.bug:view', + 'work-activity:manage', + ], + 'role-dev': [ + ...VIEW_ONLY, + 'version.devtask:view', 'version.devtask:manage', + 'version.bug:view', 'version.bug:edit', + 'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.testcase:view', + 'xiaobao.warning:view', + 'overtime:view', 'overtime:create', + 'work-activity:manage', + ], + 'role-test': [ + ...VIEW_ONLY, + 'version.testcase:view', 'version.testcase:manage', + 'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete', + 'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', 'version.devtask:view', + 'xiaobao.warning:view', + 'overtime:view', 'overtime:create', + 'work-activity:manage', + ], + 'role-design': [ + ...VIEW_ONLY, + 'version.ui_plan:view', 'version.ui_plan:manage', + 'version.research:view', 'version.product_plan:view', 'version.devtask:view', + 'version.testcase:view', 'version.bug:view', + 'xiaobao.warning:view', + 'overtime:view', 'overtime:create', + 'work-activity:manage', + ], + 'role-lead': [ + ...VIEW_ONLY, + 'version.research:view', 'version.product_plan:view', 'version.ui_plan:view', + 'version.devtask:view', 'version.devtask:manage', + 'version.testcase:view', 'version.testcase:manage', + 'version.bug:view', 'version.bug:create', 'version.bug:edit', 'version.bug:delete', + 'xiaobao.warning:view', + 'overtime:view', 'overtime:create', 'overtime:export', + 'work-activity:manage', + ], +}; + +const PROJECT_ROLE_PERMISSIONS: Record = { + owner: ['*'], + admin: ['*'], + member: [ + 'project:view', + 'version:view', + 'requirement:view', + 'version.req:view', + 'version.research:view', 'version.research:manage', + 'version.product_plan:view', 'version.product_plan:manage', + 'version.ui_plan:view', 'version.ui_plan:manage', + 'version.devtask:view', 'version.devtask:manage', + 'version.testcase:view', 'version.testcase:manage', + 'version.bug:view', 'version.bug:create', 'version.bug:edit', + 'overtime:view', 'overtime:create', + 'xiaobao.warning:view', + 'work-activity:manage', + ], + viewer: [ + 'project:view', + 'version:view', + 'requirement:view', + 'version.req:view', + 'version.research:view', + 'version.product_plan:view', + 'version.ui_plan:view', + 'version.devtask:view', + 'version.testcase:view', + 'version.bug:view', + 'xiaobao.warning:view', + ], +}; + +@Injectable() +export class PermissionService { + constructor(private readonly prisma: PrismaService) {} + + async can(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise { + if (!user) return false; + const systemPermissions = SYSTEM_ROLE_PERMISSIONS[user.roleId] ?? []; + if (systemPermissions.includes('*')) return true; + + const hasSystemPermission = systemPermissions.includes(permission); + const hasScopedResource = Boolean(scope.projectId || scope.versionId); + if (!hasScopedResource) return hasSystemPermission; + + const resolvedScope = await this.resolveScope(scope); + const projectMember = resolvedScope.projectId ? await this.prisma.projectMember.findUnique({ + where: { projectId_userId: { projectId: resolvedScope.projectId, userId: user.id } }, + }) : null; + + if (projectMember && roleAllows(PROJECT_ROLE_PERMISSIONS[projectMember.role] ?? [], permission)) { + return true; + } + + if (hasSystemPermission && isVersionMember(user, resolvedScope.versionMembers)) return true; + return false; + } + + async assertCan(user: CurrentUser | null | undefined, permission: string, scope: ResourceScope = {}): Promise { + if (!user) throw new UnauthorizedException('Authentication required'); + if (!(await this.can(user, permission, scope))) { + throw new ForbiddenException(`Missing permission: ${permission}`); + } + } + + private async resolveScope(scope: ResourceScope): Promise { + if (!scope.versionId || scope.projectId) return scope; + const version = await this.prisma.version.findUnique({ + where: { id: scope.versionId }, + select: { id: true, projectId: true, members: true }, + }); + return { + ...scope, + projectId: version?.projectId ?? scope.projectId, + versionMembers: version?.members, + }; + } +} + +function std4(module: string): string[] { + return [`${module}:view`, `${module}:create`, `${module}:edit`, `${module}:delete`]; +} + +function roleAllows(permissions: string[], permission: string): boolean { + return permissions.includes('*') || permissions.includes(permission); +} + +function isVersionMember(user: CurrentUser, members: unknown): boolean { + if (!Array.isArray(members)) return false; + return members.some((member) => { + if (typeof member === 'string') return member === user.id || member === user.name; + if (!member || typeof member !== 'object') return false; + const record = member as { id?: unknown; userId?: unknown; name?: unknown }; + return record.id === user.id || record.userId === user.id || record.name === user.name; + }); +} diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.module.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.module.ts new file mode 100644 index 0000000..1881a1b --- /dev/null +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AppDataRetirementService } from './app-data-retirement.service'; + +@Module({ + providers: [AppDataRetirementService], + exports: [AppDataRetirementService], +}) +export class AppDataRetirementModule {} diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts new file mode 100644 index 0000000..328a88d --- /dev/null +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.spec.ts @@ -0,0 +1,41 @@ +import { ConflictException } from '@nestjs/common'; +import { APP_DATA_KEYS } from '../data/data-keys'; +import { + APP_DATA_RETIREMENT_CONFIG, + AppDataRetirementService, +} from './app-data-retirement.service'; + +describe('AppDataRetirementService', () => { + const service = new AppDataRetirementService(); + + it('declares a retirement state for every allowed AppData key', () => { + expect(Object.keys(APP_DATA_RETIREMENT_CONFIG).sort()).toEqual([...APP_DATA_KEYS].sort()); + }); + + it('rejects writes to frozen business documents with replacement guidance', () => { + expect(() => service.assertWritable('dev-tasks')).toThrow(ConflictException); + + try { + service.assertWritable('dev-tasks'); + } catch (error: any) { + expect(error.getResponse()).toMatchObject({ + code: 'APP_DATA_WRITE_FROZEN', + key: 'dev-tasks', + state: 'write_frozen', + replacement: '/api/v1/versions/:versionId/dev-tasks', + }); + } + }); + + it('treats archived documents as read-only', () => { + try { + service.assertWritable('xiaobao-risk-snapshots'); + } catch (error: any) { + expect(error.getResponse()).toMatchObject({ + code: 'APP_DATA_WRITE_FROZEN', + key: 'xiaobao-risk-snapshots', + state: 'read_only_archive', + }); + } + }); +}); diff --git a/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts new file mode 100644 index 0000000..02e19be --- /dev/null +++ b/apps/server/src/modules/app-data-retirement/app-data-retirement.service.ts @@ -0,0 +1,95 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import type { AppDataKey } from '../data/data-keys'; + +export type AppDataRetirementState = 'active' | 'write_frozen' | 'read_only_archive'; + +export interface AppDataRetirementEntry { + state: AppDataRetirementState; + replacement: string; + note?: string; +} + +export const APP_DATA_RETIREMENT_CONFIG = { + 'products-overview': { + state: 'write_frozen', + replacement: '/api/v1/products, /api/v1/products/:productId/projects, /api/v1/products/:productId/versions', + }, + requirements: { + state: 'write_frozen', + replacement: '/api/v1/products/:productId/requirements', + }, + 'version-plans': { + state: 'write_frozen', + replacement: '/api/v1/versions/:versionId/plans', + }, + 'dev-tasks': { + state: 'write_frozen', + replacement: '/api/v1/versions/:versionId/dev-tasks', + }, + 'test-cases': { + state: 'write_frozen', + replacement: '/api/v1/versions/:versionId/test-cases', + }, + bugs: { + state: 'write_frozen', + replacement: '/api/v1/versions/:versionId/bugs', + }, + members: { + state: 'write_frozen', + replacement: '/api/v1/members', + note: '成员身份已迁移;部门、角色和密码策略仍需 V2.7 配置表承接。', + }, + 'task-categories': { + state: 'write_frozen', + replacement: '/api/v1/task-categories', + }, + 'task-worklogs': { + state: 'write_frozen', + replacement: '/api/v1/task-worklogs', + }, + 'work-activities': { + state: 'write_frozen', + replacement: '/api/v1/work-activities', + }, + 'xiaobao-risk-insights': { + state: 'read_only_archive', + replacement: 'V2.6 Xiaobao relation writer backed by xiaobao_risk_insights', + note: '风险解读缓存不再扩大 AppData 主写路径,后台化由 V2.6 承接。', + }, + 'xiaobao-risk-snapshots': { + state: 'read_only_archive', + replacement: 'V2.6 Xiaobao relation writer backed by xiaobao_risk_snapshots', + note: '风险快照不再扩大 AppData 主写路径,后台化由 V2.6 承接。', + }, + 'xiaobao-warning-views': { + state: 'read_only_archive', + replacement: 'V2.7 per-user warning read-state API', + note: '个人已读状态等待企业协作/通知治理阶段承接。', + }, + overtime: { + state: 'write_frozen', + replacement: '/api/v1/overtime', + note: '加班记录已迁移;加班原因配置仍需 V2.7 配置表承接。', + }, +} satisfies Record; + +@Injectable() +export class AppDataRetirementService { + getEntry(key: AppDataKey): AppDataRetirementEntry { + return APP_DATA_RETIREMENT_CONFIG[key]; + } + + assertWritable(key: AppDataKey) { + const entry = this.getEntry(key); + if (entry.state === 'active') return; + + throw new ConflictException({ + code: 'APP_DATA_WRITE_FROZEN', + message: `AppData key "${key}" is ${entry.state}; use ${entry.replacement} instead.`, + key, + state: entry.state, + replacement: entry.replacement, + note: entry.note, + }); + } +} diff --git a/apps/server/src/modules/audit/audit.controller.spec.ts b/apps/server/src/modules/audit/audit.controller.spec.ts new file mode 100644 index 0000000..c0867fd --- /dev/null +++ b/apps/server/src/modules/audit/audit.controller.spec.ts @@ -0,0 +1,20 @@ +import { Reflector } from '@nestjs/core'; +import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator'; +import { AuditController } from './audit.controller'; + +describe('AuditController', () => { + it('requires audit:view for audit queries', () => { + const metadata = new Reflector().get(PERMISSION_METADATA_KEY, AuditController.prototype.findAll); + + expect(metadata).toEqual({ permission: 'audit:view' }); + }); + + it('delegates list query parameters to the audit service', async () => { + const service = { query: jest.fn().mockResolvedValue([{ id: 'audit-1' }]) }; + const controller = new AuditController(service as any); + + await expect(controller.findAll({ actorId: 'm-8' })).resolves.toEqual([{ id: 'audit-1' }]); + + expect(service.query).toHaveBeenCalledWith({ actorId: 'm-8' }); + }); +}); diff --git a/apps/server/src/modules/audit/audit.controller.ts b/apps/server/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..d8e2b44 --- /dev/null +++ b/apps/server/src/modules/audit/audit.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { PermissionGuard } from '../../common/auth/permission.guard'; +import { RequirePermission } from '../../common/auth/permission.decorator'; +import { AuditService } from './audit.service'; +import { QueryAuditEventsDto } from './dto/query-audit-events.dto'; + +@Controller('audit') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get() + @UseGuards(PermissionGuard) + @RequirePermission('audit:view') + findAll(@Query() query: QueryAuditEventsDto) { + return this.auditService.query(query); + } +} diff --git a/apps/server/src/modules/audit/audit.module.ts b/apps/server/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..3521853 --- /dev/null +++ b/apps/server/src/modules/audit/audit.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { AuditMutationInterceptor } from '../../common/audit/audit-mutation.interceptor'; +import { AuditController } from './audit.controller'; +import { AuditService } from './audit.service'; + +@Global() +@Module({ + controllers: [AuditController], + providers: [AuditService, AuditMutationInterceptor], + exports: [AuditService, AuditMutationInterceptor], +}) +export class AuditModule {} diff --git a/apps/server/src/modules/audit/audit.service.spec.ts b/apps/server/src/modules/audit/audit.service.spec.ts new file mode 100644 index 0000000..8e5e1a1 --- /dev/null +++ b/apps/server/src/modules/audit/audit.service.spec.ts @@ -0,0 +1,74 @@ +import { AuditService } from './audit.service'; + +describe('AuditService', () => { + const create = jest.fn(); + const findMany = jest.fn(); + const prisma = { auditEvent: { create, findMany } } as any; + const service = new AuditService(prisma); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('writes append-only audit events with sensitive fields redacted', async () => { + create.mockResolvedValue({ id: 'audit-1' }); + + await service.record({ + actor: { id: 'm-8', name: '超级管理员', roleId: 'role-admin' }, + action: 'product.update', + entityType: 'product', + entityId: 'product-1', + productId: 'product-1', + before: { name: 'Old', password: '123456' }, + after: { name: 'New', nested: { apiKey: 'sk-test', keep: 'visible' } }, + metadata: { authorization: 'Bearer token', reason: 'manual edit' }, + }); + + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + actorId: 'm-8', + actorName: '超级管理员', + action: 'product.update', + entityType: 'product', + entityId: 'product-1', + productId: 'product-1', + before: { name: 'Old', password: '[REDACTED]' }, + after: { name: 'New', nested: { apiKey: '[REDACTED]', keep: 'visible' } }, + metadata: { authorization: '[REDACTED]', reason: 'manual edit' }, + }), + }); + }); + + it('queries by actor, entity, scope, and date range with bounded page size', async () => { + findMany.mockResolvedValue([]); + + await service.query({ + actorId: 'm-8', + entityType: 'bug', + entityId: 'bug-1', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + dateFrom: '2026-07-01T00:00:00.000Z', + dateTo: '2026-07-08T23:59:59.000Z', + take: '500', + }); + + expect(findMany).toHaveBeenCalledWith({ + where: { + actorId: 'm-8', + entityType: 'bug', + entityId: 'bug-1', + productId: 'product-1', + projectId: 'project-1', + versionId: 'version-1', + createdAt: { + gte: new Date('2026-07-01T00:00:00.000Z'), + lte: new Date('2026-07-08T23:59:59.000Z'), + }, + }, + orderBy: { createdAt: 'desc' }, + take: 100, + }); + }); +}); diff --git a/apps/server/src/modules/audit/audit.service.ts b/apps/server/src/modules/audit/audit.service.ts new file mode 100644 index 0000000..a29705a --- /dev/null +++ b/apps/server/src/modules/audit/audit.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import type { CurrentUser } from '../../common/auth/auth-context.service'; +import type { QueryAuditEventsDto } from './dto/query-audit-events.dto'; + +export interface AuditRecordInput { + actor?: CurrentUser | null; + action: string; + entityType: string; + entityId: string; + productId?: string | null; + projectId?: string | null; + versionId?: string | null; + scope?: unknown; + before?: unknown; + after?: unknown; + metadata?: unknown; +} + +@Injectable() +export class AuditService { + constructor(private readonly prisma: PrismaService) {} + + record(input: AuditRecordInput) { + return this.prisma.auditEvent.create({ + data: { + actorId: input.actor?.id ?? null, + actorName: input.actor?.name ?? input.actor?.username ?? '', + action: input.action, + entityType: input.entityType, + entityId: input.entityId, + productId: input.productId ?? null, + projectId: input.projectId ?? null, + versionId: input.versionId ?? null, + scope: toJson(input.scope ?? {}), + before: input.before === undefined ? undefined : toJson(redactSensitive(input.before)), + after: input.after === undefined ? undefined : toJson(redactSensitive(input.after)), + metadata: toJson(redactSensitive(input.metadata ?? {})), + }, + }); + } + + query(query: QueryAuditEventsDto) { + return this.prisma.auditEvent.findMany({ + where: buildWhere(query), + orderBy: { createdAt: 'desc' }, + take: clampTake(query.take), + }); + } +} + +function buildWhere(query: QueryAuditEventsDto) { + const where: Record = {}; + for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) { + if (query[key]) where[key] = query[key]; + } + + const dateRange: Record = {}; + if (query.dateFrom) dateRange.gte = new Date(query.dateFrom); + if (query.dateTo) dateRange.lte = new Date(query.dateTo); + if (Object.keys(dateRange).length > 0) where.createdAt = dateRange; + return where; +} + +function clampTake(value: string | undefined): number { + const parsed = Number(value ?? 50); + if (!Number.isFinite(parsed) || parsed <= 0) return 50; + return Math.min(100, Math.floor(parsed)); +} + +function redactSensitive(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => redactSensitive(item)); + if (value instanceof Date) return value.toISOString(); + if (!value || typeof value !== 'object') return value; + + return Object.fromEntries(Object.entries(value as Record).map(([key, item]) => [ + key, + isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(item), + ])); +} + +function isSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); + return ['password', 'token', 'secret', 'apikey', 'authorization'].some((sensitive) => normalized.includes(sensitive)); +} + +function toJson(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} diff --git a/apps/server/src/modules/audit/dto/query-audit-events.dto.ts b/apps/server/src/modules/audit/dto/query-audit-events.dto.ts new file mode 100644 index 0000000..b7d1630 --- /dev/null +++ b/apps/server/src/modules/audit/dto/query-audit-events.dto.ts @@ -0,0 +1,39 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class QueryAuditEventsDto { + @IsOptional() + @IsString() + actorId?: string; + + @IsOptional() + @IsString() + entityType?: string; + + @IsOptional() + @IsString() + entityId?: string; + + @IsOptional() + @IsString() + productId?: string; + + @IsOptional() + @IsString() + projectId?: string; + + @IsOptional() + @IsString() + versionId?: string; + + @IsOptional() + @IsString() + dateFrom?: string; + + @IsOptional() + @IsString() + dateTo?: string; + + @IsOptional() + @IsString() + take?: string; +} diff --git a/apps/server/src/modules/bug/bug.controller.ts b/apps/server/src/modules/bug/bug.controller.ts new file mode 100644 index 0000000..12a731a --- /dev/null +++ b/apps/server/src/modules/bug/bug.controller.ts @@ -0,0 +1,79 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateBugDto } from './dto/create-bug.dto'; +import { UpdateBugDto } from './dto/update-bug.dto'; +import { BugService } from './bug.service'; + +@Controller('versions/:versionId/bugs') +export class BugController { + constructor(private readonly bugService: BugService) {} + + @Post() + @ProtectedMutation('version.bug:create', { versionIdParam: 'versionId' }, { + action: 'bug.create', + entityType: 'bug', + versionIdParam: 'versionId', + }) + create(@Param('versionId') versionId: string, @Body() dto: CreateBugDto) { + return this.bugService.create(versionId, dto); + } + + @Get() + findAll(@Param('versionId') versionId: string) { + return this.bugService.findAll(versionId); + } + + @Patch(':id') + @ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, { + action: 'bug.update', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateBugDto) { + return this.bugService.update(versionId, id, dto); + } + + @Patch(':id/status') + @ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, { + action: 'bug.status', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + updateStatus( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('status') status: string, + @Body() body: { operator?: string; resolution?: string }, + ) { + return this.bugService.updateStatus(versionId, id, status, body); + } + + @Patch(':id/transfer') + @ProtectedMutation('version.bug:edit', { versionIdParam: 'versionId' }, { + action: 'bug.transfer', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + transfer( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('assigneeId') assigneeId: string, + @Body('operator') operator?: string, + ) { + return this.bugService.transfer(versionId, id, assigneeId, operator); + } + + @Delete(':id') + @ProtectedMutation('version.bug:delete', { versionIdParam: 'versionId' }, { + action: 'bug.delete', + entityType: 'bug', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + remove(@Param('versionId') versionId: string, @Param('id') id: string) { + return this.bugService.remove(versionId, id); + } +} diff --git a/apps/server/src/modules/bug/bug.module.ts b/apps/server/src/modules/bug/bug.module.ts new file mode 100644 index 0000000..e9843c6 --- /dev/null +++ b/apps/server/src/modules/bug/bug.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { BugController } from './bug.controller'; +import { BugService } from './bug.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [BugController], + providers: [BugService], + exports: [BugService], +}) +export class BugModule {} diff --git a/apps/server/src/modules/bug/bug.service.spec.ts b/apps/server/src/modules/bug/bug.service.spec.ts new file mode 100644 index 0000000..682afe2 --- /dev/null +++ b/apps/server/src/modules/bug/bug.service.spec.ts @@ -0,0 +1,153 @@ +import { NotFoundException } from '@nestjs/common'; +import { BugService } from './bug.service'; + +describe('BugService domain writes', () => { + const makeService = () => { + const workActivity = { + record: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }; + const prisma = { + version: { + findUnique: jest.fn(), + }, + bug: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + return { + prisma, + workActivity, + service: new BugService(prisma as any, workActivity as any), + }; + }; + + it('creates bugs directly under a version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.bug.create.mockResolvedValue({ id: 'bug-1', versionId: 'version-1', title: '登录报错' }); + + await service.create('version-1', { + bugNo: 'BUG-001', + testCaseId: 'tc-1', + title: '登录报错', + description: '登录按钮无响应', + severity: 'critical', + priority: 'P0', + assigneeId: 'dev-1', + reportedBy: 'tester-1', + plannedFixAt: '2026-07-08T18:00:00.000Z', + } as any); + + expect(prisma.bug.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + testCaseId: 'tc-1', + testCaseVersionId: 'version-1', + code: 'BUG-001', + title: '登录报错', + severity: 'critical', + priority: 0, + assigneeId: 'dev-1', + reporterId: 'tester-1', + plannedFixAt: new Date('2026-07-08T18:00:00.000Z'), + status: 'open', + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'bug', + sourceId: 'bug-1', + action: 'bug_created', + })); + }); + + it('changes status and records closing evidence inside the version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.bug.findFirst.mockResolvedValue({ + id: 'bug-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'verifying', + title: '登录报错', + assigneeId: 'dev-1', + reporterId: 'tester-1', + }); + prisma.bug.update.mockResolvedValue({ + id: 'bug-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'closed', + title: '登录报错', + assigneeId: 'dev-1', + reporterId: 'tester-1', + }); + + await service.updateStatus('version-1', 'bug-1', 'closed', { operator: 'tester-1', resolution: '复测通过' }); + + expect(prisma.bug.update).toHaveBeenCalledWith({ + where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } }, + data: expect.objectContaining({ + status: 'closed', + closedAt: expect.any(Date), + resolution: '复测通过', + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'bug', + sourceId: 'bug-1', + action: 'bug_closed', + })); + }); + + it('transfers bugs by id plus version id and records activity evidence', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.bug.findFirst.mockResolvedValue({ + id: 'bug-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'open', + title: '登录报错', + assigneeId: 'dev-1', + reporterId: 'tester-1', + }); + prisma.bug.update.mockResolvedValue({ + id: 'bug-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + title: '登录报错', + assigneeId: 'dev-2', + reporterId: 'tester-1', + }); + + await service.transfer('version-1', 'bug-1', 'dev-2', 'tester-1'); + + expect(prisma.bug.update).toHaveBeenCalledWith({ + where: { id_versionId: { id: 'bug-1', versionId: 'version-1' } }, + data: { assigneeId: 'dev-2' }, + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + action: 'bug_transferred', + metadata: expect.objectContaining({ fromAssigneeId: 'dev-1', toAssigneeId: 'dev-2' }), + })); + }); + + it('rejects updates outside the version partition', async () => { + const { prisma, service } = makeService(); + prisma.bug.findFirst.mockResolvedValue(null); + + await expect(service.update('version-1', 'missing-bug', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.bug.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/bug/bug.service.ts b/apps/server/src/modules/bug/bug.service.ts new file mode 100644 index 0000000..bde049c --- /dev/null +++ b/apps/server/src/modules/bug/bug.service.ts @@ -0,0 +1,176 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateBugDto } from './dto/create-bug.dto'; +import { UpdateBugDto } from './dto/update-bug.dto'; + +@Injectable() +export class BugService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity: WorkActivityService, + ) {} + + async create(versionId: string, dto: CreateBugDto) { + const version = await this.ensureVersion(versionId); + const item = await this.prisma.bug.create({ + data: { + ...this.toBugData(dto), + versionId, + productId: version.productId, + projectId: version.projectId, + testCaseVersionId: dto.testCaseVersionId ?? (dto.testCaseId ? versionId : null), + code: dto.code?.trim() || dto.bugNo?.trim() || createFallbackCode('BUG'), + title: dto.title, + status: dto.status ?? 'open', + }, + }); + const activity = await this.recordBugActivity(item, 'bug_created', 'creation', `新建 Bug:${item.title}`, { + operator: dto.reporterId ?? dto.reportedBy, + }); + return { item, activities: [activity] }; + } + + findAll(versionId: string) { + return this.prisma.bug.findMany({ + where: { versionId }, + orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }], + }); + } + + async update(versionId: string, id: string, dto: UpdateBugDto) { + await this.ensureBugInVersion(versionId, id); + const item = await this.prisma.bug.update({ + where: { id_versionId: { id, versionId } }, + data: this.toBugData(dto), + }); + return { item, activities: [] }; + } + + async updateStatus( + versionId: string, + id: string, + status: string, + options: { operator?: string; resolution?: string } = {}, + ) { + const current = await this.ensureBugInVersion(versionId, id); + const data: Record = { status }; + if (status === 'fixed' && !current.resolvedAt) data.resolvedAt = new Date(); + if (status === 'closed' && !current.closedAt) data.closedAt = new Date(); + if (options.resolution?.trim()) data.resolution = options.resolution.trim(); + const item = await this.prisma.bug.update({ + where: { id_versionId: { id, versionId } }, + data, + }); + const activity = await this.recordStatusActivity(item, current.status, status, options.operator); + return { item, activities: activity ? [activity] : [] }; + } + + async transfer(versionId: string, id: string, assigneeId: string, operator?: string) { + const current = await this.ensureBugInVersion(versionId, id); + const item = await this.prisma.bug.update({ + where: { id_versionId: { id, versionId } }, + data: { assigneeId }, + }); + const activity = await this.recordBugActivity( + item, + 'bug_transferred', + 'progress', + `转交 Bug:${item.title} → ${assigneeId}`, + { fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId, operator }, + ); + return { item, activities: [activity] }; + } + + async remove(versionId: string, id: string) { + await this.ensureBugInVersion(versionId, id); + return this.prisma.bug.delete({ where: { id_versionId: { id, versionId } } }); + } + + private toBugData(dto: Partial) { + return { + ...(dto.testCaseId !== undefined && { testCaseId: emptyToNull(dto.testCaseId) }), + ...(dto.testCaseVersionId !== undefined && { testCaseVersionId: emptyToNull(dto.testCaseVersionId) }), + ...(dto.code !== undefined || dto.bugNo !== undefined ? { code: dto.code?.trim() || dto.bugNo?.trim() } : {}), + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description ?? '' }), + ...(dto.status !== undefined && { status: dto.status }), + ...(dto.severity !== undefined && { severity: dto.severity ?? 'minor' }), + ...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }), + ...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }), + ...(dto.reporterId !== undefined || dto.reportedBy !== undefined ? { reporterId: emptyToNull(dto.reporterId ?? dto.reportedBy) } : {}), + ...(dto.plannedFixAt !== undefined && { plannedFixAt: parseOptionalDate(dto.plannedFixAt) }), + ...(dto.resolvedAt !== undefined && { resolvedAt: parseOptionalDate(dto.resolvedAt) }), + ...(dto.closedAt !== undefined && { closedAt: parseOptionalDate(dto.closedAt) }), + ...(dto.resolution !== undefined && { resolution: emptyToNull(dto.resolution) }), + }; + } + + private async ensureVersion(versionId: string) { + const version = await this.prisma.version.findUnique({ where: { id: versionId } }); + if (!version) throw new NotFoundException('版本不存在'); + if (!version.projectId) throw new BadRequestException('Bug 所属版本必须归属于项目'); + return { ...version, projectId: version.projectId }; + } + + private async ensureBugInVersion(versionId: string, id: string) { + const bug = await this.prisma.bug.findFirst({ where: { id, versionId } }); + if (!bug) throw new NotFoundException('Bug 不存在'); + return bug; + } + + private recordStatusActivity(bug: any, fromStatus: string, toStatus: string, operator?: string) { + if (toStatus === 'fixing') { + return this.recordBugActivity(bug, 'bug_fixing', 'progress', `开始修复 Bug:${bug.title}`, { fromStatus, toStatus, operator }); + } + if (toStatus === 'fixed') { + return this.recordBugActivity(bug, 'bug_fixed', 'delivery', `已修复 Bug:${bug.title}`, { fromStatus, toStatus, operator }); + } + if (toStatus === 'closed') { + return this.recordBugActivity(bug, 'bug_closed', 'delivery', `已关闭 Bug:${bug.title}`, { fromStatus, toStatus, operator }); + } + return undefined; + } + + private recordBugActivity(bug: any, action: string, category: string, summary: string, metadata: Record = {}) { + return this.workActivity.record({ + versionId: bug.versionId, + productId: bug.productId, + projectId: bug.projectId, + actorId: metadata.operator as string | undefined ?? bug.assigneeId ?? bug.reporterId, + sourceType: 'bug', + sourceId: bug.id, + action, + category, + title: bug.title, + summary, + metadata, + }); + } +} + +function createFallbackCode(prefix: string) { + return `${prefix}-${Date.now().toString(36).toUpperCase()}`; +} + +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 parseOptionalDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function parsePriority(value: string | number | null | undefined): number | undefined { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined; + 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))) : undefined; +} diff --git a/apps/server/src/modules/bug/dto/create-bug.dto.ts b/apps/server/src/modules/bug/dto/create-bug.dto.ts new file mode 100644 index 0000000..bd28431 --- /dev/null +++ b/apps/server/src/modules/bug/dto/create-bug.dto.ts @@ -0,0 +1,65 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateBugDto { + @IsString() + @IsOptional() + testCaseId?: string; + + @IsString() + @IsOptional() + testCaseVersionId?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + bugNo?: string; + + @IsString() + title!: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsString() + @IsOptional() + severity?: string; + + @IsOptional() + priority?: string | number; + + @IsString() + @IsOptional() + assigneeId?: string; + + @IsString() + @IsOptional() + reporterId?: string; + + @IsString() + @IsOptional() + reportedBy?: string; + + @IsString() + @IsOptional() + plannedFixAt?: string; + + @IsString() + @IsOptional() + resolvedAt?: string; + + @IsString() + @IsOptional() + closedAt?: string; + + @IsString() + @IsOptional() + resolution?: string; +} diff --git a/apps/server/src/modules/bug/dto/update-bug.dto.ts b/apps/server/src/modules/bug/dto/update-bug.dto.ts new file mode 100644 index 0000000..438fd87 --- /dev/null +++ b/apps/server/src/modules/bug/dto/update-bug.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateBugDto } from './create-bug.dto'; + +export class UpdateBugDto extends PartialType(CreateBugDto) {} diff --git a/apps/server/src/modules/consistency/consistency.controller.spec.ts b/apps/server/src/modules/consistency/consistency.controller.spec.ts new file mode 100644 index 0000000..cf47c82 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.controller.spec.ts @@ -0,0 +1,19 @@ +import { Reflector } from '@nestjs/core'; +import { PERMISSION_METADATA_KEY } from '../../common/auth/permission.decorator'; +import { ConsistencyController } from './consistency.controller'; + +describe('ConsistencyController', () => { + it('requires consistency:view for consistency checks', () => { + const metadata = new Reflector().get(PERMISSION_METADATA_KEY, ConsistencyController.prototype.run); + + expect(metadata).toEqual({ permission: 'consistency:view' }); + }); + + it('delegates consistency checks to the service', async () => { + const service = { run: jest.fn().mockResolvedValue({ status: 'pass' }) }; + const controller = new ConsistencyController(service as any); + + await expect(controller.run()).resolves.toEqual({ status: 'pass' }); + expect(service.run).toHaveBeenCalledWith(); + }); +}); diff --git a/apps/server/src/modules/consistency/consistency.controller.ts b/apps/server/src/modules/consistency/consistency.controller.ts new file mode 100644 index 0000000..e1744de --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.controller.ts @@ -0,0 +1,16 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { PermissionGuard } from '../../common/auth/permission.guard'; +import { RequirePermission } from '../../common/auth/permission.decorator'; +import { ConsistencyService } from './consistency.service'; + +@Controller('consistency') +export class ConsistencyController { + constructor(private readonly consistencyService: ConsistencyService) {} + + @Get() + @UseGuards(PermissionGuard) + @RequirePermission('consistency:view') + run() { + return this.consistencyService.run(); + } +} diff --git a/apps/server/src/modules/consistency/consistency.module.ts b/apps/server/src/modules/consistency/consistency.module.ts new file mode 100644 index 0000000..100e571 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ConsistencyController } from './consistency.controller'; +import { ConsistencyService } from './consistency.service'; + +@Module({ + controllers: [ConsistencyController], + providers: [ConsistencyService], +}) +export class ConsistencyModule {} diff --git a/apps/server/src/modules/consistency/consistency.service.spec.ts b/apps/server/src/modules/consistency/consistency.service.spec.ts new file mode 100644 index 0000000..60aa074 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.service.spec.ts @@ -0,0 +1,73 @@ +import { ConsistencyService } from './consistency.service'; + +describe('ConsistencyService', () => { + const makePrisma = () => { + const counts = { + product: 1, + project: 2, + version: 3, + requirement: 4, + versionPlan: 5, + devTask: 6, + testCase: 7, + bug: 8, + user: 9, + taskCategory: 10, + taskWorklog: 11, + overtimeRecord: 12, + workActivity: 13, + auditEvent: 14, + }; + const prisma: any = { + $queryRawUnsafe: jest.fn((sql: string) => { + if (sql.includes('dev_tasks') && sql.includes("version_id = ''")) return Promise.resolve([{ count: 1n }]); + if (sql.includes('requirements') && sql.includes('missing_version')) return Promise.resolve([{ count: 2n }]); + if (sql.includes('audit_events') && sql.includes("entity_type = 'bug'")) return Promise.resolve([{ count: 0n }]); + return Promise.resolve([{ count: 0n }]); + }), + }; + for (const [model, count] of Object.entries(counts)) { + prisma[model] = { count: jest.fn().mockResolvedValue(count) }; + } + return prisma; + }; + + it('returns counts plus error/warn consistency groups', async () => { + const prisma = makePrisma(); + const service = new ConsistencyService(prisma); + + const result = await service.run(); + + expect(result.status).toBe('fail'); + expect(result.counts.devTasks).toBe(6); + expect(result.summary.errors).toBeGreaterThan(0); + expect(result.summary.warnings).toBeGreaterThan(0); + expect(result.checks.partitionKeys).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'dev_tasks.version_id.present', + severity: 'error', + count: 1, + }), + ]), + ); + expect(result.checks.orphanReferences).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'requirements.version_id.exists', + severity: 'error', + count: 2, + }), + ]), + ); + expect(result.checks.auditCoverage).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'audit.coverage.bug', + severity: 'warn', + count: 0, + }), + ]), + ); + }); +}); diff --git a/apps/server/src/modules/consistency/consistency.service.ts b/apps/server/src/modules/consistency/consistency.service.ts new file mode 100644 index 0000000..dca6a15 --- /dev/null +++ b/apps/server/src/modules/consistency/consistency.service.ts @@ -0,0 +1,186 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +export type ConsistencySeverity = 'ok' | 'warn' | 'error'; +export type ConsistencyStatus = 'pass' | 'fail'; + +export interface ConsistencyCheckResult { + id: string; + label: string; + severity: ConsistencySeverity; + count: number; + message: string; +} + +export interface ConsistencyResult { + generatedAt: string; + status: ConsistencyStatus; + counts: Record; + checks: { + partitionKeys: ConsistencyCheckResult[]; + orphanReferences: ConsistencyCheckResult[]; + auditCoverage: ConsistencyCheckResult[]; + }; + summary: { + errors: number; + warnings: number; + human: string; + }; +} + +const COUNT_MODELS: Array<[string, string]> = [ + ['products', 'product'], + ['projects', 'project'], + ['versions', 'version'], + ['requirements', 'requirement'], + ['versionPlans', 'versionPlan'], + ['devTasks', 'devTask'], + ['testCases', 'testCase'], + ['bugs', 'bug'], + ['members', 'user'], + ['taskCategories', 'taskCategory'], + ['taskWorklogs', 'taskWorklog'], + ['overtimeRecords', 'overtimeRecord'], + ['workActivities', 'workActivity'], + ['auditEvents', 'auditEvent'], +]; + +const PARTITION_KEY_CHECKS = [ + check('requirements.product_id.present', 'requirements must keep product_id partition key', 'error', "SELECT COUNT(*) AS count FROM requirements WHERE product_id IS NULL OR product_id = ''"), + check('dev_tasks.version_id.present', 'dev_tasks must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM dev_tasks WHERE version_id IS NULL OR version_id = ''"), + check('test_cases.version_id.present', 'test_cases must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM test_cases WHERE version_id IS NULL OR version_id = ''"), + check('bugs.version_id.present', 'bugs must keep version_id partition key', 'error', "SELECT COUNT(*) AS count FROM bugs WHERE version_id IS NULL OR version_id = ''"), + check('work_activities.created_at.present', 'work_activities must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM work_activities WHERE created_at IS NULL'), + check('task_worklogs.created_at.present', 'task_worklogs must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM task_worklogs WHERE created_at IS NULL'), + check('overtime_records.created_at.present', 'overtime_records must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM overtime_records WHERE created_at IS NULL'), + check('audit_events.created_at.present', 'audit_events must keep created_at range partition key', 'error', 'SELECT COUNT(*) AS count FROM audit_events WHERE created_at IS NULL'), +]; + +const ORPHAN_REFERENCE_CHECKS = [ + check('projects.product_id.exists', 'projects.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM projects p LEFT JOIN products pr ON pr.id = p.product_id WHERE pr.id IS NULL'), + check('versions.product_id.exists', 'versions.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN products p ON p.id = v.product_id WHERE p.id IS NULL'), + check('versions.project_id.exists', 'versions.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM versions v LEFT JOIN projects p ON p.id = v.project_id WHERE v.project_id IS NOT NULL AND p.id IS NULL'), + check('requirements.product_id.exists', 'requirements.product_id must reference products.id', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN products p ON p.id = r.product_id WHERE p.id IS NULL'), + check('requirements.project_id.exists', 'requirements.project_id must reference projects.id when present', 'error', 'SELECT COUNT(*) AS count FROM requirements r LEFT JOIN projects p ON p.id = r.project_id WHERE r.project_id IS NOT NULL AND p.id IS NULL'), + check('requirements.version_id.exists', 'requirements.version_id must reference versions.id when present', 'error', 'SELECT COUNT(*) AS count /* missing_version */ FROM requirements r LEFT JOIN versions v ON v.id = r.version_id WHERE r.version_id IS NOT NULL AND v.id IS NULL'), + check('version_plans.version_id.exists', 'version_plans.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM version_plans vp LEFT JOIN versions v ON v.id = vp.version_id WHERE v.id IS NULL'), + check('dev_tasks.version_id.exists', 'dev_tasks.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN versions v ON v.id = dt.version_id WHERE v.id IS NULL'), + check('dev_tasks.requirement.exists', 'dev_tasks requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM dev_tasks dt LEFT JOIN requirements r ON r.id = dt.requirement_id AND r.product_id = dt.requirement_product_id WHERE dt.requirement_id IS NOT NULL AND r.id IS NULL'), + check('test_cases.version_id.exists', 'test_cases.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN versions v ON v.id = tc.version_id WHERE v.id IS NULL'), + check('test_cases.requirement.exists', 'test_cases requirement composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM test_cases tc LEFT JOIN requirements r ON r.id = tc.requirement_id AND r.product_id = tc.requirement_product_id WHERE tc.requirement_id IS NOT NULL AND r.id IS NULL'), + check('bugs.version_id.exists', 'bugs.version_id must reference versions.id', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN versions v ON v.id = b.version_id WHERE v.id IS NULL'), + check('bugs.test_case.exists', 'bugs test_case composite ref must exist when present', 'error', 'SELECT COUNT(*) AS count FROM bugs b LEFT JOIN test_cases tc ON tc.id = b.test_case_id AND tc.version_id = b.test_case_version_id WHERE b.test_case_id IS NOT NULL AND tc.id IS NULL'), +]; + +const AUDIT_ENTITY_TYPES = [ + 'product', + 'project', + 'version', + 'requirement', + 'version_plan', + 'dev_task', + 'test_case', + 'bug', + 'member', + 'task_category', + 'task_worklog', + 'overtime', + 'work_activity', +]; + +@Injectable() +export class ConsistencyService { + constructor(private readonly prisma: PrismaService) {} + + async run(): Promise { + const [counts, partitionKeys, orphanReferences, auditCoverage] = await Promise.all([ + this.collectCounts(), + this.runChecks(PARTITION_KEY_CHECKS), + this.runChecks(ORPHAN_REFERENCE_CHECKS), + this.runAuditCoverageChecks(), + ]); + const allChecks = [...partitionKeys, ...orphanReferences, ...auditCoverage]; + const errors = allChecks.filter((item) => item.severity === 'error').length; + const warnings = allChecks.filter((item) => item.severity === 'warn').length; + const status: ConsistencyStatus = errors > 0 ? 'fail' : 'pass'; + + return { + generatedAt: new Date().toISOString(), + status, + counts, + checks: { partitionKeys, orphanReferences, auditCoverage }, + summary: { + errors, + warnings, + human: buildHumanSummary(status, errors, warnings, counts), + }, + }; + } + + private async collectCounts() { + const entries = await Promise.all( + COUNT_MODELS.map(async ([label, model]) => [label, await (this.prisma as any)[model].count()] as const), + ); + return Object.fromEntries(entries); + } + + private async runChecks(checks: ConsistencyCheck[]) { + return Promise.all(checks.map((item) => this.runSqlCheck(item))); + } + + private async runAuditCoverageChecks() { + return Promise.all(AUDIT_ENTITY_TYPES.map(async (entityType) => { + const count = await this.rawCount(`SELECT COUNT(*) AS count FROM audit_events WHERE entity_type = '${entityType}'`); + const label = `audit_events should contain mutation events for ${entityType}`; + return { + id: `audit.coverage.${entityType}`, + label, + severity: count === 0 ? 'warn' : 'ok', + count, + message: count === 0 ? `${label}: no events yet` : `${label}: ${count}`, + } satisfies ConsistencyCheckResult; + })); + } + + private async runSqlCheck(item: ConsistencyCheck): Promise { + const count = await this.rawCount(item.sql); + const severity = count > 0 ? item.severityWhenNonZero : 'ok'; + return { + id: item.id, + label: item.label, + severity, + count, + message: count > 0 ? `${item.label}: ${count}` : `${item.label}: ok`, + }; + } + + private async rawCount(sql: string): Promise { + const rows = await this.prisma.$queryRawUnsafe>(sql); + return Number(rows[0]?.count ?? 0); + } +} + +interface ConsistencyCheck { + id: string; + label: string; + severityWhenNonZero: Exclude; + sql: string; +} + +function check( + id: string, + label: string, + severityWhenNonZero: Exclude, + sql: string, +): ConsistencyCheck { + return { id, label, severityWhenNonZero, sql }; +} + +function buildHumanSummary( + status: ConsistencyStatus, + errors: number, + warnings: number, + counts: Record, +) { + return `V2.5 consistency ${status}: ${errors} error(s), ${warnings} warning(s), ${counts.auditEvents ?? 0} audit event(s).`; +} diff --git a/apps/server/src/modules/data/data.module.ts b/apps/server/src/modules/data/data.module.ts index 897227d..90d099c 100644 --- a/apps/server/src/modules/data/data.module.ts +++ b/apps/server/src/modules/data/data.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AppDataRetirementModule } from '../app-data-retirement/app-data-retirement.module'; import { MigrationModule } from '../migration/migration.module'; import { DataController } from './data.controller'; import { DataService } from './data.service'; @Module({ - imports: [MigrationModule], + imports: [MigrationModule, AppDataRetirementModule], controllers: [DataController], providers: [DataService], }) diff --git a/apps/server/src/modules/data/data.service.spec.ts b/apps/server/src/modules/data/data.service.spec.ts index ac65290..1a667a0 100644 --- a/apps/server/src/modules/data/data.service.spec.ts +++ b/apps/server/src/modules/data/data.service.spec.ts @@ -14,10 +14,14 @@ describe('DataService', () => { const syncService = { syncAfterAppDataPut: jest.fn(), }; + const retirementService = { + assertWritable: jest.fn(), + }; return { prisma, syncService, - service: new DataService(prisma as any, syncService as any), + retirementService, + service: new (DataService as any)(prisma, syncService, retirementService) as DataService, }; }; @@ -55,7 +59,7 @@ describe('DataService', () => { }); it('upserts JSON values for allowed keys', async () => { - const { prisma, service, syncService } = makeService(); + const { prisma, service, syncService, retirementService } = makeService(); const value = [{ id: 'p1', name: 'Product 1' }]; const updatedAt = new Date('2026-07-02T08:01:00.000Z'); prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt }); @@ -70,9 +74,34 @@ describe('DataService', () => { update: { value }, create: { key: 'products-overview', value }, }); + expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview'); expect(syncService.syncAfterAppDataPut).toHaveBeenCalledWith('products-overview'); }); + it('rejects frozen AppData writes before touching storage or relation sync', async () => { + const { prisma, service, syncService, retirementService } = makeService(); + retirementService.assertWritable.mockImplementation(() => { + throw new ConflictException({ + code: 'APP_DATA_WRITE_FROZEN', + key: 'dev-tasks', + state: 'write_frozen', + replacement: '/api/v1/versions/:versionId/dev-tasks', + }); + }); + + await expect(service.put('dev-tasks', [{ id: 'dt-1' }])).rejects.toMatchObject({ + response: expect.objectContaining({ + code: 'APP_DATA_WRITE_FROZEN', + key: 'dev-tasks', + state: 'write_frozen', + }), + }); + expect(prisma.appData.create).not.toHaveBeenCalled(); + expect(prisma.appData.updateMany).not.toHaveBeenCalled(); + expect(prisma.appData.upsert).not.toHaveBeenCalled(); + expect(syncService.syncAfterAppDataPut).not.toHaveBeenCalled(); + }); + it('allows supporting business data keys migrated from browser storage', async () => { const { prisma, service } = makeService(); const value: unknown[] = []; diff --git a/apps/server/src/modules/data/data.service.ts b/apps/server/src/modules/data/data.service.ts index 9b67cbd..1bb8ceb 100644 --- a/apps/server/src/modules/data/data.service.ts +++ b/apps/server/src/modules/data/data.service.ts @@ -1,8 +1,9 @@ -import { BadRequestException, ConflictException, Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, Logger, Optional } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; +import { AppDataRetirementService } from '../app-data-retirement/app-data-retirement.service'; import { AppDataV23SyncService } from '../migration/app-data-v23-sync.service'; -import { isAppDataKey } from './data-keys'; +import { type AppDataKey, isAppDataKey } from './data-keys'; type AppDataRow = { key: string; @@ -17,6 +18,8 @@ export class DataService { constructor( private prisma: PrismaService, private readonly appDataSync?: AppDataV23SyncService, + @Optional() + private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(), ) {} async get(key: string) { @@ -27,6 +30,7 @@ export class DataService { async put(key: string, value: unknown, version?: string | null) { this.ensureAllowedKey(key); + this.appDataRetirement.assertWritable(key); const jsonValue = value as Prisma.InputJsonValue; if (version === null) { @@ -66,7 +70,7 @@ export class DataService { return this.toResponseAfterSync(key, row); } - private ensureAllowedKey(key: string) { + private ensureAllowedKey(key: string): asserts key is AppDataKey { if (!isAppDataKey(key)) { throw new BadRequestException(`Unsupported data key: ${key}`); } diff --git a/apps/server/src/modules/dev-task/dev-task.controller.ts b/apps/server/src/modules/dev-task/dev-task.controller.ts new file mode 100644 index 0000000..3942e0a --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.controller.ts @@ -0,0 +1,93 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateDevTaskDto } from './dto/create-dev-task.dto'; +import { UpdateDevTaskDto } from './dto/update-dev-task.dto'; +import { DevTaskService } from './dev-task.service'; + +@Controller('versions/:versionId/dev-tasks') +export class DevTaskController { + constructor(private readonly devTaskService: DevTaskService) {} + + @Post() + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.create', + entityType: 'dev_task', + versionIdParam: 'versionId', + }) + create(@Param('versionId') versionId: string, @Body() dto: CreateDevTaskDto) { + return this.devTaskService.create(versionId, dto); + } + + @Get() + findAll(@Param('versionId') versionId: string) { + return this.devTaskService.findAll(versionId); + } + + @Patch(':id') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.update', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateDevTaskDto) { + return this.devTaskService.update(versionId, id, dto); + } + + @Patch(':id/status') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.status', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + updateStatus( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('status') status: string, + ) { + return this.devTaskService.updateStatus(versionId, id, status); + } + + @Patch(':id/block') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.block', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + setBlocked( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('blocked') blocked: boolean, + @Body('reason') reason?: string, + ) { + return this.devTaskService.setBlocked(versionId, id, blocked, reason); + } + + @Patch(':id/transfer') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.transfer', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + transfer( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('assigneeId') assigneeId: string, + ) { + return this.devTaskService.transfer(versionId, id, assigneeId); + } + + @Delete(':id') + @ProtectedMutation('version.devtask:manage', { versionIdParam: 'versionId' }, { + action: 'dev_task.delete', + entityType: 'dev_task', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + remove(@Param('versionId') versionId: string, @Param('id') id: string) { + return this.devTaskService.remove(versionId, id); + } +} diff --git a/apps/server/src/modules/dev-task/dev-task.module.ts b/apps/server/src/modules/dev-task/dev-task.module.ts new file mode 100644 index 0000000..770f99c --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { DevTaskController } from './dev-task.controller'; +import { DevTaskService } from './dev-task.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [DevTaskController], + providers: [DevTaskService], + exports: [DevTaskService], +}) +export class DevTaskModule {} diff --git a/apps/server/src/modules/dev-task/dev-task.service.spec.ts b/apps/server/src/modules/dev-task/dev-task.service.spec.ts new file mode 100644 index 0000000..684ac17 --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.service.spec.ts @@ -0,0 +1,153 @@ +import { NotFoundException } from '@nestjs/common'; +import { DevTaskService } from './dev-task.service'; + +describe('DevTaskService domain writes', () => { + const makeService = () => { + const workActivity = { + record: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }; + const prisma = { + version: { + findUnique: jest.fn(), + }, + devTask: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + return { + prisma, + workActivity, + service: new DevTaskService(prisma as any, workActivity as any), + }; + }; + + it('creates dev tasks directly under a version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.devTask.create.mockResolvedValue({ id: 'task-1', versionId: 'version-1', title: '开发登录' }); + + await service.create('version-1', { + requirementId: 'req-1', + requirementProductId: 'product-1', + taskNo: 'DEV-001', + title: '开发登录', + categoryId: 'cat-fe', + assigneeId: 'member-1', + priority: 'P1', + expectedStartAt: '2026-07-08T09:00:00.000Z', + expectedEndAt: '2026-07-08T18:00:00.000Z', + estimateHours: 8, + references: [{ type: 'requirement', id: 'REQ-001', label: 'REQ-001 登录' }], + createdBy: 'member-pm', + } as any); + + expect(prisma.devTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + requirementId: 'req-1', + requirementProductId: 'product-1', + code: 'DEV-001', + title: '开发登录', + categoryId: 'cat-fe', + assigneeId: 'member-1', + priority: 1, + expectedStartAt: new Date('2026-07-08T09:00:00.000Z'), + expectedEndAt: new Date('2026-07-08T18:00:00.000Z'), + estimateHours: 8, + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'dev_task', + sourceId: 'task-1', + action: 'dev_task_created', + })); + }); + + it('changes status by id plus version id and records activity evidence', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.devTask.findFirst.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'todo', + title: '开发登录', + assigneeId: 'member-1', + }); + prisma.devTask.update.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'in_progress', + title: '开发登录', + assigneeId: 'member-1', + }); + + await service.updateStatus('version-1', 'task-1', 'in_progress'); + + expect(prisma.devTask.update).toHaveBeenCalledWith({ + where: { id_versionId: { id: 'task-1', versionId: 'version-1' } }, + data: expect.objectContaining({ + status: 'in_progress', + startDate: expect.any(Date), + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'dev_task', + sourceId: 'task-1', + action: 'dev_task_started', + })); + }); + + it('blocks and unblocks tasks inside the version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.devTask.findFirst.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'in_progress', + title: '开发登录', + assigneeId: 'member-1', + }); + prisma.devTask.update.mockResolvedValue({ + id: 'task-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + isBlocked: true, + blockReason: '接口未就绪', + title: '开发登录', + assigneeId: 'member-1', + }); + + await service.setBlocked('version-1', 'task-1', true, '接口未就绪'); + + expect(prisma.devTask.update).toHaveBeenCalledWith({ + where: { id_versionId: { id: 'task-1', versionId: 'version-1' } }, + data: { isBlocked: true, blockReason: '接口未就绪' }, + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + action: 'dev_task_blocked', + metadata: expect.objectContaining({ blocker: '接口未就绪' }), + })); + }); + + it('rejects updates outside the version partition', async () => { + const { prisma, service } = makeService(); + prisma.devTask.findFirst.mockResolvedValue(null); + + await expect(service.update('version-1', 'missing-task', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.devTask.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/dev-task/dev-task.service.ts b/apps/server/src/modules/dev-task/dev-task.service.ts new file mode 100644 index 0000000..384ee95 --- /dev/null +++ b/apps/server/src/modules/dev-task/dev-task.service.ts @@ -0,0 +1,205 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateDevTaskDto } from './dto/create-dev-task.dto'; +import { UpdateDevTaskDto } from './dto/update-dev-task.dto'; + +@Injectable() +export class DevTaskService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity: WorkActivityService, + ) {} + + async create(versionId: string, dto: CreateDevTaskDto) { + const version = await this.ensureVersion(versionId); + if (!version.projectId) { + throw new BadRequestException('开发任务所属版本必须归属于项目'); + } + const item = await this.prisma.devTask.create({ + data: { + ...this.toTaskData(dto), + versionId, + productId: version.productId, + projectId: version.projectId, + code: dto.code?.trim() || dto.taskNo?.trim() || createFallbackDevCode(), + title: dto.title, + status: dto.status ?? 'todo', + isBlocked: dto.isBlocked ?? false, + }, + }); + const activity = await this.recordTaskActivity(item, 'dev_task_created', 'creation', `新建开发任务:${item.title}`); + return { item, activities: [activity] }; + } + + findAll(versionId: string) { + return this.prisma.devTask.findMany({ + where: { versionId }, + orderBy: [{ status: 'asc' }, { updatedAt: 'desc' }], + }); + } + + async update(versionId: string, id: string, dto: UpdateDevTaskDto) { + await this.ensureTaskInVersion(versionId, id); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data: this.toTaskData(dto), + }); + return { item, activities: [] }; + } + + async updateStatus(versionId: string, id: string, status: string) { + const current = await this.ensureTaskInVersion(versionId, id); + const data: Record = { status }; + if (status === 'in_progress' && !current.startDate) data.startDate = new Date(); + if (status === 'submitted' && !current.completedAt) data.completedAt = new Date(); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data, + }); + const activity = await this.recordStatusActivity(item, current.status, status); + return { item, activities: activity ? [activity] : [] }; + } + + async setBlocked(versionId: string, id: string, blocked: boolean, reason?: string) { + await this.ensureTaskInVersion(versionId, id); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data: { + isBlocked: blocked, + blockReason: blocked ? reason : null, + }, + }); + const activity = await this.recordTaskActivity( + item, + blocked ? 'dev_task_blocked' : 'dev_task_unblocked', + blocked ? 'risk' : 'progress', + blocked ? `标记阻塞:${reason?.trim() || item.title}` : `解除阻塞:${item.title}`, + { blocker: blocked ? reason : undefined }, + ); + return { item, activities: [activity] }; + } + + async transfer(versionId: string, id: string, assigneeId: string) { + const current = await this.ensureTaskInVersion(versionId, id); + const item = await this.prisma.devTask.update({ + where: { id_versionId: { id, versionId } }, + data: { assigneeId }, + }); + const activity = await this.recordTaskActivity( + item, + 'dev_task_transferred', + 'progress', + `转派开发任务:${item.title}`, + { fromAssigneeId: current.assigneeId, toAssigneeId: assigneeId }, + ); + return { item, activities: [activity] }; + } + + async remove(versionId: string, id: string) { + await this.ensureTaskInVersion(versionId, id); + return this.prisma.devTask.delete({ where: { id_versionId: { id, versionId } } }); + } + + private toTaskData(dto: Partial) { + return { + ...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }), + ...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }), + ...(dto.categoryId !== undefined && { categoryId: emptyToNull(dto.categoryId) }), + ...(dto.code !== undefined || dto.taskNo !== undefined ? { code: dto.code?.trim() || dto.taskNo?.trim() } : {}), + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description ?? '' }), + ...(dto.status !== undefined && { status: dto.status }), + ...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }), + ...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }), + ...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}), + ...(dto.isBlocked !== undefined && { isBlocked: dto.isBlocked }), + ...(dto.blockReason !== undefined && { blockReason: emptyToNull(dto.blockReason) }), + ...(dto.expectedStartAt !== undefined && { expectedStartAt: parseOptionalDate(dto.expectedStartAt) }), + ...(dto.expectedEndAt !== undefined && { expectedEndAt: parseOptionalDate(dto.expectedEndAt) }), + ...(dto.actualStartAt !== undefined || dto.startDate !== undefined + ? { startDate: parseOptionalDate(dto.actualStartAt ?? dto.startDate) } + : {}), + ...(dto.actualEndAt !== undefined || dto.completedAt !== undefined + ? { completedAt: parseOptionalDate(dto.actualEndAt ?? dto.completedAt) } + : {}), + ...(dto.estimateHours !== undefined && { estimateHours: dto.estimateHours }), + ...(dto.aiEstimateHours !== undefined && { aiEstimateHours: dto.aiEstimateHours }), + ...(dto.references !== undefined && { references: toJsonInput(dto.references) }), + ...(dto.aiDraft !== undefined && { aiDraft: dto.aiDraft }), + ...(dto.aiDraftAt !== undefined && { aiDraftAt: parseOptionalDate(dto.aiDraftAt) }), + }; + } + + private async ensureVersion(versionId: string) { + const version = await this.prisma.version.findUnique({ where: { id: versionId } }); + if (!version) throw new NotFoundException('版本不存在'); + return version; + } + + private async ensureTaskInVersion(versionId: string, id: string) { + const task = await this.prisma.devTask.findFirst({ where: { id, versionId } }); + if (!task) throw new NotFoundException('开发任务不存在'); + return task; + } + + private recordStatusActivity(task: any, fromStatus: string, toStatus: string) { + if (toStatus === 'in_progress') { + return this.recordTaskActivity(task, 'dev_task_started', 'progress', `开始开发:${task.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'testing') { + return this.recordTaskActivity(task, 'dev_task_self_testing', 'progress', `进入自测:${task.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'submitted') { + return this.recordTaskActivity(task, 'dev_task_submitted', 'delivery', `已提测开发任务:${task.title}`, { fromStatus, toStatus }); + } + return undefined; + } + + private recordTaskActivity(task: any, action: string, category: string, summary: string, metadata: Record = {}) { + return this.workActivity.record({ + versionId: task.versionId, + productId: task.productId, + projectId: task.projectId, + actorId: task.assigneeId ?? task.creatorId, + sourceType: 'dev_task', + sourceId: task.id, + action, + category, + title: task.title, + summary, + metadata, + }); + } +} + +function createFallbackDevCode() { + return `DEV-${Date.now().toString(36).toUpperCase()}`; +} + +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 parseOptionalDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function parsePriority(value: string | number | null | undefined): number | undefined { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined; + 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))) : undefined; +} + +function toJsonInput(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} diff --git a/apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts b/apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts new file mode 100644 index 0000000..33ab377 --- /dev/null +++ b/apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts @@ -0,0 +1,99 @@ +import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateDevTaskDto { + @IsString() + @IsOptional() + requirementId?: string; + + @IsString() + @IsOptional() + requirementProductId?: string; + + @IsString() + @IsOptional() + categoryId?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + taskNo?: string; + + @IsString() + title!: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsOptional() + priority?: string | number; + + @IsString() + @IsOptional() + assigneeId?: string; + + @IsString() + @IsOptional() + creatorId?: string; + + @IsString() + @IsOptional() + createdBy?: string; + + @IsBoolean() + @IsOptional() + isBlocked?: boolean; + + @IsString() + @IsOptional() + blockReason?: string; + + @IsString() + @IsOptional() + expectedStartAt?: string; + + @IsString() + @IsOptional() + expectedEndAt?: string; + + @IsString() + @IsOptional() + actualStartAt?: string; + + @IsString() + @IsOptional() + actualEndAt?: string; + + @IsString() + @IsOptional() + startDate?: string; + + @IsString() + @IsOptional() + completedAt?: string; + + @IsOptional() + estimateHours?: number; + + @IsOptional() + aiEstimateHours?: number; + + @IsArray() + @IsOptional() + references?: unknown[]; + + @IsBoolean() + @IsOptional() + aiDraft?: boolean; + + @IsString() + @IsOptional() + aiDraftAt?: string; +} diff --git a/apps/server/src/modules/dev-task/dto/update-dev-task.dto.ts b/apps/server/src/modules/dev-task/dto/update-dev-task.dto.ts new file mode 100644 index 0000000..77bcddc --- /dev/null +++ b/apps/server/src/modules/dev-task/dto/update-dev-task.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateDevTaskDto } from './create-dev-task.dto'; + +export class UpdateDevTaskDto extends PartialType(CreateDevTaskDto) {} diff --git a/apps/server/src/modules/member/dto/create-member.dto.ts b/apps/server/src/modules/member/dto/create-member.dto.ts new file mode 100644 index 0000000..0784ddc --- /dev/null +++ b/apps/server/src/modules/member/dto/create-member.dto.ts @@ -0,0 +1,34 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateMemberDto { + @IsString() + name!: string; + + @IsString() + @IsOptional() + username?: string; + + @IsString() + @IsOptional() + departmentId?: string; + + @IsString() + @IsOptional() + roleId?: string; + + @IsString() + @IsOptional() + phone?: string; + + @IsString() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + password?: string; + + @IsBoolean() + @IsOptional() + isSystem?: boolean; +} diff --git a/apps/server/src/modules/member/dto/update-member.dto.ts b/apps/server/src/modules/member/dto/update-member.dto.ts new file mode 100644 index 0000000..eeb4fa2 --- /dev/null +++ b/apps/server/src/modules/member/dto/update-member.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateMemberDto } from './create-member.dto'; + +export class UpdateMemberDto extends PartialType(CreateMemberDto) {} diff --git a/apps/server/src/modules/member/member.controller.ts b/apps/server/src/modules/member/member.controller.ts new file mode 100644 index 0000000..d0a4db8 --- /dev/null +++ b/apps/server/src/modules/member/member.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateMemberDto } from './dto/create-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; +import { MemberService } from './member.service'; + +@Controller('members') +export class MemberController { + constructor(private readonly memberService: MemberService) {} + + @Get() + findAll() { + return this.memberService.findAll(); + } + + @Post() + @ProtectedMutation('member:create', {}, { action: 'member.create', entityType: 'member' }) + create(@Body() dto: CreateMemberDto) { + return this.memberService.create(dto); + } + + @Patch(':id') + @ProtectedMutation('member:edit', {}, { + action: 'member.update', + entityType: 'member', + entityIdParam: 'id', + }) + update(@Param('id') id: string, @Body() dto: UpdateMemberDto) { + return this.memberService.update(id, dto); + } + + @Delete(':id') + @ProtectedMutation('member:delete', {}, { + action: 'member.delete', + entityType: 'member', + entityIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.memberService.remove(id); + } +} diff --git a/apps/server/src/modules/member/member.module.ts b/apps/server/src/modules/member/member.module.ts new file mode 100644 index 0000000..924e68b --- /dev/null +++ b/apps/server/src/modules/member/member.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { MemberController } from './member.controller'; +import { MemberService } from './member.service'; + +@Module({ + controllers: [MemberController], + providers: [MemberService], + exports: [MemberService], +}) +export class MemberModule {} diff --git a/apps/server/src/modules/member/member.service.spec.ts b/apps/server/src/modules/member/member.service.spec.ts new file mode 100644 index 0000000..4eabc09 --- /dev/null +++ b/apps/server/src/modules/member/member.service.spec.ts @@ -0,0 +1,94 @@ +import { BadRequestException } from '@nestjs/common'; +import { MemberService } from './member.service'; + +describe('MemberService domain writes', () => { + const makeService = () => { + const prisma = { + user: { + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + return { prisma, service: new MemberService(prisma as any) }; + }; + + it('creates members with UI role and department fields in users', async () => { + const { prisma, service } = makeService(); + prisma.user.create.mockResolvedValue({ + id: 'member-1', + name: '张三', + username: 'zhangsan', + departmentId: 'dept-2', + roleId: 'role-dev', + phone: '13000000000', + email: 'zhangsan@example.com', + password: 'Ftb12345', + isSystem: false, + createdAt: new Date('2026-07-08T00:00:00.000Z'), + }); + + await service.create({ + name: '张三', + username: 'zhangsan', + departmentId: 'dept-2', + roleId: 'role-dev', + phone: '13000000000', + email: 'zhangsan@example.com', + password: 'Ftb12345', + }); + + expect(prisma.user.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: '张三', + username: 'zhangsan', + departmentId: 'dept-2', + roleId: 'role-dev', + phone: '13000000000', + email: 'zhangsan@example.com', + password: 'Ftb12345', + }), + }); + }); + + it('protects the built-in admin from deletion and privileged field edits', async () => { + const { prisma, service } = makeService(); + prisma.user.update.mockResolvedValue({ + id: 'm-8', + name: '超级管理员', + username: 'admin', + roleId: 'role-admin', + departmentId: '', + phone: '13200132009', + email: 'admin@example.com', + password: 'Ftb12345', + isSystem: true, + createdAt: new Date('2026-07-08T00:00:00.000Z'), + }); + + await expect(service.remove('m-8')).rejects.toBeInstanceOf(BadRequestException); + + await service.update('m-8', { + name: '误改', + username: 'other', + roleId: 'role-viewer', + departmentId: 'dept-1', + phone: '13200132009', + }); + + expect(prisma.user.delete).not.toHaveBeenCalled(); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 'm-8' }, + data: expect.not.objectContaining({ + name: '误改', + username: 'other', + roleId: 'role-viewer', + departmentId: 'dept-1', + }), + }); + expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ phone: '13200132009', isSystem: true }), + })); + }); +}); diff --git a/apps/server/src/modules/member/member.service.ts b/apps/server/src/modules/member/member.service.ts new file mode 100644 index 0000000..7333228 --- /dev/null +++ b/apps/server/src/modules/member/member.service.ts @@ -0,0 +1,95 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateMemberDto } from './dto/create-member.dto'; +import { UpdateMemberDto } from './dto/update-member.dto'; + +const SYSTEM_ADMIN_MEMBER_ID = 'm-8'; +const SYSTEM_ADMIN_NAME = '超级管理员'; +const SYSTEM_ADMIN_USERNAME = 'admin'; +const SYSTEM_ADMIN_ROLE_ID = 'role-admin'; + +@Injectable() +export class MemberService { + constructor(private readonly prisma: PrismaService) {} + + async findAll() { + const rows = await (this.prisma.user as any).findMany({ orderBy: [{ isSystem: 'desc' }, { name: 'asc' }] }); + return rows.map(toMember); + } + + async create(dto: CreateMemberDto) { + const data = toUserData(dto); + const row = await (this.prisma.user as any).create({ data }); + return toMember(row); + } + + async update(id: string, dto: UpdateMemberDto) { + const data = id === SYSTEM_ADMIN_MEMBER_ID + ? { ...sanitizeSystemAdminPatch(dto), isSystem: true } + : toUserData(dto, { partial: true }); + const row = await (this.prisma.user as any).update({ where: { id }, data }); + return toMember(row); + } + + async remove(id: string) { + if (id === SYSTEM_ADMIN_MEMBER_ID) { + throw new BadRequestException('系统内置超级管理员不可删除'); + } + await (this.prisma.user as any).delete({ where: { id } }); + return { deleted: true }; + } +} + +function sanitizeSystemAdminPatch(dto: UpdateMemberDto) { + const data = toUserData(dto, { partial: true }); + delete (data as any).name; + delete (data as any).username; + delete (data as any).departmentId; + delete (data as any).roleId; + delete (data as any).email; + return data; +} + +function toUserData(dto: Partial, options: { partial?: boolean } = {}) { + const id = (dto as any).id as string | undefined; + const email = dto.email?.trim() || (options.partial ? undefined : `${id || dto.username || Date.now()}@local.ftb`); + return { + ...(id !== undefined && { id }), + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.username !== undefined && { username: emptyToNull(dto.username) }), + ...(dto.departmentId !== undefined && { departmentId: emptyToNull(dto.departmentId) }), + ...(dto.roleId !== undefined && { roleId: dto.roleId || 'member' }), + ...(dto.phone !== undefined && { phone: dto.phone ?? '' }), + ...(email !== undefined && { email }), + ...(dto.password !== undefined && { password: dto.password ?? '' }), + ...(dto.isSystem !== undefined && { isSystem: dto.isSystem }), + }; +} + +function toMember(row: any) { + return { + id: row.id, + name: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_NAME : row.name, + username: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_USERNAME : row.username ?? undefined, + departmentId: row.id === SYSTEM_ADMIN_MEMBER_ID ? '' : row.departmentId ?? '', + roleId: row.id === SYSTEM_ADMIN_MEMBER_ID ? SYSTEM_ADMIN_ROLE_ID : row.roleId ?? 'member', + phone: row.phone ?? '', + email: row.email?.endsWith('@local.ftb') ? '' : row.email ?? '', + password: row.password ?? '', + createdAt: toIsoDate(row.createdAt), + isSystem: Boolean(row.isSystem) || row.id === SYSTEM_ADMIN_MEMBER_ID, + }; +} + +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 toIsoDate(value: unknown): string { + if (value instanceof Date) return value.toISOString().slice(0, 10); + if (typeof value === 'string' && value) return value.slice(0, 10); + return new Date().toISOString().slice(0, 10); +} 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/overtime/dto/create-overtime.dto.ts b/apps/server/src/modules/overtime/dto/create-overtime.dto.ts new file mode 100644 index 0000000..6ad5309 --- /dev/null +++ b/apps/server/src/modules/overtime/dto/create-overtime.dto.ts @@ -0,0 +1,34 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateOvertimeDto { + @IsString() + @IsOptional() + productId?: string; + + @IsString() + @IsOptional() + projectId?: string; + + @IsString() + @IsOptional() + versionId?: string; + + @IsString() + person!: string; + + @IsString() + startTime!: string; + + @IsString() + endTime!: string; + + @IsOptional() + duration?: number; + + @IsString() + reasonId!: string; + + @IsString() + @IsOptional() + remark?: string; +} diff --git a/apps/server/src/modules/overtime/dto/update-overtime.dto.ts b/apps/server/src/modules/overtime/dto/update-overtime.dto.ts new file mode 100644 index 0000000..08d5da0 --- /dev/null +++ b/apps/server/src/modules/overtime/dto/update-overtime.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateOvertimeDto } from './create-overtime.dto'; + +export class UpdateOvertimeDto extends PartialType(CreateOvertimeDto) {} diff --git a/apps/server/src/modules/overtime/overtime.controller.ts b/apps/server/src/modules/overtime/overtime.controller.ts new file mode 100644 index 0000000..fea15a6 --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateOvertimeDto } from './dto/create-overtime.dto'; +import { UpdateOvertimeDto } from './dto/update-overtime.dto'; +import { OvertimeService } from './overtime.service'; + +@Controller('overtime') +export class OvertimeController { + constructor(private readonly overtimeService: OvertimeService) {} + + @Get() + findAll() { + return this.overtimeService.findAll(); + } + + @Post() + @ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, { + action: 'overtime.create', + entityType: 'overtime', + productIdBody: 'productId', + projectIdBody: 'projectId', + versionIdBody: 'versionId', + }) + create(@Body() dto: CreateOvertimeDto) { + return this.overtimeService.create(dto); + } + + @Patch(':id') + @ProtectedMutation('overtime:create', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, { + action: 'overtime.update', + entityType: 'overtime', + entityIdParam: 'id', + productIdBody: 'productId', + projectIdBody: 'projectId', + versionIdBody: 'versionId', + }) + update(@Param('id') id: string, @Body() dto: UpdateOvertimeDto) { + return this.overtimeService.update(id, dto); + } + + @Delete(':id') + @ProtectedMutation('overtime:delete', {}, { + action: 'overtime.delete', + entityType: 'overtime', + entityIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.overtimeService.remove(id); + } +} diff --git a/apps/server/src/modules/overtime/overtime.module.ts b/apps/server/src/modules/overtime/overtime.module.ts new file mode 100644 index 0000000..0351b3f --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { OvertimeController } from './overtime.controller'; +import { OvertimeService } from './overtime.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [OvertimeController], + providers: [OvertimeService], + exports: [OvertimeService], +}) +export class OvertimeModule {} diff --git a/apps/server/src/modules/overtime/overtime.service.spec.ts b/apps/server/src/modules/overtime/overtime.service.spec.ts new file mode 100644 index 0000000..df0a5e8 --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.service.spec.ts @@ -0,0 +1,37 @@ +import { OvertimeService } from './overtime.service'; + +describe('OvertimeService domain writes', () => { + it('writes overtime records to relation table', async () => { + const prisma = { + overtimeRecord: { + create: jest.fn().mockResolvedValue({ id: 'ot-1' }), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + const service = new OvertimeService(prisma as any); + + await service.create({ + projectId: 'project-1', + versionId: 'version-1', + person: 'member-1', + startTime: '2026-07-08T19:00:00.000Z', + endTime: '2026-07-08T21:00:00.000Z', + duration: 2, + reasonId: 'reason-4', + }); + + expect(prisma.overtimeRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + projectId: 'project-1', + versionId: 'version-1', + userId: 'member-1', + reason: 'reason-4', + startAt: new Date('2026-07-08T19:00:00.000Z'), + endAt: new Date('2026-07-08T21:00:00.000Z'), + hours: 2, + }), + }); + }); +}); diff --git a/apps/server/src/modules/overtime/overtime.service.ts b/apps/server/src/modules/overtime/overtime.service.ts new file mode 100644 index 0000000..1b1d8c4 --- /dev/null +++ b/apps/server/src/modules/overtime/overtime.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateOvertimeDto } from './dto/create-overtime.dto'; +import { UpdateOvertimeDto } from './dto/update-overtime.dto'; + +@Injectable() +export class OvertimeService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity?: WorkActivityService, + ) {} + + async create(dto: CreateOvertimeDto) { + const item = await this.prisma.overtimeRecord.create({ + data: { + productId: dto.productId || null, + projectId: dto.projectId || null, + versionId: dto.versionId || null, + userId: dto.person, + reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId, + startAt: new Date(dto.startTime), + endAt: new Date(dto.endTime), + hours: dto.duration ?? 0, + }, + }); + if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId); + return item; + } + + findAll() { + return this.prisma.overtimeRecord.findMany({ orderBy: { createdAt: 'desc' } }); + } + + update(id: string, dto: UpdateOvertimeDto) { + return this.prisma.overtimeRecord.updateMany({ where: { id }, data: toData(dto) }); + } + + async remove(id: string) { + await this.prisma.overtimeRecord.deleteMany({ where: { id } }); + return { deleted: true }; + } +} + +function toData(dto: UpdateOvertimeDto) { + return { + ...(dto.productId !== undefined && { productId: dto.productId || null }), + ...(dto.projectId !== undefined && { projectId: dto.projectId || null }), + ...(dto.versionId !== undefined && { versionId: dto.versionId || null }), + ...(dto.person !== undefined && { userId: dto.person }), + ...(dto.reasonId !== undefined && { reason: dto.remark ? `${dto.reasonId}:${dto.remark}` : dto.reasonId }), + ...(dto.startTime !== undefined && { startAt: new Date(dto.startTime) }), + ...(dto.endTime !== undefined && { endAt: new Date(dto.endTime) }), + ...(dto.duration !== undefined && { hours: dto.duration ?? 0 }), + }; +} diff --git a/apps/server/src/modules/product/product.controller.ts b/apps/server/src/modules/product/product.controller.ts index f4cfe06..0c261c6 100644 --- a/apps/server/src/modules/product/product.controller.ts +++ b/apps/server/src/modules/product/product.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { ProductService } from './product.service'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; @@ -8,6 +9,7 @@ export class ProductController { constructor(private readonly productService: ProductService) {} @Post() + @ProtectedMutation('product:create', {}, { action: 'product.create', entityType: 'product' }) create(@Body() dto: CreateProductDto) { return this.productService.create(dto); } @@ -28,11 +30,23 @@ export class ProductController { } @Patch(':id') + @ProtectedMutation('product:edit', { productIdParam: 'id' }, { + action: 'product.update', + entityType: 'product', + entityIdParam: 'id', + productIdParam: 'id', + }) update(@Param('id') id: string, @Body() dto: UpdateProductDto) { return this.productService.update(id, dto); } @Delete(':id') + @ProtectedMutation('product:delete', { productIdParam: 'id' }, { + action: 'product.delete', + entityType: 'product', + entityIdParam: 'id', + productIdParam: 'id', + }) remove(@Param('id') id: string) { return this.productService.remove(id); } 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..d4c926b --- /dev/null +++ b/apps/server/src/modules/project/project.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +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() + @ProtectedMutation('project:create', { productIdParam: 'productId' }, { + action: 'project.create', + entityType: 'project', + productIdParam: 'productId', + }) + 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') + @ProtectedMutation('project:edit', { productIdParam: 'productId', projectIdParam: 'projectId' }, { + action: 'project.update', + entityType: 'project', + entityIdParam: 'projectId', + productIdParam: 'productId', + projectIdParam: 'projectId', + }) + update( + @Param('productId') productId: string, + @Param('projectId') projectId: string, + @Body() dto: UpdateProjectDto, + ) { + return this.projectService.update(productId, projectId, dto); + } + + @Delete(':projectId') + @ProtectedMutation('project:delete', { productIdParam: 'productId', projectIdParam: 'projectId' }, { + action: 'project.delete', + entityType: 'project', + entityIdParam: 'projectId', + productIdParam: 'productId', + projectIdParam: '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/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..8a9baa2 100644 --- a/apps/server/src/modules/requirement/requirement.controller.ts +++ b/apps/server/src/modules/requirement/requirement.controller.ts @@ -1,4 +1,5 @@ import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; import { RequirementService } from './requirement.service'; import { CreateRequirementDto } from './dto/create-requirement.dto'; import { UpdateRequirementDto } from './dto/update-requirement.dto'; @@ -9,6 +10,11 @@ export class RequirementController { constructor(private readonly requirementService: RequirementService) {} @Post() + @ProtectedMutation('requirement:create', { productIdParam: 'productId' }, { + action: 'requirement.create', + entityType: 'requirement', + productIdParam: 'productId', + }) create( @Param('productId') productId: string, @Body() dto: CreateRequirementDto, @@ -20,8 +26,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') @@ -33,6 +57,12 @@ export class RequirementController { } @Patch(':id') + @ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, { + action: 'requirement.update', + entityType: 'requirement', + entityIdParam: 'id', + productIdParam: 'productId', + }) update( @Param('productId') productId: string, @Param('id') id: string, @@ -42,6 +72,12 @@ export class RequirementController { } @Patch(':id/status') + @ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, { + action: 'requirement.status', + entityType: 'requirement', + entityIdParam: 'id', + productIdParam: 'productId', + }) updateStatus( @Param('productId') productId: string, @Param('id') id: string, @@ -51,6 +87,12 @@ export class RequirementController { } @Delete(':id') + @ProtectedMutation('requirement:delete', { productIdParam: 'productId' }, { + action: 'requirement.delete', + entityType: 'requirement', + entityIdParam: 'id', + productIdParam: 'productId', + }) remove( @Param('productId') productId: string, @Param('id') id: string, diff --git a/apps/server/src/modules/requirement/requirement.service.spec.ts b/apps/server/src/modules/requirement/requirement.service.spec.ts index ecd88c9..7523a77 100644 --- a/apps/server/src/modules/requirement/requirement.service.spec.ts +++ b/apps/server/src/modules/requirement/requirement.service.spec.ts @@ -37,12 +37,90 @@ 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({ id: 'req-1', productId: 'product-1', - status: 'draft', + status: 'pending_review', }); prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' }); @@ -54,12 +132,43 @@ 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({ id: 'req-1', productId: 'product-1', - status: 'draft', + status: 'pending_review', }); prisma.requirement.delete.mockResolvedValue({ id: 'req-1', productId: 'product-1' }); @@ -69,4 +178,50 @@ describe('RequirementService with V2.2 composite requirement key', () => { where: { id_productId: { id: 'req-1', productId: 'product-1' } }, }); }); + + it.each([ + ['pending_review', 'adopted'], + ['pending_review', 'rejected'], + ['rejected', 'pending_review'], + ['adopted', 'planned'], + ['adopted', 'closed'], + ['planned', 'developing'], + ['planned', 'closed'], + ['developing', 'testing'], + ['testing', 'released'], + ['released', 'closed'], + ])('allows current requirement workflow transition %s -> %s', async (fromStatus, toStatus) => { + const { prisma, service } = makeService(); + prisma.requirement.findFirst.mockResolvedValue({ + id: 'req-1', + productId: 'product-1', + status: fromStatus, + }); + prisma.requirement.update.mockResolvedValue({ + id: 'req-1', + productId: 'product-1', + status: toStatus, + }); + + await service.updateStatus('product-1', 'req-1', toStatus); + + expect(prisma.requirement.update).toHaveBeenCalledWith({ + where: { id_productId: { id: 'req-1', productId: 'product-1' } }, + data: { status: toStatus }, + }); + }); + + it('rejects skipping adopted requirements directly to development', async () => { + const { prisma, service } = makeService(); + prisma.requirement.findFirst.mockResolvedValue({ + id: 'req-1', + productId: 'product-1', + status: 'adopted', + }); + + await expect(service.updateStatus('product-1', 'req-1', 'developing')).rejects.toThrow( + '无法从 "adopted" 转换到 "developing"', + ); + expect(prisma.requirement.update).not.toHaveBeenCalled(); + }); }); diff --git a/apps/server/src/modules/requirement/requirement.service.ts b/apps/server/src/modules/requirement/requirement.service.ts index fbe65ab..a072d72 100644 --- a/apps/server/src/modules/requirement/requirement.service.ts +++ b/apps/server/src/modules/requirement/requirement.service.ts @@ -5,13 +5,28 @@ import { CreateRequirementDto } from './dto/create-requirement.dto'; import { UpdateRequirementDto } from './dto/update-requirement.dto'; const VALID_TRANSITIONS: Record = { - [RequirementStatus.DRAFT]: [RequirementStatus.REVIEWING], - [RequirementStatus.REVIEWING]: [RequirementStatus.APPROVED, RequirementStatus.REJECTED], - [RequirementStatus.APPROVED]: [RequirementStatus.DELIVERED], - [RequirementStatus.REJECTED]: [RequirementStatus.DRAFT], - [RequirementStatus.DELIVERED]: [], + [RequirementStatus.PENDING_REVIEW]: [RequirementStatus.ADOPTED, RequirementStatus.REJECTED], + [RequirementStatus.REJECTED]: [RequirementStatus.PENDING_REVIEW], + [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) @@ -24,27 +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 || '', - 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) { @@ -60,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), }); } @@ -87,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/server/src/modules/task-category/dto/create-task-category.dto.ts b/apps/server/src/modules/task-category/dto/create-task-category.dto.ts new file mode 100644 index 0000000..2b26b46 --- /dev/null +++ b/apps/server/src/modules/task-category/dto/create-task-category.dto.ts @@ -0,0 +1,24 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateTaskCategoryDto { + @IsString() + name!: string; + + @IsString() + group!: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + color?: string; + + @IsOptional() + sortOrder?: number; + + @IsBoolean() + @IsOptional() + isSystem?: boolean; +} diff --git a/apps/server/src/modules/task-category/dto/update-task-category.dto.ts b/apps/server/src/modules/task-category/dto/update-task-category.dto.ts new file mode 100644 index 0000000..0f83383 --- /dev/null +++ b/apps/server/src/modules/task-category/dto/update-task-category.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTaskCategoryDto } from './create-task-category.dto'; + +export class UpdateTaskCategoryDto extends PartialType(CreateTaskCategoryDto) {} diff --git a/apps/server/src/modules/task-category/task-category.controller.ts b/apps/server/src/modules/task-category/task-category.controller.ts new file mode 100644 index 0000000..6eea00d --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.controller.ts @@ -0,0 +1,41 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateTaskCategoryDto } from './dto/create-task-category.dto'; +import { UpdateTaskCategoryDto } from './dto/update-task-category.dto'; +import { TaskCategoryService } from './task-category.service'; + +@Controller('task-categories') +export class TaskCategoryController { + constructor(private readonly taskCategoryService: TaskCategoryService) {} + + @Get() + findAll() { + return this.taskCategoryService.findAll(); + } + + @Post() + @ProtectedMutation('task-category:manage', {}, { action: 'task_category.create', entityType: 'task_category' }) + create(@Body() dto: CreateTaskCategoryDto) { + return this.taskCategoryService.create(dto); + } + + @Patch(':id') + @ProtectedMutation('task-category:manage', {}, { + action: 'task_category.update', + entityType: 'task_category', + entityIdParam: 'id', + }) + update(@Param('id') id: string, @Body() dto: UpdateTaskCategoryDto) { + return this.taskCategoryService.update(id, dto); + } + + @Delete(':id') + @ProtectedMutation('task-category:manage', {}, { + action: 'task_category.delete', + entityType: 'task_category', + entityIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.taskCategoryService.remove(id); + } +} diff --git a/apps/server/src/modules/task-category/task-category.module.ts b/apps/server/src/modules/task-category/task-category.module.ts new file mode 100644 index 0000000..57c5df3 --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TaskCategoryController } from './task-category.controller'; +import { TaskCategoryService } from './task-category.service'; + +@Module({ + controllers: [TaskCategoryController], + providers: [TaskCategoryService], + exports: [TaskCategoryService], +}) +export class TaskCategoryModule {} diff --git a/apps/server/src/modules/task-category/task-category.service.spec.ts b/apps/server/src/modules/task-category/task-category.service.spec.ts new file mode 100644 index 0000000..d4e7ab2 --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.service.spec.ts @@ -0,0 +1,41 @@ +import { TaskCategoryService } from './task-category.service'; + +describe('TaskCategoryService domain writes', () => { + const makeService = () => { + const prisma = { + taskCategory: { + create: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + return { prisma, service: new TaskCategoryService(prisma as any) }; + }; + + it('creates task categories with uniqueness scoped by group and name', async () => { + const { prisma, service } = makeService(); + prisma.taskCategory.create.mockResolvedValue({ id: 'cat-1', name: '前端开发', group: 'development' }); + + await service.create({ name: '前端开发', group: 'development', code: 'frontend_development' }); + + expect(prisma.taskCategory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + name: '前端开发', + group: 'development', + code: 'frontend_development', + isSystem: false, + }), + }); + }); + + it('does not delete system categories', async () => { + const { prisma, service } = makeService(); + prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-system', isSystem: true }]); + + const result = await service.remove('cat-system'); + + expect(result).toEqual({ deleted: false }); + expect(prisma.taskCategory.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/task-category/task-category.service.ts b/apps/server/src/modules/task-category/task-category.service.ts new file mode 100644 index 0000000..861e013 --- /dev/null +++ b/apps/server/src/modules/task-category/task-category.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateTaskCategoryDto } from './dto/create-task-category.dto'; +import { UpdateTaskCategoryDto } from './dto/update-task-category.dto'; + +@Injectable() +export class TaskCategoryService { + constructor(private readonly prisma: PrismaService) {} + + findAll() { + return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }); + } + + create(dto: CreateTaskCategoryDto) { + return this.prisma.taskCategory.create({ + data: { + name: dto.name, + group: dto.group, + code: dto.code, + isSystem: dto.isSystem ?? false, + }, + }); + } + + update(id: string, dto: UpdateTaskCategoryDto) { + return this.prisma.taskCategory.update({ + where: { id }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + ...(dto.group !== undefined && { group: dto.group }), + ...(dto.code !== undefined && { code: dto.code }), + ...(dto.isSystem !== undefined && { isSystem: dto.isSystem }), + }, + }); + } + + async remove(id: string) { + const [target] = await this.prisma.taskCategory.findMany({ where: { id }, take: 1 }); + if (!target || target.isSystem) return { deleted: false }; + await this.prisma.taskCategory.delete({ where: { id } }); + return { deleted: true }; + } +} diff --git a/apps/server/src/modules/task-worklog/dto/create-task-worklog.dto.ts b/apps/server/src/modules/task-worklog/dto/create-task-worklog.dto.ts new file mode 100644 index 0000000..dc54016 --- /dev/null +++ b/apps/server/src/modules/task-worklog/dto/create-task-worklog.dto.ts @@ -0,0 +1,35 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateTaskWorklogDto { + @IsString() + taskId!: string; + + @IsString() + userId!: string; + + @IsString() + date!: string; + + @IsOptional() + hours?: number; + + @IsString() + @IsOptional() + workContent?: string; + + @IsString() + @IsOptional() + sourceType?: string; + + @IsString() + @IsOptional() + versionId?: string; + + @IsString() + @IsOptional() + productId?: string; + + @IsString() + @IsOptional() + projectId?: string; +} diff --git a/apps/server/src/modules/task-worklog/task-worklog.controller.ts b/apps/server/src/modules/task-worklog/task-worklog.controller.ts new file mode 100644 index 0000000..c7d2ac8 --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto'; +import { TaskWorklogService } from './task-worklog.service'; + +@Controller('task-worklogs') +export class TaskWorklogController { + constructor(private readonly taskWorklogService: TaskWorklogService) {} + + @Get() + findAll() { + return this.taskWorklogService.findAll(); + } + + @Post() + @ProtectedMutation('version.devtask:manage', { versionIdBody: 'versionId' }, { + action: 'task_worklog.create', + entityType: 'task_worklog', + versionIdBody: 'versionId', + }) + create(@Body() dto: CreateTaskWorklogDto) { + return this.taskWorklogService.create(dto); + } + + @Delete(':id') + @ProtectedMutation('version.devtask:manage', {}, { + action: 'task_worklog.delete', + entityType: 'task_worklog', + entityIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.taskWorklogService.remove(id); + } +} diff --git a/apps/server/src/modules/task-worklog/task-worklog.module.ts b/apps/server/src/modules/task-worklog/task-worklog.module.ts new file mode 100644 index 0000000..db27151 --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { TaskWorklogController } from './task-worklog.controller'; +import { TaskWorklogService } from './task-worklog.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [TaskWorklogController], + providers: [TaskWorklogService], + exports: [TaskWorklogService], +}) +export class TaskWorklogModule {} diff --git a/apps/server/src/modules/task-worklog/task-worklog.service.spec.ts b/apps/server/src/modules/task-worklog/task-worklog.service.spec.ts new file mode 100644 index 0000000..e9ce36f --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.service.spec.ts @@ -0,0 +1,36 @@ +import { TaskWorklogService } from './task-worklog.service'; + +describe('TaskWorklogService append writes', () => { + it('appends task worklogs as relational evidence rows', async () => { + const prisma = { + taskWorklog: { + create: jest.fn().mockResolvedValue({ id: 'wl-1' }), + deleteMany: jest.fn(), + findMany: jest.fn(), + }, + }; + const service = new TaskWorklogService(prisma as any); + + await service.create({ + taskId: 'task-1', + userId: 'member-1', + date: '2026-07-08', + hours: 2, + workContent: '补充日报', + versionId: 'version-1', + }); + + expect(prisma.taskWorklog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + sourceType: 'dev_task', + sourceId: 'task-1', + sourceVersionId: 'version-1', + versionId: 'version-1', + userId: 'member-1', + workDate: new Date('2026-07-08T00:00:00.000Z'), + hours: 2, + content: '补充日报', + }), + }); + }); +}); diff --git a/apps/server/src/modules/task-worklog/task-worklog.service.ts b/apps/server/src/modules/task-worklog/task-worklog.service.ts new file mode 100644 index 0000000..e310d14 --- /dev/null +++ b/apps/server/src/modules/task-worklog/task-worklog.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateTaskWorklogDto } from './dto/create-task-worklog.dto'; + +@Injectable() +export class TaskWorklogService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity?: WorkActivityService, + ) {} + + async create(dto: CreateTaskWorklogDto) { + const item = await this.prisma.taskWorklog.create({ + data: { + versionId: dto.versionId ?? null, + productId: dto.productId ?? null, + projectId: dto.projectId ?? null, + userId: dto.userId, + sourceType: dto.sourceType ?? 'dev_task', + sourceId: dto.taskId, + sourceVersionId: dto.versionId ?? null, + workDate: parseDateOnly(dto.date), + hours: dto.hours ?? 0, + content: dto.workContent ?? '', + }, + }); + if (dto.versionId) await this.workActivity?.markXiaobaoSummaryDirty(dto.versionId); + return item; + } + + findAll() { + return this.prisma.taskWorklog.findMany({ orderBy: { createdAt: 'desc' } }); + } + + async remove(id: string) { + await this.prisma.taskWorklog.deleteMany({ where: { id } }); + return { deleted: true }; + } +} + +function parseDateOnly(value: string): Date { + return new Date(`${value.slice(0, 10)}T00:00:00.000Z`); +} diff --git a/apps/server/src/modules/test-case/dto/create-test-case.dto.ts b/apps/server/src/modules/test-case/dto/create-test-case.dto.ts new file mode 100644 index 0000000..7a07a88 --- /dev/null +++ b/apps/server/src/modules/test-case/dto/create-test-case.dto.ts @@ -0,0 +1,86 @@ +import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateTestCaseDto { + @IsString() + @IsOptional() + requirementId?: string; + + @IsString() + @IsOptional() + requirementProductId?: string; + + @IsString() + @IsOptional() + categoryId?: string; + + @IsString() + @IsOptional() + code?: string; + + @IsString() + @IsOptional() + caseNo?: string; + + @IsString() + title!: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsOptional() + roundNo?: number; + + @IsOptional() + priority?: string | number; + + @IsString() + @IsOptional() + assigneeId?: string; + + @IsString() + @IsOptional() + creatorId?: string; + + @IsString() + @IsOptional() + createdBy?: string; + + @IsString() + @IsOptional() + plannedTestAt?: string; + + @IsString() + @IsOptional() + plannedEndAt?: string; + + @IsString() + @IsOptional() + startedAt?: string; + + @IsString() + @IsOptional() + completedAt?: string; + + @IsOptional() + estimateHours?: number; + + @IsOptional() + aiEstimateHours?: number; + + @IsArray() + @IsOptional() + references?: unknown[]; + + @IsBoolean() + @IsOptional() + aiDraft?: boolean; + + @IsString() + @IsOptional() + aiDraftAt?: string; +} diff --git a/apps/server/src/modules/test-case/dto/update-test-case.dto.ts b/apps/server/src/modules/test-case/dto/update-test-case.dto.ts new file mode 100644 index 0000000..d3cc875 --- /dev/null +++ b/apps/server/src/modules/test-case/dto/update-test-case.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTestCaseDto } from './create-test-case.dto'; + +export class UpdateTestCaseDto extends PartialType(CreateTestCaseDto) {} diff --git a/apps/server/src/modules/test-case/test-case.controller.ts b/apps/server/src/modules/test-case/test-case.controller.ts new file mode 100644 index 0000000..befdc01 --- /dev/null +++ b/apps/server/src/modules/test-case/test-case.controller.ts @@ -0,0 +1,72 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateTestCaseDto } from './dto/create-test-case.dto'; +import { UpdateTestCaseDto } from './dto/update-test-case.dto'; +import { TestCaseService } from './test-case.service'; + +@Controller('versions/:versionId/test-cases') +export class TestCaseController { + constructor(private readonly testCaseService: TestCaseService) {} + + @Post() + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.create', + entityType: 'test_case', + versionIdParam: 'versionId', + }) + create(@Param('versionId') versionId: string, @Body() dto: CreateTestCaseDto) { + return this.testCaseService.create(versionId, dto); + } + + @Post('batch') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.batch_create', + entityType: 'test_case', + versionIdParam: 'versionId', + }) + createMany(@Param('versionId') versionId: string, @Body('items') items: CreateTestCaseDto[]) { + return this.testCaseService.createMany(versionId, items ?? []); + } + + @Get() + findAll(@Param('versionId') versionId: string) { + return this.testCaseService.findAll(versionId); + } + + @Patch(':id') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.update', + entityType: 'test_case', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateTestCaseDto) { + return this.testCaseService.update(versionId, id, dto); + } + + @Patch(':id/status') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.status', + entityType: 'test_case', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + updateStatus( + @Param('versionId') versionId: string, + @Param('id') id: string, + @Body('status') status: string, + ) { + return this.testCaseService.updateStatus(versionId, id, status); + } + + @Delete(':id') + @ProtectedMutation('version.testcase:manage', { versionIdParam: 'versionId' }, { + action: 'test_case.delete', + entityType: 'test_case', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + remove(@Param('versionId') versionId: string, @Param('id') id: string) { + return this.testCaseService.remove(versionId, id); + } +} diff --git a/apps/server/src/modules/test-case/test-case.module.ts b/apps/server/src/modules/test-case/test-case.module.ts new file mode 100644 index 0000000..ee97dad --- /dev/null +++ b/apps/server/src/modules/test-case/test-case.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { TestCaseController } from './test-case.controller'; +import { TestCaseService } from './test-case.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [TestCaseController], + providers: [TestCaseService], + exports: [TestCaseService], +}) +export class TestCaseModule {} diff --git a/apps/server/src/modules/test-case/test-case.service.spec.ts b/apps/server/src/modules/test-case/test-case.service.spec.ts new file mode 100644 index 0000000..bffd6b4 --- /dev/null +++ b/apps/server/src/modules/test-case/test-case.service.spec.ts @@ -0,0 +1,141 @@ +import { NotFoundException } from '@nestjs/common'; +import { TestCaseService } from './test-case.service'; + +describe('TestCaseService domain writes', () => { + const makeService = () => { + const workActivity = { + record: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }; + const prisma = { + version: { + findUnique: jest.fn(), + }, + testCase: { + create: jest.fn(), + createMany: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + return { + prisma, + workActivity, + service: new TestCaseService(prisma as any, workActivity as any), + }; + }; + + it('creates test cases directly under a version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.testCase.create.mockResolvedValue({ id: 'tc-1', versionId: 'version-1', title: '登录冒烟' }); + + await service.create('version-1', { + requirementId: 'req-1', + requirementProductId: 'product-1', + caseNo: 'TC-001', + title: '登录冒烟', + categoryId: 'cat-test', + assigneeId: 'tester-1', + priority: 'P1', + plannedTestAt: '2026-07-08T09:00:00.000Z', + plannedEndAt: '2026-07-08T10:00:00.000Z', + estimateHours: 1, + references: [{ type: 'requirement', id: 'REQ-001', label: 'REQ-001 登录' }], + createdBy: 'member-pm', + } as any); + + expect(prisma.testCase.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + requirementId: 'req-1', + requirementProductId: 'product-1', + code: 'TC-001', + title: '登录冒烟', + categoryId: 'cat-test', + assigneeId: 'tester-1', + priority: 1, + plannedTestAt: new Date('2026-07-08T09:00:00.000Z'), + plannedEndAt: new Date('2026-07-08T10:00:00.000Z'), + estimateHours: 1, + status: 'pending', + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'test_case', + sourceId: 'tc-1', + action: 'test_case_created', + })); + }); + + it('changes status by id plus version id and records activity evidence', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.testCase.findFirst.mockResolvedValue({ + id: 'tc-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '登录冒烟', + assigneeId: 'tester-1', + }); + prisma.testCase.update.mockResolvedValue({ + id: 'tc-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'running', + title: '登录冒烟', + assigneeId: 'tester-1', + }); + + await service.updateStatus('version-1', 'tc-1', 'running'); + + expect(prisma.testCase.update).toHaveBeenCalledWith({ + where: { id_versionId: { id: 'tc-1', versionId: 'version-1' } }, + data: expect.objectContaining({ + status: 'running', + startedAt: expect.any(Date), + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'test_case', + sourceId: 'tc-1', + action: 'test_case_started', + })); + }); + + it('creates copied round test cases in the same version partition', async () => { + const { prisma, service } = makeService(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.testCase.createMany.mockResolvedValue({ count: 2 }); + prisma.testCase.findMany.mockResolvedValue([{ id: 'tc-copy-1' }, { id: 'tc-copy-2' }]); + + await service.createMany('version-1', [ + { caseNo: 'TC-101', title: '第二轮登录', roundNo: 2, createdBy: 'tester-1' }, + { caseNo: 'TC-102', title: '第二轮支付', roundNo: 2, createdBy: 'tester-1' }, + ] as any); + + expect(prisma.testCase.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ versionId: 'version-1', productId: 'product-1', projectId: 'project-1', roundNo: 2, status: 'pending' }), + expect.objectContaining({ versionId: 'version-1', productId: 'product-1', projectId: 'project-1', roundNo: 2, status: 'pending' }), + ], + skipDuplicates: true, + }); + }); + + it('rejects updates outside the version partition', async () => { + const { prisma, service } = makeService(); + prisma.testCase.findFirst.mockResolvedValue(null); + + await expect(service.update('version-1', 'missing-case', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.testCase.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/test-case/test-case.service.ts b/apps/server/src/modules/test-case/test-case.service.ts new file mode 100644 index 0000000..7e658c5 --- /dev/null +++ b/apps/server/src/modules/test-case/test-case.service.ts @@ -0,0 +1,194 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateTestCaseDto } from './dto/create-test-case.dto'; +import { UpdateTestCaseDto } from './dto/update-test-case.dto'; + +@Injectable() +export class TestCaseService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity: WorkActivityService, + ) {} + + async create(versionId: string, dto: CreateTestCaseDto) { + const version = await this.ensureVersion(versionId); + const item = await this.prisma.testCase.create({ + data: { + ...this.toTestCaseData(dto), + versionId, + productId: version.productId, + projectId: version.projectId, + code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'), + title: dto.title, + status: dto.status ?? 'pending', + }, + }); + const activity = await this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`); + return { item, activities: [activity] }; + } + + async createMany(versionId: string, dtos: CreateTestCaseDto[]) { + if (dtos.length === 0) return { items: [], activities: [] }; + const version = await this.ensureVersion(versionId); + const rows = dtos.map((dto) => ({ + ...this.toTestCaseData(dto), + versionId, + productId: version.productId, + projectId: version.projectId, + code: dto.code?.trim() || dto.caseNo?.trim() || createFallbackCode('TC'), + title: dto.title, + status: dto.status ?? 'pending', + })); + await this.prisma.testCase.createMany({ data: rows, skipDuplicates: true }); + const items = await this.prisma.testCase.findMany({ + where: { versionId, code: { in: rows.map((row) => row.code) } }, + }); + const activities = await Promise.all(items.map((item) => ( + this.recordTestCaseActivity(item, 'test_case_created', 'creation', `新建测试用例:${item.title}`) + ))); + return { items, activities }; + } + + findAll(versionId: string) { + return this.prisma.testCase.findMany({ + where: { versionId }, + orderBy: [{ roundNo: 'asc' }, { code: 'asc' }], + }); + } + + async update(versionId: string, id: string, dto: UpdateTestCaseDto) { + await this.ensureTestCaseInVersion(versionId, id); + const item = await this.prisma.testCase.update({ + where: { id_versionId: { id, versionId } }, + data: this.toTestCaseData(dto), + }); + return { item, activities: [] }; + } + + async updateStatus(versionId: string, id: string, status: string) { + const current = await this.ensureTestCaseInVersion(versionId, id); + const data: Record = { status }; + if (status === 'running' && !current.startedAt) data.startedAt = new Date(); + if ((status === 'passed' || status === 'failed' || status === 'blocked') && !current.completedAt) { + data.completedAt = new Date(); + } + const item = await this.prisma.testCase.update({ + where: { id_versionId: { id, versionId } }, + data, + }); + const activity = await this.recordStatusActivity(item, current.status, status); + return { item, activities: activity ? [activity] : [] }; + } + + async remove(versionId: string, id: string) { + await this.ensureTestCaseInVersion(versionId, id); + return this.prisma.testCase.delete({ where: { id_versionId: { id, versionId } } }); + } + + private toTestCaseData(dto: Partial) { + return { + ...(dto.requirementId !== undefined && { requirementId: emptyToNull(dto.requirementId) }), + ...(dto.requirementProductId !== undefined && { requirementProductId: emptyToNull(dto.requirementProductId) }), + ...(dto.categoryId !== undefined && { categoryId: emptyToNull(dto.categoryId) }), + ...(dto.code !== undefined || dto.caseNo !== undefined ? { code: dto.code?.trim() || dto.caseNo?.trim() } : {}), + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description ?? '' }), + ...(dto.status !== undefined && { status: dto.status }), + ...(dto.roundNo !== undefined && { roundNo: normalizeRoundNo(dto.roundNo) }), + ...(dto.priority !== undefined && { priority: parsePriority(dto.priority) ?? 0 }), + ...(dto.assigneeId !== undefined && { assigneeId: emptyToNull(dto.assigneeId) }), + ...(dto.creatorId !== undefined || dto.createdBy !== undefined ? { creatorId: emptyToNull(dto.creatorId ?? dto.createdBy) } : {}), + ...(dto.plannedTestAt !== undefined && { plannedTestAt: parseOptionalDate(dto.plannedTestAt) }), + ...(dto.plannedEndAt !== undefined && { plannedEndAt: parseOptionalDate(dto.plannedEndAt) }), + ...(dto.startedAt !== undefined && { startedAt: parseOptionalDate(dto.startedAt) }), + ...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }), + ...(dto.estimateHours !== undefined && { estimateHours: dto.estimateHours }), + ...(dto.aiEstimateHours !== undefined && { aiEstimateHours: dto.aiEstimateHours }), + ...(dto.references !== undefined && { references: toJsonInput(dto.references) }), + ...(dto.aiDraft !== undefined && { aiDraft: dto.aiDraft }), + ...(dto.aiDraftAt !== undefined && { aiDraftAt: parseOptionalDate(dto.aiDraftAt) }), + }; + } + + private async ensureVersion(versionId: string) { + const version = await this.prisma.version.findUnique({ where: { id: versionId } }); + if (!version) throw new NotFoundException('版本不存在'); + if (!version.projectId) throw new BadRequestException('测试用例所属版本必须归属于项目'); + return { ...version, projectId: version.projectId }; + } + + private async ensureTestCaseInVersion(versionId: string, id: string) { + const testCase = await this.prisma.testCase.findFirst({ where: { id, versionId } }); + if (!testCase) throw new NotFoundException('测试用例不存在'); + return testCase; + } + + private recordStatusActivity(testCase: any, fromStatus: string, toStatus: string) { + if (toStatus === 'running') { + return this.recordTestCaseActivity(testCase, 'test_case_started', 'progress', `开始测试:${testCase.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'passed') { + return this.recordTestCaseActivity(testCase, 'test_case_passed', 'delivery', `测试通过:${testCase.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'failed') { + return this.recordTestCaseActivity(testCase, 'test_case_failed', 'risk', `测试不通过:${testCase.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'blocked') { + return this.recordTestCaseActivity(testCase, 'test_case_blocked', 'risk', `测试阻塞:${testCase.title}`, { fromStatus, toStatus }); + } + return undefined; + } + + private recordTestCaseActivity(testCase: any, action: string, category: string, summary: string, metadata: Record = {}) { + return this.workActivity.record({ + versionId: testCase.versionId, + productId: testCase.productId, + projectId: testCase.projectId, + actorId: testCase.assigneeId ?? testCase.creatorId, + sourceType: 'test_case', + sourceId: testCase.id, + action, + category, + title: testCase.title, + summary, + metadata, + }); + } +} + +function createFallbackCode(prefix: string) { + return `${prefix}-${Date.now().toString(36).toUpperCase()}`; +} + +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 parseOptionalDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function normalizeRoundNo(value: number | null | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return 1; + return Math.floor(value); +} + +function parsePriority(value: string | number | null | undefined): number | undefined { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'number') return Number.isFinite(value) ? Math.max(0, Math.min(4, Math.floor(value))) : undefined; + 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))) : undefined; +} + +function toJsonInput(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} diff --git a/apps/server/src/modules/v25-domain-mutation-contracts.spec.ts b/apps/server/src/modules/v25-domain-mutation-contracts.spec.ts new file mode 100644 index 0000000..e793dc7 --- /dev/null +++ b/apps/server/src/modules/v25-domain-mutation-contracts.spec.ts @@ -0,0 +1,77 @@ +import { Reflector } from '@nestjs/core'; +import { PERMISSION_METADATA_KEY, type RequiredPermissionMetadata } from '../common/auth/permission.decorator'; +import { AUDIT_MUTATION_METADATA_KEY, type AuditMutationMetadata } from '../common/audit/audit-mutation.decorator'; +import { ProductController } from './product/product.controller'; +import { ProjectController } from './project/project.controller'; +import { VersionController } from './version/version.controller'; +import { RequirementController } from './requirement/requirement.controller'; +import { VersionPlanController } from './version-plan/version-plan.controller'; +import { DevTaskController } from './dev-task/dev-task.controller'; +import { TestCaseController } from './test-case/test-case.controller'; +import { BugController } from './bug/bug.controller'; +import { MemberController } from './member/member.controller'; +import { TaskCategoryController } from './task-category/task-category.controller'; +import { TaskWorklogController } from './task-worklog/task-worklog.controller'; +import { OvertimeController } from './overtime/overtime.controller'; +import { WorkActivityController } from './work-activity/work-activity.controller'; + +describe('V2.5 domain mutation contracts', () => { + const reflector = new Reflector(); + + const cases: Array<[Function, string, RequiredPermissionMetadata, AuditMutationMetadata]> = [ + [ProductController, 'create', { permission: 'product:create' }, { action: 'product.create', entityType: 'product' }], + [ProductController, 'update', { permission: 'product:edit', productIdParam: 'id' }, { action: 'product.update', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }], + [ProductController, 'remove', { permission: 'product:delete', productIdParam: 'id' }, { action: 'product.delete', entityType: 'product', entityIdParam: 'id', productIdParam: 'id' }], + [ProjectController, 'create', { permission: 'project:create', productIdParam: 'productId' }, { action: 'project.create', entityType: 'project', productIdParam: 'productId' }], + [ProjectController, 'update', { permission: 'project:edit', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.update', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }], + [ProjectController, 'remove', { permission: 'project:delete', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'project.delete', entityType: 'project', entityIdParam: 'projectId', productIdParam: 'productId', projectIdParam: 'projectId' }], + [VersionController, 'create', { permission: 'version:create', productIdParam: 'productId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId' }], + [VersionController, 'createForProject', { permission: 'version:create', productIdParam: 'productId', projectIdParam: 'projectId' }, { action: 'version.create', entityType: 'version', productIdParam: 'productId', projectIdParam: 'projectId' }], + [VersionController, 'update', { permission: 'version:edit', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.update', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }], + [VersionController, 'remove', { permission: 'version:delete', productIdParam: 'productId', versionIdParam: 'versionId' }, { action: 'version.delete', entityType: 'version', entityIdParam: 'versionId', productIdParam: 'productId', versionIdParam: 'versionId' }], + [RequirementController, 'create', { permission: 'requirement:create', productIdParam: 'productId' }, { action: 'requirement.create', entityType: 'requirement', productIdParam: 'productId' }], + [RequirementController, 'update', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.update', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }], + [RequirementController, 'updateStatus', { permission: 'requirement:edit', productIdParam: 'productId' }, { action: 'requirement.status', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }], + [RequirementController, 'remove', { permission: 'requirement:delete', productIdParam: 'productId' }, { action: 'requirement.delete', entityType: 'requirement', entityIdParam: 'id', productIdParam: 'productId' }], + [VersionPlanController, 'create', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.create', entityType: 'version_plan', versionIdParam: 'versionId' }], + [VersionPlanController, 'update', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.update', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }], + [VersionPlanController, 'complete', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.complete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }], + [VersionPlanController, 'remove', { permission: 'version:edit', versionIdParam: 'versionId' }, { action: 'version_plan.delete', entityType: 'version_plan', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'create', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.create', entityType: 'dev_task', versionIdParam: 'versionId' }], + [DevTaskController, 'update', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.update', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'updateStatus', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.status', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'setBlocked', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.block', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'transfer', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.transfer', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [DevTaskController, 'remove', { permission: 'version.devtask:manage', versionIdParam: 'versionId' }, { action: 'dev_task.delete', entityType: 'dev_task', entityIdParam: 'id', versionIdParam: 'versionId' }], + [TestCaseController, 'create', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.create', entityType: 'test_case', versionIdParam: 'versionId' }], + [TestCaseController, 'createMany', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.batch_create', entityType: 'test_case', versionIdParam: 'versionId' }], + [TestCaseController, 'update', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.update', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }], + [TestCaseController, 'updateStatus', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.status', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }], + [TestCaseController, 'remove', { permission: 'version.testcase:manage', versionIdParam: 'versionId' }, { action: 'test_case.delete', entityType: 'test_case', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'create', { permission: 'version.bug:create', versionIdParam: 'versionId' }, { action: 'bug.create', entityType: 'bug', versionIdParam: 'versionId' }], + [BugController, 'update', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.update', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'updateStatus', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.status', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'transfer', { permission: 'version.bug:edit', versionIdParam: 'versionId' }, { action: 'bug.transfer', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [BugController, 'remove', { permission: 'version.bug:delete', versionIdParam: 'versionId' }, { action: 'bug.delete', entityType: 'bug', entityIdParam: 'id', versionIdParam: 'versionId' }], + [MemberController, 'create', { permission: 'member:create' }, { action: 'member.create', entityType: 'member' }], + [MemberController, 'update', { permission: 'member:edit' }, { action: 'member.update', entityType: 'member', entityIdParam: 'id' }], + [MemberController, 'remove', { permission: 'member:delete' }, { action: 'member.delete', entityType: 'member', entityIdParam: 'id' }], + [TaskCategoryController, 'create', { permission: 'task-category:manage' }, { action: 'task_category.create', entityType: 'task_category' }], + [TaskCategoryController, 'update', { permission: 'task-category:manage' }, { action: 'task_category.update', entityType: 'task_category', entityIdParam: 'id' }], + [TaskCategoryController, 'remove', { permission: 'task-category:manage' }, { action: 'task_category.delete', entityType: 'task_category', entityIdParam: 'id' }], + [TaskWorklogController, 'create', { permission: 'version.devtask:manage', versionIdBody: 'versionId' }, { action: 'task_worklog.create', entityType: 'task_worklog', versionIdBody: 'versionId' }], + [TaskWorklogController, 'remove', { permission: 'version.devtask:manage' }, { action: 'task_worklog.delete', entityType: 'task_worklog', entityIdParam: 'id' }], + [OvertimeController, 'create', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.create', entityType: 'overtime', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }], + [OvertimeController, 'update', { permission: 'overtime:create', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'overtime.update', entityType: 'overtime', entityIdParam: 'id', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }], + [OvertimeController, 'remove', { permission: 'overtime:delete' }, { action: 'overtime.delete', entityType: 'overtime', entityIdParam: 'id' }], + [WorkActivityController, 'create', { permission: 'work-activity:manage', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }, { action: 'work_activity.create', entityType: 'work_activity', versionIdBody: 'versionId', projectIdBody: 'projectId', productIdBody: 'productId' }], + [WorkActivityController, 'remove', { permission: 'work-activity:manage' }, { action: 'work_activity.delete', entityType: 'work_activity', entityIdParam: 'id' }], + ]; + + it.each(cases)('%p.%s declares server permission and audit metadata', (controller, methodName, permission, audit) => { + const handler = controller.prototype[methodName]; + + expect(reflector.get(PERMISSION_METADATA_KEY, handler)).toEqual(permission); + expect(reflector.get(AUDIT_MUTATION_METADATA_KEY, handler)).toEqual(audit); + }); +}); diff --git a/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts b/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts new file mode 100644 index 0000000..b3a352c --- /dev/null +++ b/apps/server/src/modules/version-plan/dto/create-version-plan.dto.ts @@ -0,0 +1,59 @@ +import { IsArray, IsIn, IsOptional, IsString } from 'class-validator'; + +export class CreateVersionPlanDto { + @IsIn(['research', 'product', 'ui']) + type!: 'research' | 'product' | 'ui'; + + @IsString() + title!: string; + + @IsString() + @IsOptional() + status?: string; + + @IsString() + @IsOptional() + owner?: string; + + @IsString() + @IsOptional() + ownerId?: string; + + @IsString() + @IsOptional() + startTime?: string; + + @IsString() + @IsOptional() + endTime?: string; + + @IsString() + @IsOptional() + expectedStartAt?: string; + + @IsString() + @IsOptional() + expectedEndAt?: string; + + @IsString() + @IsOptional() + actualStartAt?: string; + + @IsString() + @IsOptional() + completedAt?: string; + + @IsString() + @IsOptional() + resultUrl?: string; + + @IsArray() + @IsOptional() + linkedRequirementIds?: string[]; + + @IsOptional() + requirementCoverage?: unknown[]; + + @IsOptional() + logs?: unknown[]; +} diff --git a/apps/server/src/modules/version-plan/dto/update-version-plan.dto.ts b/apps/server/src/modules/version-plan/dto/update-version-plan.dto.ts new file mode 100644 index 0000000..7ecf85e --- /dev/null +++ b/apps/server/src/modules/version-plan/dto/update-version-plan.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVersionPlanDto } from './create-version-plan.dto'; + +export class UpdateVersionPlanDto extends PartialType(CreateVersionPlanDto) {} diff --git a/apps/server/src/modules/version-plan/version-plan.controller.ts b/apps/server/src/modules/version-plan/version-plan.controller.ts new file mode 100644 index 0000000..035f82f --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { CreateVersionPlanDto } from './dto/create-version-plan.dto'; +import { UpdateVersionPlanDto } from './dto/update-version-plan.dto'; +import { VersionPlanService } from './version-plan.service'; + +@Controller('versions/:versionId/plans') +export class VersionPlanController { + constructor(private readonly versionPlanService: VersionPlanService) {} + + @Post() + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.create', + entityType: 'version_plan', + versionIdParam: 'versionId', + }) + create(@Param('versionId') versionId: string, @Body() dto: CreateVersionPlanDto) { + return this.versionPlanService.create(versionId, dto); + } + + @Get() + findAll(@Param('versionId') versionId: string) { + return this.versionPlanService.findAll(versionId); + } + + @Patch(':id') + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.update', + entityType: 'version_plan', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + update(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) { + return this.versionPlanService.update(versionId, id, dto); + } + + @Patch(':id/complete') + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.complete', + entityType: 'version_plan', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + complete(@Param('versionId') versionId: string, @Param('id') id: string, @Body() dto: UpdateVersionPlanDto) { + return this.versionPlanService.complete(versionId, id, dto); + } + + @Delete(':id') + @ProtectedMutation('version:edit', { versionIdParam: 'versionId' }, { + action: 'version_plan.delete', + entityType: 'version_plan', + entityIdParam: 'id', + versionIdParam: 'versionId', + }) + remove(@Param('versionId') versionId: string, @Param('id') id: string) { + return this.versionPlanService.remove(versionId, id); + } +} diff --git a/apps/server/src/modules/version-plan/version-plan.module.ts b/apps/server/src/modules/version-plan/version-plan.module.ts new file mode 100644 index 0000000..d63834d --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityModule } from '../work-activity/work-activity.module'; +import { VersionPlanController } from './version-plan.controller'; +import { VersionPlanService } from './version-plan.service'; + +@Module({ + imports: [WorkActivityModule], + controllers: [VersionPlanController], + providers: [VersionPlanService], + exports: [VersionPlanService], +}) +export class VersionPlanModule {} diff --git a/apps/server/src/modules/version-plan/version-plan.service.spec.ts b/apps/server/src/modules/version-plan/version-plan.service.spec.ts new file mode 100644 index 0000000..139bdc8 --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.service.spec.ts @@ -0,0 +1,109 @@ +import { NotFoundException } from '@nestjs/common'; +import { VersionPlanService } from './version-plan.service'; + +describe('VersionPlanService domain writes', () => { + const makeService = () => { + const workActivity = { + record: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }; + const prisma = { + version: { + findUnique: jest.fn(), + }, + versionPlan: { + create: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + return { + prisma, + workActivity, + service: new VersionPlanService(prisma as any, workActivity as any), + }; + }; + + it('creates version plans directly under a version partition', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.version.findUnique.mockResolvedValue({ id: 'version-1', productId: 'product-1', projectId: 'project-1' }); + prisma.versionPlan.create.mockResolvedValue({ id: 'plan-1', versionId: 'version-1', title: '产品方案' }); + + await service.create('version-1', { + type: 'product', + title: '产品方案', + owner: 'member-1', + startTime: '2026-07-08T09:00:00.000Z', + endTime: '2026-07-08T18:00:00.000Z', + linkedRequirementIds: ['req-1'], + } as any); + + expect(prisma.versionPlan.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + type: 'product', + title: '产品方案', + ownerId: 'member-1', + expectedStartAt: new Date('2026-07-08T09:00:00.000Z'), + expectedEndAt: new Date('2026-07-08T18:00:00.000Z'), + requirementCoverage: [{ requirementId: 'req-1', status: 'not_started' }], + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'version_plan', + sourceId: 'plan-1', + action: 'version_plan_created', + })); + }); + + it('updates plan status inside version scope and records activity evidence', async () => { + const { prisma, workActivity, service } = makeService(); + prisma.versionPlan.findFirst.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'pending', + title: '产品方案', + ownerId: 'member-1', + }); + prisma.versionPlan.update.mockResolvedValue({ + id: 'plan-1', + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + status: 'in_progress', + title: '产品方案', + ownerId: 'member-1', + }); + + await service.update('version-1', 'plan-1', { status: 'in_progress' }); + + expect(prisma.versionPlan.update).toHaveBeenCalledWith({ + where: { id: 'plan-1' }, + data: expect.objectContaining({ + status: 'in_progress', + actualStartAt: expect.any(Date), + }), + }); + expect(workActivity.record).toHaveBeenCalledWith(expect.objectContaining({ + versionId: 'version-1', + sourceType: 'version_plan', + sourceId: 'plan-1', + action: 'version_plan_started', + })); + }); + + it('rejects plan updates outside the version partition', async () => { + const { prisma, service } = makeService(); + prisma.versionPlan.findFirst.mockResolvedValue(null); + + await expect(service.update('version-1', 'missing-plan', { title: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException); + expect(prisma.versionPlan.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/modules/version-plan/version-plan.service.ts b/apps/server/src/modules/version-plan/version-plan.service.ts new file mode 100644 index 0000000..43a5ad4 --- /dev/null +++ b/apps/server/src/modules/version-plan/version-plan.service.ts @@ -0,0 +1,152 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { WorkActivityService } from '../work-activity/work-activity.service'; +import { CreateVersionPlanDto } from './dto/create-version-plan.dto'; +import { UpdateVersionPlanDto } from './dto/update-version-plan.dto'; + +@Injectable() +export class VersionPlanService { + constructor( + private readonly prisma: PrismaService, + private readonly workActivity: WorkActivityService, + ) {} + + async create(versionId: string, dto: CreateVersionPlanDto) { + const version = await this.ensureVersion(versionId); + const item = await this.prisma.versionPlan.create({ + data: { + ...this.toPlanData(dto), + versionId, + productId: version.productId, + projectId: version.projectId, + type: dto.type, + title: dto.title, + status: dto.status ?? 'pending', + }, + }); + const activity = await this.recordPlanActivity(item, 'version_plan_created', 'creation', `新建计划:${item.title}`); + return { item, activities: [activity] }; + } + + findAll(versionId: string) { + return this.prisma.versionPlan.findMany({ + where: { versionId }, + orderBy: [{ type: 'asc' }, { createdAt: 'desc' }], + }); + } + + async update(versionId: string, id: string, dto: UpdateVersionPlanDto) { + const current = await this.ensurePlanInVersion(versionId, id); + const data = this.toPlanData(dto); + if (dto.status === 'in_progress' && !current.actualStartAt) { + data.actualStartAt = new Date(); + } + if (dto.status === 'completed' && !current.completedAt) { + data.completedAt = new Date(); + } + const item = await this.prisma.versionPlan.update({ + where: { id }, + data, + }); + const activity = current.status !== item.status ? await this.recordStatusActivity(item, current.status, item.status) : undefined; + return { item, activities: activity ? [activity] : [] }; + } + + async complete(versionId: string, id: string, dto: UpdateVersionPlanDto) { + return this.update(versionId, id, { + ...dto, + status: 'completed', + completedAt: dto.completedAt ?? new Date().toISOString(), + }); + } + + async remove(versionId: string, id: string) { + await this.ensurePlanInVersion(versionId, id); + return this.prisma.versionPlan.delete({ where: { id } }); + } + + private toPlanData(dto: Partial) { + return { + ...(dto.type !== undefined && { type: dto.type }), + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.status !== undefined && { status: dto.status }), + ...(dto.owner !== undefined || dto.ownerId !== undefined ? { ownerId: emptyToNull(dto.ownerId ?? dto.owner) } : {}), + ...(dto.startTime !== undefined || dto.expectedStartAt !== undefined + ? { expectedStartAt: parseOptionalDate(dto.expectedStartAt ?? dto.startTime) } + : {}), + ...(dto.endTime !== undefined || dto.expectedEndAt !== undefined + ? { expectedEndAt: parseOptionalDate(dto.expectedEndAt ?? dto.endTime) } + : {}), + ...(dto.actualStartAt !== undefined && { actualStartAt: parseOptionalDate(dto.actualStartAt) }), + ...(dto.completedAt !== undefined && { completedAt: parseOptionalDate(dto.completedAt) }), + ...(dto.resultUrl !== undefined && { resultUrl: emptyToNull(dto.resultUrl) }), + ...(dto.requirementCoverage !== undefined || dto.linkedRequirementIds !== undefined + ? { requirementCoverage: toJsonInput(dto.requirementCoverage ?? buildRequirementCoverage(dto.linkedRequirementIds)) } + : {}), + ...(dto.logs !== undefined && { logs: toJsonInput(dto.logs) }), + }; + } + + private async ensureVersion(versionId: string) { + const version = await this.prisma.version.findUnique({ where: { id: versionId } }); + if (!version) throw new NotFoundException('版本不存在'); + return version; + } + + private async ensurePlanInVersion(versionId: string, id: string) { + const plan = await this.prisma.versionPlan.findFirst({ where: { id, versionId } }); + if (!plan) throw new NotFoundException('计划不存在'); + return plan; + } + + private recordStatusActivity(plan: any, fromStatus: string, toStatus: string) { + if (toStatus === 'in_progress') { + return this.recordPlanActivity(plan, 'version_plan_started', 'progress', `开始计划:${plan.title}`, { fromStatus, toStatus }); + } + if (toStatus === 'completed') { + return this.recordPlanActivity(plan, 'version_plan_completed', 'delivery', `完成计划:${plan.title}`, { fromStatus, toStatus }); + } + return undefined; + } + + private recordPlanActivity(plan: any, action: string, category: string, summary: string, metadata: Record = {}) { + return this.workActivity.record({ + versionId: plan.versionId, + productId: plan.productId, + projectId: plan.projectId, + actorId: plan.ownerId, + sourceType: 'version_plan', + sourceId: plan.id, + action, + category, + title: plan.title, + summary, + metadata, + }); + } +} + +function buildRequirementCoverage(requirementIds: string[] | undefined) { + return (requirementIds ?? []).map((requirementId) => ({ + requirementId, + status: 'not_started', + })); +} + +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 parseOptionalDate(value: string | null | undefined): Date | null { + if (!value) return null; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : null; +} + +function toJsonInput(value: unknown): Prisma.InputJsonValue { + return value as Prisma.InputJsonValue; +} 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..feead76 --- /dev/null +++ b/apps/server/src/modules/version/version.controller.ts @@ -0,0 +1,73 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +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') + @ProtectedMutation('version:create', { productIdParam: 'productId' }, { + action: 'version.create', + entityType: 'version', + productIdParam: 'productId', + }) + 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') + @ProtectedMutation('version:create', { productIdParam: 'productId', projectIdParam: 'projectId' }, { + action: 'version.create', + entityType: 'version', + productIdParam: 'productId', + projectIdParam: 'projectId', + }) + 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') + @ProtectedMutation('version:edit', { productIdParam: 'productId', versionIdParam: 'versionId' }, { + action: 'version.update', + entityType: 'version', + entityIdParam: 'versionId', + productIdParam: 'productId', + versionIdParam: 'versionId', + }) + update( + @Param('productId') productId: string, + @Param('versionId') versionId: string, + @Body() dto: UpdateVersionDto, + ) { + return this.versionService.update(productId, versionId, dto); + } + + @Delete('versions/:versionId') + @ProtectedMutation('version:delete', { productIdParam: 'productId', versionIdParam: 'versionId' }, { + action: 'version.delete', + entityType: 'version', + entityIdParam: 'versionId', + productIdParam: 'productId', + versionIdParam: '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/server/src/modules/work-activity/work-activity.controller.ts b/apps/server/src/modules/work-activity/work-activity.controller.ts new file mode 100644 index 0000000..fa77d69 --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.controller.ts @@ -0,0 +1,35 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ProtectedMutation } from '../../common/audit/protected-mutation.decorator'; +import { WorkActivityRecordInput, WorkActivityService } from './work-activity.service'; + +@Controller('work-activities') +export class WorkActivityController { + constructor(private readonly workActivityService: WorkActivityService) {} + + @Get() + findAll() { + return this.workActivityService.findAll(); + } + + @Post() + @ProtectedMutation('work-activity:manage', { productIdBody: 'productId', projectIdBody: 'projectId', versionIdBody: 'versionId' }, { + action: 'work_activity.create', + entityType: 'work_activity', + productIdBody: 'productId', + projectIdBody: 'projectId', + versionIdBody: 'versionId', + }) + create(@Body() dto: WorkActivityRecordInput) { + return this.workActivityService.record(dto); + } + + @Delete(':id') + @ProtectedMutation('work-activity:manage', {}, { + action: 'work_activity.delete', + entityType: 'work_activity', + entityIdParam: 'id', + }) + remove(@Param('id') id: string) { + return this.workActivityService.remove(id); + } +} diff --git a/apps/server/src/modules/work-activity/work-activity.module.ts b/apps/server/src/modules/work-activity/work-activity.module.ts new file mode 100644 index 0000000..ab70dee --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { WorkActivityController } from './work-activity.controller'; +import { WorkActivityService } from './work-activity.service'; + +@Module({ + controllers: [WorkActivityController], + providers: [WorkActivityService], + exports: [WorkActivityService], +}) +export class WorkActivityModule {} diff --git a/apps/server/src/modules/work-activity/work-activity.service.spec.ts b/apps/server/src/modules/work-activity/work-activity.service.spec.ts new file mode 100644 index 0000000..2540c1c --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.service.spec.ts @@ -0,0 +1,63 @@ +import { WorkActivityService } from './work-activity.service'; + +describe('WorkActivityService relational evidence', () => { + const makeService = () => { + const prisma = { + workActivity: { + create: jest.fn().mockResolvedValue({ id: 'activity-1' }), + }, + xiaobaoRiskSummary: { + upsert: jest.fn().mockResolvedValue({}), + }, + }; + + return { + prisma, + service: new WorkActivityService(prisma as any), + }; + }; + + it('records activity rows and marks the affected version summary dirty', async () => { + const { prisma, service } = makeService(); + + await service.record({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + actorId: 'member-1', + sourceType: 'dev_task', + sourceId: 'task-1', + action: 'dev_task_started', + title: '开发登录', + category: 'progress', + summary: '开始开发:开发登录', + metadata: { fromStatus: 'todo', toStatus: 'in_progress' }, + }); + + expect(prisma.workActivity.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + versionId: 'version-1', + productId: 'product-1', + projectId: 'project-1', + actorId: 'member-1', + sourceType: 'dev_task', + sourceId: 'task-1', + sourceVersionId: 'version-1', + action: 'dev_task_started', + title: '开发登录', + metadata: expect.objectContaining({ + category: 'progress', + summary: '开始开发:开发登录', + fromStatus: 'todo', + toStatus: 'in_progress', + }), + }), + }); + expect(prisma.xiaobaoRiskSummary.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { versionId: 'version-1' }, + update: { dirty: true }, + }), + ); + }); +}); diff --git a/apps/server/src/modules/work-activity/work-activity.service.ts b/apps/server/src/modules/work-activity/work-activity.service.ts new file mode 100644 index 0000000..42305ec --- /dev/null +++ b/apps/server/src/modules/work-activity/work-activity.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +export interface WorkActivityRecordInput { + versionId?: string | null; + productId?: string | null; + projectId?: string | null; + actorId?: string | null; + actorName?: string | null; + sourceType: string; + sourceId: string; + sourceVersionId?: string | null; + action: string; + title: string; + category?: string; + summary?: string; + metadata?: Record; + occurredAt?: string | Date | null; +} + +@Injectable() +export class WorkActivityService { + constructor(private readonly prisma: PrismaService) {} + + findAll() { + return this.prisma.workActivity.findMany({ orderBy: { occurredAt: 'desc' } }); + } + + async record(input: WorkActivityRecordInput) { + const versionId = input.versionId ?? input.sourceVersionId ?? null; + const metadata = { + ...(input.metadata ?? {}), + ...(input.category ? { category: input.category } : {}), + ...(input.summary ? { summary: input.summary } : {}), + }; + + const activity = await this.prisma.workActivity.create({ + data: { + versionId, + productId: input.productId ?? null, + projectId: input.projectId ?? null, + actorId: input.actorId ?? null, + actorName: input.actorName ?? '', + sourceType: input.sourceType, + sourceId: input.sourceId, + sourceVersionId: input.sourceVersionId ?? versionId, + action: input.action, + title: input.title, + metadata, + occurredAt: parseDate(input.occurredAt) ?? new Date(), + }, + }); + + if (versionId) { + await this.markXiaobaoSummaryDirty(versionId); + } + + return activity; + } + + async remove(id: string) { + await this.prisma.workActivity.deleteMany({ where: { id } }); + return { deleted: true }; + } + + async markXiaobaoSummaryDirty(versionId: string) { + const riskSignature = `dirty:${versionId}`; + await this.prisma.xiaobaoRiskSummary.upsert({ + where: { versionId }, + update: { dirty: true }, + create: { + versionId, + riskLevel: 'attention', + riskScore: 1, + confidence: 0, + riskSignature, + summary: { + versionId, + riskLevel: 'attention', + riskScore: 1, + confidence: 0, + riskSignature, + dirty: true, + }, + dirty: true, + }, + }); + } +} + +function parseDate(value: string | Date | null | undefined): Date | undefined { + if (!value) return undefined; + if (value instanceof Date) return value; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date : undefined; +} diff --git a/apps/web/app/admin/audit/page.tsx b/apps/web/app/admin/audit/page.tsx new file mode 100644 index 0000000..3fa38e9 --- /dev/null +++ b/apps/web/app/admin/audit/page.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Clock3, RefreshCw, Search, ShieldCheck } from 'lucide-react'; +import { RouteGuard } from '@/components/auth/Guard'; +import { type AuditEvent, type AuditQuery, listAuditEvents } from '@/lib/audit-api'; + +const EMPTY_QUERY: AuditQuery = { take: '50' }; + +export default function AuditPage() { + return ( + + + + ); +} + +function AuditPageContent() { + const [query, setQuery] = useState(EMPTY_QUERY); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchEvents = async () => { + setLoading(true); + setError(null); + try { + setEvents(await listAuditEvents(query)); + } catch (e: any) { + setError(e?.message ?? '读取审计失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void fetchEvents(); }, []); + + const entityTypes = useMemo( + () => Array.from(new Set(events.map((event) => event.entityType))).sort(), + [events], + ); + + return ( +
+
+
+ +

审计事件

+ + {events.length} + +
+ +
+ +
+
+ setQuery((prev) => ({ ...prev, actorId }))} /> + setQuery((prev) => ({ ...prev, entityType }))} /> + setQuery((prev) => ({ ...prev, entityId }))} /> + setQuery((prev) => ({ ...prev, productId }))} /> + setQuery((prev) => ({ ...prev, projectId }))} /> + setQuery((prev) => ({ ...prev, versionId }))} /> + setQuery((prev) => ({ ...prev, take }))} /> + + + {entityTypes.map((type) => +
+ + {error && ( +
{error}
+ )} + +
+ + + + + + + + + + + + + {events.map((event) => ( + + + + + + + + + ))} + {events.length === 0 && ( + + + + )} + +
时间操作实体操作人作用域结果
{event.action}{event.entityType}{event.entityId}{event.actorName || event.actorId || '-'}{formatScope(event)} +
+ 查看 JSON +
{JSON.stringify({ before: event.before, after: event.after, metadata: event.metadata }, null, 2)}
+
+
+ {loading ? '加载中...' : '暂无审计事件'} +
+
+
+
+ ); +} + +function FilterInput({ label, value, onChange, list }: { label: string; value?: string; onChange: (value: string) => void; list?: string }) { + return ( + + ); +} + +function TimeLabel({ value }: { value: string }) { + return ( + + + {formatDateTime(value)} + + ); +} + +function formatScope(event: AuditEvent) { + return [ + event.productId ? `P:${event.productId}` : '', + event.projectId ? `J:${event.projectId}` : '', + event.versionId ? `V:${event.versionId}` : '', + ].filter(Boolean).join(' / ') || '-'; +} + +function formatDateTime(value: string) { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) return value; + return date.toISOString().slice(0, 16).replace('T', ' '); +} diff --git a/apps/web/app/admin/consistency/page.tsx b/apps/web/app/admin/consistency/page.tsx new file mode 100644 index 0000000..b64f056 --- /dev/null +++ b/apps/web/app/admin/consistency/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, CheckCircle2, Database, RefreshCw } from 'lucide-react'; +import { RouteGuard } from '@/components/auth/Guard'; +import { + type ConsistencyCheckResult, + type ConsistencyResult, + getConsistencyReport, + summarizeConsistencyResult, +} from '@/lib/consistency-api'; + +export default function ConsistencyPage() { + return ( + + + + ); +} + +function ConsistencyPageContent() { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchReport = async () => { + setLoading(true); + setError(null); + try { + setReport(await getConsistencyReport()); + } catch (e: any) { + setError(e?.message ?? '读取一致性报告失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void fetchReport(); }, []); + + const totals = useMemo(() => report ? summarizeConsistencyResult(report) : null, [report]); + + return ( +
+
+
+ +

一致性校验

+ {report && } +
+ +
+ +
+ {error && ( +
{error}
+ )} + + {report && totals && ( + <> +
+ + + + +
+ +
+
+ 数据计数 +
+
+ {Object.entries(report.counts).map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+
+ + + + + + )} + + {!report && !error && ( +
+ {loading ? '校验中...' : '暂无校验结果'} +
+ )} +
+
+ ); +} + +function Metric({ label, value, tone }: { label: string; value: number; tone?: 'ok' | 'warn' | 'error' }) { + const toneClass = tone === 'error' + ? 'text-red-600' + : tone === 'warn' + ? 'text-amber-600' + : tone === 'ok' + ? 'text-emerald-600' + : 'text-[var(--ink)]'; + return ( +
+
{value}
+
{label}
+
+ ); +} + +function CheckGroup({ title, checks }: { title: string; checks: ConsistencyCheckResult[] }) { + return ( +
+
+

{title}

+ {checks.length} +
+
+ {checks.map((check) => ( +
+ +
+
{check.id}
+
{check.message}
+
+
{check.count}
+
+ ))} +
+
+ ); +} + +function StatusPill({ status }: { status: ConsistencyResult['status'] }) { + const ok = status === 'pass'; + return ( + + {ok ? : } + {ok ? 'PASS' : 'FAIL'} + + ); +} + +function SeverityBadge({ severity }: { severity: ConsistencyCheckResult['severity'] }) { + const className = severity === 'error' + ? 'bg-red-50 text-red-700 border-red-200' + : severity === 'warn' + ? 'bg-amber-50 text-amber-700 border-amber-200' + : 'bg-emerald-50 text-emerald-700 border-emerald-200'; + return ( + + {severity} + + ); +} 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' && ( <>