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:
@@ -0,0 +1,85 @@
|
||||
ALTER TABLE "comments" DROP CONSTRAINT IF EXISTS "comments_task_id_fkey";
|
||||
DROP TABLE IF EXISTS "comments";
|
||||
|
||||
CREATE TABLE "comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"entity_type" TEXT NOT NULL,
|
||||
"entity_id" TEXT NOT NULL,
|
||||
"entity_version_id" TEXT,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"author_id" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"mentioned_member_ids" JSONB NOT NULL DEFAULT '[]',
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "comments_entity_created_idx" ON "comments"("entity_type", "entity_id", "created_at");
|
||||
CREATE INDEX "comments_author_created_idx" ON "comments"("author_id", "created_at");
|
||||
|
||||
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
ALTER TABLE "project_members" ADD COLUMN IF NOT EXISTS "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
CREATE INDEX IF NOT EXISTS "project_members_user_role_idx" ON "project_members"("user_id", "role");
|
||||
|
||||
CREATE TABLE "notifications" (
|
||||
"id" TEXT NOT NULL,
|
||||
"recipient_id" TEXT NOT NULL,
|
||||
"actor_id" TEXT,
|
||||
"type" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL DEFAULT '',
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_id" TEXT NOT NULL,
|
||||
"resource_version_id" TEXT,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"read_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "notifications_recipient_read_created_idx" ON "notifications"("recipient_id", "read_at", "created_at");
|
||||
CREATE INDEX "notifications_resource_idx" ON "notifications"("resource_type", "resource_id");
|
||||
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actor_id" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"resource_type" TEXT NOT NULL,
|
||||
"resource_id" TEXT NOT NULL,
|
||||
"product_id" TEXT,
|
||||
"project_id" TEXT,
|
||||
"version_id" TEXT,
|
||||
"before" JSONB NOT NULL DEFAULT '{}',
|
||||
"after" JSONB NOT NULL DEFAULT '{}',
|
||||
"metadata" JSONB NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "audit_logs_resource_created_idx" ON "audit_logs"("resource_type", "resource_id", "created_at");
|
||||
CREATE INDEX "audit_logs_actor_created_idx" ON "audit_logs"("actor_id", "created_at");
|
||||
|
||||
CREATE TABLE "governance_dictionaries" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL DEFAULT 'global',
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"code" TEXT,
|
||||
"group" TEXT,
|
||||
"value" JSONB NOT NULL DEFAULT '{}',
|
||||
"is_system" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "governance_dictionaries_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE ("scope", "kind", "name")
|
||||
);
|
||||
|
||||
CREATE INDEX "governance_dictionaries_kind_deleted_idx" ON "governance_dictionaries"("kind", "deleted_at");
|
||||
@@ -152,7 +152,6 @@ model Task {
|
||||
assignee User? @relation("TaskAssignee", fields: [assigneeId], references: [id])
|
||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||
watchers TaskWatcher[]
|
||||
comments Comment[]
|
||||
|
||||
@@map("tasks")
|
||||
}
|
||||
@@ -170,30 +169,100 @@ model TaskWatcher {
|
||||
}
|
||||
|
||||
model Comment {
|
||||
id String @id @default(cuid())
|
||||
taskId String @map("task_id")
|
||||
authorId String @map("author_id")
|
||||
content String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
task Task @relation(fields: [taskId], references: [id])
|
||||
id String @id @default(cuid())
|
||||
entityType String @map("entity_type")
|
||||
entityId String @map("entity_id")
|
||||
entityVersionId String? @map("entity_version_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
authorId String @map("author_id")
|
||||
content String
|
||||
mentionedMemberIds Json @default("[]") @map("mentioned_member_ids")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([entityType, entityId, createdAt], name: "comments_entity_created_idx")
|
||||
@@index([authorId, createdAt], name: "comments_author_created_idx")
|
||||
@@map("comments")
|
||||
}
|
||||
|
||||
model ProjectMember {
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
userId String @map("user_id")
|
||||
role String @default("member")
|
||||
id String @id @default(cuid())
|
||||
projectId String @map("project_id")
|
||||
userId String @map("user_id")
|
||||
role String @default("member")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
project Project @relation(fields: [projectId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([projectId, userId])
|
||||
@@index([userId, role], name: "project_members_user_role_idx")
|
||||
@@map("project_members")
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
recipientId String @map("recipient_id")
|
||||
actorId String? @map("actor_id")
|
||||
type String
|
||||
title String
|
||||
body String @default("")
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
resourceVersionId String? @map("resource_version_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
metadata Json @default("{}")
|
||||
readAt DateTime? @map("read_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([recipientId, readAt, createdAt], name: "notifications_recipient_read_created_idx")
|
||||
@@index([resourceType, resourceId], name: "notifications_resource_idx")
|
||||
@@map("notifications")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
actorId String? @map("actor_id")
|
||||
action String
|
||||
resourceType String @map("resource_type")
|
||||
resourceId String @map("resource_id")
|
||||
productId String? @map("product_id")
|
||||
projectId String? @map("project_id")
|
||||
versionId String? @map("version_id")
|
||||
before Json @default("{}")
|
||||
after Json @default("{}")
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([resourceType, resourceId, createdAt], name: "audit_logs_resource_created_idx")
|
||||
@@index([actorId, createdAt], name: "audit_logs_actor_created_idx")
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
model GovernanceDictionary {
|
||||
id String @id @default(cuid())
|
||||
scope String @default("global")
|
||||
kind String
|
||||
name String
|
||||
code String?
|
||||
group String?
|
||||
value Json @default("{}")
|
||||
isSystem Boolean @default(false) @map("is_system")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([scope, kind, name])
|
||||
@@index([kind, deletedAt], name: "governance_dictionaries_kind_deleted_idx")
|
||||
@@map("governance_dictionaries")
|
||||
}
|
||||
|
||||
model TaskCategory {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
|
||||
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
50
apps/server/prisma/v27-enterprise-schema.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const migrationPath = join(
|
||||
process.cwd(),
|
||||
'prisma',
|
||||
'migrations',
|
||||
'20260708000000_v27_enterprise_collab_governance',
|
||||
'migration.sql',
|
||||
);
|
||||
|
||||
function readMigrationSql() {
|
||||
if (!existsSync(migrationPath)) {
|
||||
throw new Error(`Missing V2.7 migration: ${migrationPath}`);
|
||||
}
|
||||
return readFileSync(migrationPath, 'utf8');
|
||||
}
|
||||
|
||||
describe('V2.7 enterprise collaboration schema migration', () => {
|
||||
it('creates notification records for assignment, mention, risk alert, and overdue events', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(/CREATE TABLE "notifications"/);
|
||||
expect(sql).toMatch(/"type" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"recipient_id" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"read_at" TIMESTAMP\(3\)/);
|
||||
expect(sql).toMatch(/CREATE INDEX "notifications_recipient_read_created_idx"/);
|
||||
});
|
||||
|
||||
it('creates polymorphic comments with mention metadata', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(/CREATE TABLE "comments"/);
|
||||
expect(sql).toMatch(/"entity_type" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"entity_id" TEXT NOT NULL/);
|
||||
expect(sql).toMatch(/"mentioned_member_ids" JSONB NOT NULL DEFAULT '\[\]'/);
|
||||
expect(sql).toMatch(/CREATE INDEX "comments_entity_created_idx"/);
|
||||
});
|
||||
|
||||
it('adds audit logs and governance dictionaries with soft-delete support', () => {
|
||||
const sql = readMigrationSql();
|
||||
|
||||
expect(sql).toMatch(/CREATE TABLE "audit_logs"/);
|
||||
expect(sql).toMatch(/"before" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||
expect(sql).toMatch(/"after" JSONB NOT NULL DEFAULT '\{\}'/);
|
||||
expect(sql).toMatch(/CREATE TABLE "governance_dictionaries"/);
|
||||
expect(sql).toMatch(/"deleted_at" TIMESTAMP\(3\)/);
|
||||
expect(sql).toMatch(/CONSTRAINT "governance_dictionaries_scope_kind_name_key" UNIQUE/);
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,11 @@ import { ConsistencyModule } from './modules/consistency/consistency.module';
|
||||
import { JobsModule } from './modules/jobs/jobs.module';
|
||||
import { XiaobaoModule } from './modules/xiaobao/xiaobao.module';
|
||||
import { OpsModule } from './modules/ops/ops.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { CommentModule } from './modules/comment/comment.module';
|
||||
import { ProjectMemberModule } from './modules/project-member/project-member.module';
|
||||
import { ManagementModule } from './modules/management/management.module';
|
||||
import { GovernanceModule } from './modules/governance/governance.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -53,6 +58,11 @@ import { OpsModule } from './modules/ops/ops.module';
|
||||
JobsModule,
|
||||
XiaobaoModule,
|
||||
OpsModule,
|
||||
NotificationModule,
|
||||
CommentModule,
|
||||
ProjectMemberModule,
|
||||
ManagementModule,
|
||||
GovernanceModule,
|
||||
AiModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
51
apps/server/src/common/audit/audit.service.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe('AuditService V2.7 adapter contract', () => {
|
||||
it('writes an append-only audit event with actor and resource scope', async () => {
|
||||
const prisma = {
|
||||
auditLog: {
|
||||
create: jest.fn().mockResolvedValue({ id: 'audit-1' }),
|
||||
},
|
||||
};
|
||||
const service = new AuditService(prisma as any);
|
||||
|
||||
await service.record({
|
||||
actorId: 'm-admin',
|
||||
action: 'project_member.role_changed',
|
||||
resourceType: 'project_member',
|
||||
resourceId: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
before: { role: 'member' },
|
||||
after: { role: 'admin' },
|
||||
});
|
||||
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
actorId: 'm-admin',
|
||||
action: 'project_member.role_changed',
|
||||
resourceType: 'project_member',
|
||||
resourceId: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
before: { role: 'member' },
|
||||
after: { role: 'admin' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not fail the business action when audit storage is unavailable', async () => {
|
||||
const prisma = {
|
||||
auditLog: {
|
||||
create: jest.fn().mockRejectedValue(new Error('database unavailable')),
|
||||
},
|
||||
};
|
||||
const service = new AuditService(prisma as any);
|
||||
(service as any).logger.warn = jest.fn();
|
||||
|
||||
await expect(service.record({
|
||||
actorId: 'm-admin',
|
||||
action: 'comment.created',
|
||||
resourceType: 'comment',
|
||||
resourceId: 'comment-1',
|
||||
})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
43
apps/server/src/common/audit/audit.service.ts
Normal file
43
apps/server/src/common/audit/audit.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private readonly logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async record(input: AuditEventInput): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
action: input.action,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
productId: input.productId ?? null,
|
||||
projectId: input.projectId ?? null,
|
||||
versionId: input.versionId ?? null,
|
||||
before: input.before ?? {},
|
||||
after: input.after ?? {},
|
||||
metadata: input.metadata ?? {},
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Audit write failed for ${input.action}: ${error?.message ?? error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/server/src/common/common-domain.module.ts
Normal file
9
apps/server/src/common/common-domain.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit/audit.service';
|
||||
import { RbacService } from './rbac/rbac.service';
|
||||
|
||||
@Module({
|
||||
providers: [AuditService, RbacService],
|
||||
exports: [AuditService, RbacService],
|
||||
})
|
||||
export class CommonDomainModule {}
|
||||
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
93
apps/server/src/common/rbac/rbac.service.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService V2.7 adapter contract', () => {
|
||||
const makeService = () => {
|
||||
const prisma = {
|
||||
projectMember: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
return { prisma, service: new RbacService(prisma as any) };
|
||||
};
|
||||
|
||||
it('allows a system admin actor without querying project membership', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
|
||||
await expect(service.assertProjectRole({
|
||||
actorId: 'm-admin',
|
||||
projectId: 'project-1',
|
||||
allowedRoles: ['owner'],
|
||||
permissions: ['*'],
|
||||
})).resolves.toEqual({
|
||||
actorId: 'm-admin',
|
||||
projectId: 'project-1',
|
||||
role: 'owner',
|
||||
via: 'system',
|
||||
});
|
||||
|
||||
expect(prisma.projectMember.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows actors with an explicit global permission', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.assertGlobalPermission({
|
||||
actorId: 'm-pm',
|
||||
permissions: ['management:view'],
|
||||
requiredPermissions: ['management:view'],
|
||||
})).resolves.toEqual({
|
||||
actorId: 'm-pm',
|
||||
via: 'permission',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects actors without the required global permission', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.assertGlobalPermission({
|
||||
actorId: 'm-dev',
|
||||
permissions: ['project:view'],
|
||||
requiredPermissions: ['governance:manage'],
|
||||
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('allows project owners to perform admin-scoped actions', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findUnique.mockResolvedValue({
|
||||
id: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-owner',
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
await expect(service.assertProjectRole({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
allowedRoles: ['admin'],
|
||||
permissions: [],
|
||||
})).resolves.toEqual({
|
||||
actorId: 'm-owner',
|
||||
projectId: 'project-1',
|
||||
role: 'owner',
|
||||
via: 'project_member',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects actors below the required project role', async () => {
|
||||
const { prisma, service } = makeService();
|
||||
prisma.projectMember.findUnique.mockResolvedValue({
|
||||
id: 'pm-1',
|
||||
projectId: 'project-1',
|
||||
userId: 'm-viewer',
|
||||
role: 'viewer',
|
||||
});
|
||||
|
||||
await expect(service.assertProjectRole({
|
||||
actorId: 'm-viewer',
|
||||
projectId: 'project-1',
|
||||
allowedRoles: ['member'],
|
||||
permissions: [],
|
||||
})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
94
apps/server/src/common/rbac/rbac.service.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
export type ProjectGovernanceRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
export interface ProjectRoleAssertion {
|
||||
actorId?: string;
|
||||
projectId?: string | null;
|
||||
allowedRoles: ProjectGovernanceRole[];
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface GlobalPermissionAssertion {
|
||||
actorId?: string;
|
||||
permissions?: string[];
|
||||
requiredPermissions: string[];
|
||||
}
|
||||
|
||||
export interface ProjectRoleDecision {
|
||||
actorId: string;
|
||||
projectId: string;
|
||||
role: ProjectGovernanceRole;
|
||||
via: 'system' | 'project_member';
|
||||
}
|
||||
|
||||
export interface GlobalPermissionDecision {
|
||||
actorId: string;
|
||||
via: 'system' | 'permission';
|
||||
}
|
||||
|
||||
const ROLE_RANK: Record<ProjectGovernanceRole, number> = {
|
||||
owner: 4,
|
||||
admin: 3,
|
||||
member: 2,
|
||||
viewer: 1,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RbacService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async assertGlobalPermission(input: GlobalPermissionAssertion): Promise<GlobalPermissionDecision> {
|
||||
const actorId = input.actorId?.trim();
|
||||
if (!actorId) {
|
||||
throw new ForbiddenException('Missing actor scope');
|
||||
}
|
||||
if (input.permissions?.includes('*')) {
|
||||
return { actorId, via: 'system' };
|
||||
}
|
||||
if (hasAnyPermission(input.permissions ?? [], input.requiredPermissions)) {
|
||||
return { actorId, via: 'permission' };
|
||||
}
|
||||
throw new ForbiddenException('Insufficient global permission');
|
||||
}
|
||||
|
||||
async assertProjectRole(input: ProjectRoleAssertion): Promise<ProjectRoleDecision> {
|
||||
const actorId = input.actorId?.trim();
|
||||
const projectId = input.projectId?.trim();
|
||||
if (!actorId || !projectId) {
|
||||
throw new ForbiddenException('Missing actor or project scope');
|
||||
}
|
||||
|
||||
if (input.permissions?.includes('*')) {
|
||||
return { actorId, projectId, role: 'owner', via: 'system' };
|
||||
}
|
||||
|
||||
const membership = await this.prisma.projectMember.findUnique({
|
||||
where: { projectId_userId: { projectId, userId: actorId } },
|
||||
});
|
||||
const role = normalizeProjectRole(membership?.role);
|
||||
if (!role || !hasRequiredRole(role, input.allowedRoles)) {
|
||||
throw new ForbiddenException('Insufficient project role');
|
||||
}
|
||||
|
||||
return { actorId, projectId, role, via: 'project_member' };
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProjectRole(role: string | null | undefined): ProjectGovernanceRole | null {
|
||||
if (role === 'owner' || role === 'admin' || role === 'member' || role === 'viewer') return role;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasRequiredRole(role: ProjectGovernanceRole, allowedRoles: ProjectGovernanceRole[]): boolean {
|
||||
if (allowedRoles.length === 0) return false;
|
||||
const minimumRank = Math.min(...allowedRoles.map((allowedRole) => ROLE_RANK[allowedRole]));
|
||||
return ROLE_RANK[role] >= minimumRank;
|
||||
}
|
||||
|
||||
export function hasAnyPermission(permissions: string[], requiredPermissions: string[]): boolean {
|
||||
if (requiredPermissions.length === 0) return false;
|
||||
const granted = new Set(permissions);
|
||||
return requiredPermissions.some((permission) => granted.has(permission));
|
||||
}
|
||||
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
24
apps/server/src/modules/comment/comment.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps/server/src/modules/comment/comment.module.ts
Normal file
13
apps/server/src/modules/comment/comment.module.ts
Normal 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 {}
|
||||
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
157
apps/server/src/modules/comment/comment.service.ts
Normal file
157
apps/server/src/modules/comment/comment.service.ts
Normal 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;
|
||||
}
|
||||
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal 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[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DeleteCommentDto {
|
||||
@IsString()
|
||||
actorId!: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
46
apps/server/src/modules/governance/governance.controller.ts
Normal file
46
apps/server/src/modules/governance/governance.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
apps/server/src/modules/governance/governance.module.ts
Normal file
12
apps/server/src/modules/governance/governance.module.ts
Normal 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 {}
|
||||
@@ -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: '新功能' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
227
apps/server/src/modules/governance/governance.service.ts
Normal file
227
apps/server/src/modules/governance/governance.service.ts
Normal 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;
|
||||
}
|
||||
18
apps/server/src/modules/management/management.controller.ts
Normal file
18
apps/server/src/modules/management/management.controller.ts
Normal 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) : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
11
apps/server/src/modules/management/management.module.ts
Normal file
11
apps/server/src/modules/management/management.module.ts
Normal 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 {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
162
apps/server/src/modules/management/management.service.ts
Normal file
162
apps/server/src/modules/management/management.service.ts
Normal 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));
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class MarkNotificationReadDto {
|
||||
@IsString()
|
||||
recipientId!: string;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
10
apps/server/src/modules/notification/notification.module.ts
Normal file
10
apps/server/src/modules/notification/notification.module.ts
Normal 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 {}
|
||||
@@ -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' },
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
99
apps/server/src/modules/notification/notification.service.ts
Normal file
99
apps/server/src/modules/notification/notification.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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' }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
129
apps/server/src/modules/project-member/project-member.service.ts
Normal file
129
apps/server/src/modules/project-member/project-member.service.ts
Normal 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;
|
||||
}
|
||||
152
apps/web/app/admin/governance/page.tsx
Normal file
152
apps/web/app/admin/governance/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, Plus, RefreshCcw, Trash2, Upload } from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
type GovernanceKind = 'task_category' | 'requirement_type' | 'requirement_platform' | 'requirement_source';
|
||||
|
||||
interface GovernanceItem {
|
||||
id: string;
|
||||
kind?: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
group?: string | null;
|
||||
isSystem?: boolean;
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<GovernanceKind, string> = {
|
||||
task_category: '任务类型',
|
||||
requirement_type: '需求类型',
|
||||
requirement_platform: '支持端',
|
||||
requirement_source: '需求来源',
|
||||
};
|
||||
|
||||
function GovernancePageInner() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
||||
const [kind, setKind] = useState<GovernanceKind>('task_category');
|
||||
const [items, setItems] = useState<GovernanceItem[]>([]);
|
||||
const [name, setName] = useState('');
|
||||
const [group, setGroup] = useState('other');
|
||||
const [exportText, setExportText] = useState('');
|
||||
const actorId = user?.id ?? '';
|
||||
const permissions = role?.permissions ?? [];
|
||||
|
||||
const reload = async () => {
|
||||
const rows = await api.get<GovernanceItem[]>(`/governance/dictionaries?kind=${kind}`);
|
||||
setItems(rows);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload().catch(() => setItems([]));
|
||||
}, [kind]);
|
||||
|
||||
const create = async () => {
|
||||
if (!actorId || !name.trim()) return;
|
||||
await api.post('/governance/dictionaries', { actorId, permissions, kind, name: name.trim(), group });
|
||||
setName('');
|
||||
await reload();
|
||||
};
|
||||
|
||||
const remove = async (item: GovernanceItem) => {
|
||||
if (!actorId) return;
|
||||
await api.deleteWithBody(`/governance/dictionaries/${kind}/${item.id}`, { actorId, permissions });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const exportAll = async () => {
|
||||
const data = await api.get('/governance/export');
|
||||
setExportText(JSON.stringify(data, null, 2));
|
||||
};
|
||||
|
||||
const importAll = async () => {
|
||||
if (!actorId || !exportText.trim()) return;
|
||||
const parsed = JSON.parse(exportText);
|
||||
const sourceItems = Array.isArray(parsed.items)
|
||||
? parsed.items
|
||||
: [...(parsed.dictionaries ?? []), ...(parsed.taskCategories ?? []).map((item: GovernanceItem) => ({ ...item, kind: 'task_category' }))];
|
||||
await api.post('/governance/import', { actorId, permissions, items: sourceItems });
|
||||
await reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[var(--bg)] p-6">
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[18px] font-semibold text-[var(--ink)]">治理设置</h1>
|
||||
<p className="mt-1 text-[12px] text-[var(--ink-muted)]">统一维护任务类型、需求类型、支持端与来源字典。</p>
|
||||
</div>
|
||||
<button onClick={reload} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[12px] text-[var(--ink-soft)]">
|
||||
<RefreshCcw className="h-3.5 w-3.5" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(KIND_LABEL).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setKind(key as GovernanceKind)}
|
||||
className={`h-8 rounded-md px-3 text-[12px] font-medium ${kind === key ? 'bg-[var(--accent)] text-white' : 'border border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)]'}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="grid grid-cols-[1fr_140px_auto] gap-2 border-b border-[var(--line)] p-3">
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} placeholder={`新增${KIND_LABEL[kind]}`} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<input value={group} onChange={(event) => setGroup(event.target.value)} placeholder="分组" className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
|
||||
<button onClick={create} disabled={!name.trim()} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||
<Plus className="h-3.5 w-3.5" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[1fr_160px_80px_32px] items-center gap-3 px-4 py-2.5">
|
||||
<span className="truncate text-[13px] text-[var(--ink)]">{item.name}</span>
|
||||
<span className="truncate text-[11px] text-[var(--ink-muted)]">{item.code ?? '-'}</span>
|
||||
<span className="truncate text-[11px] text-[var(--ink-muted)]">{item.group ?? '-'}</span>
|
||||
<button onClick={() => remove(item).catch(() => {})} disabled={item.isSystem} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600 disabled:opacity-30" title="删除">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无字典项</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<button onClick={exportAll} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] px-3 text-[12px] text-[var(--ink-soft)]">
|
||||
<Download className="h-3.5 w-3.5" /> 导出
|
||||
</button>
|
||||
<button onClick={importAll} disabled={!exportText.trim()} className="inline-flex h-8 items-center gap-1 rounded-md border border-[var(--line)] px-3 text-[12px] text-[var(--ink-soft)] disabled:opacity-50">
|
||||
<Upload className="h-3.5 w-3.5" /> 导入
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={exportText}
|
||||
onChange={(event) => setExportText(event.target.value)}
|
||||
rows={10}
|
||||
className="w-full rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 py-2 font-mono text-[11px] leading-5 focus:border-[var(--accent)] focus:outline-none"
|
||||
placeholder="点击导出生成 JSON,也可以粘贴 JSON 后导入"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GovernancePage() {
|
||||
return (
|
||||
<RouteGuard permission="governance:manage">
|
||||
<GovernancePageInner />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
132
apps/web/app/admin/management/page.tsx
Normal file
132
apps/web/app/admin/management/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity, AlertTriangle, Blocks, Gauge, Users } from 'lucide-react';
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
interface ManagementOverview {
|
||||
activeVersionCount: number;
|
||||
overdueItemCount: number;
|
||||
blockedItemCount: number;
|
||||
riskCounts: Record<string, number>;
|
||||
memberLoads: Array<{ memberId: string; openItemCount: number }>;
|
||||
activeVersions: Array<{ id: string; name: string; projectId?: string | null; releaseDate?: string | null }>;
|
||||
overdueItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null; dueAt?: string | null }>;
|
||||
blockedItems: Array<{ type: string; id: string; title: string; versionId: string; ownerId?: string | null }>;
|
||||
highRiskVersions: Array<{ versionId: string; riskLevel: string; riskScore: number }>;
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
version_plan: '计划',
|
||||
dev_task: '开发',
|
||||
test_case: '测试',
|
||||
bug: 'Bug',
|
||||
};
|
||||
|
||||
function ManagementPageInner() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const role = useMemberStore((s) => s.roles.find((item) => item.id === user?.roleId));
|
||||
const [overview, setOverview] = useState<ManagementOverview | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const permissions = useMemo(() => role?.permissions ?? [], [role?.permissions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ actorId: user.id });
|
||||
if (permissions.length > 0) params.set('permissions', permissions.join(','));
|
||||
api.get<ManagementOverview>(`/management/overview?${params.toString()}`)
|
||||
.then(setOverview)
|
||||
.catch(() => setOverview(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [permissions, user?.id]);
|
||||
|
||||
const riskTotal = overview ? Object.values(overview.riskCounts).reduce((sum, count) => sum + count, 0) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[var(--bg)] p-6">
|
||||
<div className="mx-auto max-w-6xl space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[18px] font-semibold text-[var(--ink)]">管理驾驶舱</h1>
|
||||
<p className="mt-1 text-[12px] text-[var(--ink-muted)]">关系表实时聚合,不读取 AppData。</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-3 py-1.5 text-[11px] text-[var(--ink-muted)]">
|
||||
{loading ? '刷新中' : '已同步'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Metric icon={Activity} label="活跃版本" value={overview?.activeVersionCount ?? 0} />
|
||||
<Metric icon={AlertTriangle} label="逾期事项" value={overview?.overdueItemCount ?? 0} tone="warn" />
|
||||
<Metric icon={Blocks} label="阻塞事项" value={overview?.blockedItemCount ?? 0} tone="danger" />
|
||||
<Metric icon={Gauge} label="风险版本" value={riskTotal} tone="risk" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1.15fr_0.85fr] gap-4">
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]">逾期与阻塞</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{[...(overview?.overdueItems ?? []), ...(overview?.blockedItems ?? [])].slice(0, 12).map((item) => (
|
||||
<div key={`${item.type}-${item.id}`} className="grid grid-cols-[72px_1fr_120px] gap-3 px-4 py-2.5 text-[12px]">
|
||||
<span className="rounded bg-[var(--bg-subtle)] px-2 py-1 text-center text-[11px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
|
||||
<span className="min-w-0 truncate text-[var(--ink)]">{item.title}</span>
|
||||
<span className="truncate text-right text-[var(--ink-muted)]">{item.ownerId ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!overview || (overview.overdueItems.length + overview.blockedItems.length) === 0) && (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无逾期或阻塞事项</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)]">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-3 text-[13px] font-semibold text-[var(--ink)]">
|
||||
<Users className="h-4 w-4 text-[var(--accent)]" /> 成员负载
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--line)]">
|
||||
{(overview?.memberLoads ?? []).slice(0, 10).map((item) => (
|
||||
<div key={item.memberId} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<span className="min-w-0 flex-1 truncate text-[12px] text-[var(--ink)]">{item.memberId}</span>
|
||||
<span className="rounded bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700">{item.openItemCount}</span>
|
||||
</div>
|
||||
))}
|
||||
{(!overview || overview.memberLoads.length === 0) && (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无负载数据</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ icon: Icon, label, value, tone = 'neutral' }: { icon: any; label: string; value: number; tone?: 'neutral' | 'warn' | 'danger' | 'risk' }) {
|
||||
const toneClass = {
|
||||
neutral: 'text-[var(--accent)] bg-blue-50',
|
||||
warn: 'text-orange-700 bg-orange-50',
|
||||
danger: 'text-red-700 bg-red-50',
|
||||
risk: 'text-purple-700 bg-purple-50',
|
||||
}[tone];
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[12px] text-[var(--ink-muted)]">{label}</span>
|
||||
<span className={`rounded-md p-1.5 ${toneClass}`}><Icon className="h-4 w-4" /></span>
|
||||
</div>
|
||||
<div className="text-[26px] font-semibold tabular-nums text-[var(--ink)]">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManagementPage() {
|
||||
return (
|
||||
<RouteGuard permission="management:view">
|
||||
<ManagementPageInner />
|
||||
</RouteGuard>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { VersionStatus, VERSION_STATUS_LABEL, VERSION_STATUS_BG } from '@/lib/ve
|
||||
import { calcGroupProgress as calcDevTaskProgress, aggregateDevTaskHours } from '@/lib/dev-task';
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { MemberChips } from '@/components/version/MemberChips';
|
||||
import { ProjectMemberPanel } from '@/components/project/ProjectMemberPanel';
|
||||
import { getRequirementCoverageSummary, type VersionPlan } from '@/lib/version-plan';
|
||||
import { buildVersionTimelineSummary, calcStageEffortMetrics, formatVersionOverviewDateTime, getVersionCardDefaultExpanded, mergeStageProgressWithEffort } from '@/lib/version-overview';
|
||||
import { calcScopedVersionProgress } from '@/lib/version-progress';
|
||||
@@ -451,6 +452,8 @@ export default function ProjectDetailPage() {
|
||||
|
||||
<TeamSection teamByRole={teamByRole} />
|
||||
|
||||
<ProjectMemberPanel projectId={projectId} />
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
|
||||
import { X, Link2, ChevronRight, ArrowRightLeft } from 'lucide-react';
|
||||
import { BugStatusBadge } from './BugStatusBadge';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
@@ -228,6 +229,13 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel, readOnly = false
|
||||
)}
|
||||
|
||||
<ActivityLogPanel sourceType="bug" sourceId={bug.id} legacyEntries={legacyLogEntries} />
|
||||
<CommentPanel
|
||||
entityType="bug"
|
||||
entityId={bug.id}
|
||||
entityVersionId={bug.versionId}
|
||||
versionId={bug.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
126
apps/web/components/comment/CommentPanel.tsx
Normal file
126
apps/web/components/comment/CommentPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Send, Trash2 } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useCommentStore, commentKey } from '@/stores/useCommentStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { extractMentionNames, mergeMentionMemberIds } from '@/lib/comment-mentions';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
export type CommentEntityType = 'dev_task' | 'test_case' | 'bug' | 'requirement' | 'version_plan';
|
||||
|
||||
interface Props {
|
||||
entityType: CommentEntityType;
|
||||
entityId: string;
|
||||
entityVersionId?: string | null;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function CommentPanel({ entityType, entityId, entityVersionId, productId, projectId, versionId, readOnly = false }: Props) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { members, fetchMembers } = useMemberStore();
|
||||
const { commentsByKey, fetchComments, createComment, deleteComment } = useCommentStore();
|
||||
const [content, setContent] = useState('');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const key = commentKey(entityType, entityId);
|
||||
const comments = commentsByKey[key] ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
void fetchComments(entityType, entityId).catch(() => {});
|
||||
void fetchMembers().catch(() => {});
|
||||
}, [entityId, entityType, fetchComments, fetchMembers]);
|
||||
|
||||
const mentionNames = useMemo(() => extractMentionNames(content), [content]);
|
||||
const mentionMemberIds = useMemo(() => mergeMentionMemberIds({
|
||||
content,
|
||||
explicitMemberIds: selectedIds,
|
||||
members: members.map((member) => ({ id: member.id, name: member.name })),
|
||||
}), [content, members, selectedIds]);
|
||||
|
||||
const submit = async () => {
|
||||
if (readOnly || !user?.id || !content.trim()) return;
|
||||
await createComment({
|
||||
actorId: user.id,
|
||||
entityType,
|
||||
entityId,
|
||||
entityVersionId,
|
||||
productId,
|
||||
projectId,
|
||||
versionId,
|
||||
content: content.trim(),
|
||||
mentionMemberIds,
|
||||
});
|
||||
setContent('');
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const toggleMember = (id: string) => {
|
||||
setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-[var(--ink-muted)]">
|
||||
<MessageSquare className="h-3 w-3" /> 评论
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-[12px] text-[var(--ink-muted)]">暂无评论</p>
|
||||
) : (
|
||||
comments.map((comment) => {
|
||||
const author = members.find((member) => member.id === comment.authorId)?.name ?? comment.authorId;
|
||||
return (
|
||||
<div key={comment.id} className="rounded-md bg-[var(--bg-subtle)] px-3 py-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-[12px] font-medium text-[var(--ink)]">{author}</span>
|
||||
<span className="text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(comment.createdAt)}</span>
|
||||
{!readOnly && user?.id === comment.authorId && (
|
||||
<button onClick={() => deleteComment(entityType, entityId, comment.id, user.id).catch(() => {})} className="ml-auto rounded p-1 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="删除评论">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-[12px] leading-5 text-[var(--ink-soft)]">{comment.content}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className="mt-3 space-y-2 border-t border-[var(--line)] pt-3">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="写评论,输入 @成员名 可提及"
|
||||
className="w-full resize-none rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 py-2 text-[12px] leading-5 focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.slice(0, 8).map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => toggleMember(member.id)}
|
||||
className={`h-6 rounded-md border px-2 text-[11px] ${selectedIds.includes(member.id) ? 'border-blue-300 bg-blue-50 text-blue-700' : 'border-[var(--line)] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
@{member.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-[var(--ink-muted)]">
|
||||
{mentionNames.length > 0 || selectedIds.length > 0 ? `将通知 ${mentionMemberIds.length} 人` : '未提及成员'}
|
||||
</span>
|
||||
<button onClick={submit} disabled={!content.trim() || !user?.id} className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||
<Send className="h-3 w-3" /> 发送
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Clock, User, Tag, Play, Trash2,
|
||||
import { StatusBadge } from './StatusBadge';
|
||||
import { CategoryChip } from './CategoryChip';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { useDevTaskStore } from '@/stores/useDevTaskStore';
|
||||
import { useWorkActivityStore } from '@/stores/useWorkActivityStore';
|
||||
import { useTaskCategoryStore } from '@/stores/useTaskCategoryStore';
|
||||
@@ -507,6 +508,14 @@ export function DevTaskDetailDrawer({ taskId, allTaskIds, onClose, contextLabel,
|
||||
|
||||
<ActivityLogPanel sourceType="dev_task" sourceId={task.id} />
|
||||
|
||||
<CommentPanel
|
||||
entityType="dev_task"
|
||||
entityId={task.id}
|
||||
entityVersionId={task.versionId}
|
||||
versionId={task.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{predecessors.length > 0 && (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-2">前置任务</div>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity } from 'lucide-react';
|
||||
import { Inbox, Package, FolderKanban, Tag, Users, LayoutGrid, Lightbulb, Clock, Shield, Settings, Sparkles, TriangleAlert, MessageCircleQuestionMark, ScrollText, Database, Activity, BarChart3, SlidersHorizontal } from 'lucide-react';
|
||||
import { useHasPermission } from '@/components/auth/Guard';
|
||||
import { NotificationBell } from '@/components/notification/NotificationBell';
|
||||
import { useXiaobaoWarningRisks } from '@/hooks/useXiaobaoWarningRisks';
|
||||
import { useWorkspaceWorkItems } from '@/hooks/useWorkspaceWorkItems';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
@@ -48,6 +49,8 @@ const NAV_GROUPS = [
|
||||
{ label: '审计', path: '/admin/audit', icon: ScrollText, permission: 'audit:view' },
|
||||
{ label: '一致性', path: '/admin/consistency', icon: Database, permission: 'consistency:view' },
|
||||
{ label: '运维', path: '/admin/ops', icon: Activity, permission: 'ops:view' },
|
||||
{ label: '管理驾驶舱', path: '/admin/management', icon: BarChart3, permission: 'management:view' },
|
||||
{ label: '治理设置', path: '/admin/governance', icon: SlidersHorizontal, permission: 'governance:manage' },
|
||||
{ label: 'AI 配置', path: '/admin/ai-config', icon: Sparkles, permission: '*' },
|
||||
],
|
||||
},
|
||||
@@ -119,6 +122,7 @@ function UserBlock() {
|
||||
<p className="truncate text-[13px] font-medium text-[var(--ink)]">{user.name}</p>
|
||||
<p className="truncate text-[11px] text-[var(--ink-muted)]">{role?.name ?? '-'}</p>
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<button
|
||||
onClick={() => router.push('/profile')}
|
||||
className="rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
|
||||
78
apps/web/components/notification/NotificationBell.tsx
Normal file
78
apps/web/components/notification/NotificationBell.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, CheckCheck } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useNotificationStore } from '@/stores/useNotificationStore';
|
||||
import { formatDateTime } from '@/lib/format';
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
assignment: '分配',
|
||||
mention: '提及',
|
||||
risk_alert: '风险',
|
||||
overdue_item: '逾期',
|
||||
};
|
||||
|
||||
export function NotificationBell() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { notifications, unreadCount, fetchNotifications, markRead, markAllRead } = useNotificationStore();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.id) void fetchNotifications(user.id).catch(() => {});
|
||||
}, [fetchNotifications, user?.id]);
|
||||
|
||||
if (!user?.id) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="relative rounded-md p-1 text-[var(--ink-muted)] hover:bg-[var(--bg-card)] hover:text-[var(--accent)]"
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="h-4 w-4" strokeWidth={1.8} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -right-1 -top-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-red-600 px-1 text-[9px] font-semibold leading-none text-white">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-9 right-0 z-50 w-80 overflow-hidden rounded-lg border border-[var(--line)] bg-[var(--bg-card)] shadow-2xl">
|
||||
<div className="flex h-10 items-center justify-between border-b border-[var(--line)] px-3">
|
||||
<span className="text-[12px] font-semibold text-[var(--ink)]">通知</span>
|
||||
<button
|
||||
onClick={() => markAllRead(user.id).catch(() => {})}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-[11px] text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)]"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" /> 全部已读
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[12px] text-[var(--ink-muted)]">暂无通知</div>
|
||||
) : (
|
||||
notifications.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => !item.readAt && markRead(item.id, user.id).catch(() => {})}
|
||||
className={`block w-full border-b border-[var(--line)] px-3 py-2.5 text-left last:border-b-0 hover:bg-[var(--bg-subtle)] ${item.readAt ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${item.readAt ? 'bg-zinc-300' : 'bg-blue-600'}`} />
|
||||
<span className="rounded bg-[var(--bg-subtle)] px-1.5 py-0.5 text-[10px] text-[var(--ink-muted)]">{TYPE_LABEL[item.type] ?? item.type}</span>
|
||||
<span className="ml-auto text-[10px] tabular-nums text-[var(--ink-muted)]">{formatDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="line-clamp-1 text-[12px] font-medium text-[var(--ink)]">{item.title}</div>
|
||||
{item.body && <div className="mt-0.5 line-clamp-2 text-[11px] leading-4 text-[var(--ink-muted)]">{item.body}</div>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/components/project/ProjectMemberPanel.tsx
Normal file
105
apps/web/components/project/ProjectMemberPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ShieldCheck, Trash2, UserPlus } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
|
||||
type ProjectRole = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
interface ProjectMemberRow {
|
||||
id: string;
|
||||
projectId: string;
|
||||
userId: string;
|
||||
role: ProjectRole;
|
||||
user?: { id: string; name: string; email?: string };
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<ProjectRole, string> = {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
member: 'Member',
|
||||
viewer: 'Viewer',
|
||||
};
|
||||
|
||||
export function ProjectMemberPanel({ projectId }: { projectId: string }) {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { members, fetchMembers } = useMemberStore();
|
||||
const [rows, setRows] = useState<ProjectMemberRow[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [selectedRole, setSelectedRole] = useState<ProjectRole>('member');
|
||||
const actorId = user?.id ?? '';
|
||||
|
||||
const reload = async () => {
|
||||
if (!projectId) return;
|
||||
const data = await api.get<ProjectMemberRow[]>(`/projects/${projectId}/members`);
|
||||
setRows(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchMembers().catch(() => {});
|
||||
void reload().catch(() => {});
|
||||
}, [projectId]);
|
||||
|
||||
const add = async () => {
|
||||
if (!actorId || !selectedUserId) return;
|
||||
await api.post(`/projects/${projectId}/members`, { actorId, userId: selectedUserId, role: selectedRole });
|
||||
setSelectedUserId('');
|
||||
await reload();
|
||||
};
|
||||
|
||||
const updateRole = async (member: ProjectMemberRow, role: ProjectRole) => {
|
||||
if (!actorId) return;
|
||||
await api.patch(`/projects/${projectId}/members/${member.userId}/role`, { actorId, role });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const remove = async (member: ProjectMemberRow) => {
|
||||
if (!actorId) return;
|
||||
await api.deleteWithBody(`/projects/${projectId}/members/${member.userId}`, { actorId });
|
||||
await reload();
|
||||
};
|
||||
|
||||
const existingUserIds = new Set(rows.map((row) => row.userId));
|
||||
const candidates = members.filter((member) => !existingUserIds.has(member.id));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--line)] bg-[var(--bg-card)] p-4">
|
||||
<div className="mb-3 flex items-center gap-1.5 text-[12px] font-semibold text-[var(--ink)]">
|
||||
<ShieldCheck className="h-4 w-4 text-[var(--accent)]" /> 项目成员治理
|
||||
</div>
|
||||
<div className="mb-3 grid grid-cols-[1fr_108px_auto] gap-2">
|
||||
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
||||
<option value="">选择成员</option>
|
||||
{candidates.map((member) => <option key={member.id} value={member.id}>{member.name}</option>)}
|
||||
</select>
|
||||
<select value={selectedRole} onChange={(event) => setSelectedRole(event.target.value as ProjectRole)} className="h-8 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[12px]">
|
||||
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
||||
</select>
|
||||
<button onClick={add} disabled={!selectedUserId} className="inline-flex h-8 items-center gap-1 rounded-md bg-[var(--accent)] px-3 text-[12px] font-medium text-white disabled:opacity-50">
|
||||
<UserPlus className="h-3 w-3" /> 添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-[var(--line)] rounded-md border border-[var(--line)]">
|
||||
{rows.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-[12px] text-[var(--ink-muted)]">暂无项目成员</div>
|
||||
) : rows.map((row) => (
|
||||
<div key={row.id} className="grid grid-cols-[1fr_112px_32px] items-center gap-2 px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[12px] font-medium text-[var(--ink)]">{row.user?.name ?? row.userId}</div>
|
||||
<div className="truncate text-[10px] text-[var(--ink-muted)]">{row.user?.email ?? row.userId}</div>
|
||||
</div>
|
||||
<select value={row.role} onChange={(event) => updateRole(row, event.target.value as ProjectRole).catch(() => {})} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg)] px-2 text-[11px]">
|
||||
{Object.entries(ROLE_LABEL).map(([role, label]) => <option key={role} value={role}>{label}</option>)}
|
||||
</select>
|
||||
<button onClick={() => remove(row).catch(() => {})} className="rounded p-1.5 text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600" title="移除成员">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import type { Requirement, DictItem, SourceTarget } from '@/lib/requirement';
|
||||
import { REQ_STATUS_LABEL, REQ_STATUS_COLOR, SOURCE_TYPE_LABEL } from '@/lib/requirement';
|
||||
|
||||
@@ -114,6 +115,16 @@ export function RequirementDetail({
|
||||
<InfoItem label="来源对象" value={sourceTargetName} />
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
<section className="border-b border-[var(--line)] px-5 py-4 last:border-b-0">
|
||||
<CommentPanel
|
||||
entityType="requirement"
|
||||
entityId={req.id}
|
||||
entityVersionId={req.versionId}
|
||||
projectId={req.projectId}
|
||||
versionId={req.versionId}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X, AlertTriangle, Link2, ChevronRight, Bug as BugIcon, Trash2, ArrowRig
|
||||
import { TestCaseStatusBadge } from './TestCaseStatusBadge';
|
||||
import { BugStatusBadge } from '@/components/bug/BugStatusBadge';
|
||||
import { ActivityLogPanel } from '@/components/ActivityLogPanel';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import { useTestCaseStore } from '@/stores/useTestCaseStore';
|
||||
import { useBugStore } from '@/stores/useBugStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
@@ -328,6 +329,13 @@ export function TestCaseDetailDrawer({ testCaseId, onClose, onCreateBug, context
|
||||
</div>
|
||||
|
||||
<ActivityLogPanel sourceType="test_case" sourceId={tc.id} />
|
||||
<CommentPanel
|
||||
entityType="test_case"
|
||||
entityId={tc.id}
|
||||
entityVersionId={tc.versionId}
|
||||
versionId={tc.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useMemberStore } from '@/stores/useMemberStore';
|
||||
import { useAuthStore } from '@/stores/useAuthStore';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
import { CommentPanel } from '@/components/comment/CommentPanel';
|
||||
import {
|
||||
getResearchDirectionProgressSummary,
|
||||
getRequirementCoverageSummary,
|
||||
@@ -242,6 +243,14 @@ export function PlanDetailDrawer({ planId, onClose, contextLabel, readOnly = fal
|
||||
|
||||
<PlanLogTimeline logs={plan.logs} className="border-l-0 border-t border-[var(--line)] pt-4 pl-0" />
|
||||
|
||||
<CommentPanel
|
||||
entityType="version_plan"
|
||||
entityId={plan.id}
|
||||
entityVersionId={plan.versionId}
|
||||
versionId={plan.versionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
{/* Result */}
|
||||
{plan.status === 'completed' && plan.resultUrl && (
|
||||
<div className="rounded-lg bg-[var(--bg-subtle)] p-3">
|
||||
|
||||
@@ -92,6 +92,8 @@ export const api = {
|
||||
patch: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
deleteWithBody: <T>(path: string, data: unknown) =>
|
||||
request<T>(path, { method: 'DELETE', body: JSON.stringify(data) }),
|
||||
postRaw: async <T>(path: string, data: unknown, timeoutMs = 120000): Promise<T> => {
|
||||
// 调用 AI 类长耗时接口时使用,跳过 checkApi 短路(确保走真实请求)
|
||||
const controller = new AbortController();
|
||||
|
||||
20
apps/web/lib/comment-mentions.test.ts
Normal file
20
apps/web/lib/comment-mentions.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractMentionNames, mergeMentionMemberIds } from './comment-mentions';
|
||||
|
||||
test('extractMentionNames reads @memberName mentions from comment content', () => {
|
||||
assert.deepEqual(extractMentionNames('请 @Alice 和 @张三 看一下'), ['Alice', '张三']);
|
||||
});
|
||||
|
||||
test('mergeMentionMemberIds keeps text mention matches before explicit selection and dedupes', () => {
|
||||
const ids = mergeMentionMemberIds({
|
||||
content: '请 @Alice 看一下',
|
||||
explicitMemberIds: ['m-bob', 'm-alice'],
|
||||
members: [
|
||||
{ id: 'm-alice', name: 'Alice' },
|
||||
{ id: 'm-bob', name: 'Bob' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(ids, ['m-alice', 'm-bob']);
|
||||
});
|
||||
36
apps/web/lib/comment-mentions.ts
Normal file
36
apps/web/lib/comment-mentions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export interface CommentMentionMember {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MergeMentionMemberIdsInput {
|
||||
content: string;
|
||||
explicitMemberIds: string[];
|
||||
members: CommentMentionMember[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function mergeMentionMemberIds(input: MergeMentionMemberIdsInput): string[] {
|
||||
const ids = new Set<string>();
|
||||
const names = new Set(extractMentionNames(input.content).map(normalizeName));
|
||||
for (const member of input.members) {
|
||||
if (names.has(normalizeName(member.name))) ids.add(member.id);
|
||||
}
|
||||
for (const id of input.explicitMemberIds) {
|
||||
const normalized = id.trim();
|
||||
if (normalized) ids.add(normalized);
|
||||
}
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
20
apps/web/lib/notification.test.ts
Normal file
20
apps/web/lib/notification.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { countUnreadNotifications, sortNotificationsNewestFirst } from './notification';
|
||||
|
||||
test('countUnreadNotifications counts only rows without readAt', () => {
|
||||
assert.equal(countUnreadNotifications([
|
||||
{ id: 'n-1', readAt: null } as any,
|
||||
{ id: 'n-2', readAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||
{ id: 'n-3' } as any,
|
||||
]), 2);
|
||||
});
|
||||
|
||||
test('sortNotificationsNewestFirst keeps newest createdAt first', () => {
|
||||
const sorted = sortNotificationsNewestFirst([
|
||||
{ id: 'old', createdAt: '2026-07-07T08:00:00.000Z' } as any,
|
||||
{ id: 'new', createdAt: '2026-07-08T08:00:00.000Z' } as any,
|
||||
]);
|
||||
|
||||
assert.deepEqual(sorted.map((item) => item.id), ['new', 'old']);
|
||||
});
|
||||
32
apps/web/lib/notification.ts
Normal file
32
apps/web/lib/notification.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type NotificationType = 'assignment' | 'mention' | 'risk_alert' | 'overdue_item';
|
||||
|
||||
export interface NotificationRecord {
|
||||
id: string;
|
||||
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?: Record<string, unknown>;
|
||||
readAt?: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function countUnreadNotifications(items: Pick<NotificationRecord, 'readAt'>[]): number {
|
||||
return items.filter((item) => !item.readAt).length;
|
||||
}
|
||||
|
||||
export function sortNotificationsNewestFirst<T extends Pick<NotificationRecord, 'createdAt'>>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => dateValue(b.createdAt) - dateValue(a.createdAt));
|
||||
}
|
||||
|
||||
function dateValue(value: string): number {
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
@@ -64,6 +64,22 @@ export const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{ module: 'role', moduleLabel: '角色', category: 'main', actions: std4('role') },
|
||||
{ module: 'audit', moduleLabel: '审计', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'audit:view' }] },
|
||||
{ module: 'consistency', moduleLabel: '一致性', category: 'main', actions: [{ action: 'view', label: '查看', permission: 'consistency:view' }] },
|
||||
{
|
||||
module: 'management',
|
||||
moduleLabel: '管理驾驶舱',
|
||||
category: 'main',
|
||||
actions: [
|
||||
{ action: 'view', label: '查看', permission: 'management:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
module: 'governance',
|
||||
moduleLabel: '治理设置',
|
||||
category: 'main',
|
||||
actions: [
|
||||
{ action: 'manage', label: '管理', permission: 'governance:manage' },
|
||||
],
|
||||
},
|
||||
{ module: 'version.req', moduleLabel: '需求 Tab', category: 'version_tab', actions: stdTab('version.req') },
|
||||
{ module: 'version.research', moduleLabel: '调研 Tab', category: 'version_tab', actions: stdTab('version.research') },
|
||||
{ module: 'version.product_plan', moduleLabel: '产品方案 Tab', category: 'version_tab', actions: stdTab('version.product_plan') },
|
||||
@@ -86,6 +102,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
...std4('requirement').map((a) => a.permission),
|
||||
'version.req:view', 'version.req:manage',
|
||||
'version.product_plan:view', 'version.product_plan:manage',
|
||||
'management:view',
|
||||
'xiaobao.warning:view', 'xiaobao.warning:manage',
|
||||
'overtime:view', 'member:view', 'role:view',
|
||||
'version.research:view', 'version.ui_plan:view', 'version.devtask:view',
|
||||
|
||||
79
apps/web/stores/useCommentStore.ts
Normal file
79
apps/web/stores/useCommentStore.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import type { CommentEntityType } from '@/components/comment/CommentPanel';
|
||||
|
||||
export interface CommentRecord {
|
||||
id: string;
|
||||
entityType: CommentEntityType;
|
||||
entityId: string;
|
||||
entityVersionId?: string | null;
|
||||
authorId: string;
|
||||
content: string;
|
||||
mentionedMemberIds?: string[];
|
||||
createdAt: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
interface CreateCommentInput {
|
||||
actorId: string;
|
||||
entityType: CommentEntityType;
|
||||
entityId: string;
|
||||
entityVersionId?: string | null;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
content: string;
|
||||
mentionMemberIds?: string[];
|
||||
}
|
||||
|
||||
interface CommentState {
|
||||
commentsByKey: Record<string, CommentRecord[]>;
|
||||
loadingKeys: string[];
|
||||
fetchComments: (entityType: CommentEntityType, entityId: string) => Promise<void>;
|
||||
createComment: (input: CreateCommentInput) => Promise<void>;
|
||||
deleteComment: (entityType: CommentEntityType, entityId: string, id: string, actorId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function commentKey(entityType: CommentEntityType, entityId: string) {
|
||||
return `${entityType}:${entityId}`;
|
||||
}
|
||||
|
||||
export const useCommentStore = create<CommentState>((set, get) => ({
|
||||
commentsByKey: {},
|
||||
loadingKeys: [],
|
||||
|
||||
fetchComments: async (entityType, entityId) => {
|
||||
const key = commentKey(entityType, entityId);
|
||||
set({ loadingKeys: [...get().loadingKeys.filter((item) => item !== key), key] });
|
||||
const params = new URLSearchParams({ entityType, entityId });
|
||||
const rows = await api.get<CommentRecord[]>(`/comments?${params.toString()}`);
|
||||
set({
|
||||
commentsByKey: { ...get().commentsByKey, [key]: rows },
|
||||
loadingKeys: get().loadingKeys.filter((item) => item !== key),
|
||||
});
|
||||
},
|
||||
|
||||
createComment: async (input) => {
|
||||
const row = await api.post<CommentRecord>('/comments', input);
|
||||
const key = commentKey(input.entityType, input.entityId);
|
||||
set({
|
||||
commentsByKey: {
|
||||
...get().commentsByKey,
|
||||
[key]: [...(get().commentsByKey[key] ?? []), row],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
deleteComment: async (entityType, entityId, id, actorId) => {
|
||||
const row = await api.deleteWithBody<CommentRecord>(`/comments/${id}`, { actorId });
|
||||
const key = commentKey(entityType, entityId);
|
||||
set({
|
||||
commentsByKey: {
|
||||
...get().commentsByKey,
|
||||
[key]: (get().commentsByKey[key] ?? []).map((item) => item.id === id ? row : item).filter((item) => !item.deletedAt),
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
52
apps/web/stores/useNotificationStore.ts
Normal file
52
apps/web/stores/useNotificationStore.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import { countUnreadNotifications, sortNotificationsNewestFirst, type NotificationRecord } from '@/lib/notification';
|
||||
|
||||
interface NotificationState {
|
||||
notifications: NotificationRecord[];
|
||||
loading: boolean;
|
||||
loadedFor?: string;
|
||||
unreadCount: number;
|
||||
fetchNotifications: (recipientId: string, options?: { unreadOnly?: boolean }) => Promise<void>;
|
||||
markRead: (id: string, recipientId: string) => Promise<void>;
|
||||
markAllRead: (recipientId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useNotificationStore = create<NotificationState>((set, get) => ({
|
||||
notifications: [],
|
||||
loading: false,
|
||||
loadedFor: undefined,
|
||||
unreadCount: 0,
|
||||
|
||||
fetchNotifications: async (recipientId, options) => {
|
||||
if (!recipientId) return;
|
||||
set({ loading: true });
|
||||
const params = new URLSearchParams({ recipientId });
|
||||
if (options?.unreadOnly) params.set('unreadOnly', 'true');
|
||||
const rows = await api.get<NotificationRecord[]>(`/notifications?${params.toString()}`);
|
||||
const notifications = sortNotificationsNewestFirst(rows);
|
||||
set({
|
||||
notifications,
|
||||
unreadCount: countUnreadNotifications(notifications),
|
||||
loadedFor: recipientId,
|
||||
loading: false,
|
||||
});
|
||||
},
|
||||
|
||||
markRead: async (id, recipientId) => {
|
||||
await api.patch(`/notifications/${id}/read`, { recipientId });
|
||||
const notifications = get().notifications.map((item) =>
|
||||
item.id === id ? { ...item, readAt: item.readAt ?? new Date().toISOString() } : item,
|
||||
);
|
||||
set({ notifications, unreadCount: countUnreadNotifications(notifications) });
|
||||
},
|
||||
|
||||
markAllRead: async (recipientId) => {
|
||||
await api.patch('/notifications/read-all', { recipientId });
|
||||
const readAt = new Date().toISOString();
|
||||
const notifications = get().notifications.map((item) => item.readAt ? item : { ...item, readAt });
|
||||
set({ notifications, unreadCount: 0 });
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMemberStore } from './useMemberStore';
|
||||
import {
|
||||
mergeDailySnapshotCacheForSave,
|
||||
mergeInsightCacheForSave,
|
||||
@@ -74,6 +76,7 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||
const snapshots = mergeDailySnapshotCacheForSave(get().snapshots, Array.isArray(remote) ? remote : [], item);
|
||||
set({ snapshots, error: undefined });
|
||||
await saveServerData('xiaobao-risk-snapshots', snapshots);
|
||||
void notifyRiskManagers(item).catch(() => {});
|
||||
});
|
||||
snapshotSaveQueue = task.catch(() => undefined);
|
||||
try {
|
||||
@@ -119,3 +122,32 @@ export const useXiaobaoRiskStore = create<XiaobaoRiskState>((set, get) => ({
|
||||
set({ pendingInsightKeys: get().pendingInsightKeys.filter((item) => item !== key) });
|
||||
},
|
||||
}));
|
||||
|
||||
async function notifyRiskManagers(item: XiaobaoRiskSnapshot) {
|
||||
if (!['at_risk', 'likely_delayed', 'blocked'].includes(item.riskLevel)) return;
|
||||
const memberStore = useMemberStore.getState();
|
||||
if (!memberStore.loaded) {
|
||||
await memberStore.fetchMembers().catch(() => undefined);
|
||||
}
|
||||
const state = useMemberStore.getState();
|
||||
const roleMap = new Map(state.roles.map((role) => [role.id, role]));
|
||||
const recipients = state.members.filter((member) => {
|
||||
const permissions = roleMap.get(member.roleId)?.permissions ?? [];
|
||||
return permissions.includes('*') || permissions.includes('xiaobao.warning:manage');
|
||||
});
|
||||
await Promise.all(recipients.map((member) => api.post('/notifications', {
|
||||
recipientId: member.id,
|
||||
actorId: 'xiaobao',
|
||||
type: 'risk_alert',
|
||||
title: '小宝预警更新',
|
||||
body: `版本 ${item.versionId} 当前风险 ${item.riskLevel},风险分 ${item.riskScore}`,
|
||||
resourceType: 'version',
|
||||
resourceId: item.versionId,
|
||||
versionId: item.versionId,
|
||||
metadata: {
|
||||
riskLevel: item.riskLevel,
|
||||
riskScore: item.riskScore,
|
||||
riskSignature: `${item.versionId}:${item.date}:${item.riskScore}:${item.riskLevel}`,
|
||||
},
|
||||
}).catch(() => undefined)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user