merge: 集成V2.7 企业协作与管理治理

# Conflicts:
#	apps/server/src/app.module.ts
#	apps/web/components/layout/Sidebar.tsx
#	apps/web/lib/permissions.ts
#	docs/architecture.md
#	docs/decisions.md
#	docs/roadmap.md
This commit is contained in:
2026-07-08 18:10:08 +08:00
60 changed files with 3219 additions and 19 deletions

View File

@@ -0,0 +1,24 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { CommentService, type CommentEntityType } from './comment.service';
import { CreateCommentDto } from './dto/create-comment.dto';
import { DeleteCommentDto } from './dto/delete-comment.dto';
@Controller('comments')
export class CommentController {
constructor(private readonly commentService: CommentService) {}
@Get()
list(@Query('entityType') entityType: CommentEntityType, @Query('entityId') entityId: string) {
return this.commentService.list(entityType, entityId);
}
@Post()
create(@Body() dto: CreateCommentDto) {
return this.commentService.create(dto);
}
@Delete(':id')
remove(@Param('id') id: string, @Body() dto: DeleteCommentDto) {
return this.commentService.remove(id, dto.actorId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { NotificationModule } from '../notification/notification.module';
import { CommentController } from './comment.controller';
import { CommentService } from './comment.service';
@Module({
imports: [CommonDomainModule, NotificationModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
})
export class CommentModule {}

View File

@@ -0,0 +1,112 @@
import { BadRequestException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { NotificationService } from '../notification/notification.service';
import { CommentService } from './comment.service';
describe('CommentService', () => {
const makeService = () => {
const prisma = {
comment: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
projectMember: {
findMany: jest.fn(),
},
};
const notifications = {
createMany: jest.fn(),
} as unknown as NotificationService;
const audit = {
record: jest.fn(),
} as unknown as AuditService;
return {
prisma,
notifications,
audit,
service: new CommentService(prisma as any, notifications, audit),
};
};
it('creates a polymorphic comment, extracts @mentions, notifies mentioned members, and writes audit', async () => {
const { prisma, notifications, audit, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([
{ userId: 'm-alice', user: { id: 'm-alice', name: 'Alice' } },
{ userId: 'm-bob', user: { id: 'm-bob', name: 'Bob' } },
]);
prisma.comment.create.mockResolvedValue({
id: 'comment-1',
entityType: 'dev_task',
entityId: 'task-1',
mentionedMemberIds: ['m-alice', 'm-bob'],
});
const result = await service.create({
actorId: 'm-author',
entityType: 'dev_task',
entityId: 'task-1',
entityVersionId: 'ver-1',
projectId: 'project-1',
versionId: 'ver-1',
content: '请 @Alice 看一下接口Bob 也同步一下',
mentionMemberIds: ['m-bob'],
});
expect(result.id).toBe('comment-1');
expect(prisma.comment.create).toHaveBeenCalledWith({
data: expect.objectContaining({
entityType: 'dev_task',
entityId: 'task-1',
authorId: 'm-author',
mentionedMemberIds: ['m-alice', 'm-bob'],
}),
});
expect((notifications.createMany as jest.Mock)).toHaveBeenCalledWith([
expect.objectContaining({ recipientId: 'm-alice', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
expect.objectContaining({ recipientId: 'm-bob', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
]);
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-author',
action: 'comment.created',
resourceType: 'comment',
resourceId: 'comment-1',
}));
});
it('soft deletes a comment and writes audit', async () => {
const { prisma, audit, service } = makeService();
prisma.comment.findUnique.mockResolvedValue({
id: 'comment-1',
authorId: 'm-author',
entityType: 'bug',
entityId: 'bug-1',
deletedAt: null,
});
prisma.comment.update.mockResolvedValue({ id: 'comment-1', deletedAt: new Date('2026-07-08T08:00:00.000Z') });
await service.remove('comment-1', 'm-author');
expect(prisma.comment.update).toHaveBeenCalledWith({
where: { id: 'comment-1' },
data: { deletedAt: expect.any(Date) },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-author',
action: 'comment.deleted',
resourceType: 'comment',
resourceId: 'comment-1',
}));
});
it('rejects unsupported comment entity types', async () => {
const { service } = makeService();
await expect(service.create({
actorId: 'm-author',
entityType: 'task',
entityId: 'task-1',
content: 'legacy task comment',
} as any)).rejects.toBeInstanceOf(BadRequestException);
});
});

View File

@@ -0,0 +1,157 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { PrismaService } from '../../prisma/prisma.service';
import { NotificationService } from '../notification/notification.service';
export const COMMENT_ENTITY_TYPES = ['dev_task', 'test_case', 'bug', 'requirement', 'version_plan'] as const;
export type CommentEntityType = (typeof COMMENT_ENTITY_TYPES)[number];
export interface CommentCreateInput {
actorId: string;
entityType: CommentEntityType;
entityId: string;
entityVersionId?: string | null;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
content: string;
mentionMemberIds?: string[];
}
@Injectable()
export class CommentService {
constructor(
private readonly prisma: PrismaService,
private readonly notificationService: NotificationService,
private readonly auditService: AuditService,
) {}
list(entityType: CommentEntityType, entityId: string) {
assertCommentEntityType(entityType);
return this.prisma.comment.findMany({
where: { entityType, entityId: requireText(entityId, 'entityId'), deletedAt: null },
orderBy: { createdAt: 'asc' },
});
}
async create(input: CommentCreateInput) {
assertCommentEntityType(input.entityType);
const actorId = requireText(input.actorId, 'actorId');
const content = requireText(input.content, 'content');
const mentionedMemberIds = await this.resolveMentionMemberIds(content, input.projectId, input.mentionMemberIds);
const comment = await this.prisma.comment.create({
data: {
entityType: input.entityType,
entityId: requireText(input.entityId, 'entityId'),
entityVersionId: input.entityVersionId ?? null,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
authorId: actorId,
content,
mentionedMemberIds,
},
});
await this.notificationService.createMany(mentionedMemberIds
.filter((recipientId) => recipientId !== actorId)
.map((recipientId) => ({
recipientId,
actorId,
type: 'mention',
title: '你被提及了',
body: content,
resourceType: 'comment',
resourceId: comment.id,
resourceVersionId: input.entityVersionId ?? null,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
metadata: {
entityType: input.entityType,
entityId: input.entityId,
},
})));
await this.auditService.record({
actorId,
action: 'comment.created',
resourceType: 'comment',
resourceId: comment.id,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
after: comment,
});
return comment;
}
async remove(id: string, actorId: string) {
const comment = await this.prisma.comment.findUnique({ where: { id: requireText(id, 'id') } });
if (!comment || comment.deletedAt) throw new NotFoundException('Comment not found');
const removed = await this.prisma.comment.update({
where: { id: comment.id },
data: { deletedAt: new Date() },
});
await this.auditService.record({
actorId: requireText(actorId, 'actorId'),
action: 'comment.deleted',
resourceType: 'comment',
resourceId: comment.id,
productId: comment.productId,
projectId: comment.projectId,
versionId: comment.versionId,
before: comment,
after: removed,
});
return removed;
}
private async resolveMentionMemberIds(content: string, projectId?: string | null, explicitIds: string[] = []): Promise<string[]> {
const ids = new Set<string>();
const mentionNames = extractMentionNames(content);
if (mentionNames.length > 0 && projectId?.trim()) {
const projectMembers = await this.prisma.projectMember.findMany({
where: { projectId: projectId.trim() },
include: { user: { select: { id: true, name: true } } },
});
const wanted = new Set(mentionNames.map(normalizeMentionName));
for (const member of projectMembers as Array<{ userId: string; user?: { id?: string; name?: string } }>) {
const name = normalizeMentionName(member.user?.name);
if (name && wanted.has(name)) ids.add(member.user?.id ?? member.userId);
}
}
for (const explicitId of explicitIds) {
const id = explicitId.trim();
if (id) ids.add(id);
}
return Array.from(ids);
}
}
export function extractMentionNames(content: string): string[] {
const names: string[] = [];
const pattern = /@([\p{L}\p{N}_\-.]+)/gu;
for (const match of content.matchAll(pattern)) {
if (match[1]) names.push(match[1]);
}
return names;
}
function assertCommentEntityType(entityType: string): asserts entityType is CommentEntityType {
if (!COMMENT_ENTITY_TYPES.includes(entityType as CommentEntityType)) {
throw new BadRequestException(`Unsupported comment entity type: ${entityType}`);
}
}
function normalizeMentionName(name?: string | null): string {
return name?.trim().toLowerCase() ?? '';
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}

View File

@@ -0,0 +1,37 @@
import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
import { COMMENT_ENTITY_TYPES, type CommentEntityType } from '../comment.service';
export class CreateCommentDto {
@IsString()
actorId!: string;
@IsIn(COMMENT_ENTITY_TYPES)
entityType!: CommentEntityType;
@IsString()
entityId!: string;
@IsOptional()
@IsString()
entityVersionId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
versionId?: string;
@IsString()
content!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
mentionMemberIds?: string[];
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class DeleteCommentDto {
@IsString()
actorId!: string;
}

View File

@@ -0,0 +1,56 @@
import { IsArray, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { GOVERNANCE_DICTIONARY_KINDS, type GovernanceDictionaryKind } from '../governance.service';
export class GovernanceDictionaryDto {
@IsString()
actorId!: string;
@IsIn(GOVERNANCE_DICTIONARY_KINDS)
kind!: GovernanceDictionaryKind;
@IsString()
name!: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
group?: string;
@IsOptional()
@IsString()
scope?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class RemoveGovernanceDictionaryDto {
@IsString()
actorId!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class ImportGovernanceDictionariesDto {
@IsString()
actorId!: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => GovernanceDictionaryDto)
items!: GovernanceDictionaryDto[];
}

View File

@@ -0,0 +1,46 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { GovernanceDictionaryDto, ImportGovernanceDictionariesDto, RemoveGovernanceDictionaryDto } from './dto/governance-dictionary.dto';
import { GovernanceDictionaryKind, GovernanceService } from './governance.service';
@Controller('governance')
export class GovernanceController {
constructor(private readonly governanceService: GovernanceService) {}
@Get('dictionaries')
list(@Query('kind') kind: GovernanceDictionaryKind) {
return this.governanceService.list(kind);
}
@Post('dictionaries')
create(@Body() dto: GovernanceDictionaryDto) {
return this.governanceService.create(dto);
}
@Patch('dictionaries/:kind/:id')
update(
@Param('kind') kind: GovernanceDictionaryKind,
@Param('id') id: string,
@Body() dto: GovernanceDictionaryDto,
) {
return this.governanceService.update({ ...dto, kind, id });
}
@Delete('dictionaries/:kind/:id')
remove(
@Param('kind') kind: GovernanceDictionaryKind,
@Param('id') id: string,
@Body() dto: RemoveGovernanceDictionaryDto,
) {
return this.governanceService.remove({ actorId: dto.actorId, permissions: dto.permissions, kind, id });
}
@Get('export')
exportAll() {
return this.governanceService.exportAll();
}
@Post('import')
importAll(@Body() dto: ImportGovernanceDictionariesDto) {
return this.governanceService.importAll(dto.actorId, dto.items, dto.permissions);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { GovernanceController } from './governance.controller';
import { GovernanceService } from './governance.service';
@Module({
imports: [CommonDomainModule],
controllers: [GovernanceController],
providers: [GovernanceService],
exports: [GovernanceService],
})
export class GovernanceModule {}

View File

@@ -0,0 +1,93 @@
import { BadRequestException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { RbacService } from '../../common/rbac/rbac.service';
import { GovernanceService } from './governance.service';
describe('GovernanceService', () => {
const makeService = () => {
const prisma = {
taskCategory: {
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
devTask: { count: jest.fn() },
testCase: { count: jest.fn() },
governanceDictionary: {
create: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
},
};
const audit = { record: jest.fn() } as unknown as AuditService;
const rbac = {
assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-admin', via: 'system' }),
} as unknown as RbacService;
return { prisma, audit, rbac, service: new GovernanceService(prisma as any, audit, rbac) };
};
it('requires governance manage permission through the RBAC adapter', async () => {
const { prisma, rbac, service } = makeService();
(rbac.assertGlobalPermission as jest.Mock).mockRejectedValue(new Error('forbidden'));
await expect(service.create({
actorId: 'm-dev',
permissions: ['project:view'],
kind: 'requirement_type',
name: '新功能',
})).rejects.toThrow('forbidden');
expect(prisma.governanceDictionary.create).not.toHaveBeenCalled();
});
it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => {
const { prisma, rbac, service } = makeService();
prisma.devTask.count.mockResolvedValue(1);
prisma.testCase.count.mockResolvedValue(0);
await expect(service.remove({
actorId: 'm-admin',
permissions: ['governance:manage'],
kind: 'task_category',
id: 'cat-1',
})).rejects.toBeInstanceOf(BadRequestException);
expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({
actorId: 'm-admin',
permissions: ['governance:manage'],
requiredPermissions: ['governance:manage'],
});
expect(prisma.taskCategory.delete).not.toHaveBeenCalled();
});
it('soft deletes requirement dictionaries and writes audit', async () => {
const { prisma, audit, service } = makeService();
prisma.governanceDictionary.update.mockResolvedValue({ id: 'dict-1', kind: 'requirement_type', deletedAt: new Date('2026-07-08T00:00:00.000Z') });
await service.remove({ actorId: 'm-admin', permissions: ['governance:manage'], kind: 'requirement_type', id: 'dict-1' });
expect(prisma.governanceDictionary.update).toHaveBeenCalledWith({
where: { id: 'dict-1' },
data: { deletedAt: expect.any(Date) },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-admin',
action: 'governance.dictionary_deleted',
resourceType: 'governance_dictionary',
resourceId: 'dict-1',
}));
});
it('exports task categories and governance dictionaries together', async () => {
const { prisma, service } = makeService();
prisma.taskCategory.findMany.mockResolvedValue([{ id: 'cat-1', name: '前端' }]);
prisma.governanceDictionary.findMany.mockResolvedValue([{ id: 'type-1', kind: 'requirement_type', name: '新功能' }]);
await expect(service.exportAll()).resolves.toEqual({
taskCategories: [{ id: 'cat-1', name: '前端' }],
dictionaries: [{ id: 'type-1', kind: 'requirement_type', name: '新功能' }],
});
});
});

View File

@@ -0,0 +1,227 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { RbacService } from '../../common/rbac/rbac.service';
import { PrismaService } from '../../prisma/prisma.service';
export const GOVERNANCE_DICTIONARY_KINDS = ['task_category', 'requirement_type', 'requirement_platform', 'requirement_source'] as const;
export type GovernanceDictionaryKind = (typeof GOVERNANCE_DICTIONARY_KINDS)[number];
export interface GovernanceDictionaryInput {
actorId: string;
kind: GovernanceDictionaryKind;
id?: string;
name: string;
code?: string | null;
group?: string | null;
scope?: string;
value?: unknown;
permissions?: string[];
}
export interface GovernanceRemoveInput {
actorId: string;
kind: GovernanceDictionaryKind;
id: string;
permissions?: string[];
}
@Injectable()
export class GovernanceService {
constructor(
private readonly prisma: PrismaService,
private readonly auditService: AuditService,
private readonly rbacService: RbacService,
) {}
list(kind: GovernanceDictionaryKind) {
assertDictionaryKind(kind);
if (kind === 'task_category') {
return this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] });
}
return this.prisma.governanceDictionary.findMany({
where: { kind, deletedAt: null },
orderBy: { name: 'asc' },
});
}
async create(input: GovernanceDictionaryInput) {
assertDictionaryKind(input.kind);
const actorId = requireText(input.actorId, 'actorId');
await this.assertManagePermission(actorId, input.permissions);
const created = input.kind === 'task_category'
? await this.prisma.taskCategory.create({
data: {
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? 'other',
},
})
: await this.prisma.governanceDictionary.create({
data: {
scope: input.scope ?? 'global',
kind: input.kind,
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? null,
value: input.value ?? {},
},
});
await this.auditService.record({
actorId,
action: 'governance.dictionary_created',
resourceType: this.resourceType(input.kind),
resourceId: created.id,
after: created,
});
return created;
}
async update(input: GovernanceDictionaryInput & { id: string }) {
assertDictionaryKind(input.kind);
const actorId = requireText(input.actorId, 'actorId');
await this.assertManagePermission(actorId, input.permissions);
const id = requireText(input.id, 'id');
const updated = input.kind === 'task_category'
? await this.prisma.taskCategory.update({
where: { id },
data: {
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? 'other',
},
})
: await this.prisma.governanceDictionary.update({
where: { id },
data: {
name: requireText(input.name, 'name'),
code: input.code ?? null,
group: input.group ?? null,
value: input.value ?? {},
},
});
await this.auditService.record({
actorId,
action: 'governance.dictionary_updated',
resourceType: this.resourceType(input.kind),
resourceId: id,
after: updated,
});
return updated;
}
async remove(input: GovernanceRemoveInput) {
assertDictionaryKind(input.kind);
const actorId = requireText(input.actorId, 'actorId');
await this.assertManagePermission(actorId, input.permissions);
const id = requireText(input.id, 'id');
if (input.kind === 'task_category') {
const [devTaskCount, testCaseCount] = await Promise.all([
this.prisma.devTask.count({ where: { categoryId: id } }),
this.prisma.testCase.count({ where: { categoryId: id } }),
]);
if (devTaskCount + testCaseCount > 0) {
throw new BadRequestException('Dictionary item is in use and cannot be hard deleted');
}
const removed = await this.prisma.taskCategory.delete({ where: { id } });
await this.auditService.record({
actorId,
action: 'governance.dictionary_deleted',
resourceType: 'task_category',
resourceId: id,
before: removed,
});
return removed;
}
const removed = await this.prisma.governanceDictionary.update({
where: { id },
data: { deletedAt: new Date() },
});
await this.auditService.record({
actorId,
action: 'governance.dictionary_deleted',
resourceType: 'governance_dictionary',
resourceId: id,
after: removed,
});
return removed;
}
async exportAll() {
const [taskCategories, dictionaries] = await Promise.all([
this.prisma.taskCategory.findMany({ orderBy: [{ group: 'asc' }, { name: 'asc' }] }),
this.prisma.governanceDictionary.findMany({ where: { deletedAt: null }, orderBy: [{ kind: 'asc' }, { name: 'asc' }] }),
]);
return { taskCategories, dictionaries };
}
async importAll(actorId: string, items: GovernanceDictionaryInput[], permissions: string[] = []) {
await this.assertManagePermission(actorId, permissions);
const results = [];
for (const item of items) {
assertDictionaryKind(item.kind);
if (item.kind === 'task_category') {
results.push(await this.create({ ...item, actorId, permissions }));
} else {
const saved = await this.prisma.governanceDictionary.upsert({
where: {
scope_kind_name: {
scope: item.scope ?? 'global',
kind: item.kind,
name: requireText(item.name, 'name'),
},
},
update: {
code: item.code ?? null,
group: item.group ?? null,
value: item.value ?? {},
deletedAt: null,
},
create: {
scope: item.scope ?? 'global',
kind: item.kind,
name: requireText(item.name, 'name'),
code: item.code ?? null,
group: item.group ?? null,
value: item.value ?? {},
},
});
results.push(saved);
}
}
await this.auditService.record({
actorId: requireText(actorId, 'actorId'),
action: 'governance.dictionary_imported',
resourceType: 'governance_dictionary',
resourceId: 'bulk',
after: { count: results.length },
});
return { count: results.length, items: results };
}
private resourceType(kind: GovernanceDictionaryKind) {
return kind === 'task_category' ? 'task_category' : 'governance_dictionary';
}
private assertManagePermission(actorId: string, permissions: string[] = []) {
return this.rbacService.assertGlobalPermission({
actorId,
permissions,
requiredPermissions: ['governance:manage'],
});
}
}
function assertDictionaryKind(kind: string): asserts kind is GovernanceDictionaryKind {
if (!GOVERNANCE_DICTIONARY_KINDS.includes(kind as GovernanceDictionaryKind)) {
throw new BadRequestException(`Unsupported governance dictionary kind: ${kind}`);
}
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ManagementService } from './management.service';
@Controller('management')
export class ManagementController {
constructor(private readonly managementService: ManagementService) {}
@Get('overview')
getOverview(
@Query('actorId') actorId: string,
@Query('permissions') permissions?: string,
) {
return this.managementService.getOverview({
actorId,
permissions: permissions ? permissions.split(',').map((item) => item.trim()).filter(Boolean) : [],
});
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { ManagementController } from './management.controller';
import { ManagementService } from './management.service';
@Module({
imports: [CommonDomainModule],
controllers: [ManagementController],
providers: [ManagementService],
})
export class ManagementModule {}

View File

@@ -0,0 +1,90 @@
import { ManagementService } from './management.service';
describe('ManagementService', () => {
const makeService = () => {
const prisma = {
appData: { findMany: jest.fn() },
projectMember: { findMany: jest.fn() },
version: { findMany: jest.fn() },
versionPlan: { findMany: jest.fn() },
devTask: { findMany: jest.fn() },
testCase: { findMany: jest.fn() },
bug: { findMany: jest.fn() },
xiaobaoRiskSummary: { findMany: jest.fn() },
};
const rbac = {
assertGlobalPermission: jest.fn().mockResolvedValue({ actorId: 'm-manager', via: 'permission' }),
};
return { prisma, rbac, service: new ManagementService(prisma as any, rbac as any) };
};
it('requires management view permission through the RBAC adapter', async () => {
const { rbac, service } = makeService();
rbac.assertGlobalPermission.mockRejectedValue(new Error('forbidden'));
await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).rejects.toThrow('forbidden');
});
it('returns an empty dashboard when actor has no managed projects', async () => {
const { prisma, rbac, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([]);
await expect(service.getOverview({ actorId: 'm-dev', permissions: ['management:view'] })).resolves.toEqual({
activeVersionCount: 0,
overdueItemCount: 0,
blockedItemCount: 0,
riskCounts: {},
memberLoads: [],
activeVersions: [],
overdueItems: [],
blockedItems: [],
highRiskVersions: [],
});
expect(rbac.assertGlobalPermission).toHaveBeenCalledWith({
actorId: 'm-dev',
permissions: ['management:view'],
requiredPermissions: ['management:view'],
});
expect(prisma.appData.findMany).not.toHaveBeenCalled();
});
it('aggregates active versions, overdue work, blockers, risks, and member load from relation tables only', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([{ projectId: 'project-1' }]);
prisma.version.findMany.mockResolvedValue([
{ id: 'ver-1', projectId: 'project-1', name: 'V1', releaseDate: new Date('2026-07-20T00:00:00.000Z') },
]);
prisma.versionPlan.findMany.mockResolvedValue([
{ id: 'plan-1', versionId: 'ver-1', title: '产品方案', ownerId: 'm-pm', status: 'in_progress', expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.devTask.findMany.mockResolvedValue([
{ id: 'task-1', versionId: 'ver-1', title: '接口开发', assigneeId: 'm-dev', status: 'in_progress', isBlocked: true, expectedEndAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.testCase.findMany.mockResolvedValue([
{ id: 'tc-1', versionId: 'ver-1', title: '权限测试', assigneeId: 'm-qa', status: 'running', plannedEndAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.bug.findMany.mockResolvedValue([
{ id: 'bug-1', versionId: 'ver-1', title: '线上缺陷', assigneeId: 'm-dev', status: 'open', plannedFixAt: new Date('2026-07-01T00:00:00.000Z') },
]);
prisma.xiaobaoRiskSummary.findMany.mockResolvedValue([
{ versionId: 'ver-1', riskLevel: 'likely_delayed', riskScore: 82 },
]);
const result = await service.getOverview({
actorId: 'm-manager',
permissions: [],
now: new Date('2026-07-08T00:00:00.000Z'),
});
expect(result.activeVersionCount).toBe(1);
expect(result.overdueItemCount).toBe(4);
expect(result.blockedItemCount).toBe(1);
expect(result.riskCounts).toEqual({ likely_delayed: 1 });
expect(result.memberLoads).toEqual([
{ memberId: 'm-dev', openItemCount: 2 },
{ memberId: 'm-pm', openItemCount: 1 },
{ memberId: 'm-qa', openItemCount: 1 },
]);
expect(prisma.appData.findMany).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,162 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { RbacService } from '../../common/rbac/rbac.service';
import { PrismaService } from '../../prisma/prisma.service';
export interface ManagementOverviewQuery {
actorId?: string;
permissions?: string[];
now?: Date;
}
type WorkItem = {
id: string;
versionId: string;
title: string;
ownerId?: string | null;
assigneeId?: string | null;
status?: string;
isBlocked?: boolean;
expectedEndAt?: Date | string | null;
plannedEndAt?: Date | string | null;
plannedFixAt?: Date | string | null;
};
@Injectable()
export class ManagementService {
constructor(
private readonly prisma: PrismaService,
private readonly rbacService: RbacService,
) {}
async getOverview(query: ManagementOverviewQuery) {
const actorId = query.actorId?.trim();
if (!actorId) throw new BadRequestException('actorId is required');
const permission = await this.rbacService.assertGlobalPermission({
actorId,
permissions: query.permissions ?? [],
requiredPermissions: ['management:view'],
});
const now = query.now ?? new Date();
const allProjects = permission.via === 'system';
const projectIds = allProjects ? undefined : await this.getManagedProjectIds(actorId);
if (!allProjects && projectIds?.length === 0) return emptyOverview();
const activeVersions = await this.prisma.version.findMany({
where: {
...(projectIds ? { projectId: { in: projectIds } } : {}),
OR: [{ releaseDate: null }, { releaseDate: { gte: now } }],
},
orderBy: { releaseDate: 'asc' },
});
const versionIds = activeVersions.map((version: { id: string }) => version.id);
if (versionIds.length === 0) {
return { ...emptyOverview(), activeVersions };
}
const [versionPlans, devTasks, testCases, bugs, highRiskVersions] = await Promise.all([
this.prisma.versionPlan.findMany({
where: { versionId: { in: versionIds }, status: { not: 'completed' } },
}),
this.prisma.devTask.findMany({
where: { versionId: { in: versionIds }, status: { not: 'submitted' } },
}),
this.prisma.testCase.findMany({
where: { versionId: { in: versionIds }, status: { notIn: ['passed', 'failed', 'blocked'] } },
}),
this.prisma.bug.findMany({
where: { versionId: { in: versionIds }, status: { in: ['open', 'fixing', 'fixed', 'verifying'] } },
}),
this.prisma.xiaobaoRiskSummary.findMany({
where: { versionId: { in: versionIds }, riskLevel: { not: 'on_track' } },
orderBy: [{ riskScore: 'desc' }, { updatedAt: 'desc' }],
}),
]);
const overdueItems = [
...collectOverdue(versionPlans, 'version_plan', now, 'expectedEndAt'),
...collectOverdue(devTasks, 'dev_task', now, 'expectedEndAt'),
...collectOverdue(testCases, 'test_case', now, 'plannedEndAt'),
...collectOverdue(bugs, 'bug', now, 'plannedFixAt'),
];
const blockedItems = [
...devTasks.filter((item: WorkItem) => item.isBlocked).map((item: WorkItem) => toDashboardItem(item, 'dev_task')),
...testCases.filter((item: WorkItem) => item.status === 'blocked').map((item: WorkItem) => toDashboardItem(item, 'test_case')),
];
return {
activeVersionCount: activeVersions.length,
overdueItemCount: overdueItems.length,
blockedItemCount: blockedItems.length,
riskCounts: countByRiskLevel(highRiskVersions),
memberLoads: buildMemberLoads(versionPlans, devTasks, testCases, bugs),
activeVersions,
overdueItems,
blockedItems,
highRiskVersions,
};
}
private async getManagedProjectIds(actorId: string): Promise<string[]> {
const rows = await this.prisma.projectMember.findMany({
where: { userId: actorId, role: { in: ['owner', 'admin'] } },
select: { projectId: true },
});
return Array.from(new Set(rows.map((row: { projectId: string }) => row.projectId)));
}
}
function emptyOverview() {
return {
activeVersionCount: 0,
overdueItemCount: 0,
blockedItemCount: 0,
riskCounts: {},
memberLoads: [],
activeVersions: [],
overdueItems: [],
blockedItems: [],
highRiskVersions: [],
};
}
function collectOverdue(items: WorkItem[], type: string, now: Date, dateField: keyof WorkItem) {
return items
.filter((item) => isBefore(item[dateField], now))
.map((item) => ({ ...toDashboardItem(item, type), dueAt: item[dateField] }));
}
function toDashboardItem(item: WorkItem, type: string) {
return {
type,
id: item.id,
versionId: item.versionId,
title: item.title,
ownerId: item.ownerId ?? item.assigneeId ?? null,
status: item.status,
};
}
function isBefore(value: unknown, now: Date): boolean {
if (!value) return false;
const time = value instanceof Date ? value.getTime() : new Date(String(value)).getTime();
return Number.isFinite(time) && time < now.getTime();
}
function countByRiskLevel(rows: Array<{ riskLevel: string }>): Record<string, number> {
return rows.reduce<Record<string, number>>((acc, row) => {
acc[row.riskLevel] = (acc[row.riskLevel] ?? 0) + 1;
return acc;
}, {});
}
function buildMemberLoads(...groups: WorkItem[][]) {
const counts = new Map<string, number>();
for (const item of groups.flat()) {
const memberId = item.ownerId ?? item.assigneeId;
if (!memberId) continue;
counts.set(memberId, (counts.get(memberId) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([memberId, openItemCount]) => ({ memberId, openItemCount }))
.sort((a, b) => b.openItemCount - a.openItemCount || a.memberId.localeCompare(b.memberId));
}

View File

@@ -0,0 +1,47 @@
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
import { NOTIFICATION_TYPES, type NotificationType } from '../notification.service';
export class CreateNotificationDto {
@IsString()
recipientId!: string;
@IsOptional()
@IsString()
actorId?: string;
@IsIn(NOTIFICATION_TYPES)
type!: NotificationType;
@IsString()
title!: string;
@IsOptional()
@IsString()
body?: string;
@IsString()
resourceType!: string;
@IsString()
resourceId!: string;
@IsOptional()
@IsString()
resourceVersionId?: string;
@IsOptional()
@IsString()
productId?: string;
@IsOptional()
@IsString()
projectId?: string;
@IsOptional()
@IsString()
versionId?: string;
@IsOptional()
@IsObject()
metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class MarkNotificationReadDto {
@IsString()
recipientId!: string;
}

View File

@@ -0,0 +1,37 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { CreateNotificationDto } from './dto/create-notification.dto';
import { MarkNotificationReadDto } from './dto/mark-notification-read.dto';
import { NotificationService } from './notification.service';
@Controller('notifications')
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
@Get()
list(
@Query('recipientId') recipientId: string,
@Query('unreadOnly') unreadOnly?: string,
@Query('limit') limit?: string,
) {
return this.notificationService.list({
recipientId,
unreadOnly: unreadOnly === 'true',
limit: limit ? Number(limit) : undefined,
});
}
@Post()
create(@Body() dto: CreateNotificationDto) {
return this.notificationService.create(dto);
}
@Patch(':id/read')
markRead(@Param('id') id: string, @Body() dto: MarkNotificationReadDto) {
return this.notificationService.markRead(id, dto.recipientId);
}
@Patch('read-all')
markAllRead(@Body() dto: MarkNotificationReadDto) {
return this.notificationService.markAllRead(dto.recipientId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NotificationController } from './notification.controller';
import { NotificationService } from './notification.service';
@Module({
controllers: [NotificationController],
providers: [NotificationService],
exports: [NotificationService],
})
export class NotificationModule {}

View File

@@ -0,0 +1,79 @@
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
const makeService = () => {
const prisma = {
notification: {
create: jest.fn(),
createMany: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
},
};
return { prisma, service: new NotificationService(prisma as any) };
};
it('lists unread notifications for one recipient in newest-first order', async () => {
const { prisma, service } = makeService();
prisma.notification.findMany.mockResolvedValue([{ id: 'n-1' }]);
await expect(service.list({ recipientId: 'm-1', unreadOnly: true, limit: 20 })).resolves.toEqual([{ id: 'n-1' }]);
expect(prisma.notification.findMany).toHaveBeenCalledWith({
where: { recipientId: 'm-1', readAt: null },
orderBy: { createdAt: 'desc' },
take: 20,
});
});
it('marks one notification as read only for the requesting recipient', async () => {
const { prisma, service } = makeService();
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
await service.markRead('n-1', 'm-1');
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { id: 'n-1', recipientId: 'm-1', readAt: null },
data: { readAt: expect.any(Date) },
});
});
it('marks all unread notifications for one recipient as read', async () => {
const { prisma, service } = makeService();
prisma.notification.updateMany.mockResolvedValue({ count: 2 });
await service.markAllRead('m-1');
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
where: { recipientId: 'm-1', readAt: null },
data: { readAt: expect.any(Date) },
});
});
it('creates notifications with stable V2.7 event types', async () => {
const { prisma, service } = makeService();
prisma.notification.create.mockResolvedValue({ id: 'n-risk' });
await service.create({
recipientId: 'm-manager',
actorId: 'xiaobao',
type: 'risk_alert',
title: '版本存在延期风险',
resourceType: 'version',
resourceId: 'ver-1',
projectId: 'project-1',
versionId: 'ver-1',
metadata: { riskLevel: 'likely_delayed' },
});
expect(prisma.notification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
recipientId: 'm-manager',
type: 'risk_alert',
resourceType: 'version',
resourceId: 'ver-1',
metadata: { riskLevel: 'likely_delayed' },
}),
});
});
});

View File

@@ -0,0 +1,99 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
export const NOTIFICATION_TYPES = ['assignment', 'mention', 'risk_alert', 'overdue_item'] as const;
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
export interface NotificationListQuery {
recipientId: string;
unreadOnly?: boolean;
limit?: number;
}
export interface NotificationCreateInput {
recipientId: string;
actorId?: string | null;
type: NotificationType;
title: string;
body?: string;
resourceType: string;
resourceId: string;
resourceVersionId?: string | null;
productId?: string | null;
projectId?: string | null;
versionId?: string | null;
metadata?: unknown;
}
@Injectable()
export class NotificationService {
constructor(private readonly prisma: PrismaService) {}
list(query: NotificationListQuery) {
const recipientId = requireText(query.recipientId, 'recipientId');
return this.prisma.notification.findMany({
where: {
recipientId,
...(query.unreadOnly ? { readAt: null } : {}),
},
orderBy: { createdAt: 'desc' },
take: normalizeLimit(query.limit),
});
}
create(input: NotificationCreateInput) {
const data = normalizeNotificationInput(input);
return this.prisma.notification.create({ data });
}
async createMany(items: NotificationCreateInput[]) {
const data = items.map(normalizeNotificationInput);
if (data.length === 0) return { count: 0 };
return this.prisma.notification.createMany({ data });
}
markRead(id: string, recipientId: string) {
return this.prisma.notification.updateMany({
where: { id: requireText(id, 'id'), recipientId: requireText(recipientId, 'recipientId'), readAt: null },
data: { readAt: new Date() },
});
}
markAllRead(recipientId: string) {
return this.prisma.notification.updateMany({
where: { recipientId: requireText(recipientId, 'recipientId'), readAt: null },
data: { readAt: new Date() },
});
}
}
function normalizeNotificationInput(input: NotificationCreateInput) {
if (!NOTIFICATION_TYPES.includes(input.type)) {
throw new BadRequestException(`Unsupported notification type: ${input.type}`);
}
return {
recipientId: requireText(input.recipientId, 'recipientId'),
actorId: input.actorId ?? null,
type: input.type,
title: requireText(input.title, 'title'),
body: input.body ?? '',
resourceType: requireText(input.resourceType, 'resourceType'),
resourceId: requireText(input.resourceId, 'resourceId'),
resourceVersionId: input.resourceVersionId ?? null,
productId: input.productId ?? null,
projectId: input.projectId ?? null,
versionId: input.versionId ?? null,
metadata: input.metadata ?? {},
};
}
function normalizeLimit(limit?: number): number {
if (!Number.isFinite(limit)) return 50;
return Math.max(1, Math.min(100, Math.floor(limit as number)));
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}

View File

@@ -0,0 +1,29 @@
import { IsIn, IsOptional, IsString } from 'class-validator';
import type { ProjectGovernanceRole } from '../../../common/rbac/rbac.service';
const PROJECT_ROLES = ['owner', 'admin', 'member', 'viewer'] as const;
export class AddProjectMemberDto {
@IsString()
actorId!: string;
@IsString()
userId!: string;
@IsOptional()
@IsIn(PROJECT_ROLES)
role?: ProjectGovernanceRole;
}
export class UpdateProjectMemberRoleDto {
@IsString()
actorId!: string;
@IsIn(PROJECT_ROLES)
role!: ProjectGovernanceRole;
}
export class RemoveProjectMemberDto {
@IsString()
actorId!: string;
}

View File

@@ -0,0 +1,36 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from '@nestjs/common';
import { AddProjectMemberDto, RemoveProjectMemberDto, UpdateProjectMemberRoleDto } from './dto/project-member.dto';
import { ProjectMemberService } from './project-member.service';
@Controller('projects/:projectId/members')
export class ProjectMemberController {
constructor(private readonly projectMemberService: ProjectMemberService) {}
@Get()
list(@Param('projectId') projectId: string) {
return this.projectMemberService.list(projectId);
}
@Post()
add(@Param('projectId') projectId: string, @Body() dto: AddProjectMemberDto) {
return this.projectMemberService.add({ ...dto, projectId });
}
@Patch(':userId/role')
updateRole(
@Param('projectId') projectId: string,
@Param('userId') userId: string,
@Body() dto: UpdateProjectMemberRoleDto,
) {
return this.projectMemberService.updateRole({ ...dto, projectId, userId });
}
@Delete(':userId')
remove(
@Param('projectId') projectId: string,
@Param('userId') userId: string,
@Body() dto: RemoveProjectMemberDto,
) {
return this.projectMemberService.remove({ ...dto, projectId, userId });
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CommonDomainModule } from '../../common/common-domain.module';
import { ProjectMemberController } from './project-member.controller';
import { ProjectMemberService } from './project-member.service';
@Module({
imports: [CommonDomainModule],
controllers: [ProjectMemberController],
providers: [ProjectMemberService],
exports: [ProjectMemberService],
})
export class ProjectMemberModule {}

View File

@@ -0,0 +1,138 @@
import { BadRequestException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { RbacService } from '../../common/rbac/rbac.service';
import { ProjectMemberService } from './project-member.service';
describe('ProjectMemberService', () => {
const makeService = () => {
const prisma = {
projectMember: {
count: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
};
const rbac = {
assertProjectRole: jest.fn().mockResolvedValue({ role: 'owner' }),
} as unknown as RbacService;
const audit = {
record: jest.fn(),
} as unknown as AuditService;
return {
prisma,
rbac,
audit,
service: new ProjectMemberService(prisma as any, rbac, audit),
};
};
it('lists project members with their user profile', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findMany.mockResolvedValue([{ id: 'pm-1', role: 'owner' }]);
await expect(service.list('project-1')).resolves.toEqual([{ id: 'pm-1', role: 'owner' }]);
expect(prisma.projectMember.findMany).toHaveBeenCalledWith({
where: { projectId: 'project-1' },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
});
});
it('creates a project member after admin-or-owner authorization and writes audit', async () => {
const { prisma, rbac, audit, service } = makeService();
prisma.projectMember.create.mockResolvedValue({ id: 'pm-2', role: 'member' });
await service.add({ actorId: 'm-owner', projectId: 'project-1', userId: 'm-dev', role: 'member' });
expect((rbac.assertProjectRole as jest.Mock)).toHaveBeenCalledWith({
actorId: 'm-owner',
projectId: 'project-1',
allowedRoles: ['admin'],
});
expect(prisma.projectMember.create).toHaveBeenCalledWith({
data: { projectId: 'project-1', userId: 'm-dev', role: 'member' },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
actorId: 'm-owner',
action: 'project_member.created',
resourceType: 'project_member',
projectId: 'project-1',
}));
});
it('rejects demoting the last project owner', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-owner',
projectId: 'project-1',
userId: 'm-owner',
role: 'owner',
});
prisma.projectMember.count.mockResolvedValue(1);
await expect(service.updateRole({
actorId: 'm-owner',
projectId: 'project-1',
userId: 'm-owner',
role: 'admin',
})).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.projectMember.update).not.toHaveBeenCalled();
});
it('rejects removing the last project owner', async () => {
const { prisma, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-owner',
projectId: 'project-1',
userId: 'm-owner',
role: 'owner',
});
prisma.projectMember.count.mockResolvedValue(1);
await expect(service.remove({
actorId: 'm-owner',
projectId: 'project-1',
userId: 'm-owner',
})).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.projectMember.delete).not.toHaveBeenCalled();
});
it('updates roles and writes an audit diff', async () => {
const { prisma, audit, service } = makeService();
prisma.projectMember.findUnique.mockResolvedValue({
id: 'pm-dev',
projectId: 'project-1',
userId: 'm-dev',
role: 'member',
});
prisma.projectMember.update.mockResolvedValue({
id: 'pm-dev',
projectId: 'project-1',
userId: 'm-dev',
role: 'admin',
});
await service.updateRole({
actorId: 'm-owner',
projectId: 'project-1',
userId: 'm-dev',
role: 'admin',
});
expect(prisma.projectMember.update).toHaveBeenCalledWith({
where: { projectId_userId: { projectId: 'project-1', userId: 'm-dev' } },
data: { role: 'admin' },
});
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
action: 'project_member.role_changed',
before: expect.objectContaining({ role: 'member' }),
after: expect.objectContaining({ role: 'admin' }),
}));
});
});

View File

@@ -0,0 +1,129 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AuditService } from '../../common/audit/audit.service';
import { ProjectGovernanceRole, RbacService, normalizeProjectRole } from '../../common/rbac/rbac.service';
import { PrismaService } from '../../prisma/prisma.service';
export interface ProjectMemberMutationInput {
actorId: string;
projectId: string;
userId: string;
role?: ProjectGovernanceRole;
}
@Injectable()
export class ProjectMemberService {
constructor(
private readonly prisma: PrismaService,
private readonly rbacService: RbacService,
private readonly auditService: AuditService,
) {}
list(projectId: string) {
return this.prisma.projectMember.findMany({
where: { projectId: requireText(projectId, 'projectId') },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
});
}
async add(input: ProjectMemberMutationInput) {
const projectId = requireText(input.projectId, 'projectId');
const userId = requireText(input.userId, 'userId');
const actorId = requireText(input.actorId, 'actorId');
const role = normalizeRequiredRole(input.role ?? 'member');
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
const created = await this.prisma.projectMember.create({
data: { projectId, userId, role },
});
await this.auditService.record({
actorId,
action: 'project_member.created',
resourceType: 'project_member',
resourceId: created.id,
projectId,
after: created,
});
return created;
}
async updateRole(input: Required<ProjectMemberMutationInput>) {
const projectId = requireText(input.projectId, 'projectId');
const userId = requireText(input.userId, 'userId');
const actorId = requireText(input.actorId, 'actorId');
const role = normalizeRequiredRole(input.role);
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
const existing = await this.findMembership(projectId, userId);
if (existing.role === 'owner' && role !== 'owner') {
await this.assertOwnerWillRemain(projectId);
}
const updated = await this.prisma.projectMember.update({
where: { projectId_userId: { projectId, userId } },
data: { role },
});
await this.auditService.record({
actorId,
action: 'project_member.role_changed',
resourceType: 'project_member',
resourceId: updated.id,
projectId,
before: existing,
after: updated,
});
return updated;
}
async remove(input: Omit<ProjectMemberMutationInput, 'role'>) {
const projectId = requireText(input.projectId, 'projectId');
const userId = requireText(input.userId, 'userId');
const actorId = requireText(input.actorId, 'actorId');
await this.rbacService.assertProjectRole({ actorId, projectId, allowedRoles: ['admin'] });
const existing = await this.findMembership(projectId, userId);
if (existing.role === 'owner') {
await this.assertOwnerWillRemain(projectId);
}
const removed = await this.prisma.projectMember.delete({
where: { projectId_userId: { projectId, userId } },
});
await this.auditService.record({
actorId,
action: 'project_member.deleted',
resourceType: 'project_member',
resourceId: removed.id,
projectId,
before: existing,
});
return removed;
}
private async findMembership(projectId: string, userId: string) {
const membership = await this.prisma.projectMember.findUnique({
where: { projectId_userId: { projectId, userId } },
});
if (!membership) throw new NotFoundException('Project member not found');
return membership;
}
private async assertOwnerWillRemain(projectId: string) {
const ownerCount = await this.prisma.projectMember.count({ where: { projectId, role: 'owner' } });
if (ownerCount <= 1) {
throw new BadRequestException('Cannot remove or demote the last project owner');
}
}
}
function normalizeRequiredRole(role: string | undefined): ProjectGovernanceRole {
const normalized = normalizeProjectRole(role);
if (!normalized) throw new BadRequestException(`Unsupported project member role: ${role}`);
return normalized;
}
function requireText(value: string | undefined | null, field: string): string {
const normalized = value?.trim();
if (!normalized) throw new BadRequestException(`${field} is required`);
return normalized;
}