From bcbe84bb6e68e4436027f47856978bef361672a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=80=82?= Date: Wed, 8 Jul 2026 16:19:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(v2.7):=20=E5=A2=9E=E5=8A=A0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A9=BE=E9=A9=B6=E8=88=B1=E4=B8=8E=E6=B2=BB=E7=90=86?= =?UTF-8?q?=E5=AD=97=E5=85=B8=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 4 +- .../dto/governance-dictionary.dto.ts | 41 ++++ .../governance/governance.controller.ts | 46 ++++ .../modules/governance/governance.module.ts | 12 + .../governance/governance.service.spec.ts | 69 ++++++ .../modules/governance/governance.service.ts | 211 ++++++++++++++++++ .../management/management.controller.ts | 18 ++ .../modules/management/management.module.ts | 9 + .../management/management.service.spec.ts | 75 +++++++ .../modules/management/management.service.ts | 153 +++++++++++++ 10 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/modules/governance/dto/governance-dictionary.dto.ts create mode 100644 apps/server/src/modules/governance/governance.controller.ts create mode 100644 apps/server/src/modules/governance/governance.module.ts create mode 100644 apps/server/src/modules/governance/governance.service.spec.ts create mode 100644 apps/server/src/modules/governance/governance.service.ts create mode 100644 apps/server/src/modules/management/management.controller.ts create mode 100644 apps/server/src/modules/management/management.module.ts create mode 100644 apps/server/src/modules/management/management.service.spec.ts create mode 100644 apps/server/src/modules/management/management.service.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 4ddbdbd..fae2742 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -13,9 +13,11 @@ import { HealthModule } from './modules/health/health.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: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule], + imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule, ProjectMemberModule, ManagementModule, GovernanceModule], controllers: [], providers: [ { diff --git a/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts b/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts new file mode 100644 index 0000000..9037dfb --- /dev/null +++ b/apps/server/src/modules/governance/dto/governance-dictionary.dto.ts @@ -0,0 +1,41 @@ +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; +} + +export class RemoveGovernanceDictionaryDto { + @IsString() + actorId!: string; +} + +export class ImportGovernanceDictionariesDto { + @IsString() + actorId!: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => GovernanceDictionaryDto) + items!: GovernanceDictionaryDto[]; +} diff --git a/apps/server/src/modules/governance/governance.controller.ts b/apps/server/src/modules/governance/governance.controller.ts new file mode 100644 index 0000000..a3f7eea --- /dev/null +++ b/apps/server/src/modules/governance/governance.controller.ts @@ -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, kind, id }); + } + + @Get('export') + exportAll() { + return this.governanceService.exportAll(); + } + + @Post('import') + importAll(@Body() dto: ImportGovernanceDictionariesDto) { + return this.governanceService.importAll(dto.actorId, dto.items); + } +} diff --git a/apps/server/src/modules/governance/governance.module.ts b/apps/server/src/modules/governance/governance.module.ts new file mode 100644 index 0000000..1143d42 --- /dev/null +++ b/apps/server/src/modules/governance/governance.module.ts @@ -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 {} diff --git a/apps/server/src/modules/governance/governance.service.spec.ts b/apps/server/src/modules/governance/governance.service.spec.ts new file mode 100644 index 0000000..a73f236 --- /dev/null +++ b/apps/server/src/modules/governance/governance.service.spec.ts @@ -0,0 +1,69 @@ +import { BadRequestException } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.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; + return { prisma, audit, service: new GovernanceService(prisma as any, audit) }; + }; + + it('blocks hard deletion of a task category that is used by dev tasks or test cases', async () => { + const { prisma, service } = makeService(); + prisma.devTask.count.mockResolvedValue(1); + prisma.testCase.count.mockResolvedValue(0); + + await expect(service.remove({ + actorId: 'm-admin', + kind: 'task_category', + id: 'cat-1', + })).rejects.toBeInstanceOf(BadRequestException); + + 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', 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: '新功能' }], + }); + }); +}); diff --git a/apps/server/src/modules/governance/governance.service.ts b/apps/server/src/modules/governance/governance.service.ts new file mode 100644 index 0000000..2d4e7a7 --- /dev/null +++ b/apps/server/src/modules/governance/governance.service.ts @@ -0,0 +1,211 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AuditService } from '../../common/audit/audit.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; +} + +export interface GovernanceRemoveInput { + actorId: string; + kind: GovernanceDictionaryKind; + id: string; +} + +@Injectable() +export class GovernanceService { + constructor( + private readonly prisma: PrismaService, + private readonly auditService: AuditService, + ) {} + + 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'); + 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'); + 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'); + 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[]) { + const results = []; + for (const item of items) { + assertDictionaryKind(item.kind); + if (item.kind === 'task_category') { + results.push(await this.create({ ...item, actorId })); + } 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'; + } +} + +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; +} diff --git a/apps/server/src/modules/management/management.controller.ts b/apps/server/src/modules/management/management.controller.ts new file mode 100644 index 0000000..45ba5ee --- /dev/null +++ b/apps/server/src/modules/management/management.controller.ts @@ -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) : [], + }); + } +} diff --git a/apps/server/src/modules/management/management.module.ts b/apps/server/src/modules/management/management.module.ts new file mode 100644 index 0000000..8a7f281 --- /dev/null +++ b/apps/server/src/modules/management/management.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ManagementController } from './management.controller'; +import { ManagementService } from './management.service'; + +@Module({ + controllers: [ManagementController], + providers: [ManagementService], +}) +export class ManagementModule {} diff --git a/apps/server/src/modules/management/management.service.spec.ts b/apps/server/src/modules/management/management.service.spec.ts new file mode 100644 index 0000000..f22b81e --- /dev/null +++ b/apps/server/src/modules/management/management.service.spec.ts @@ -0,0 +1,75 @@ +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() }, + }; + return { prisma, service: new ManagementService(prisma as any) }; + }; + + it('returns an empty dashboard when actor has no managed projects', async () => { + const { prisma, service } = makeService(); + prisma.projectMember.findMany.mockResolvedValue([]); + + await expect(service.getOverview({ actorId: 'm-dev', permissions: [] })).resolves.toEqual({ + activeVersionCount: 0, + overdueItemCount: 0, + blockedItemCount: 0, + riskCounts: {}, + memberLoads: [], + activeVersions: [], + overdueItems: [], + blockedItems: [], + highRiskVersions: [], + }); + 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(); + }); +}); diff --git a/apps/server/src/modules/management/management.service.ts b/apps/server/src/modules/management/management.service.ts new file mode 100644 index 0000000..eea6be2 --- /dev/null +++ b/apps/server/src/modules/management/management.service.ts @@ -0,0 +1,153 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +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) {} + + async getOverview(query: ManagementOverviewQuery) { + const actorId = query.actorId?.trim(); + if (!actorId) throw new BadRequestException('actorId is required'); + const now = query.now ?? new Date(); + const allProjects = query.permissions?.includes('*') === true; + 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 { + 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 { + return rows.reduce>((acc, row) => { + acc[row.riskLevel] = (acc[row.riskLevel] ?? 0) + 1; + return acc; + }, {}); +} + +function buildMemberLoads(...groups: WorkItem[][]) { + const counts = new Map(); + 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)); +}