diff --git a/prisma/migrations/20260708094000_rename_launcher_common_settings/migration.sql b/prisma/migrations/20260708094000_rename_launcher_common_settings/migration.sql index d38de64..5949800 100644 --- a/prisma/migrations/20260708094000_rename_launcher_common_settings/migration.sql +++ b/prisma/migrations/20260708094000_rename_launcher_common_settings/migration.sql @@ -1,3 +1,8 @@ ALTER TABLE IF EXISTS "launcher_common_settings" RENAME TO "launcher_settings"; -COMMENT ON TABLE "launcher_settings" IS '런처 소스, 빌드 환경 등 내부 설정'; +DO $$ +BEGIN + IF to_regclass('public.launcher_settings') IS NOT NULL THEN + COMMENT ON TABLE "launcher_settings" IS '런처 소스, 빌드 환경 등 내부 설정'; + END IF; +END $$; diff --git a/prisma/migrations/20260709011000_add_admin_roles/migration.sql b/prisma/migrations/20260709011000_add_admin_roles/migration.sql index 180fce8..9fa1dae 100644 --- a/prisma/migrations/20260709011000_add_admin_roles/migration.sql +++ b/prisma/migrations/20260709011000_add_admin_roles/migration.sql @@ -24,9 +24,9 @@ CREATE UNIQUE INDEX IF NOT EXISTS "admin_roles_code_key" CREATE INDEX IF NOT EXISTS "admin_roles_is_active_sort_order_idx" ON "admin_roles"("is_active", "sort_order"); -INSERT INTO "admin_roles" ("code", "name", "description", "sort_order", "is_active") +INSERT INTO "admin_roles" ("id", "code", "name", "description", "sort_order", "is_active", "updated_at") VALUES - ('OWNER', 'OWNER', '전체 관리자 권한', 1, true), - ('ADMIN', 'ADMIN', '관리자 권한', 2, true), - ('VIEWER', 'VIEWER', '조회 권한', 3, true) + (gen_random_uuid(), 'OWNER', 'OWNER', '전체 관리자 권한', 1, true, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'ADMIN', 'ADMIN', '관리자 권한', 2, true, CURRENT_TIMESTAMP), + (gen_random_uuid(), 'VIEWER', 'VIEWER', '조회 권한', 3, true, CURRENT_TIMESTAMP) ON CONFLICT ("code") DO NOTHING; diff --git a/prisma/migrations/20260709012000_add_release_candidate_hidden_at/migration.sql b/prisma/migrations/20260709012000_add_release_candidate_hidden_at/migration.sql new file mode 100644 index 0000000..dfcedc3 --- /dev/null +++ b/prisma/migrations/20260709012000_add_release_candidate_hidden_at/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "build_jobs" + ADD COLUMN IF NOT EXISTS "release_candidate_hidden_at" TIMESTAMPTZ(3); + +CREATE INDEX IF NOT EXISTS "build_jobs_release_candidate_hidden_at_idx" + ON "build_jobs"("release_candidate_hidden_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bda4915..6c98cb3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -220,10 +220,12 @@ model BuildJob { logs String? startedAt DateTime? @map("started_at") @db.Timestamptz(3) finishedAt DateTime? @map("finished_at") @db.Timestamptz(3) + releaseCandidateHiddenAt DateTime? @map("release_candidate_hidden_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) component AppComponent @relation(fields: [componentId], references: [id], onDelete: Cascade) @@index([componentId, createdAt]) + @@index([releaseCandidateHiddenAt]) @@map("build_jobs") } diff --git a/src/apps/apps.controller.ts b/src/apps/apps.controller.ts index d098366..96deef0 100644 --- a/src/apps/apps.controller.ts +++ b/src/apps/apps.controller.ts @@ -77,8 +77,9 @@ export class AppsController { @Query('limit') limit?: string, @Query('since') since?: string, @Query('status') status?: string, + @Query('hideReleaseCandidates') hideReleaseCandidates?: string, ) { - return this.appsService.listBuildJobs(appKey, page, limit, since, status); + return this.appsService.listBuildJobs(appKey, page, limit, since, status, hideReleaseCandidates); } @Post('builds') @@ -91,6 +92,11 @@ export class AppsController { return this.appsService.cancelBuildJob(buildJobId); } + @Patch('builds/:buildJobId/hide-release-candidate') + hideReleaseCandidate(@Param('buildJobId') buildJobId: string) { + return this.appsService.hideReleaseCandidate(buildJobId); + } + @Delete('builds/:buildJobId') deleteBuildJob(@Param('buildJobId') buildJobId: string) { return this.appsService.deleteBuildJob(buildJobId); @@ -132,6 +138,11 @@ export class AppsController { return this.appsService.createRelease(body); } + @Delete('releases/:releaseId') + deleteRelease(@Param('releaseId') releaseId: string) { + return this.appsService.deleteRelease(releaseId); + } + @Get('deployments') listDeployments( @Query('appKey') appKey?: string, diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index e14c0b7..8bbce71 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -1139,6 +1139,36 @@ export class AppsService { } } + async deleteRelease(releaseId: string) { + const release = await this.prisma.appRelease.findUnique({ + where: { id: releaseId }, + select: { + id: true, + status: true, + deployments: { + where: { status: 'SUCCEEDED' }, + select: { id: true }, + take: 1, + }, + }, + }); + + if (!release) { + throw new NotFoundException('릴리즈를 찾을 수 없습니다.'); + } + + if (release.status === 'PENDING' || release.status === 'RUNNING') { + throw new BadRequestException('진행 중인 릴리즈는 삭제할 수 없습니다.'); + } + + if (release.deployments.length > 0) { + throw new BadRequestException('이미 성공 배포된 릴리즈는 삭제할 수 없습니다.'); + } + + await this.prisma.appRelease.delete({ where: { id: releaseId } }); + return { deleted: true }; + } + async listMigrationJobs(appId?: string, page?: string, limit?: string, since?: string) { const pagination = parsePagination(page, limit); const sinceDate = parseSinceDate(since); @@ -1326,13 +1356,15 @@ export class AppsService { } } - async listBuildJobs(appKey?: string, page?: string, limit?: string, since?: string, status?: string) { + async listBuildJobs(appKey?: string, page?: string, limit?: string, since?: string, status?: string, hideReleaseCandidates?: string) { const pagination = parsePagination(page, limit); const sinceDate = parseSinceDate(since); + const excludeHiddenReleaseCandidates = hideReleaseCandidates === 'true'; const where: Prisma.BuildJobWhereInput = { ...(appKey ? { component: { app: { key: appKey } } } : {}), ...(sinceDate ? { createdAt: { gte: sinceDate } } : {}), ...(status ? { status: normalizeJobStatus(status) } : {}), + ...(excludeHiddenReleaseCandidates ? { releaseCandidateHiddenAt: null } : {}), }; const include = { component: { @@ -1347,6 +1379,7 @@ export class AppsService { where: { ...(appKey ? { component: { app: { key: appKey } } } : {}), ...(status ? { status: normalizeJobStatus(status) } : {}), + ...(excludeHiddenReleaseCandidates ? { releaseCandidateHiddenAt: null } : {}), }, orderBy: { createdAt: 'desc' }, include, @@ -1941,6 +1974,44 @@ export class AppsService { return { deleted: true }; } + async hideReleaseCandidate(buildJobId: string) { + const buildJob = await this.prisma.buildJob.findUnique({ + where: { id: buildJobId }, + select: { + id: true, + status: true, + releaseCandidateHiddenAt: true, + }, + }); + + if (!buildJob) { + throw new NotFoundException('빌드 작업을 찾을 수 없습니다.'); + } + + if (buildJob.status === 'PENDING' || buildJob.status === 'RUNNING') { + throw new BadRequestException('진행 중인 빌드는 릴리즈 후보에서 숨길 수 없습니다.'); + } + + const usedReleases = await this.prisma.appRelease.findMany({ + select: { imageTags: true }, + }); + if (getUsedReleaseBuildIds(usedReleases).includes(buildJob.id)) { + throw new BadRequestException('이미 릴리즈에 사용된 빌드는 후보에서 숨길 수 없습니다.'); + } + + const updated = await this.prisma.buildJob.update({ + where: { id: buildJob.id }, + data: { releaseCandidateHiddenAt: buildJob.releaseCandidateHiddenAt ?? new Date() }, + include: { + component: { + include: { app: true }, + }, + }, + }); + + return { buildJob: updated }; + } + private async kubectl(args: string[]) { const kubectlBin = this.configService.get('KUBECTL_BIN', 'kubectl'); const env = await this.kubectlEnv();