From c6d6bbd7562ab894e2fb4dcc8486bfbda92109d5 Mon Sep 17 00:00:00 2001 From: Kevin Powell Noumbissie <10553243+KepoParis@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:13:59 +0200 Subject: [PATCH] feat(environment): warn about linked deployments before environment deletion --- apps/client/src/components/EnvironmentForm.vue | 11 ++++++++++- apps/client/src/components/ProjectResources.vue | 8 ++++---- apps/client/src/utils/project-utils.ts | 3 ++- .../environment-datastore.service.spec.ts | 11 +++++++---- .../environment/environment-datastore.service.ts | 9 +++++++-- .../environment/environment-testing.utils.ts | 16 +++++++++++++++- .../environment/environment.controller.spec.ts | 4 ++-- .../environment/environment.service.spec.ts | 7 ++++--- .../modules/environment/environment.service.ts | 10 +++++++--- packages/shared/src/contracts/environment.ts | 3 ++- packages/shared/src/schemas/environment.ts | 5 +++++ 11 files changed, 65 insertions(+), 22 deletions(-) diff --git a/apps/client/src/components/EnvironmentForm.vue b/apps/client/src/components/EnvironmentForm.vue index acae68cbd3..114c54f6b3 100644 --- a/apps/client/src/components/EnvironmentForm.vue +++ b/apps/client/src/components/EnvironmentForm.vue @@ -3,6 +3,7 @@ import type { CleanedCluster, CreateEnvironment, Environment, + EnvironmentWithDeploymentsCount, } from '@cpn-console/shared' import { deleteValidationInput, @@ -24,7 +25,7 @@ interface OptionType { } const props = withDefaults(defineProps<{ - environment?: Partial> + environment?: Partial> isEditable?: boolean canManage: boolean isProjectLocked?: boolean @@ -343,6 +344,14 @@ watch(localEnvironment.value, () => { v-if="isDeletingEnvironment" class="fr-mt-4w" > + -import type { CleanedCluster, Cluster, CreateEnvironment, Environment, Repo, Stage, UpdateEnvironment, Zone } from '@cpn-console/shared' +import type { CleanedCluster, Cluster, CreateEnvironment, Environment, EnvironmentWithDeploymentsCount, Repo, Stage, UpdateEnvironment, Zone } from '@cpn-console/shared' import type { Project } from '@/utils/project-utils.js' import type { RepoFormResult } from '@/utils/repository-utils.js' import { logger } from '@cpn-console/logger/browser' @@ -27,7 +27,7 @@ const snackbarStore = useSnackbarStore() const stageStore = useStageStore() const userStore = useUserStore() -const environments = ref<(Environment & { cluster?: Cluster, zone?: Zone, stage?: Stage })[]>([]) +const environments = ref<(EnvironmentWithDeploymentsCount & { cluster?: Cluster, zone?: Zone, stage?: Stage })[]>([]) const repositories = ref<(Repo & { source: Source })[]>([]) const projectUsage = ref<({ hprod: { cpu: number, gpu: number, memory: number } @@ -45,7 +45,7 @@ const repositoriesId = 'repositoriesTable' const environmentsId = 'environmentsTable' const syncFormId = 'syncFormId' const selectedRepo = ref() -const selectedEnv = ref() +const selectedEnv = ref() const newResource = ref<'repo' | 'env'>() const hideEnvs = computed(() => props.asProfile === 'user' && !ProjectAuthorized.ListEnvironments({ projectPermissions: props.project.myPerms })) const hideRepos = computed(() => props.asProfile === 'user' && !ProjectAuthorized.ListRepositories({ projectPermissions: props.project.myPerms })) @@ -139,7 +139,7 @@ const timeAgo = new TimeAgo('fr-FR') async function reload() { environments.value = await props.project.Environments.list() - .then(envs => envs.map((environment: Environment) => { + .then(envs => envs.map((environment: EnvironmentWithDeploymentsCount) => { const cluster = clusterStore.clusters.find(cluster => cluster.id === environment.clusterId) const zone = zoneStore.zones.find(zone => zone.id === cluster?.zoneId) const stage = stageStore.stages.find(stage => stage.id === environment.stageId) diff --git a/apps/client/src/utils/project-utils.ts b/apps/client/src/utils/project-utils.ts index 9a124f515e..4fb2770113 100644 --- a/apps/client/src/utils/project-utils.ts +++ b/apps/client/src/utils/project-utils.ts @@ -4,6 +4,7 @@ import type { CreateRepositoryBodyV2, Deployment, Environment, + EnvironmentWithDeploymentsCount, GetLogsQuery, PermissionTarget, PluginsUpdateBody, @@ -83,7 +84,7 @@ export class Project implements ProjectV2 { operationsInProgress: Ref myPerms: bigint repositories: Ref - environments: Ref + environments: Ref deployments: Ref services: ProjectService[] = [] lastSuccessProvisionningVersion: string | null diff --git a/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts b/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts index 6189f20316..abca043c83 100644 --- a/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts +++ b/apps/server-nestjs/src/modules/environment/environment-datastore.service.spec.ts @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { PrismaService } from '../infrastructure/database/prisma.service' import { EnvironmentDatastoreService } from './environment-datastore.service' -import { makeCluster, makeEnvironment, makeStage } from './environment-testing.utils' +import { makeCluster, makeEnvironment, makeEnvironmentWithStageAndCount, makeStage } from './environment-testing.utils' describe('environmentDatastoreService', () => { let module: TestingModule @@ -41,15 +41,18 @@ describe('environmentDatastoreService', () => { }) describe('getEnvironmentsByProjectId', () => { - it('should return environments for a project with their stage', async () => { - const environments = [makeEnvironment({ projectId: 'project1' })] + it('should return environments for a project with their stage and their deployments count', async () => { + const environments = [makeEnvironmentWithStageAndCount({ projectId: 'project1', _count: { deployments: 2 } })] prisma.environment.findMany.mockResolvedValue(environments) const result = await service.getEnvironmentsByProjectId('project1') expect(prisma.environment.findMany).toHaveBeenCalledWith({ where: { projectId: 'project1' }, - include: { stage: true }, + include: { + stage: true, + _count: { select: { deployments: true } }, + }, }) expect(result).toEqual(environments) }) diff --git a/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts b/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts index 1dab19eb23..c7119c1e54 100644 --- a/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts +++ b/apps/server-nestjs/src/modules/environment/environment-datastore.service.ts @@ -5,6 +5,8 @@ import { PROD_STAGE_NAME } from './environment.constants' export type EnvironmentWithCluster = Environment & { cluster: Cluster } export type EnvironmentWithStage = Environment & { stage: Stage } +export type EnvironmentWithStageAndCount = EnvironmentWithStage & { _count: { deployments: number } } +export type EnvironmentWithDeploymentsCount = EnvironmentWithStage & { deploymentsCount: number } export interface EnvironmentResourcesSum { _sum: { @@ -25,10 +27,13 @@ export class EnvironmentDatastoreService { }) } - getEnvironmentsByProjectId(projectId: string): Promise { + getEnvironmentsByProjectId(projectId: string): Promise { return this.prisma.environment.findMany({ where: { projectId }, - include: { stage: true }, + include: { + stage: true, + _count: { select: { deployments: true } }, + }, }) } diff --git a/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts b/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts index 5f0d0ee58d..08d5ab4017 100644 --- a/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts +++ b/apps/server-nestjs/src/modules/environment/environment-testing.utils.ts @@ -1,5 +1,5 @@ import type { Cluster, Environment, Project, Stage } from '@prisma/client' -import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import type { EnvironmentWithCluster, EnvironmentWithDeploymentsCount, EnvironmentWithStage, EnvironmentWithStageAndCount } from './environment-datastore.service' import { faker } from '@faker-js/faker' export function makeEnvironment(overrides: Partial = {}): Environment { @@ -85,3 +85,17 @@ export function makeEnvironmentWithStage(overrides: Partial = {}): EnvironmentWithStageAndCount { + return { + ...makeEnvironmentWithStage(overrides), + _count: overrides._count ?? { deployments: 0 }, + } +} + +export function makeEnvironmentWithDeploymentsCount(overrides: Partial = {}): EnvironmentWithDeploymentsCount { + return { + ...makeEnvironmentWithStage(overrides), + deploymentsCount: overrides.deploymentsCount ?? 0, + } +} diff --git a/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts b/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts index 1449e91a3a..ea28d54e67 100644 --- a/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts +++ b/apps/server-nestjs/src/modules/environment/environment.controller.spec.ts @@ -8,7 +8,7 @@ import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { ProjectGuard } from '../infrastructure/permission/project/project.guard' -import { makeEnvironment, makeEnvironmentWithStage } from './environment-testing.utils' +import { makeEnvironment, makeEnvironmentWithDeploymentsCount } from './environment-testing.utils' import { EnvironmentController } from './environment.controller' import { EnvironmentService } from './environment.service' @@ -64,7 +64,7 @@ describe('environmentController', () => { describe('list', () => { it('should call environmentService.listByProjectId with projectId', async () => { - const expectedResult = [makeEnvironmentWithStage({ projectId })] + const expectedResult = [makeEnvironmentWithDeploymentsCount({ projectId })] service.listByProjectId.mockResolvedValue(expectedResult) diff --git a/apps/server-nestjs/src/modules/environment/environment.service.spec.ts b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts index 83067a1df6..91b2c8c4ea 100644 --- a/apps/server-nestjs/src/modules/environment/environment.service.spec.ts +++ b/apps/server-nestjs/src/modules/environment/environment.service.spec.ts @@ -14,7 +14,7 @@ import { EnvironmentDatastoreService } from './environment-datastore.service' import { makeEnvironment, makeEnvironmentWithCluster, - makeEnvironmentWithStage, + makeEnvironmentWithStageAndCount, } from './environment-testing.utils' import { EnvironmentValidationService } from './environment-validation.service' import { EnvironmentService } from './environment.service' @@ -77,13 +77,14 @@ describe('environmentService', () => { describe('listByProjectId', () => { it('should return environments by projectId', async () => { - const environments = [makeEnvironmentWithStage({ id: environmentId, projectId })] + const environments = [makeEnvironmentWithStageAndCount({ id: environmentId, projectId, _count: { deployments: 3 } })] datastore.getEnvironmentsByProjectId.mockResolvedValue(environments) const result = await service.listByProjectId(projectId) expect(datastore.getEnvironmentsByProjectId).toHaveBeenCalledWith(projectId) - expect(result).toEqual(environments) + const { _count, ...environment } = environments[0] + expect(result).toEqual([{ ...environment, deploymentsCount: 3 }]) }) }) diff --git a/apps/server-nestjs/src/modules/environment/environment.service.ts b/apps/server-nestjs/src/modules/environment/environment.service.ts index 1b5b2149cd..85de7b3ffe 100644 --- a/apps/server-nestjs/src/modules/environment/environment.service.ts +++ b/apps/server-nestjs/src/modules/environment/environment.service.ts @@ -1,7 +1,7 @@ import type { CreateEnvironment, UpdateEnvironment } from '@cpn-console/shared' import type { Environment } from '@prisma/client' import type { EventLogAction } from '../events/app-events.service' -import type { EnvironmentWithCluster, EnvironmentWithStage } from './environment-datastore.service' +import type { EnvironmentWithCluster, EnvironmentWithDeploymentsCount } from './environment-datastore.service' import { Inject, Injectable, @@ -21,8 +21,12 @@ export class EnvironmentService { @Inject(AppEventsService) private readonly appEvents: AppEventsService, ) {} - async listByProjectId(projectId: string): Promise { - return this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + async listByProjectId(projectId: string): Promise { + const environments = await this.environmentDatastoreService.getEnvironmentsByProjectId(projectId) + return environments.map(({ _count, ...environment }) => ({ + ...environment, + deploymentsCount: _count.deployments, + })) } async createEnvironment(projectId: string, environmentToCreate: CreateEnvironment, userId: string, requestId: string): Promise { diff --git a/packages/shared/src/contracts/environment.ts b/packages/shared/src/contracts/environment.ts index 8d9320733e..fc69b0665d 100644 --- a/packages/shared/src/contracts/environment.ts +++ b/packages/shared/src/contracts/environment.ts @@ -5,6 +5,7 @@ import { apiPrefix, apiPrefixV2, contractInstance } from '../api-client.js' import { CreateEnvironmentSchema, EnvironmentSchema, + EnvironmentWithDeploymentsCountSchema, UpdateEnvironmentSchema, } from '../schemas/index.js' import { baseHeaders, ErrorSchema } from './_utils.js' @@ -118,7 +119,7 @@ export const environmentContractV2 = contractInstance.router({ summary: 'Get environments', description: 'Retrieved project environments.', responses: { - 200: EnvironmentSchema.array(), + 200: EnvironmentWithDeploymentsCountSchema.array(), 400: ErrorSchema, 401: ErrorSchema, 403: ErrorSchema, diff --git a/packages/shared/src/schemas/environment.ts b/packages/shared/src/schemas/environment.ts index f3c9bd2d8c..47e4785932 100644 --- a/packages/shared/src/schemas/environment.ts +++ b/packages/shared/src/schemas/environment.ts @@ -21,6 +21,10 @@ export const EnvironmentSchema = z.object({ autosync: z.boolean(), }).extend(AtDatesToStringExtend) +export const EnvironmentWithDeploymentsCountSchema = EnvironmentSchema.extend({ + deploymentsCount: z.number().int().gte(0), +}) + export const CreateEnvironmentSchema = EnvironmentSchema.omit({ id: true, createdAt: true, @@ -36,5 +40,6 @@ export const UpdateEnvironmentSchema = EnvironmentSchema.pick({ }) export type Environment = Zod.infer +export type EnvironmentWithDeploymentsCount = Zod.infer export type CreateEnvironment = Zod.infer export type UpdateEnvironment = Zod.infer