diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index 88fb9a02c1..62f60b3675 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -20,6 +20,7 @@ import { ProjectServicesModule } from './modules/project-services/project-servic import { ProjectModule } from './modules/project/project.module' import { RepositoryModule } from './modules/repository/repository.module' import { SystemConfigModule } from './modules/system-config/system-config.module' +import { StageModule } from './modules/stage/stage.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' import { getDotenvPaths } from './utils/dotenv.utils' @@ -49,6 +50,7 @@ import { getDotenvPaths } from './utils/dotenv.utils' ProjectServicesModule, RepositoryModule, ScheduleModule.forRoot(), + StageModule, SystemConfigModule, SystemSettingsModule, VersionModule, diff --git a/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts b/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts new file mode 100644 index 0000000000..e74752f6a3 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage-queries.utils.ts @@ -0,0 +1,135 @@ +import type { Cluster, Prisma, Stage } from '@prisma/client' + +export const stageSelect = { + id: true, + name: true, +} satisfies Prisma.StageSelect +export type StageRecord = Prisma.StageGetPayload<{ select: typeof stageSelect }> + +export const stageWithClustersSelect = { + id: true, + name: true, + clusters: { select: { id: true } }, +} satisfies Prisma.StageSelect +export type StageWithClustersRecord = Prisma.StageGetPayload<{ select: typeof stageWithClustersSelect }> + +export const stageEnvironmentsSelect = { + id: true, + name: true, + projectId: true, + memory: true, + cpu: true, + gpu: true, + autosync: true, + clusterId: true, + stageId: true, + createdAt: true, + updatedAt: true, + cluster: { select: { label: true } }, + project: { + select: { + name: true, + owner: true, + slug: true, + }, + }, +} satisfies Prisma.EnvironmentSelect +export type StageEnvironmentsRecord = Prisma.EnvironmentGetPayload<{ select: typeof stageEnvironmentsSelect }> + +export function listStages(tx: Prisma.TransactionClient) { + return tx.stage.findMany({ + select: stageWithClustersSelect, + }) +} + +export function getAllStageIds(tx: Prisma.TransactionClient) { + return tx.stage.findMany({ + select: { id: true }, + }) +} + +export function getStageById(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.stage.findUnique({ + where: { id }, + select: stageWithClustersSelect, + }) +} + +export function getStageByName(tx: Prisma.TransactionClient, name: Stage['name']) { + return tx.stage.findUnique({ where: { name } }) +} + +export function getStageAssociatedEnvironments(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.environment.findMany({ + where: { stageId: id }, + select: stageEnvironmentsSelect, + }) +} + +export function getStageAssociatedEnvironmentCount(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.environment.count({ + where: { stageId: id }, + }) +} + +export function createStage(tx: Prisma.TransactionClient, { name }: { name: Stage['name'] }) { + return tx.stage.create({ + data: { name }, + select: stageSelect, + }) +} + +export function updateStageName(tx: Prisma.TransactionClient, id: Stage['id'], name: Stage['name']) { + return tx.stage.update({ + where: { id }, + data: { name }, + }) +} + +export function linkStageToClusters(tx: Prisma.TransactionClient, id: Stage['id'], clusterIds: Cluster['id'][]) { + return tx.stage.update({ + where: { id }, + data: { + clusters: { + connect: clusterIds.map(clusterId => ({ id: clusterId })), + }, + }, + }) +} + +export function disconnectClusterFromStage(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageId: Stage['id']) { + return tx.stage.update({ + where: { id: stageId }, + data: { + clusters: { + disconnect: { id: clusterId }, + }, + }, + }) +} + +export function deleteStage(tx: Prisma.TransactionClient, id: Stage['id']) { + return tx.stage.delete({ where: { id } }) +} + +export function linkClusterToStages(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageIds: Stage['id'][]) { + return tx.cluster.update({ + where: { id: clusterId }, + data: { + stages: { + connect: stageIds.map(stageId => ({ id: stageId })), + }, + }, + }) +} + +export function removeClusterFromStage(tx: Prisma.TransactionClient, clusterId: Cluster['id'], stageId: Stage['id']) { + return tx.cluster.update({ + where: { id: clusterId }, + data: { + stages: { + disconnect: { id: stageId }, + }, + }, + }) +} diff --git a/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts new file mode 100644 index 0000000000..7fd15b2645 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage-testing.utils.ts @@ -0,0 +1,35 @@ +import type { StageEnvironmentsRecord, StageRecord, StageWithClustersRecord } from './stage-queries.utils' +import { faker } from '@faker-js/faker' +import { makeEnvironment } from '../environment/environment-testing.utils' +import { makeUser } from '../project/project-testing.utils' + +export function makeStageRecord(overrides: Partial = {}): StageRecord { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 20), + ...overrides, + } satisfies StageRecord +} + +export function makeStageWithClusters(overrides: Partial = {}): StageWithClustersRecord { + return { + id: faker.string.uuid(), + name: faker.helpers.slugify(faker.word.sample(3)).toLowerCase().slice(0, 20), + clusters: [{ id: faker.string.uuid() }], + ...overrides, + } satisfies StageWithClustersRecord +} + +export function makeStageEnvironmentRecord(overrides: Partial = {}): StageEnvironmentsRecord { + const environment = makeEnvironment() + return { + ...environment, + cluster: { label: faker.helpers.slugify(faker.word.sample(3)).toLowerCase() }, + project: { + slug: faker.helpers.slugify(faker.word.sample(3)).toLowerCase(), + name: faker.company.name(), + owner: makeUser(), + }, + ...overrides, + } satisfies StageEnvironmentsRecord +} diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts new file mode 100644 index 0000000000..d685cfce1a --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.controller.spec.ts @@ -0,0 +1,147 @@ +import type { Stage, UpdateStageBody } from '@cpn-console/shared' +import type { NestFastifyApplication } from '@nestjs/platform-fastify' +import type { TestingModule } from '@nestjs/testing' +import type { MockProxy } from 'vitest-mock-extended' +import { faker } from '@faker-js/faker' +import { FastifyAdapter } from '@nestjs/platform-fastify' +import { Test } from '@nestjs/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mock } from 'vitest-mock-extended' +import { UserGuard } from '../infrastructure/permission/user/user.guard' +import { makeStageEnvironmentRecord, makeStageWithClusters } from './stage-testing.utils' +import { StageController } from './stage.controller' +import { StageService } from './stage.service' +import { toStage, toStageAssociatedEnvironments, toStages } from './stage.utils' + +describe('stageController', () => { + let module: TestingModule + let controller: StageController + let service: MockProxy + + const stage: Stage = { + id: faker.string.uuid(), + name: 'dev', + clusterIds: [], + } + + beforeEach(async () => { + service = mock() + + module = await Test.createTestingModule({ + controllers: [StageController], + providers: [ + { provide: StageService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + controller = module.get(StageController) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) + + it('maps raw stage records to the contract shape', async () => { + const records = [makeStageWithClusters()] + service.listStages.mockResolvedValue(records) + + expect(await controller.list()).toEqual(toStages(records)) + expect(service.listStages).toHaveBeenCalledTimes(1) + }) + + it('maps stage environments for the contract response', async () => { + const records = [makeStageEnvironmentRecord()] + const stageId = faker.string.uuid() + service.getStageAssociatedEnvironments.mockResolvedValue(records) + + expect(await controller.getStageEnvironments(stageId)).toEqual(toStageAssociatedEnvironments(records)) + expect(service.getStageAssociatedEnvironments).toHaveBeenCalledWith(stageId) + }) + + it('delegates create with the validated body', async () => { + const record = makeStageWithClusters() + service.createStage.mockResolvedValue(record) + + expect(await controller.create(stage)).toEqual(toStage(record)) + expect(service.createStage).toHaveBeenCalledWith(stage) + }) + + it('delegates update with stageId and validated body', async () => { + const stageId = faker.string.uuid() + const updateBody: UpdateStageBody = { name: 'staging', clusterIds: [] } + const record = makeStageWithClusters() + service.updateStage.mockResolvedValue(record) + + expect(await controller.update(stageId, updateBody)).toEqual(toStage(record)) + expect(service.updateStage).toHaveBeenCalledWith(stageId, updateBody) + }) + + it('delegates delete with stageId', async () => { + const stageId = faker.string.uuid() + service.deleteStage.mockResolvedValue(undefined) + + await controller.delete(stageId) + expect(service.deleteStage).toHaveBeenCalledWith(stageId) + }) +}) + +describe('stageController http pipeline', () => { + it('serves an anonymous GET /api/v1/stages with 200', async () => { + const service = mock() + service.listStages.mockResolvedValue([]) + + const testingModule = await Test.createTestingModule({ + controllers: [StageController], + providers: [ + { provide: StageService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: vi.fn(() => { throw new Error('UserGuard must not run on the public list route') }) }) + .compile() + + const app = testingModule.createNestApplication(new FastifyAdapter()) + await app.init() + await app.getHttpAdapter().getInstance().ready() + + const response = await app.inject({ method: 'GET', url: '/api/v1/stages' }) + + expect(response.statusCode).toBe(200) + + await app.close() + }) + + it.each([ + ['GET', '/api/v1/stages/not-an-uuid/environments'], + ['PUT', '/api/v1/stages/not-an-uuid'], + ['DELETE', '/api/v1/stages/not-an-uuid'], + ])('rejects non-UUID stage params with 400 on %s %s', async (method, url) => { + const service = mock() + + const testingModule = await Test.createTestingModule({ + controllers: [StageController], + providers: [ + { provide: StageService, useValue: service }, + ], + }) + .overrideGuard(UserGuard) + .useValue({ canActivate: () => true }) + .compile() + + const app = testingModule.createNestApplication(new FastifyAdapter()) + await app.init() + await app.getHttpAdapter().getInstance().ready() + + const response = await app.inject({ method, url, payload: method === 'PUT' ? {} : undefined }) + + expect(response.statusCode).toBe(400) + expect(service.getStageAssociatedEnvironments).not.toHaveBeenCalled() + expect(service.updateStage).not.toHaveBeenCalled() + expect(service.deleteStage).not.toHaveBeenCalled() + + await app.close() + }) +}) diff --git a/apps/server-nestjs/src/modules/stage/stage.controller.ts b/apps/server-nestjs/src/modules/stage/stage.controller.ts new file mode 100644 index 0000000000..254608e0c6 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.controller.ts @@ -0,0 +1,57 @@ +import type { CreateStageBody, Stage, StageAssociatedEnvironments, UpdateStageBody } from '@cpn-console/shared' +import { CreateStageBodySchema, UpdateStageBodySchema } from '@cpn-console/shared' +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Put, UseGuards } from '@nestjs/common' +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 { StageService } from './stage.service' +import { toStage, toStageAssociatedEnvironments, toStages } from './stage.utils' + +@Controller('api/v1/stages') +export class StageController { + constructor(@Inject(StageService) private readonly service: StageService) {} + + @Get() + async list(): Promise { + return toStages(await this.service.listStages()) + } + + @Get(':stageId/environments') + @UseGuards(UserGuard) + @RequireAdminPermission('ListStages') + async getStageEnvironments( + @Param('stageId', ParseUUIDPipe) stageId: string, + ): Promise { + return toStageAssociatedEnvironments(await this.service.getStageAssociatedEnvironments(stageId)) + } + + @Post() + @HttpCode(HttpStatus.CREATED) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageStages') + async create( + @Body(new ZodValidationPipe(CreateStageBodySchema)) data: CreateStageBody, + ): Promise { + return toStage(await this.service.createStage(data)) + } + + @Put(':stageId') + @UseGuards(UserGuard) + @RequireAdminPermission('ManageStages') + async update( + @Param('stageId', ParseUUIDPipe) stageId: string, + @Body(new ZodValidationPipe(UpdateStageBodySchema)) data: UpdateStageBody, + ): Promise { + return toStage(await this.service.updateStage(stageId, data)) + } + + @Delete(':stageId') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(UserGuard) + @RequireAdminPermission('ManageStages') + async delete( + @Param('stageId', ParseUUIDPipe) stageId: string, + ): Promise { + await this.service.deleteStage(stageId) + } +} diff --git a/apps/server-nestjs/src/modules/stage/stage.module.ts b/apps/server-nestjs/src/modules/stage/stage.module.ts new file mode 100644 index 0000000000..a7bfcdc751 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { AuthModule } from '../infrastructure/auth/auth.module' +import { DatabaseModule } from '../infrastructure/database/database.module' +import { UserPermissionModule } from '../infrastructure/permission/user/user.module' +import { StageController } from './stage.controller' +import { StageService } from './stage.service' + +@Module({ + imports: [AuthModule, DatabaseModule, UserPermissionModule], + controllers: [StageController], + providers: [StageService], + exports: [StageService], +}) +export class StageModule {} diff --git a/apps/server-nestjs/src/modules/stage/stage.service.spec.ts b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts new file mode 100644 index 0000000000..ee7e465bf8 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.service.spec.ts @@ -0,0 +1,124 @@ +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 { makeCluster } from '../environment/environment-testing.utils' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { makeStageEnvironmentRecord, makeStageRecord, makeStageWithClusters } from './stage-testing.utils' +import { StageService } from './stage.service' + +describe('stageService', () => { + let service: StageService + let prisma: DeepMockProxy + + beforeEach(async () => { + prisma = mockDeep() + prisma.$transaction.mockImplementation(fn => fn(prisma)) + + const moduleRef = await Test.createTestingModule({ + providers: [ + StageService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile() + + service = moduleRef.get(StageService) + }) + + it('lists raw stage records', async () => { + const stages = [makeStageWithClusters(), makeStageWithClusters()] + prisma.stage.findMany.mockResolvedValue(stages) + + expect(await service.listStages()).toEqual(stages) + }) + + it('returns raw stage environment records', async () => { + const environment = makeStageEnvironmentRecord() + prisma.environment.findMany.mockResolvedValue([environment]) + + expect(await service.getStageAssociatedEnvironments(faker.string.uuid())).toEqual([environment]) + }) + + it('creates a stage and links clusters', async () => { + const stage = makeStageRecord() + const created = makeStageWithClusters({ id: stage.id, name: stage.name }) + prisma.stage.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(created) + prisma.stage.create.mockResolvedValue(stage) + prisma.stage.update.mockResolvedValue(stage) + + const clusterIds = [faker.string.uuid()] + const result = await service.createStage({ name: stage.name, clusterIds }) + + expect(result).toEqual(created) + expect(prisma.stage.create).toHaveBeenCalledWith({ data: { name: stage.name }, select: expect.anything() }) + expect(prisma.stage.update).toHaveBeenCalled() + }) + + it('rejects stage creation when the name is taken', async () => { + prisma.stage.findUnique.mockResolvedValue(makeStageRecord()) + + await expect( + service.createStage({ name: 'taken', clusterIds: [] }), + ).rejects.toThrow('Un type d\'environnement portant ce nom existe déjà') + }) + + it('updates stage name and cluster links', async () => { + const stage = makeStageWithClusters() + prisma.stage.findUnique.mockResolvedValue(stage) + prisma.stage.update.mockResolvedValue(stage) + + const clusterIds = [faker.string.uuid()] + const result = await service.updateStage(stage.id, { name: 'new-name', clusterIds }) + + expect(result.id).toEqual(stage.id) + expect(prisma.stage.update).toHaveBeenCalled() + }) + + it('rejects updating a missing stage', async () => { + prisma.stage.findUnique.mockResolvedValue(null) + + await expect( + service.updateStage(faker.string.uuid(), { name: 'new', clusterIds: [] }), + ).rejects.toThrow() + }) + + it('deletes a stage when no environments are attached', async () => { + prisma.environment.count.mockResolvedValue(0) + prisma.stage.delete.mockResolvedValue(makeStageRecord()) + + await service.deleteStage(faker.string.uuid()) + + expect(prisma.stage.delete).toHaveBeenCalled() + }) + + it('rejects stage deletion when environments are attached', async () => { + prisma.environment.count.mockResolvedValue(3) + + await expect( + service.deleteStage(faker.string.uuid()), + ).rejects.toThrow('Impossible de supprimer le stage') + }) + + it('links a cluster to all stages when linkToAll is set', async () => { + prisma.stage.findMany.mockResolvedValue([makeStageRecord(), makeStageRecord()]) + prisma.cluster.update.mockResolvedValue(makeCluster()) + + const clusterId = faker.string.uuid() + await service.linkClusterToStages(clusterId, [], true) + + expect(prisma.stage.findMany).toHaveBeenCalled() + expect(prisma.cluster.update).toHaveBeenCalled() + }) + + it('links nothing when stageIds is empty and linkToAll is not set', async () => { + prisma.stage.findMany.mockResolvedValue([makeStageRecord(), makeStageRecord()]) + prisma.cluster.update.mockResolvedValue(makeCluster()) + + const clusterId = faker.string.uuid() + await service.linkClusterToStages(clusterId, []) + + expect(prisma.stage.findMany).not.toHaveBeenCalled() + expect(prisma.cluster.update).toHaveBeenCalledWith(expect.objectContaining({ data: { stages: { connect: [] } } })) + }) +}) diff --git a/apps/server-nestjs/src/modules/stage/stage.service.ts b/apps/server-nestjs/src/modules/stage/stage.service.ts new file mode 100644 index 0000000000..04f39bedd8 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.service.ts @@ -0,0 +1,96 @@ +import type { CreateStageBody, Stage, UpdateStageBody } from '@cpn-console/shared' +import type { Cluster } from '@prisma/client' +import type { StageEnvironmentsRecord, StageWithClustersRecord } from './stage-queries.utils' +import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common' +import { PrismaService } from '../infrastructure/database/prisma.service' +import { + createStage, + deleteStage, + disconnectClusterFromStage, + getAllStageIds, + getStageAssociatedEnvironmentCount, + getStageAssociatedEnvironments, + getStageById, + getStageByName, + linkClusterToStages, + linkStageToClusters, + listStages, + updateStageName, +} from './stage-queries.utils' + +@Injectable() +export class StageService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async listStages(): Promise { + return listStages(this.prisma) + } + + async getStageAssociatedEnvironments(stageId: Stage['id']): Promise { + return getStageAssociatedEnvironments(this.prisma, stageId) + } + + async createStage({ clusterIds = [], name }: CreateStageBody): Promise { + return this.prisma.$transaction(async (tx) => { + const isNameTaken = await getStageByName(tx, name) + if (isNameTaken) throw new BadRequestException('Un type d\'environnement portant ce nom existe déjà') + + const stage = await createStage(tx, { name }) + + if (clusterIds.length) { + await linkStageToClusters(tx, stage.id, clusterIds) + } + + const created = await getStageById(tx, stage.id) + if (!created) throw new NotFoundException() + return created + }) + } + + async updateStage(stageId: Stage['id'], { clusterIds, name }: UpdateStageBody): Promise { + return this.prisma.$transaction(async (tx) => { + const dbStage = await getStageById(tx, stageId) + if (!dbStage) throw new NotFoundException() + + if (name !== undefined && name !== dbStage.name) { + await updateStageName(tx, stageId, name) + } + + const dbClusters = dbStage.clusters + if (dbClusters?.length) { + const clustersToRemove = dbClusters.filter(dbCluster => !clusterIds.includes(dbCluster.id)) + for (const clusterToRemove of clustersToRemove) { + await disconnectClusterFromStage(tx, clusterToRemove.id, stageId) + } + } + + if (clusterIds.length) { + await linkStageToClusters(tx, stageId, clusterIds) + } + + const updated = await getStageById(tx, stageId) + if (!updated) throw new NotFoundException() + return updated + }) + } + + async deleteStage(stageId: Stage['id']): Promise { + await this.prisma.$transaction(async (tx) => { + const attachedEnvironmentCount = await getStageAssociatedEnvironmentCount(tx, stageId) + if (attachedEnvironmentCount > 0) { + throw new BadRequestException('Impossible de supprimer le stage, des environnements en activité y ont souscrit') + } + + await deleteStage(tx, stageId) + }) + } + + async linkClusterToStages(clusterId: Cluster['id'], stageIds: Stage['id'][], linkToAll: boolean = false): Promise { + await this.prisma.$transaction(async (tx) => { + if (linkToAll === true) { + stageIds = (await getAllStageIds(tx)).map(({ id }) => id) + } + await linkClusterToStages(tx, clusterId, stageIds) + }) + } +} diff --git a/apps/server-nestjs/src/modules/stage/stage.utils.ts b/apps/server-nestjs/src/modules/stage/stage.utils.ts new file mode 100644 index 0000000000..a541a28e39 --- /dev/null +++ b/apps/server-nestjs/src/modules/stage/stage.utils.ts @@ -0,0 +1,22 @@ +import type { Stage, StageAssociatedEnvironments } from '@cpn-console/shared' +import type { StageEnvironmentsRecord, StageWithClustersRecord } from './stage-queries.utils' + +export function toStage({ clusters, ...stage }: StageWithClustersRecord): Stage { + return { + ...stage, + clusterIds: clusters.map(({ id }) => id), + } +} + +export function toStages(records: StageWithClustersRecord[]): Stage[] { + return records.map(toStage) +} + +export function toStageAssociatedEnvironments(records: StageEnvironmentsRecord[]): StageAssociatedEnvironments { + return records.map(environment => ({ + project: environment.project.slug, + name: environment.name, + cluster: environment.cluster.label, + owner: environment.project.owner.email, + })) +} diff --git a/packages/shared/src/contracts/stage.ts b/packages/shared/src/contracts/stage.ts index a8f20f62cf..1c39e5edb0 100644 --- a/packages/shared/src/contracts/stage.ts +++ b/packages/shared/src/contracts/stage.ts @@ -1,10 +1,14 @@ -import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' +import type { ClientInferResponseBody } from '@ts-rest/core' import { ContractNoBody } from '@ts-rest/core' import { z } from 'zod' import { apiPrefix, contractInstance } from '../api-client.js' import { StageSchema } from '../schemas/index.js' import { baseHeaders, ErrorSchema } from './_utils.js' +export const CreateStageBodySchema = StageSchema.omit({ id: true }).partial({ clusterIds: true }) + +export const UpdateStageBodySchema = StageSchema.pick({ clusterIds: true, name: true }) + export const stageContract = contractInstance.router({ listStages: { method: 'GET', @@ -23,7 +27,7 @@ export const stageContract = contractInstance.router({ contentType: 'application/json', summary: 'Create stage', description: 'Create new stage.', - body: StageSchema.omit({ id: true }).partial({ clusterIds: true }), + body: CreateStageBodySchema, responses: { 201: StageSchema, 400: ErrorSchema, @@ -61,7 +65,7 @@ export const stageContract = contractInstance.router({ stageId: z.string() .uuid(), }), - body: StageSchema.pick({ clusterIds: true, name: true }), + body: UpdateStageBodySchema, responses: { 200: StageSchema, 500: ErrorSchema, @@ -88,8 +92,8 @@ export const stageContract = contractInstance.router({ pathPrefix: `${apiPrefix}/stages`, }) -export type CreateStageBody = ClientInferRequest['body'] +export type CreateStageBody = z.infer -export type UpdateStageBody = ClientInferRequest['body'] +export type UpdateStageBody = z.infer export type StageAssociatedEnvironments = ClientInferResponseBody