diff --git a/prisma/auth.schema.prisma b/prisma/auth.schema.prisma index bf150fd..11eb34f 100644 --- a/prisma/auth.schema.prisma +++ b/prisma/auth.schema.prisma @@ -44,6 +44,7 @@ model User { userLicenses UserLicense[] refreshTokens RefreshToken[] affiliations UserAffiliation[] + appRoles UserAppRole[] @@map("users") } @@ -113,6 +114,7 @@ model Organization { type OrganizationType @default(OTHER) domain String? defaultLicenseCode String? @map("default_license_code") + instructorInviteCode String @unique @map("instructor_invite_code") isActive Boolean @default(true) @map("is_active") createdById String? @map("created_by_id") @db.Uuid createdByName String? @map("created_by_name") @@ -145,3 +147,22 @@ model UserAffiliation { @@index([organizationId]) @@map("user_affiliations") } + +model UserAppRole { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + appKey String @map("app_key") + roleCode String @map("role_code") + assignedById String? @map("assigned_by") @db.Uuid + assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3) + startsAt DateTime? @map("starts_at") @db.Timestamptz(3) + endsAt DateTime? @map("ends_at") @db.Timestamptz(3) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, appKey, roleCode]) + @@index([appKey, roleCode]) + @@index([userId, appKey]) + @@map("user_app_roles") +} diff --git a/prisma/migrations/20260807113000_add_build_artifact_sha256/migration.sql b/prisma/migrations/20260807113000_add_build_artifact_sha256/migration.sql new file mode 100644 index 0000000..c5a6484 --- /dev/null +++ b/prisma/migrations/20260807113000_add_build_artifact_sha256/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "build_jobs" +ADD COLUMN "artifact_sha256" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8035032..f5a74a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -215,6 +215,7 @@ model BuildJob { branch String commitSha String? @map("commit_sha") artifact String? + artifactSha256 String? @map("artifact_sha256") image String? status JobStatus @default(PENDING) requestedById String? @map("requested_by_id") @db.Uuid diff --git a/src/apps/apps.service.ts b/src/apps/apps.service.ts index 327ef0b..7a915a2 100644 --- a/src/apps/apps.service.ts +++ b/src/apps/apps.service.ts @@ -509,6 +509,15 @@ export class AppsService { throw new Error('릴리즈 기준 빌드 작업을 찾을 수 없습니다.'); } + const missingArtifactMetadata = sourceBuildJobs.find((job) => ( + job.component.type === 'ELECTRON' && (!job.artifact || !job.artifactSha256) + )); + if (missingArtifactMetadata) { + throw new Error( + `${missingArtifactMetadata.component.name} 빌드에 R2 산출물 또는 SHA-256 정보가 없습니다. 새 빌드 후 릴리즈하세요.`, + ); + } + for (const job of sourceBuildJobs) { await this.appendReleaseLog(releaseId, `[source] ${job.component.name} develop ${job.commitSha?.slice(0, 7) ?? '-'}`); } @@ -516,21 +525,10 @@ export class AppsService { await this.promoteReleaseBranches(sourceBuildJobs, 'develop', release.branch); await this.appendReleaseLog(releaseId, '[promote] completed'); - const releaseBuildJobs = await this.createReleaseBuildJobs(sourceBuildJobs, release.branch); - for (const job of releaseBuildJobs) { - await this.appendReleaseLog(releaseId, `[build] queued ${job.component.name} ${job.branch} ${job.commitSha?.slice(0, 7) ?? '-'}`); - } - await this.appendReleaseLog(releaseId, '[wait] waiting for release build jobs'); - const completedBuildJobs = await this.waitForBuildJobs(releaseBuildJobs.map((job) => job.id)); - const failed = completedBuildJobs.find((job) => job.status !== 'SUCCEEDED'); - if (failed) { - throw new Error(`${failed.component.name} release 빌드 실패: ${failed.status}`); - } - await this.appendReleaseLog(releaseId, '[build] release build jobs completed'); - - const commitShas = [...new Set(completedBuildJobs.map((job) => job.commitSha).filter(Boolean))]; - const artifact = completedBuildJobs.find((job) => job.artifact)?.artifact ?? null; - const imageTags = createReleaseImageTags(completedBuildJobs, release.app.components); + await this.appendReleaseLog(releaseId, '[artifact] reusing selected develop build artifacts'); + const commitShas = [...new Set(sourceBuildJobs.map((job) => job.commitSha).filter(Boolean))]; + const artifact = sourceBuildJobs.find((job) => job.artifact)?.artifact ?? null; + const imageTags = createReleaseImageTags(sourceBuildJobs, release.app.components); await this.appendReleaseLog(releaseId, '[done] release completed'); await this.prisma.appRelease.update({ @@ -572,87 +570,6 @@ export class AppsService { }); } - private async createReleaseBuildJobs( - sourceBuildJobs: Array<{ - componentId: string; - commitSha: string | null; - requestedById?: string | null; - requestedByName?: string | null; - requestedByEmail?: string | null; - component: { - id: string; - name: string; - type: string; - repository: string | null; - buildTarget: string | null; - app: { name: string }; - }; - }>, - branch: string, - ) { - const jobs = []; - for (const sourceJob of sourceBuildJobs) { - const component = sourceJob.component; - const artifact = getBuildArtifact(component, branch, sourceJob.commitSha); - const image = getBuildImage( - component, - branch, - sourceJob.commitSha, - this.configService.get('BUILD_REGISTRY', 'registry.local'), - ); - const job = await this.prisma.buildJob.create({ - data: { - componentId: component.id, - branch, - commitSha: sourceJob.commitSha, - artifact, - image, - status: 'PENDING', - requestedById: sourceJob.requestedById, - requestedByName: sourceJob.requestedByName, - requestedByEmail: sourceJob.requestedByEmail, - logs: getBuildPlanLogs({ - appName: component.app.name, - componentName: component.name, - componentType: component.type, - repository: component.repository || '', - branch, - commitSha: sourceJob.commitSha, - buildTarget: component.buildTarget, - artifact, - image, - giteaBaseUrl: this.configService.get( - 'GITEA_BASE_URL', - 'http://localhost:3000', - ), - }).join('\n'), - }, - include: { component: true }, - }); - jobs.push(job); - } - return jobs; - } - - private async waitForBuildJobs(buildJobIds: string[]) { - const timeoutMs = Number(this.configService.get('RELEASE_BUILD_TIMEOUT_MS', '1800000')); - const startedAt = Date.now(); - - while (Date.now() - startedAt < timeoutMs) { - const jobs = await this.prisma.buildJob.findMany({ - where: { id: { in: buildJobIds } }, - include: { component: true }, - }); - if (jobs.length === buildJobIds.length && jobs.every((job) => ['SUCCEEDED', 'FAILED', 'CANCELED'].includes(job.status))) { - return jobs; - } - await delay(3000); - } - - throw new Error('release 빌드 완료 대기 시간이 초과되었습니다.'); - } - - private async promoteReleaseBranches( buildJobs: Array<{ id: string; @@ -3537,6 +3454,7 @@ function createReleaseImageTags( branch: string; commitSha: string | null; artifact: string | null; + artifactSha256: string | null; image: string | null; component: { id: string; @@ -3554,6 +3472,7 @@ function createReleaseImageTags( ): Prisma.InputJsonObject { const images: Record = {}; const artifacts: Record = {}; + const artifactChecksums: Record = {}; for (const job of buildJobs) { if (job.image && !images[job.component.name]) { @@ -3562,6 +3481,9 @@ function createReleaseImageTags( if (job.artifact && !artifacts[job.component.name]) { artifacts[job.component.name] = job.artifact; } + if (job.artifactSha256 && !artifactChecksums[job.component.name]) { + artifactChecksums[job.component.name] = job.artifactSha256; + } } for (const component of components) { @@ -3579,6 +3501,7 @@ function createReleaseImageTags( return { images, artifacts, + artifactChecksums, buildJobs: buildJobs.map((job) => ({ id: job.id, componentId: job.component.id, @@ -3588,6 +3511,7 @@ function createReleaseImageTags( commitSha: job.commitSha, image: job.image, artifact: job.artifact, + artifactSha256: job.artifactSha256, })), }; } @@ -3677,7 +3601,3 @@ function getBuildPlanLogs(input: { return lines; } - -function delay(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/src/apps/build-runner.service.ts b/src/apps/build-runner.service.ts index cc45697..16946e2 100644 --- a/src/apps/build-runner.service.ts +++ b/src/apps/build-runner.service.ts @@ -447,11 +447,13 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { const destination = path.join(artifactDir, destinationName); await fs.copyFile(artifact, destination); + const artifactSha256 = await sha256File(artifact); const uploadedArtifact = await this.uploadArtifactIfConfigured( job, destination, destinationName, artifactVersion, + artifactSha256, append, ); const publicBaseUrl = this.configService.get('BUILD_ARTIFACT_BASE_URL'); @@ -462,9 +464,10 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { await this.prisma.buildJob.update({ where: { id: job.id }, - data: { artifact: artifactValue }, + data: { artifact: artifactValue, artifactSha256 }, }); job.artifact = artifactValue; + job.artifactSha256 = artifactSha256; await append(`[artifact] found ${path.relative(workDir, artifact)}`); await append(`[artifact] copied to ${artifactValue}`); @@ -475,6 +478,7 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { artifactPath: string, artifactName: string, artifactVersion: string | null, + sha256: string, append: (line: string) => Promise, ) { const bucket = this.configService.get('R2_BUCKET'); @@ -488,7 +492,6 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy { const key = this.createArtifactObjectKey(job, artifactName, artifactVersion); const stat = await fs.stat(artifactPath); - const sha256 = await sha256File(artifactPath); const client = this.getR2Client(accessKeyId, secretAccessKey); await append(`[r2] upload ${key}`); diff --git a/src/organizations/organizations.service.ts b/src/organizations/organizations.service.ts index a3e6e92..3b9b25f 100644 --- a/src/organizations/organizations.service.ts +++ b/src/organizations/organizations.service.ts @@ -1,4 +1,5 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { randomBytes } from 'crypto'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/auth-client'; @@ -48,6 +49,7 @@ export class OrganizationsService { type: true, domain: true, defaultLicenseCode: true, + instructorInviteCode: true, createdById: true, createdByName: true, createdByEmail: true, @@ -76,11 +78,13 @@ export class OrganizationsService { async create(body: CreateOrganizationDto, requester?: AdminRequester) { try { + const instructorInviteCode = await this.createInstructorInviteCode(); const organization = await this.authPrisma.organization.create({ data: { name: body.name.trim(), type: body.type, domain: body.domain?.trim() || null, + instructorInviteCode, ...(requester ? { createdById: requester.id, @@ -95,6 +99,7 @@ export class OrganizationsService { type: true, domain: true, defaultLicenseCode: true, + instructorInviteCode: true, createdById: true, createdByName: true, createdByEmail: true, @@ -135,6 +140,7 @@ export class OrganizationsService { type: true, domain: true, defaultLicenseCode: true, + instructorInviteCode: true, createdById: true, createdByName: true, createdByEmail: true, @@ -262,6 +268,21 @@ export class OrganizationsService { updatedUserCount: license ? targetUserIds.length : 0, }; } + + private async createInstructorInviteCode(): Promise { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + for (let attempt = 0; attempt < 10; attempt += 1) { + const bytes = randomBytes(6); + const code = Array.from(bytes, (byte) => alphabet[byte % alphabet.length]).join(''); + const existing = await this.authPrisma.organization.findUnique({ + where: { instructorInviteCode: code }, + select: { id: true }, + }); + if (!existing) return code; + } + + throw new ConflictException('강사 가입 코드 생성에 실패했습니다. 다시 시도해주세요.'); + } } function toPositiveInt(value: string | number | undefined, fallback: number) { diff --git a/src/users/dto.ts b/src/users/dto.ts index 058604e..2d683bc 100644 --- a/src/users/dto.ts +++ b/src/users/dto.ts @@ -1,4 +1,4 @@ -import { IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; +import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, IsUUID, MinLength } from 'class-validator'; export class ListUserSessionsQuery { @IsOptional() @@ -45,3 +45,8 @@ export class CreateManagedUserDto { @IsIn(['FREE', 'EDU', 'PRO']) licenseCode?: 'FREE' | 'EDU' | 'PRO'; } + +export class UpdateLmsInstructorDto { + @IsBoolean() + isInstructor!: boolean; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index e66c2ca..1cfa184 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; -import { CreateManagedUserDto, ListUserSessionsQuery } from './dto'; +import { CreateManagedUserDto, ListUserSessionsQuery, UpdateLmsInstructorDto } from './dto'; import { UsersService } from './users.service'; @Controller('users') @@ -27,6 +27,14 @@ export class UsersController { return this.usersService.unlockUser(userId); } + @Patch(':userId/lms-instructor') + updateLmsInstructor( + @Param('userId') userId: string, + @Body() body: UpdateLmsInstructorDto, + ) { + return this.usersService.updateLmsInstructor(userId, body.isInstructor); + } + @Delete('sessions/:sessionId') terminateSession(@Param('sessionId') sessionId: string) { return this.usersService.terminateSession(sessionId); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index ae846d5..619c28e 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -22,6 +22,7 @@ type UserSessionRow = { expiresAt: Date | null; lastSeen: Date | null; state: string; + isLmsInstructor: boolean; primaryAffiliation: { id: string; organizationId: string; @@ -82,6 +83,13 @@ export class UsersService { }, }, }, + appRoles: { + where: { + appKey: 'LMS', + roleCode: 'INSTRUCTOR', + }, + select: { id: true }, + }, }, }); @@ -104,6 +112,7 @@ export class UsersService { position: user.affiliations[0].position, } : null; + const isLmsInstructor = user.appRoles.length > 0; if (user.refreshTokens.length === 0) { return [ @@ -120,6 +129,7 @@ export class UsersService { expiresAt: null, lastSeen: user.lastLoginAt, state: user.isActive ? '세션 없음' : '잠김', + isLmsInstructor, primaryAffiliation, }, ]; @@ -138,6 +148,7 @@ export class UsersService { expiresAt: token.expiresAt, lastSeen: user.lastLoginAt ?? token.createdAt, state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now), + isLmsInstructor, primaryAffiliation, })); }); @@ -260,6 +271,45 @@ export class UsersService { return { ok: true }; } + async updateLmsInstructor(userId: string, isInstructor: boolean) { + const user = await this.authPrisma.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + + if (!user) { + throw new NotFoundException('사용자를 찾을 수 없습니다.'); + } + + if (isInstructor) { + await this.authPrisma.userAppRole.upsert({ + where: { + userId_appKey_roleCode: { + userId, + appKey: 'LMS', + roleCode: 'INSTRUCTOR', + }, + }, + create: { + userId, + appKey: 'LMS', + roleCode: 'INSTRUCTOR', + }, + update: { endsAt: null }, + }); + } else { + await this.authPrisma.userAppRole.deleteMany({ + where: { + userId, + appKey: 'LMS', + roleCode: 'INSTRUCTOR', + }, + }); + } + + return { userId, isInstructor }; + } + async terminateSession(sessionId: string) { const session = await this.authPrisma.refreshToken.findUnique({ where: { id: sessionId },