This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "build_jobs"
|
||||
ADD COLUMN "artifact_sha256" TEXT;
|
||||
@@ -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
|
||||
|
||||
+20
-100
@@ -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<string>('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<string>(
|
||||
'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<string>('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<string, string> = {};
|
||||
const artifacts: Record<string, string> = {};
|
||||
const artifactChecksums: Record<string, string> = {};
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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<string>('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<void>,
|
||||
) {
|
||||
const bucket = this.configService.get<string>('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}`);
|
||||
|
||||
@@ -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<string> {
|
||||
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) {
|
||||
|
||||
+6
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user