merge: 集成V2.5 AppData退场与权限审计
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ next-env.d.ts
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
apps/server/data/
|
apps/server/data/
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
appdata-archive-*.json
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "requirements" ALTER COLUMN "status" SET DEFAULT 'pending_review';
|
||||||
@@ -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 '{}';
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
@@ -12,6 +12,12 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
name String
|
name String
|
||||||
avatar 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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -76,7 +82,15 @@ model Version {
|
|||||||
projectId String? @map("project_id")
|
projectId String? @map("project_id")
|
||||||
name String
|
name String
|
||||||
description String @default("")
|
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")
|
releaseDate DateTime? @map("release_date")
|
||||||
|
members Json @default("[]")
|
||||||
|
progress Json @default("[]")
|
||||||
|
priority Int?
|
||||||
|
links Json @default("{}")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -95,7 +109,7 @@ model Requirement {
|
|||||||
code String
|
code String
|
||||||
title String
|
title String
|
||||||
description String @default("")
|
description String @default("")
|
||||||
status String @default("draft")
|
status String @default("pending_review")
|
||||||
priority Int @default(0)
|
priority Int @default(0)
|
||||||
type String?
|
type String?
|
||||||
sourceType String? @map("source_type")
|
sourceType String? @map("source_type")
|
||||||
@@ -409,6 +423,26 @@ model AiLog {
|
|||||||
@@map("ai_logs")
|
@@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 {
|
model XiaobaoRiskSummary {
|
||||||
versionId String @id @map("version_id")
|
versionId String @id @map("version_id")
|
||||||
riskLevel String @map("risk_level")
|
riskLevel String @map("risk_level")
|
||||||
|
|||||||
@@ -1,18 +1,54 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
import { ApiTimingInterceptor } from './common/interceptors/api-timing.interceptor';
|
||||||
|
import { AuthModule } from './common/auth/auth.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { AuditModule } from './modules/audit/audit.module';
|
||||||
import { ProductModule } from './modules/product/product.module';
|
import { ProductModule } from './modules/product/product.module';
|
||||||
|
import { ProjectModule } from './modules/project/project.module';
|
||||||
import { RequirementModule } from './modules/requirement/requirement.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 { AiModule } from './modules/ai/ai.module';
|
||||||
import { ConfigModule } from './modules/config/config.module';
|
import { ConfigModule } from './modules/config/config.module';
|
||||||
import { DataModule } from './modules/data/data.module';
|
import { DataModule } from './modules/data/data.module';
|
||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
|
import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||||
|
|
||||||
@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: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
14
apps/server/src/common/audit/audit-mutation.decorator.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
90
apps/server/src/common/audit/audit-mutation.interceptor.ts
Normal file
@@ -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<string, string>;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@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<unknown> {
|
||||||
|
const metadata = this.reflector.getAllAndOverride<AuditMutationMetadata>(AUDIT_MUTATION_METADATA_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!metadata) return next.handle();
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<MutationRequest>();
|
||||||
|
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<string, unknown> | undefined {
|
||||||
|
if (!result || typeof result !== 'object') return undefined;
|
||||||
|
const record = result as Record<string, unknown>;
|
||||||
|
if (record.item && typeof record.item === 'object') return record.item as Record<string, unknown>;
|
||||||
|
if (Array.isArray(record.items) && record.items[0] && typeof record.items[0] === 'object') return record.items[0] as Record<string, unknown>;
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEntityId(metadata: AuditMutationMetadata, request: MutationRequest, entity: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<T extends Record<string, string | undefined>>(value: T): { [K in keyof T]?: string } {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item)) as { [K in keyof T]?: string };
|
||||||
|
}
|
||||||
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
18
apps/server/src/common/audit/protected-mutation.decorator.ts
Normal file
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
70
apps/server/src/common/auth/auth-context.service.ts
Normal file
70
apps/server/src/common/auth/auth-context.service.ts
Normal file
@@ -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<string, string | string[] | undefined>;
|
||||||
|
currentUser?: CurrentUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthContextService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async resolveCurrentUser(request: AuthenticatedRequest): Promise<CurrentUser | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/server/src/common/auth/auth.module.ts
Normal file
11
apps/server/src/common/auth/auth.module.ts
Normal file
@@ -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 {}
|
||||||
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
9
apps/server/src/common/auth/current-user.decorator.ts
Normal file
@@ -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<AuthenticatedRequest>();
|
||||||
|
return request.currentUser ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
20
apps/server/src/common/auth/permission.decorator.ts
Normal file
20
apps/server/src/common/auth/permission.decorator.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
74
apps/server/src/common/auth/permission.guard.spec.ts
Normal file
@@ -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<string, string> = {}) {
|
||||||
|
return {
|
||||||
|
getHandler: () => handler,
|
||||||
|
getClass: () => class TestController {},
|
||||||
|
switchToHttp: () => ({
|
||||||
|
getRequest: () => ({ params, headers: {} }),
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
46
apps/server/src/common/auth/permission.guard.ts
Normal file
46
apps/server/src/common/auth/permission.guard.ts
Normal file
@@ -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<boolean> {
|
||||||
|
const metadata = this.reflector.getAllAndOverride<RequiredPermissionMetadata>(PERMISSION_METADATA_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (!metadata) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<AuthenticatedRequest & {
|
||||||
|
params?: Record<string, string>;
|
||||||
|
body?: Record<string, unknown>;
|
||||||
|
}>();
|
||||||
|
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<string, string>; body?: Record<string, unknown> },
|
||||||
|
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;
|
||||||
|
}
|
||||||
92
apps/server/src/common/auth/permission.service.spec.ts
Normal file
92
apps/server/src/common/auth/permission.service.spec.ts
Normal file
@@ -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>): CurrentUser {
|
||||||
|
return {
|
||||||
|
id: 'user-1',
|
||||||
|
name: 'User One',
|
||||||
|
username: 'user.one',
|
||||||
|
roleId: 'role-dev',
|
||||||
|
email: '',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
167
apps/server/src/common/auth/permission.service.ts
Normal file
167
apps/server/src/common/auth/permission.service.ts
Normal file
@@ -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<string, string[]> = {
|
||||||
|
'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<string, string[]> = {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<ResolvedScope> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppDataRetirementService } from './app-data-retirement.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AppDataRetirementService],
|
||||||
|
exports: [AppDataRetirementService],
|
||||||
|
})
|
||||||
|
export class AppDataRetirementModule {}
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<AppDataKey, AppDataRetirementEntry>;
|
||||||
|
|
||||||
|
@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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal file
20
apps/server/src/modules/audit/audit.controller.spec.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
17
apps/server/src/modules/audit/audit.controller.ts
Normal file
17
apps/server/src/modules/audit/audit.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/audit/audit.module.ts
Normal file
12
apps/server/src/modules/audit/audit.module.ts
Normal file
@@ -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 {}
|
||||||
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal file
74
apps/server/src/modules/audit/audit.service.spec.ts
Normal file
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
90
apps/server/src/modules/audit/audit.service.ts
Normal file
90
apps/server/src/modules/audit/audit.service.ts
Normal file
@@ -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<string, unknown> = {};
|
||||||
|
for (const key of ['actorId', 'entityType', 'entityId', 'productId', 'projectId', 'versionId'] as const) {
|
||||||
|
if (query[key]) where[key] = query[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange: Record<string, Date> = {};
|
||||||
|
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<string, unknown>).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;
|
||||||
|
}
|
||||||
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal file
39
apps/server/src/modules/audit/dto/query-audit-events.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
79
apps/server/src/modules/bug/bug.controller.ts
Normal file
79
apps/server/src/modules/bug/bug.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/bug/bug.module.ts
Normal file
12
apps/server/src/modules/bug/bug.module.ts
Normal file
@@ -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 {}
|
||||||
153
apps/server/src/modules/bug/bug.service.spec.ts
Normal file
153
apps/server/src/modules/bug/bug.service.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
176
apps/server/src/modules/bug/bug.service.ts
Normal file
176
apps/server/src/modules/bug/bug.service.ts
Normal file
@@ -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<string, unknown> = { 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<CreateBugDto>) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
65
apps/server/src/modules/bug/dto/create-bug.dto.ts
Normal file
65
apps/server/src/modules/bug/dto/create-bug.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
4
apps/server/src/modules/bug/dto/update-bug.dto.ts
Normal file
4
apps/server/src/modules/bug/dto/update-bug.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateBugDto } from './create-bug.dto';
|
||||||
|
|
||||||
|
export class UpdateBugDto extends PartialType(CreateBugDto) {}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
186
apps/server/src/modules/consistency/consistency.service.ts
Normal file
186
apps/server/src/modules/consistency/consistency.service.ts
Normal file
@@ -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<string, number>;
|
||||||
|
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<ConsistencyResult> {
|
||||||
|
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<ConsistencyCheckResult> {
|
||||||
|
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<number> {
|
||||||
|
const rows = await this.prisma.$queryRawUnsafe<Array<{ count: bigint | number | string }>>(sql);
|
||||||
|
return Number(rows[0]?.count ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsistencyCheck {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>;
|
||||||
|
sql: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function check(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
severityWhenNonZero: Exclude<ConsistencySeverity, 'ok'>,
|
||||||
|
sql: string,
|
||||||
|
): ConsistencyCheck {
|
||||||
|
return { id, label, severityWhenNonZero, sql };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHumanSummary(
|
||||||
|
status: ConsistencyStatus,
|
||||||
|
errors: number,
|
||||||
|
warnings: number,
|
||||||
|
counts: Record<string, number>,
|
||||||
|
) {
|
||||||
|
return `V2.5 consistency ${status}: ${errors} error(s), ${warnings} warning(s), ${counts.auditEvents ?? 0} audit event(s).`;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AppDataRetirementModule } from '../app-data-retirement/app-data-retirement.module';
|
||||||
import { MigrationModule } from '../migration/migration.module';
|
import { MigrationModule } from '../migration/migration.module';
|
||||||
import { DataController } from './data.controller';
|
import { DataController } from './data.controller';
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MigrationModule],
|
imports: [MigrationModule, AppDataRetirementModule],
|
||||||
controllers: [DataController],
|
controllers: [DataController],
|
||||||
providers: [DataService],
|
providers: [DataService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ describe('DataService', () => {
|
|||||||
const syncService = {
|
const syncService = {
|
||||||
syncAfterAppDataPut: jest.fn(),
|
syncAfterAppDataPut: jest.fn(),
|
||||||
};
|
};
|
||||||
|
const retirementService = {
|
||||||
|
assertWritable: jest.fn(),
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
prisma,
|
prisma,
|
||||||
syncService,
|
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 () => {
|
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 value = [{ id: 'p1', name: 'Product 1' }];
|
||||||
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
const updatedAt = new Date('2026-07-02T08:01:00.000Z');
|
||||||
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
prisma.appData.upsert.mockResolvedValue({ key: 'products-overview', value, updatedAt });
|
||||||
@@ -70,9 +74,34 @@ describe('DataService', () => {
|
|||||||
update: { value },
|
update: { value },
|
||||||
create: { key: 'products-overview', value },
|
create: { key: 'products-overview', value },
|
||||||
});
|
});
|
||||||
|
expect(retirementService.assertWritable).toHaveBeenCalledWith('products-overview');
|
||||||
expect(syncService.syncAfterAppDataPut).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 () => {
|
it('allows supporting business data keys migrated from browser storage', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
const value: unknown[] = [];
|
const value: unknown[] = [];
|
||||||
|
|||||||
@@ -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 { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
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 { AppDataV23SyncService } from '../migration/app-data-v23-sync.service';
|
||||||
import { isAppDataKey } from './data-keys';
|
import { type AppDataKey, isAppDataKey } from './data-keys';
|
||||||
|
|
||||||
type AppDataRow = {
|
type AppDataRow = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -17,6 +18,8 @@ export class DataService {
|
|||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
private readonly appDataSync?: AppDataV23SyncService,
|
private readonly appDataSync?: AppDataV23SyncService,
|
||||||
|
@Optional()
|
||||||
|
private readonly appDataRetirement: AppDataRetirementService = new AppDataRetirementService(),
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(key: string) {
|
async get(key: string) {
|
||||||
@@ -27,6 +30,7 @@ export class DataService {
|
|||||||
|
|
||||||
async put(key: string, value: unknown, version?: string | null) {
|
async put(key: string, value: unknown, version?: string | null) {
|
||||||
this.ensureAllowedKey(key);
|
this.ensureAllowedKey(key);
|
||||||
|
this.appDataRetirement.assertWritable(key);
|
||||||
const jsonValue = value as Prisma.InputJsonValue;
|
const jsonValue = value as Prisma.InputJsonValue;
|
||||||
|
|
||||||
if (version === null) {
|
if (version === null) {
|
||||||
@@ -66,7 +70,7 @@ export class DataService {
|
|||||||
return this.toResponseAfterSync(key, row);
|
return this.toResponseAfterSync(key, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureAllowedKey(key: string) {
|
private ensureAllowedKey(key: string): asserts key is AppDataKey {
|
||||||
if (!isAppDataKey(key)) {
|
if (!isAppDataKey(key)) {
|
||||||
throw new BadRequestException(`Unsupported data key: ${key}`);
|
throw new BadRequestException(`Unsupported data key: ${key}`);
|
||||||
}
|
}
|
||||||
|
|||||||
93
apps/server/src/modules/dev-task/dev-task.controller.ts
Normal file
93
apps/server/src/modules/dev-task/dev-task.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/dev-task/dev-task.module.ts
Normal file
12
apps/server/src/modules/dev-task/dev-task.module.ts
Normal file
@@ -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 {}
|
||||||
153
apps/server/src/modules/dev-task/dev-task.service.spec.ts
Normal file
153
apps/server/src/modules/dev-task/dev-task.service.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
205
apps/server/src/modules/dev-task/dev-task.service.ts
Normal file
205
apps/server/src/modules/dev-task/dev-task.service.ts
Normal file
@@ -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<string, unknown> = { 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<CreateDevTaskDto>) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
99
apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts
Normal file
99
apps/server/src/modules/dev-task/dto/create-dev-task.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateDevTaskDto } from './create-dev-task.dto';
|
||||||
|
|
||||||
|
export class UpdateDevTaskDto extends PartialType(CreateDevTaskDto) {}
|
||||||
34
apps/server/src/modules/member/dto/create-member.dto.ts
Normal file
34
apps/server/src/modules/member/dto/create-member.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
4
apps/server/src/modules/member/dto/update-member.dto.ts
Normal file
4
apps/server/src/modules/member/dto/update-member.dto.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateMemberDto } from './create-member.dto';
|
||||||
|
|
||||||
|
export class UpdateMemberDto extends PartialType(CreateMemberDto) {}
|
||||||
41
apps/server/src/modules/member/member.controller.ts
Normal file
41
apps/server/src/modules/member/member.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/server/src/modules/member/member.module.ts
Normal file
10
apps/server/src/modules/member/member.module.ts
Normal file
@@ -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 {}
|
||||||
94
apps/server/src/modules/member/member.service.spec.ts
Normal file
94
apps/server/src/modules/member/member.service.spec.ts
Normal file
@@ -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 }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/server/src/modules/member/member.service.ts
Normal file
95
apps/server/src/modules/member/member.service.ts
Normal file
@@ -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<CreateMemberDto>, 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);
|
||||||
|
}
|
||||||
@@ -29,7 +29,15 @@ interface VersionRow {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
status: string;
|
||||||
|
currentStage?: string;
|
||||||
|
startDate?: string;
|
||||||
|
expectedReleaseDate?: string;
|
||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
|
members: unknown[];
|
||||||
|
progress: unknown[];
|
||||||
|
priority?: number;
|
||||||
|
links: unknown;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -348,7 +356,15 @@ export function mapAppDataToV22Rows(appData: Record<string, unknown>): V22Mapped
|
|||||||
projectId,
|
projectId,
|
||||||
name: stringField(version, 'name') ?? versionId,
|
name: stringField(version, 'name') ?? versionId,
|
||||||
description: stringField(version, 'description') ?? '',
|
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'),
|
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,
|
createdAt: stringField(version, 'createdAt') ?? productRow.createdAt,
|
||||||
updatedAt: stringField(version, 'updatedAt') ?? stringField(version, 'createdAt') ?? productRow.updatedAt,
|
updatedAt: stringField(version, 'updatedAt') ?? stringField(version, 'createdAt') ?? productRow.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
34
apps/server/src/modules/overtime/dto/create-overtime.dto.ts
Normal file
34
apps/server/src/modules/overtime/dto/create-overtime.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateOvertimeDto } from './create-overtime.dto';
|
||||||
|
|
||||||
|
export class UpdateOvertimeDto extends PartialType(CreateOvertimeDto) {}
|
||||||
50
apps/server/src/modules/overtime/overtime.controller.ts
Normal file
50
apps/server/src/modules/overtime/overtime.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/overtime/overtime.module.ts
Normal file
12
apps/server/src/modules/overtime/overtime.module.ts
Normal file
@@ -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 {}
|
||||||
37
apps/server/src/modules/overtime/overtime.service.spec.ts
Normal file
37
apps/server/src/modules/overtime/overtime.service.spec.ts
Normal file
@@ -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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
apps/server/src/modules/overtime/overtime.service.ts
Normal file
56
apps/server/src/modules/overtime/overtime.service.ts
Normal file
@@ -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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body } from '@nestjs/common';
|
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 { ProductService } from './product.service';
|
||||||
import { CreateProductDto } from './dto/create-product.dto';
|
import { CreateProductDto } from './dto/create-product.dto';
|
||||||
import { UpdateProductDto } from './dto/update-product.dto';
|
import { UpdateProductDto } from './dto/update-product.dto';
|
||||||
@@ -8,6 +9,7 @@ export class ProductController {
|
|||||||
constructor(private readonly productService: ProductService) {}
|
constructor(private readonly productService: ProductService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('product:create', {}, { action: 'product.create', entityType: 'product' })
|
||||||
create(@Body() dto: CreateProductDto) {
|
create(@Body() dto: CreateProductDto) {
|
||||||
return this.productService.create(dto);
|
return this.productService.create(dto);
|
||||||
}
|
}
|
||||||
@@ -28,11 +30,23 @@ export class ProductController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('product:edit', { productIdParam: 'id' }, {
|
||||||
|
action: 'product.update',
|
||||||
|
entityType: 'product',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'id',
|
||||||
|
})
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||||
return this.productService.update(id, dto);
|
return this.productService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('product:delete', { productIdParam: 'id' }, {
|
||||||
|
action: 'product.delete',
|
||||||
|
entityType: 'product',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'id',
|
||||||
|
})
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.productService.remove(id);
|
return this.productService.remove(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,22 @@ type ProductOverviewItem = {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
projects?: { id: string; name: string; description?: string; createdAt?: 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 };
|
_count?: { requirements?: number; projects?: number; versions?: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,8 +38,16 @@ export class ProductService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAll() {
|
async findAll() {
|
||||||
|
const products = await this.prisma.product.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
_count: { select: { requirements: true, projects: true, versions: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (products.length > 0) return products;
|
||||||
|
|
||||||
const overview = await this.getAppDataOverview();
|
const overview = await this.getAppDataOverview();
|
||||||
if (overview) {
|
if (!overview) return [];
|
||||||
return overview.map((product) => {
|
return overview.map((product) => {
|
||||||
const normalized = this.normalizeOverviewProduct(product);
|
const normalized = this.normalizeOverviewProduct(product);
|
||||||
const { projects: _projects, versions: _versions, ...rest } = normalized;
|
const { projects: _projects, versions: _versions, ...rest } = normalized;
|
||||||
@@ -32,21 +55,8 @@ export class ProductService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.product.findMany({
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
include: {
|
|
||||||
_count: { select: { requirements: true, projects: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAllWithChildren() {
|
async findAllWithChildren() {
|
||||||
const overview = await this.getAppDataOverview();
|
const products = await this.prisma.product.findMany({
|
||||||
if (overview) {
|
|
||||||
return overview.map((product) => this.normalizeOverviewProduct(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.product.findMany({
|
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
projects: {
|
projects: {
|
||||||
@@ -55,34 +65,57 @@ export class ProductService {
|
|||||||
},
|
},
|
||||||
versions: {
|
versions: {
|
||||||
orderBy: { createdAt: 'desc' },
|
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 } },
|
_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) {
|
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({
|
const product = await this.prisma.product.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
requirements: { orderBy: { createdAt: 'desc' } },
|
requirements: { orderBy: { createdAt: 'desc' } },
|
||||||
|
projects: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { id: true, name: true, description: true, createdAt: true },
|
||||||
|
},
|
||||||
versions: { orderBy: { createdAt: 'desc' } },
|
versions: { orderBy: { createdAt: 'desc' } },
|
||||||
_count: { select: { projects: true } },
|
_count: { select: { projects: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!product) throw new NotFoundException('产品不存在');
|
if (product) return 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) {
|
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<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|||||||
11
apps/server/src/modules/project/dto/create-project.dto.ts
Normal file
11
apps/server/src/modules/project/dto/create-project.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateProjectDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateProjectDto } from './create-project.dto';
|
||||||
|
|
||||||
|
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}
|
||||||
53
apps/server/src/modules/project/project.controller.ts
Normal file
53
apps/server/src/modules/project/project.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/server/src/modules/project/project.module.ts
Normal file
9
apps/server/src/modules/project/project.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProjectController } from './project.controller';
|
||||||
|
import { ProjectService } from './project.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProjectController],
|
||||||
|
providers: [ProjectService],
|
||||||
|
})
|
||||||
|
export class ProjectModule {}
|
||||||
83
apps/server/src/modules/project/project.service.spec.ts
Normal file
83
apps/server/src/modules/project/project.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
import { ProjectService } from './project.service';
|
||||||
|
|
||||||
|
describe('ProjectService domain writes', () => {
|
||||||
|
const makeService = () => {
|
||||||
|
const prisma = {
|
||||||
|
product: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
create: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prisma,
|
||||||
|
service: new ProjectService(prisma as any),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates projects directly under a product relation row', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.product.findUnique.mockResolvedValue({ id: 'product-1' });
|
||||||
|
prisma.project.create.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
|
||||||
|
|
||||||
|
await service.create('product-1', { name: 'CRM', description: 'Customer system' });
|
||||||
|
|
||||||
|
expect(prisma.project.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
productId: 'product-1',
|
||||||
|
name: 'CRM',
|
||||||
|
description: 'Customer system',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists projects by product id', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.project.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.findAll('product-1');
|
||||||
|
|
||||||
|
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { productId: 'product-1' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates projects only inside their product scope', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
|
||||||
|
prisma.project.update.mockResolvedValue({ id: 'project-1', name: 'CRM v2' });
|
||||||
|
|
||||||
|
await service.update('product-1', 'project-1', { name: 'CRM v2' });
|
||||||
|
|
||||||
|
expect(prisma.project.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'project-1' },
|
||||||
|
data: { name: 'CRM v2' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when updating a project outside the product scope', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.project.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('product-1', 'project-404', { name: 'Ghost' })).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.project.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes projects only inside their product scope', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.project.findFirst.mockResolvedValue({ id: 'project-1', productId: 'product-1' });
|
||||||
|
prisma.project.delete.mockResolvedValue({ id: 'project-1' });
|
||||||
|
|
||||||
|
await service.remove('product-1', 'project-1');
|
||||||
|
|
||||||
|
expect(prisma.project.delete).toHaveBeenCalledWith({ where: { id: 'project-1' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
53
apps/server/src/modules/project/project.service.ts
Normal file
53
apps/server/src/modules/project/project.service.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { CreateProjectDto } from './dto/create-project.dto';
|
||||||
|
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProjectService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async create(productId: string, dto: CreateProjectDto) {
|
||||||
|
await this.ensureProductExists(productId);
|
||||||
|
return this.prisma.project.create({
|
||||||
|
data: {
|
||||||
|
productId,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(productId: string) {
|
||||||
|
return this.prisma.project.findMany({
|
||||||
|
where: { productId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(productId: string, projectId: string, dto: UpdateProjectDto) {
|
||||||
|
await this.ensureProjectInProduct(productId, projectId);
|
||||||
|
return this.prisma.project.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined && { name: dto.name }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(productId: string, projectId: string) {
|
||||||
|
await this.ensureProjectInProduct(productId, projectId);
|
||||||
|
return this.prisma.project.delete({ where: { id: projectId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureProductExists(productId: string) {
|
||||||
|
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
||||||
|
if (!product) throw new NotFoundException('产品不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureProjectInProduct(productId: string, projectId: string) {
|
||||||
|
const project = await this.prisma.project.findFirst({ where: { id: projectId, productId } });
|
||||||
|
if (!project) throw new NotFoundException('项目不存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
export class CreateRequirementDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -13,13 +14,46 @@ export class CreateRequirementDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
@Max(4)
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
priority?: number;
|
priority?: string | number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsOptional()
|
||||||
creatorId!: string;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get, Post, Patch, Delete, Param, Body, Query } from '@nestjs/common';
|
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 { RequirementService } from './requirement.service';
|
||||||
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
import { CreateRequirementDto } from './dto/create-requirement.dto';
|
||||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||||
@@ -9,6 +10,11 @@ export class RequirementController {
|
|||||||
constructor(private readonly requirementService: RequirementService) {}
|
constructor(private readonly requirementService: RequirementService) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@ProtectedMutation('requirement:create', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.create',
|
||||||
|
entityType: 'requirement',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
create(
|
create(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Body() dto: CreateRequirementDto,
|
@Body() dto: CreateRequirementDto,
|
||||||
@@ -20,8 +26,26 @@ export class RequirementController {
|
|||||||
findAll(
|
findAll(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Query('status') status?: 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')
|
@Get(':id')
|
||||||
@@ -33,6 +57,12 @@ export class RequirementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.update',
|
||||||
|
entityType: 'requirement',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
update(
|
update(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -42,6 +72,12 @@ export class RequirementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/status')
|
@Patch(':id/status')
|
||||||
|
@ProtectedMutation('requirement:edit', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.status',
|
||||||
|
entityType: 'requirement',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
updateStatus(
|
updateStatus(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -51,6 +87,12 @@ export class RequirementController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@ProtectedMutation('requirement:delete', { productIdParam: 'productId' }, {
|
||||||
|
action: 'requirement.delete',
|
||||||
|
entityType: 'requirement',
|
||||||
|
entityIdParam: 'id',
|
||||||
|
productIdParam: 'productId',
|
||||||
|
})
|
||||||
remove(
|
remove(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -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 () => {
|
it('updates requirements by id plus product id', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
prisma.requirement.findFirst.mockResolvedValue({
|
prisma.requirement.findFirst.mockResolvedValue({
|
||||||
id: 'req-1',
|
id: 'req-1',
|
||||||
productId: 'product-1',
|
productId: 'product-1',
|
||||||
status: 'draft',
|
status: 'pending_review',
|
||||||
});
|
});
|
||||||
prisma.requirement.update.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
|
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 () => {
|
it('deletes requirements by id plus product id', async () => {
|
||||||
const { prisma, service } = makeService();
|
const { prisma, service } = makeService();
|
||||||
prisma.requirement.findFirst.mockResolvedValue({
|
prisma.requirement.findFirst.mockResolvedValue({
|
||||||
id: 'req-1',
|
id: 'req-1',
|
||||||
productId: 'product-1',
|
productId: 'product-1',
|
||||||
status: 'draft',
|
status: 'pending_review',
|
||||||
});
|
});
|
||||||
prisma.requirement.delete.mockResolvedValue({ id: 'req-1', productId: 'product-1' });
|
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' } },
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,13 +5,28 @@ import { CreateRequirementDto } from './dto/create-requirement.dto';
|
|||||||
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
import { UpdateRequirementDto } from './dto/update-requirement.dto';
|
||||||
|
|
||||||
const VALID_TRANSITIONS: Record<string, string[]> = {
|
const VALID_TRANSITIONS: Record<string, string[]> = {
|
||||||
[RequirementStatus.DRAFT]: [RequirementStatus.REVIEWING],
|
[RequirementStatus.PENDING_REVIEW]: [RequirementStatus.ADOPTED, RequirementStatus.REJECTED],
|
||||||
[RequirementStatus.REVIEWING]: [RequirementStatus.APPROVED, RequirementStatus.REJECTED],
|
[RequirementStatus.REJECTED]: [RequirementStatus.PENDING_REVIEW],
|
||||||
[RequirementStatus.APPROVED]: [RequirementStatus.DELIVERED],
|
[RequirementStatus.ADOPTED]: [RequirementStatus.PLANNED, RequirementStatus.CLOSED],
|
||||||
[RequirementStatus.REJECTED]: [RequirementStatus.DRAFT],
|
[RequirementStatus.PLANNED]: [RequirementStatus.DEVELOPING, RequirementStatus.CLOSED],
|
||||||
[RequirementStatus.DELIVERED]: [],
|
[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() {
|
function createFallbackRequirementCode() {
|
||||||
return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random()
|
return `REQ-${Date.now().toString(36).toUpperCase()}-${Math.random()
|
||||||
.toString(36)
|
.toString(36)
|
||||||
@@ -24,27 +39,62 @@ export class RequirementService {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
create(productId: string, dto: CreateRequirementDto) {
|
create(productId: string, dto: CreateRequirementDto) {
|
||||||
|
const data = this.toRequirementData(dto);
|
||||||
return this.prisma.requirement.create({
|
return this.prisma.requirement.create({
|
||||||
data: {
|
data: {
|
||||||
|
...data,
|
||||||
productId,
|
productId,
|
||||||
code: dto.code?.trim() || createFallbackRequirementCode(),
|
code: dto.code?.trim() || createFallbackRequirementCode(),
|
||||||
title: dto.title,
|
title: dto.title,
|
||||||
description: dto.description || '',
|
description: dto.description ?? '',
|
||||||
priority: dto.priority ?? 0,
|
status: dto.status ?? RequirementStatus.PENDING_REVIEW,
|
||||||
creatorId: dto.creatorId,
|
priority: parsePriorityValue(dto.priority) ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
findAll(productId: string, status?: string) {
|
async findAll(productId: string, query: RequirementListQuery = {}) {
|
||||||
return this.prisma.requirement.findMany({
|
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: {
|
where: {
|
||||||
productId,
|
productId: normalizedProductId,
|
||||||
...(status ? { status } : {}),
|
...(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 } } },
|
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) {
|
async findOne(productId: string, id: string) {
|
||||||
@@ -60,12 +110,7 @@ export class RequirementService {
|
|||||||
await this.findOne(productId, id);
|
await this.findOne(productId, id);
|
||||||
return this.prisma.requirement.update({
|
return this.prisma.requirement.update({
|
||||||
where: { id_productId: { id, productId } },
|
where: { id_productId: { id, productId } },
|
||||||
data: {
|
data: this.toRequirementData(dto),
|
||||||
...(dto.code !== undefined && { code: dto.code }),
|
|
||||||
...(dto.title !== undefined && { title: dto.title }),
|
|
||||||
...(dto.description !== undefined && { description: dto.description }),
|
|
||||||
...(dto.priority !== undefined && { priority: dto.priority }),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +132,75 @@ export class RequirementService {
|
|||||||
await this.findOne(productId, id);
|
await this.findOne(productId, id);
|
||||||
return this.prisma.requirement.delete({ where: { id_productId: { id, productId } } });
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateTaskCategoryDto } from './create-task-category.dto';
|
||||||
|
|
||||||
|
export class UpdateTaskCategoryDto extends PartialType(CreateTaskCategoryDto) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/task-worklog/task-worklog.module.ts
Normal file
12
apps/server/src/modules/task-worklog/task-worklog.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -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: '补充日报',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
apps/server/src/modules/task-worklog/task-worklog.service.ts
Normal file
44
apps/server/src/modules/task-worklog/task-worklog.service.ts
Normal file
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateTestCaseDto } from './create-test-case.dto';
|
||||||
|
|
||||||
|
export class UpdateTestCaseDto extends PartialType(CreateTestCaseDto) {}
|
||||||
72
apps/server/src/modules/test-case/test-case.controller.ts
Normal file
72
apps/server/src/modules/test-case/test-case.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/test-case/test-case.module.ts
Normal file
12
apps/server/src/modules/test-case/test-case.module.ts
Normal file
@@ -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 {}
|
||||||
141
apps/server/src/modules/test-case/test-case.service.spec.ts
Normal file
141
apps/server/src/modules/test-case/test-case.service.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
194
apps/server/src/modules/test-case/test-case.service.ts
Normal file
194
apps/server/src/modules/test-case/test-case.service.ts
Normal file
@@ -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<string, unknown> = { 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<CreateTestCaseDto>) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateVersionPlanDto } from './create-version-plan.dto';
|
||||||
|
|
||||||
|
export class UpdateVersionPlanDto extends PartialType(CreateVersionPlanDto) {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/server/src/modules/version-plan/version-plan.module.ts
Normal file
12
apps/server/src/modules/version-plan/version-plan.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
152
apps/server/src/modules/version-plan/version-plan.service.ts
Normal file
152
apps/server/src/modules/version-plan/version-plan.service.ts
Normal file
@@ -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<CreateVersionPlanDto>) {
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
50
apps/server/src/modules/version/dto/create-version.dto.ts
Normal file
50
apps/server/src/modules/version/dto/create-version.dto.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateVersionDto } from './create-version.dto';
|
||||||
|
|
||||||
|
export class UpdateVersionDto extends PartialType(CreateVersionDto) {}
|
||||||
73
apps/server/src/modules/version/version.controller.ts
Normal file
73
apps/server/src/modules/version/version.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user