From 6fcd42cf4856f81496f2a9aaf0d1cb0889ef3210 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Fri, 5 Jun 2026 17:36:30 +0200 Subject: [PATCH] refactor(server-nestjs): migrate project bulk route Signed-off-by: William Phetsinorath Signed-off-by: Shikanime Deva Change-Id: Ia8e16b188470eb80764717c3cd8068106a6a6964 --- apps/server-nestjs/src/main.module.ts | 2 + .../project-bulk/project-bulk.controller.ts | 36 ++++++ .../project-bulk/project-bulk.module.ts | 13 +++ .../project-bulk/project-bulk.service.spec.ts | 84 ++++++++++++++ .../project-bulk/project-bulk.service.ts | 102 +++++++++++++++++ .../test/project-bulk.e2e-spec.ts | 103 ++++++++++++++++++ 6 files changed, 340 insertions(+) create mode 100644 apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts create mode 100644 apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts create mode 100644 apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts create mode 100644 apps/server-nestjs/test/project-bulk.e2e-spec.ts diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index a2ef47eee..0bc0e5a91 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -4,6 +4,7 @@ import { DeploymentModule } from './modules/deployment/deployment.module' import { HealthzModule } from './modules/healthz/healthz.module' import { KeycloakModule } from './modules/keycloak/keycloak.module' import { LogModule } from './modules/log/log.module' +import { ProjectBulkModule } from './modules/project-bulk/project-bulk.module' import { ProjectHooksModule } from './modules/project-hooks/project-hooks.module' import { ProjectMembersModule } from './modules/project-members/project-members.module' import { ProjectRolesModule } from './modules/project-roles/project-roles.module' @@ -25,6 +26,7 @@ import { VersionModule } from './modules/version/version.module' ProjectHooksModule, ProjectSecretsModule, ProjectServicesModule, + ProjectBulkModule, ProjectMembersModule, ProjectRolesModule, LogModule, diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts new file mode 100644 index 000000000..0553deafa --- /dev/null +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.controller.ts @@ -0,0 +1,36 @@ +import type { FastifyRequest } from 'fastify' +import type { UserContext } from '../infrastructure/auth/auth-user.decorator' +import { projectContract } from '@cpn-console/shared' +import { Body, Controller, HttpCode, Inject, Logger, Post, Req, UseGuards } from '@nestjs/common' +import { AuthUser } from '../infrastructure/auth/auth-user.decorator' +import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' +import { ProjectBulkService } from './project-bulk.service' + +@Controller('api/v1/projects-bulk') +export class ProjectBulkController { + private readonly logger = new Logger(ProjectBulkController.name) + + constructor( + @Inject(ProjectBulkService) private readonly projectBulk: ProjectBulkService, + ) {} + + @Post('') + @HttpCode(202) + @UseGuards(UserGuard) + @RequireAdminPermission('Manage') + async bulkAction( + @Body(new ZodValidationPipe(projectContract.bulkActionProject.body)) body: typeof projectContract.bulkActionProject.body._type, + @AuthUser() user: UserContext, + @Req() request: FastifyRequest, + ): Promise { + const target = body.projectIds === 'all' + ? 'all' + : `count=${body.projectIds.length}` + + this.logger.log(`project.bulkAction requested (action=${body.action}, target=${target})`) + await this.projectBulk.bulkAction(body, user.userId, request.id) + this.logger.log(`project.bulkAction accepted (action=${body.action})`) + } +} diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts new file mode 100644 index 000000000..23a2b704b --- /dev/null +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { InfrastructureModule } from '../infrastructure/infrastructure.module' +import { ProjectHooksModule } from '../project-hooks/project-hooks.module' +import { ProjectModule } from '../project/project.module' +import { ProjectBulkController } from './project-bulk.controller' +import { ProjectBulkService } from './project-bulk.service' + +@Module({ + imports: [InfrastructureModule, ProjectModule, ProjectHooksModule], + controllers: [ProjectBulkController], + providers: [ProjectBulkService], +}) +export class ProjectBulkModule {} diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts new file mode 100644 index 000000000..8eba3f0c4 --- /dev/null +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.spec.ts @@ -0,0 +1,84 @@ +import type { TestingModule } from '@nestjs/testing' +import type { DeepMockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { ProjectService } from '../project/project.service' +import { ProjectBulkService } from './project-bulk.service' + +describe('projectBulkService', () => { + let module: TestingModule + let service: ProjectBulkService + let prisma: DeepMockProxy + let project: DeepMockProxy + let projectHooks: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + project = mockDeep() + projectHooks = mockDeep() + + module = await Test.createTestingModule({ + providers: [ + ProjectBulkService, + { provide: PrismaService, useValue: prisma }, + { provide: ProjectService, useValue: project }, + { provide: ProjectHooksService, useValue: projectHooks }, + ], + }).compile() + + service = module.get(ProjectBulkService) + }) + + it('processes specific project ids', async () => { + const projectIds = [faker.string.uuid(), faker.string.uuid()] + + await service.bulkAction({ action: 'archive', projectIds }, 'user-id', 'request-id') + + expect(project.archive).toHaveBeenCalledTimes(2) + expect(project.archive).toHaveBeenCalledWith(projectIds[0], 'user-id', 'request-id') + expect(project.archive).toHaveBeenCalledWith(projectIds[1], 'user-id', 'request-id') + }) + + it('resolves "all" to all non-archived project ids', async () => { + const project1Id = faker.string.uuid() + const project2Id = faker.string.uuid() + + prisma.project.findMany.mockResolvedValue([{ id: project1Id }, { id: project2Id }] as any) + + await service.bulkAction({ action: 'archive', projectIds: 'all' }, 'user-id', 'request-id') + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + expect(project.archive).toHaveBeenCalledTimes(2) + }) + + it('lock action updates locked to true via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, true) + }) + + it('unlock action updates locked to false via project hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + + expect(projectHooks.updateProjectLocked).toHaveBeenCalledWith(projectId, false) + }) + + it('replay action triggers hooks', async () => { + const projectId = faker.string.uuid() + + await service.bulkAction({ action: 'replay', projectIds: [projectId] }, 'user-id', 'request-id') + + expect(projectHooks.replay).toHaveBeenCalledWith(projectId, 'user-id', 'request-id') + }) +}) diff --git a/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts new file mode 100644 index 000000000..e2b7d517c --- /dev/null +++ b/apps/server-nestjs/src/modules/project-bulk/project-bulk.service.ts @@ -0,0 +1,102 @@ +import type { projectContract } from '@cpn-console/shared' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { trace } from '@opentelemetry/api' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' +import { ProjectHooksService } from '../project-hooks/project-hooks.service' +import { ProjectService } from '../project/project.service' + +@Injectable() +export class ProjectBulkService { + private readonly logger = new Logger(ProjectBulkService.name) + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ProjectService) private readonly project: ProjectService, + @Inject(ProjectHooksService) private readonly projectHooks: ProjectHooksService, + ) {} + + @StartActiveSpan() + async bulkAction( + data: typeof projectContract.bulkActionProject.body._type, + requestorUserId?: string, + requestId?: string, + ): Promise { + const span = trace.getActiveSpan() + span?.setAttribute('project.bulk.action', data.action) + const projectIdsLog = data.projectIds === 'all' ? 'all' : `count=${data.projectIds.length}` + this.logger.log(`project.bulkAction started (action=${data.action}, projectIds=${projectIdsLog})`) + + try { + const projectIds = await this.resolveProjectIds(data.projectIds) + span?.setAttribute('project.bulk.count', projectIds.length) + + const results = await this.runTasks(projectIds, data.action, requestorUserId, requestId) + const summary = this.summarizeTasks(results) + + span?.setAttributes({ + 'project.bulk.fulfilled': summary.fulfilled, + 'project.bulk.rejected': summary.rejected, + }) + this.logger.log(`project.bulkAction completed (action=${data.action}, projectCount=${projectIds.length}, fulfilled=${summary.fulfilled}, rejected=${summary.rejected})`) + } catch (error) { + const projectIdsLabel = data.projectIds === 'all' + ? 'all' + : `count=${data.projectIds.length}` + const errorMessage = error instanceof Error ? error.message : String(error) + + this.logger.error( + `project.bulkAction failed (action=${data.action}, projectIds=${projectIdsLabel}): ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ) + throw error + } + } + + private async resolveProjectIds(projectIds: string[] | 'all'): Promise { + if (projectIds === 'all') { + return this.listProjectIdsNotArchived() + } + return projectIds + } + + private async runTasks( + projectIds: string[], + action: string, + requestorUserId?: string, + requestId?: string, + ): Promise[]> { + const tasks = projectIds.map((projectId) => { + if (action === 'archive') { + return this.project.archive(projectId, requestorUserId, requestId) + } + if (action === 'lock' || action === 'unlock') { + return this.projectHooks.updateProjectLocked(projectId, action === 'lock') + } + if (action === 'replay') { + return this.projectHooks.replay(projectId, requestorUserId, requestId) + } + return Promise.resolve() + }) + return Promise.allSettled(tasks) + } + + private summarizeTasks(results: PromiseSettledResult[]): { fulfilled: number, rejected: number } { + return results.reduce( + (acc, r) => { + if (r.status === 'fulfilled') acc.fulfilled += 1 + else acc.rejected += 1 + return acc + }, + { fulfilled: 0, rejected: 0 }, + ) + } + + private async listProjectIdsNotArchived(): Promise { + const projects = await this.prisma.project.findMany({ + select: { id: true }, + where: { status: { not: 'archived' } }, + }) + return projects.map(({ id }) => id) + } +} diff --git a/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/apps/server-nestjs/test/project-bulk.e2e-spec.ts new file mode 100644 index 000000000..5d32e6e2d --- /dev/null +++ b/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -0,0 +1,103 @@ +import type { TestingModule } from '@nestjs/testing' +import { faker } from '@faker-js/faker' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { Test } from '@nestjs/testing' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' +import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' +import { InfrastructureModule } from '../src/modules/infrastructure/infrastructure.module' +import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' +import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' + +const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) + +const describeWithProjectBulk = describe.runIf(canRunProjectBulkE2E) + +describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { + let moduleRef: TestingModule + let prisma: PrismaService + let service: ProjectBulkService + let eventEmitter: EventEmitter2 + + let ownerId: string + let projectId: string + let projectSlug: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [ConfigurationModule, InfrastructureModule, ProjectBulkModule], + }).compile() + + await moduleRef.init() + + prisma = moduleRef.get(PrismaService) + service = moduleRef.get(ProjectBulkService) + eventEmitter = moduleRef.get(EventEmitter2) + vi.spyOn(eventEmitter, 'emitAsync').mockResolvedValue([]) + + ownerId = faker.string.uuid() + projectId = faker.string.uuid() + projectSlug = faker.helpers.slugify(`e2e-project-${faker.string.uuid()}`) + + await prisma.user.create({ + data: { + id: ownerId, + email: faker.internet.email().toLowerCase(), + firstName: 'E2E', + lastName: 'Owner', + type: 'human', + }, + }) + + await prisma.project.create({ + data: { + id: projectId, + slug: projectSlug, + name: projectSlug, + ownerId, + description: 'E2E test project', + status: 'created', + locked: false, + limitless: false, + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + everyonePerms: 0n, + lastSuccessProvisionningVersion: null, + }, + }) + }) + + afterAll(async () => { + if (prisma) { + await prisma.project.deleteMany({ where: { id: projectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef?.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('locks and unlocks projects', async () => { + await service.bulkAction({ action: 'lock', projectIds: [projectId] }) + const locked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(locked.locked).toBe(true) + + await service.bulkAction({ action: 'unlock', projectIds: [projectId] }) + const unlocked = await prisma.project.findUniqueOrThrow({ where: { id: projectId }, select: { locked: true } }) + expect(unlocked.locked).toBe(false) + }) + + it('replays hooks for projects', async () => { + await service.bulkAction({ action: 'replay', projectIds: [projectId] }) + expect(eventEmitter.emitAsync).toHaveBeenCalledWith( + 'project.upsert', + expect.objectContaining({ id: projectId, slug: projectSlug }), + ) + }) +})