Compare commits

...

5 Commits

Author SHA1 Message Date
chyoo 9f67ee5719 fix(admin): filter non-container releases from deployments
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-13 17:53:48 +09:00
chyoo a6a4426249 add instructor invite code..
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-07 11:57:55 +09:00
chyoo 8b7ec648cd add tailscale node delete!
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-07 10:25:49 +09:00
chyoo 8151ec094d feat: display platform agent status and version
ci/woodpecker/push/woodpecker Pipeline was successful
2026-08-06 16:03:26 +09:00
chyoo 63e85d5306 fix: exclude database components from app releases and deployments 2026-07-15 16:10:36 +09:00
13 changed files with 291 additions and 130 deletions
+21
View File
@@ -44,6 +44,7 @@ model User {
userLicenses UserLicense[] userLicenses UserLicense[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
affiliations UserAffiliation[] affiliations UserAffiliation[]
appRoles UserAppRole[]
@@map("users") @@map("users")
} }
@@ -113,6 +114,7 @@ model Organization {
type OrganizationType @default(OTHER) type OrganizationType @default(OTHER)
domain String? domain String?
defaultLicenseCode String? @map("default_license_code") defaultLicenseCode String? @map("default_license_code")
instructorInviteCode String @unique @map("instructor_invite_code")
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
createdById String? @map("created_by_id") @db.Uuid createdById String? @map("created_by_id") @db.Uuid
createdByName String? @map("created_by_name") createdByName String? @map("created_by_name")
@@ -145,3 +147,22 @@ model UserAffiliation {
@@index([organizationId]) @@index([organizationId])
@@map("user_affiliations") @@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")
}
+4
View File
@@ -77,6 +77,9 @@ model ProvisionedDevice {
kernel String? kernel String?
distro String? distro String?
device Json? device Json?
agentVersion String? @map("agent_version")
agentUpdatedAt DateTime? @map("agent_updated_at") @db.Timestamptz(3)
agentLastSeenAt DateTime? @map("agent_last_seen_at") @db.Timestamptz(3)
installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3) installedAt DateTime @default(now()) @map("installed_at") @db.Timestamptz(3)
lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3) lastProvisionedAt DateTime @default(now()) @map("last_provisioned_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@ -85,6 +88,7 @@ model ProvisionedDevice {
@@unique([accountEmail, tailscaleHostname]) @@unique([accountEmail, tailscaleHostname])
@@index([accountEmail]) @@index([accountEmail])
@@index([tailscaleHostname]) @@index([tailscaleHostname])
@@index([agentLastSeenAt])
@@map("provisioned_devices") @@map("provisioned_devices")
} }
@@ -0,0 +1,2 @@
ALTER TABLE "build_jobs"
ADD COLUMN "artifact_sha256" TEXT;
+1
View File
@@ -215,6 +215,7 @@ model BuildJob {
branch String branch String
commitSha String? @map("commit_sha") commitSha String? @map("commit_sha")
artifact String? artifact String?
artifactSha256 String? @map("artifact_sha256")
image String? image String?
status JobStatus @default(PENDING) status JobStatus @default(PENDING)
requestedById String? @map("requested_by_id") @db.Uuid requestedById String? @map("requested_by_id") @db.Uuid
+142 -124
View File
@@ -509,6 +509,15 @@ export class AppsService {
throw new Error('릴리즈 기준 빌드 작업을 찾을 수 없습니다.'); 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) { for (const job of sourceBuildJobs) {
await this.appendReleaseLog(releaseId, `[source] ${job.component.name} develop ${job.commitSha?.slice(0, 7) ?? '-'}`); 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.promoteReleaseBranches(sourceBuildJobs, 'develop', release.branch);
await this.appendReleaseLog(releaseId, '[promote] completed'); await this.appendReleaseLog(releaseId, '[promote] completed');
const releaseBuildJobs = await this.createReleaseBuildJobs(sourceBuildJobs, release.branch); await this.appendReleaseLog(releaseId, '[artifact] reusing selected develop build artifacts');
for (const job of releaseBuildJobs) { const commitShas = [...new Set(sourceBuildJobs.map((job) => job.commitSha).filter(Boolean))];
await this.appendReleaseLog(releaseId, `[build] queued ${job.component.name} ${job.branch} ${job.commitSha?.slice(0, 7) ?? '-'}`); const artifact = sourceBuildJobs.find((job) => job.artifact)?.artifact ?? null;
} const imageTags = createReleaseImageTags(sourceBuildJobs, release.app.components);
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, '[done] release completed'); await this.appendReleaseLog(releaseId, '[done] release completed');
await this.prisma.appRelease.update({ 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( private async promoteReleaseBranches(
buildJobs: Array<{ buildJobs: Array<{
id: string; id: string;
@@ -1008,35 +925,56 @@ export class AppsService {
} }
const app = release?.app ?? quickBuild!.component.app; const app = release?.app ?? quickBuild!.component.app;
const images = release const releaseImages = release
? parseReleaseImageTags(release.imageTags).images ? parseReleaseImageTags(release.imageTags).images
: { [quickBuild!.component.name]: quickBuild!.image! }; : { [quickBuild!.component.name]: quickBuild!.image! };
const images = Object.fromEntries(
Object.entries(releaseImages).filter(([componentName]) => {
const componentType = app.components.find((component) => component.name === componentName)?.type;
return componentType === 'CONTAINER' || componentType === 'STATIC_WEB';
}),
);
const hasKubernetesWorkload = Object.keys(images).length > 0;
const isLauncherArtifactRelease = Boolean(
release
&& !hasKubernetesWorkload
&& body.updateLauncherAppVersion !== false
&& app.components.some((component) => component.type === 'ELECTRON')
&& hasReleaseArtifact(release.artifact, release.imageTags),
);
const deploymentLabel = release const deploymentLabel = release
? `${app.name} ${release.version}` ? `${app.name} ${release.version}`
: `${app.name} ${quickBuild!.component.name} ${quickBuild!.commitSha?.slice(0, 7) ?? 'quick'}`; : `${app.name} ${quickBuild!.component.name} ${quickBuild!.commitSha?.slice(0, 7) ?? 'quick'}`;
const requestedResources = body.resources; const requestedResources = body.resources;
const runtimeProfile = await this.prisma.runtimeEnvProfile.findFirst({ const runtimeProfile = hasKubernetesWorkload
where: { ? await this.prisma.runtimeEnvProfile.findFirst({
appId: app.id, where: {
environment, appId: app.id,
namespace, environment,
}, namespace,
include: { },
ingressRules: { include: {
include: { component: true }, ingressRules: {
orderBy: { host: 'asc' }, include: { component: true },
}, orderBy: { host: 'asc' },
}, },
}); },
if (Object.keys(images).length === 0) { })
throw new BadRequestException('릴리즈에 배포 가능한 이미지가 없습니다.'); : null;
if (!hasKubernetesWorkload && !isLauncherArtifactRelease) {
if (release && hasReleaseArtifact(release.artifact, release.imageTags)) {
throw new BadRequestException('AppImage 릴리즈는 런처 앱 버전 업데이트를 선택해야 합니다.');
}
throw new BadRequestException('릴리즈에 배포 가능한 이미지 또는 AppImage 산출물이 없습니다.');
} }
const namespaceExists = await this.namespaceExists(namespace); if (hasKubernetesWorkload) {
if (!namespaceExists) { const namespaceExists = await this.namespaceExists(namespace);
throw new BadRequestException( if (!namespaceExists) {
`${namespace} namespace가 없습니다. 런타임 구성에서 Namespace 생성/확인을 먼저 실행하세요.`, throw new BadRequestException(
); `${namespace} namespace가 없습니다. 런타임 구성에서 Namespace 생성/확인을 먼저 실행하세요.`,
);
}
} }
const deploymentJob = await this.prisma.deploymentJob.create({ const deploymentJob = await this.prisma.deploymentJob.create({
@@ -1053,6 +991,7 @@ export class AppsService {
`[running] deployment started: ${deploymentLabel}`, `[running] deployment started: ${deploymentLabel}`,
...(quickBuild ? [`[source] quick build ${quickBuild.id} ${quickBuild.image}`] : []), ...(quickBuild ? [`[source] quick build ${quickBuild.id} ${quickBuild.image}`] : []),
`[target] environment=${environment} namespace=${namespace}`, `[target] environment=${environment} namespace=${namespace}`,
...(isLauncherArtifactRelease ? ['[launcher] AppImage release; Kubernetes rollout skipped'] : []),
].join('\n'), ].join('\n'),
}, },
include: { include: {
@@ -1731,13 +1670,33 @@ export class AppsService {
async deleteRuntimeProfile(profileId: string) { async deleteRuntimeProfile(profileId: string) {
const profile = await this.prisma.runtimeEnvProfile.findUnique({ const profile = await this.prisma.runtimeEnvProfile.findUnique({
where: { id: profileId }, where: { id: profileId },
select: { id: true, name: true }, include: {
variables: {
include: {
component: {
select: {
name: true,
type: true,
workloadType: true,
},
},
},
},
},
}); });
if (!profile) { if (!profile) {
throw new NotFoundException('실행 설정 프로필을 찾을 수 없습니다.'); throw new NotFoundException('실행 설정 프로필을 찾을 수 없습니다.');
} }
for (const variable of profile.variables) {
await this.removeRuntimeVariableFromWorkload(
normalizeNamespace(profile.namespace),
variable.component,
variable.key,
);
}
await this.prisma.runtimeEnvProfile.delete({ await this.prisma.runtimeEnvProfile.delete({
where: { id: profileId }, where: { id: profileId },
}); });
@@ -1840,13 +1799,32 @@ export class AppsService {
async deleteRuntimeVariable(profileId: string, variableId: string) { async deleteRuntimeVariable(profileId: string, variableId: string) {
const variable = await this.prisma.runtimeEnvVariable.findFirst({ const variable = await this.prisma.runtimeEnvVariable.findFirst({
where: { id: variableId, profileId }, where: { id: variableId, profileId },
select: { id: true }, include: {
profile: {
select: {
namespace: true,
},
},
component: {
select: {
name: true,
type: true,
workloadType: true,
},
},
},
}); });
if (!variable) { if (!variable) {
throw new NotFoundException('환경변수를 찾을 수 없습니다.'); throw new NotFoundException('환경변수를 찾을 수 없습니다.');
} }
await this.removeRuntimeVariableFromWorkload(
normalizeNamespace(variable.profile.namespace),
variable.component,
variable.key,
);
await this.prisma.runtimeEnvVariable.delete({ await this.prisma.runtimeEnvVariable.delete({
where: { id: variable.id }, where: { id: variable.id },
}); });
@@ -2200,6 +2178,32 @@ export class AppsService {
} }
} }
private async removeRuntimeVariableFromWorkload(
namespace: string,
component: { name: string; type: string; workloadType: string | null } | null,
key: string,
) {
if (!component || component.type === 'DATABASE') {
return;
}
const workloadType = normalizeWorkloadType(component.workloadType, component.type);
if (workloadType === 'Job') {
return;
}
const workloadRef = `${workloadType.toLowerCase()}/${sanitizeK8sName(component.name)}`;
try {
await this.kubectl(['set', 'env', workloadRef, `${key}-`, '-n', namespace]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (/\bnot\s*found\b/i.test(message)) {
return;
}
throw error;
}
}
private async kubectlEnv() { private async kubectlEnv() {
const inClusterHost = process.env.KUBERNETES_SERVICE_HOST; const inClusterHost = process.env.KUBERNETES_SERVICE_HOST;
if (inClusterHost) { if (inClusterHost) {
@@ -3398,6 +3402,16 @@ function parseReleaseImageTags(value: Prisma.JsonValue | null) {
return { images }; return { images };
} }
function hasReleaseArtifact(artifact: string | null, imageTags: Prisma.JsonValue | null) {
if (artifact?.trim()) return true;
if (!imageTags || typeof imageTags !== 'object' || Array.isArray(imageTags)) return false;
const artifacts = (imageTags as Record<string, unknown>).artifacts;
if (!artifacts || typeof artifacts !== 'object' || Array.isArray(artifacts)) return false;
return Object.values(artifacts).some((value) => typeof value === 'string' && value.trim());
}
function getUsedReleaseBuildIds(releases: Array<{ imageTags: Prisma.JsonValue | null }>) { function getUsedReleaseBuildIds(releases: Array<{ imageTags: Prisma.JsonValue | null }>) {
const ids = new Set<string>(); const ids = new Set<string>();
for (const release of releases) { for (const release of releases) {
@@ -3467,6 +3481,7 @@ function createReleaseImageTags(
branch: string; branch: string;
commitSha: string | null; commitSha: string | null;
artifact: string | null; artifact: string | null;
artifactSha256: string | null;
image: string | null; image: string | null;
component: { component: {
id: string; id: string;
@@ -3484,6 +3499,7 @@ function createReleaseImageTags(
): Prisma.InputJsonObject { ): Prisma.InputJsonObject {
const images: Record<string, string> = {}; const images: Record<string, string> = {};
const artifacts: Record<string, string> = {}; const artifacts: Record<string, string> = {};
const artifactChecksums: Record<string, string> = {};
for (const job of buildJobs) { for (const job of buildJobs) {
if (job.image && !images[job.component.name]) { if (job.image && !images[job.component.name]) {
@@ -3492,13 +3508,17 @@ function createReleaseImageTags(
if (job.artifact && !artifacts[job.component.name]) { if (job.artifact && !artifacts[job.component.name]) {
artifacts[job.component.name] = job.artifact; artifacts[job.component.name] = job.artifact;
} }
if (job.artifactSha256 && !artifactChecksums[job.component.name]) {
artifactChecksums[job.component.name] = job.artifactSha256;
}
} }
for (const component of components) { for (const component of components) {
const imageSource = normalizeImageSource(component.imageSource, component.type); const imageSource = normalizeImageSource(component.imageSource, component.type);
if ( if (
!images[component.name] !images[component.name]
&& (imageSource === 'EXTERNAL' || imageSource === 'DATABASE') && imageSource === 'EXTERNAL'
&& component.type !== 'DATABASE'
&& component.externalImage?.trim() && component.externalImage?.trim()
) { ) {
images[component.name] = component.externalImage.trim(); images[component.name] = component.externalImage.trim();
@@ -3508,6 +3528,7 @@ function createReleaseImageTags(
return { return {
images, images,
artifacts, artifacts,
artifactChecksums,
buildJobs: buildJobs.map((job) => ({ buildJobs: buildJobs.map((job) => ({
id: job.id, id: job.id,
componentId: job.component.id, componentId: job.component.id,
@@ -3517,6 +3538,7 @@ function createReleaseImageTags(
commitSha: job.commitSha, commitSha: job.commitSha,
image: job.image, image: job.image,
artifact: job.artifact, artifact: job.artifact,
artifactSha256: job.artifactSha256,
})), })),
}; };
} }
@@ -3606,7 +3628,3 @@ function getBuildPlanLogs(input: {
return lines; return lines;
} }
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+5 -2
View File
@@ -447,11 +447,13 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
const destination = path.join(artifactDir, destinationName); const destination = path.join(artifactDir, destinationName);
await fs.copyFile(artifact, destination); await fs.copyFile(artifact, destination);
const artifactSha256 = await sha256File(artifact);
const uploadedArtifact = await this.uploadArtifactIfConfigured( const uploadedArtifact = await this.uploadArtifactIfConfigured(
job, job,
destination, destination,
destinationName, destinationName,
artifactVersion, artifactVersion,
artifactSha256,
append, append,
); );
const publicBaseUrl = this.configService.get<string>('BUILD_ARTIFACT_BASE_URL'); 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({ await this.prisma.buildJob.update({
where: { id: job.id }, where: { id: job.id },
data: { artifact: artifactValue }, data: { artifact: artifactValue, artifactSha256 },
}); });
job.artifact = artifactValue; job.artifact = artifactValue;
job.artifactSha256 = artifactSha256;
await append(`[artifact] found ${path.relative(workDir, artifact)}`); await append(`[artifact] found ${path.relative(workDir, artifact)}`);
await append(`[artifact] copied to ${artifactValue}`); await append(`[artifact] copied to ${artifactValue}`);
@@ -475,6 +478,7 @@ export class BuildRunnerService implements OnModuleInit, OnModuleDestroy {
artifactPath: string, artifactPath: string,
artifactName: string, artifactName: string,
artifactVersion: string | null, artifactVersion: string | null,
sha256: string,
append: (line: string) => Promise<void>, append: (line: string) => Promise<void>,
) { ) {
const bucket = this.configService.get<string>('R2_BUCKET'); 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 key = this.createArtifactObjectKey(job, artifactName, artifactVersion);
const stat = await fs.stat(artifactPath); const stat = await fs.stat(artifactPath);
const sha256 = await sha256File(artifactPath);
const client = this.getR2Client(accessKeyId, secretAccessKey); const client = this.getR2Client(accessKeyId, secretAccessKey);
await append(`[r2] upload ${key}`); await append(`[r2] upload ${key}`);
+5
View File
@@ -1,4 +1,5 @@
import { import {
BadRequestException,
BadGatewayException, BadGatewayException,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
@@ -118,6 +119,10 @@ export class GiteaService {
params.set('page', String(page)); params.set('page', String(page));
if (query.sha) { if (query.sha) {
params.set('sha', query.sha); params.set('sha', query.sha);
const { branches } = await this.listBranches(owner, repo) as { branches: Array<{ name?: string }> };
if (!branches.some((branch) => branch.name === query.sha)) {
throw new BadRequestException('해당 브랜치가 없습니다.');
}
} }
if (query.since) { if (query.since) {
params.set('since', query.since); params.set('since', query.since);
@@ -1,4 +1,5 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/auth-client'; import { Prisma } from '@prisma/auth-client';
@@ -48,6 +49,7 @@ export class OrganizationsService {
type: true, type: true,
domain: true, domain: true,
defaultLicenseCode: true, defaultLicenseCode: true,
instructorInviteCode: true,
createdById: true, createdById: true,
createdByName: true, createdByName: true,
createdByEmail: true, createdByEmail: true,
@@ -76,11 +78,13 @@ export class OrganizationsService {
async create(body: CreateOrganizationDto, requester?: AdminRequester) { async create(body: CreateOrganizationDto, requester?: AdminRequester) {
try { try {
const instructorInviteCode = await this.createInstructorInviteCode();
const organization = await this.authPrisma.organization.create({ const organization = await this.authPrisma.organization.create({
data: { data: {
name: body.name.trim(), name: body.name.trim(),
type: body.type, type: body.type,
domain: body.domain?.trim() || null, domain: body.domain?.trim() || null,
instructorInviteCode,
...(requester ...(requester
? { ? {
createdById: requester.id, createdById: requester.id,
@@ -95,6 +99,7 @@ export class OrganizationsService {
type: true, type: true,
domain: true, domain: true,
defaultLicenseCode: true, defaultLicenseCode: true,
instructorInviteCode: true,
createdById: true, createdById: true,
createdByName: true, createdByName: true,
createdByEmail: true, createdByEmail: true,
@@ -135,6 +140,7 @@ export class OrganizationsService {
type: true, type: true,
domain: true, domain: true,
defaultLicenseCode: true, defaultLicenseCode: true,
instructorInviteCode: true,
createdById: true, createdById: true,
createdByName: true, createdByName: true,
createdByEmail: true, createdByEmail: true,
@@ -262,6 +268,21 @@ export class OrganizationsService {
updatedUserCount: license ? targetUserIds.length : 0, 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) { function toPositiveInt(value: string | number | undefined, fallback: number) {
+6 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Delete, Get, Param } from '@nestjs/common';
import { TailscaleService } from './tailscale.service'; import { TailscaleService } from './tailscale.service';
@@ -11,6 +11,11 @@ export class TailscaleController {
return this.tailscaleService.listNodes(); return this.tailscaleService.listNodes();
} }
@Delete('nodes/:nodeId')
deleteNode(@Param('nodeId') nodeId: string) {
return this.tailscaleService.deleteNode(nodeId);
}
@Get('routes') @Get('routes')
listRoutes() { listRoutes() {
return this.tailscaleService.listRoutes(); return this.tailscaleService.listRoutes();
+19 -1
View File
@@ -63,6 +63,22 @@ export class TailscaleService {
} }
} }
async deleteNode(nodeId: string) {
const normalizedNodeId = nodeId.trim();
if (!normalizedNodeId) {
throw new BadGatewayException('삭제할 Headscale 노드 ID가 없습니다.');
}
if (!this.gatewayUrl || !this.gatewayToken) {
throw new BadGatewayException('PLATFORM_GATEWAY_URL 또는 PLATFORM_GATEWAY_TOKEN이 설정되어 있지 않습니다.');
}
await this.request<{ deleted?: boolean }>(`/api/admin/tailscale/nodes/${encodeURIComponent(normalizedNodeId)}`, {
method: 'DELETE',
});
return { deleted: true, nodeId: normalizedNodeId };
}
async listRoutes() { async listRoutes() {
if (!this.gatewayUrl || !this.gatewayToken) { if (!this.gatewayUrl || !this.gatewayToken) {
return { return {
@@ -118,11 +134,13 @@ export class TailscaleService {
} }
} }
private async request<T>(path: string): Promise<T> { private async request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${this.gatewayUrl}${path}`, { const response = await fetch(`${this.gatewayUrl}${path}`, {
...init,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'x-platform-gateway-token': this.gatewayToken ?? '', 'x-platform-gateway-token': this.gatewayToken ?? '',
...(init?.headers ?? {}),
}, },
}); });
+6 -1
View File
@@ -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 { export class ListUserSessionsQuery {
@IsOptional() @IsOptional()
@@ -45,3 +45,8 @@ export class CreateManagedUserDto {
@IsIn(['FREE', 'EDU', 'PRO']) @IsIn(['FREE', 'EDU', 'PRO'])
licenseCode?: 'FREE' | 'EDU' | 'PRO'; licenseCode?: 'FREE' | 'EDU' | 'PRO';
} }
export class UpdateLmsInstructorDto {
@IsBoolean()
isInstructor!: boolean;
}
+9 -1
View File
@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; 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'; import { UsersService } from './users.service';
@Controller('users') @Controller('users')
@@ -27,6 +27,14 @@ export class UsersController {
return this.usersService.unlockUser(userId); 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') @Delete('sessions/:sessionId')
terminateSession(@Param('sessionId') sessionId: string) { terminateSession(@Param('sessionId') sessionId: string) {
return this.usersService.terminateSession(sessionId); return this.usersService.terminateSession(sessionId);
+50
View File
@@ -22,6 +22,7 @@ type UserSessionRow = {
expiresAt: Date | null; expiresAt: Date | null;
lastSeen: Date | null; lastSeen: Date | null;
state: string; state: string;
isLmsInstructor: boolean;
primaryAffiliation: { primaryAffiliation: {
id: string; id: string;
organizationId: 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, position: user.affiliations[0].position,
} }
: null; : null;
const isLmsInstructor = user.appRoles.length > 0;
if (user.refreshTokens.length === 0) { if (user.refreshTokens.length === 0) {
return [ return [
@@ -120,6 +129,7 @@ export class UsersService {
expiresAt: null, expiresAt: null,
lastSeen: user.lastLoginAt, lastSeen: user.lastLoginAt,
state: user.isActive ? '세션 없음' : '잠김', state: user.isActive ? '세션 없음' : '잠김',
isLmsInstructor,
primaryAffiliation, primaryAffiliation,
}, },
]; ];
@@ -138,6 +148,7 @@ export class UsersService {
expiresAt: token.expiresAt, expiresAt: token.expiresAt,
lastSeen: user.lastLoginAt ?? token.createdAt, lastSeen: user.lastLoginAt ?? token.createdAt,
state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now), state: getSessionState(user.isActive, token.revokedAt, token.expiresAt, now),
isLmsInstructor,
primaryAffiliation, primaryAffiliation,
})); }));
}); });
@@ -260,6 +271,45 @@ export class UsersService {
return { ok: true }; 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) { async terminateSession(sessionId: string) {
const session = await this.authPrisma.refreshToken.findUnique({ const session = await this.authPrisma.refreshToken.findUnique({
where: { id: sessionId }, where: { id: sessionId },