Files
lms-be/src/instructor/instructor.service.ts
T

4357 lines
159 KiB
TypeScript

import {
BadGatewayException,
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
import { ChatGateway } from '../chat/chat.gateway';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformGatewayClient, type GatewayAccessAssignment, type GatewayAgent } from '../platform-control/platform-gateway.client';
import { R2PublicFileService } from '../storage/r2-public-file.service';
import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto';
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
import { groupCourseScope } from './group-course-scope';
import { normalizeCameraUrl, readWeekCamera } from '../curriculum/week-camera';
import { validateWeekCover } from '../curriculum/week-cover';
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map.dto';
import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps';
import { AiCredentialService } from '../ai/ai-credential.service';
import { UpdateCourseAiSettingDto } from '../ai/dto/update-course-ai-setting.dto';
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net';
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
const BASE_COURSE_SLUGS = ['futuredrive-autonomy-basic', 'yeungnam-autonomy-basic'];
function copyJson(value: Prisma.JsonValue): Prisma.InputJsonValue | Prisma.JsonNullValueInput {
return value === null ? Prisma.JsonNull : JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
function remapProfileModuleIds(value: Prisma.JsonValue, moduleIds: Map<string, string>) {
const config = copyJson(value);
if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
const moduleSet = (config as Record<string, unknown>).moduleSet;
if (!moduleSet || typeof moduleSet !== 'object' || Array.isArray(moduleSet)) return config;
const modules = (moduleSet as Record<string, unknown>).modules;
if (!Array.isArray(modules)) return config;
for (const module of modules) {
if (!module || typeof module !== 'object' || Array.isArray(module)) continue;
const id = (module as Record<string, unknown>).id;
if (typeof id === 'string' && moduleIds.has(id)) {
(module as Record<string, unknown>).id = moduleIds.get(id)!;
}
}
return config;
}
type SubmissionCommitFile = {
path: string;
status: string;
patch: string;
originalRef?: string | null;
modifiedRef?: string | null;
};
type SubmissionCommit = {
hash: string;
shortHash: string;
message: string;
author: string;
authorEmail: string;
date: string | null;
files: SubmissionCommitFile[];
};
type SubmissionProgress = {
commits: SubmissionCommit[];
finalFiles: SubmissionCommitFile[];
};
type StudentIssueItem = {
type: 'MISSING_SUBMISSION' | 'COMPLETED_WITHOUT_SUBMISSION' | 'LOW_AVERAGE_SCORE';
title: string;
detail: string;
week?: number;
};
@Injectable()
export class InstructorService {
private readonly logger = new Logger(InstructorService.name);
constructor(
private readonly aiCredentialService: AiCredentialService,
private readonly authService: AuthService,
private readonly chatGateway: ChatGateway,
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly platformGatewayClient: PlatformGatewayClient,
private readonly r2PublicFileService: R2PublicFileService,
) {}
async getCourseAiSetting(accessToken: string | undefined, courseId: string) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
if (!course) throw new NotFoundException('Course not found');
const setting = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } });
return {
courseId: course.id,
enabled: setting?.enabled ?? false,
provider: setting?.provider ?? 'OPENAI',
model: setting?.model ?? 'gpt-4.1-mini',
endpointUrl: setting?.provider === 'QWEN' ? setting.endpointUrl : null,
hasApiKey: Boolean(setting?.encryptedApiKey),
updatedAt: setting?.updatedAt ?? null,
};
}
async updateCourseAiSetting(
accessToken: string | undefined,
courseId: string,
input: UpdateCourseAiSettingDto,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
if (!course) throw new NotFoundException('Course not found');
const existing = await this.prisma.courseAiSetting.findUnique({ where: { courseId: course.id } });
const apiKey = input.apiKey?.trim();
if (input.enabled && !apiKey && (!existing?.encryptedApiKey || existing.provider !== input.provider)) {
throw new BadRequestException('AI 에이전트를 사용하거나 공급자를 변경하려면 해당 공급자의 API 키를 입력하세요.');
}
const endpointUrl = input.provider === 'QWEN'
? this.normalizeQwenEndpoint(input.endpointUrl)
: null;
const setting = await this.prisma.courseAiSetting.upsert({
where: { courseId: course.id },
create: {
courseId: course.id,
enabled: input.enabled,
provider: input.provider,
model: input.model.trim(),
endpointUrl,
encryptedApiKey: apiKey ? this.aiCredentialService.encrypt(apiKey) : null,
},
update: {
enabled: input.enabled,
provider: input.provider,
model: input.model.trim(),
...(input.provider !== 'QWEN' || input.endpointUrl !== undefined ? { endpointUrl } : {}),
...(apiKey ? { encryptedApiKey: this.aiCredentialService.encrypt(apiKey) } : {}),
},
});
return {
courseId: course.id,
enabled: setting.enabled,
provider: setting.provider,
model: setting.model,
endpointUrl: setting.provider === 'QWEN' ? setting.endpointUrl : null,
hasApiKey: Boolean(setting.encryptedApiKey),
updatedAt: setting.updatedAt,
};
}
private normalizeQwenEndpoint(value: string | undefined): string | null {
const endpoint = value?.trim();
if (!endpoint) return null;
let url: URL;
try {
url = new URL(endpoint);
} catch {
throw new BadRequestException('Qwen 엔드포인트 주소가 올바르지 않습니다.');
}
if (url.protocol !== 'https:' || url.username || url.password) {
throw new BadRequestException('Qwen 엔드포인트는 인증정보가 없는 HTTPS 주소여야 합니다.');
}
return endpoint.replace(/\/+$/, '');
}
async listCourses(accessToken: string | undefined) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
const courses = await this.prisma.course.findMany({
where: {
isActive: true,
teacherId: instructor.id,
},
orderBy: { createdAt: 'asc' },
select: {
id: true,
slug: true,
title: true,
subtitle: true,
description: true,
organizationName: true,
teacher: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
return {
courses: courses.map((course) => ({
id: course.id,
slug: course.slug,
title: course.title,
subtitle: course.subtitle,
description: course.description,
organizationName: course.organizationName,
teacher: course.teacher,
})),
};
}
async listStudents(accessToken: string | undefined, courseId?: string | null) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationId && !organizationName) {
return {
organization: null,
summary: this.toSummary([]),
students: [],
};
}
const course = organizationName
? await this.findInstructorCourse(organizationName, instructor.id, courseId)
: null;
const enrollments = await this.prisma.studentEnrollment.findMany({
where: {
status: 'ACTIVE',
...(course ? { courseId: course.id } : {}),
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[],
user: {
OR: [
{ authRole: null },
{ authRole: 'STUDENT' },
{ authRole: 'USER' },
],
},
},
orderBy: [
{ updatedAt: 'desc' },
],
include: {
course: true,
user: {
include: {
groupMemberships: {
where: {
group: {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
},
},
take: 1,
include: {
group: true,
},
},
progress: {
where: course ? { courseId: course.id } : undefined,
orderBy: { updatedAt: 'desc' },
take: 1,
include: {
course: true,
},
},
},
},
},
});
const courseWeeks = course?.weeks ?? [];
const weekProgressItems = course
? await this.prisma.courseStudentWeekProgress.findMany({
where: {
courseId: course.id,
userId: { in: enrollments.map((enrollment) => enrollment.userId) },
},
select: {
userId: true,
week: true,
submittedAt: true,
},
})
: [];
const weekProgressByUserWeek = new Map(weekProgressItems.map((item) => [`${item.userId}:${item.week}`, item]));
const completedWeekNumbersByGroupName = new Map<string, number[]>();
if (course && organizationName) {
const groupSettings = await this.prisma.instructorCurriculumSetting.findMany({
where: {
organizationName,
courseId: course.id,
groupId: { not: null },
},
include: {
group: true,
},
});
groupSettings.forEach((setting) => {
const completedThroughWeek = setting.activeLearningStep ? setting.activeWeek - 1 : setting.activeWeek;
completedWeekNumbersByGroupName.set(
setting.group?.name ?? '미배정',
courseWeeks.filter((week) => week.week <= completedThroughWeek).map((week) => week.week),
);
});
}
const students = enrollments
.filter((enrollment) => enrollment.user.id !== instructor.id)
.map((enrollment) => {
const user = enrollment.user;
const progress = user.progress[0];
const group = user.groupMemberships[0]?.group;
const groupName = group?.name ?? '미배정';
const progressPercent = progress && progress.totalWeeks > 0
? Math.round((progress.completedWeeks / progress.totalWeeks) * 100)
: 0;
const issues: StudentIssueItem[] = [];
const completedWeekNumbers = completedWeekNumbersByGroupName.get(groupName) ?? [];
completedWeekNumbers.forEach((weekNumber) => {
if (weekProgressByUserWeek.get(`${user.id}:${weekNumber}`)?.submittedAt) return;
const weekTitle = courseWeeks.find((week) => week.week === weekNumber)?.title ?? `${weekNumber}주차`;
issues.push({
type: 'COMPLETED_WITHOUT_SUBMISSION',
title: `${weekNumber}주차 완료 후 미제출`,
detail: `${weekTitle} 주차가 완료 처리되었지만 제출 기록이 없습니다.`,
week: weekNumber,
});
});
courseWeeks
.filter((week) => {
const isSubmitted = Boolean(weekProgressByUserWeek.get(`${user.id}:${week.week}`)?.submittedAt);
const isPastByProgress = (progress?.completedWeeks ?? 0) >= week.week;
const isCompletedByGroup = completedWeekNumbers.includes(week.week);
return !isSubmitted && (isPastByProgress || isCompletedByGroup);
})
.forEach((week) => {
if (issues.some((issue) => issue.week === week.week && issue.type === 'COMPLETED_WITHOUT_SUBMISSION')) return;
issues.push({
type: 'MISSING_SUBMISSION',
title: `${week.week}주차 미제출`,
detail: `${week.title} 주차의 소스 제출 기록이 없습니다.`,
week: week.week,
});
});
if (progress && progress.averageScore > 0 && progress.averageScore < 80) {
issues.push({
type: 'LOW_AVERAGE_SCORE',
title: '평균 점수 기준 미달',
detail: `현재 평균 점수 ${progress.averageScore}점으로 기준 80점 미만입니다.`,
});
}
return {
id: user.id,
name: user.name ?? user.email.split('@')[0],
email: user.email,
group: groupName,
week: progress && progress.completedWeeks > 0 ? `${progress.completedWeeks}주차` : '시작 전',
progress: progressPercent,
commits: progress?.commitCount ?? 0,
issues: issues.length,
issueItems: issues,
lastActive: this.formatRelativeTime(progress?.updatedAt ?? user.updatedAt),
online: false,
courseTitle: progress?.course.title ?? enrollment.course.title,
};
});
return {
organization: organizationName
? {
id: organizationId,
name: organizationName,
}
: null,
summary: this.toSummary(students),
students,
};
}
async listSubmissions(accessToken: string | undefined) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationId && !organizationName) {
return {
organization: null,
summary: {
totalItems: 0,
submittedItems: 0,
pendingItems: 0,
notInstalledItems: 0,
totalCommits: 0,
},
courses: [],
students: [],
items: [],
};
}
const enrollments = await this.prisma.studentEnrollment.findMany({
where: {
status: 'ACTIVE',
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[],
user: {
OR: [
{ authRole: null },
{ authRole: 'STUDENT' },
{ authRole: 'USER' },
],
},
},
include: {
user: {
include: {
groupMemberships: {
where: {
group: {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
},
},
take: 1,
include: { group: true },
},
},
},
},
orderBy: { updatedAt: 'desc' },
});
const students = enrollments
.map((enrollment) => enrollment.user)
.filter((user) => user.id !== instructor.id)
.map((user) => ({
id: user.id,
name: user.name ?? user.email.split('@')[0],
email: user.email,
group: user.groupMemberships[0]?.group.name ?? '미배정',
}));
const studentIds = students.map((student) => student.id);
let courses = await this.prisma.course.findMany({
where: {
isActive: true,
OR: [
organizationName ? { organizationName } : undefined,
{ teacherId: instructor.id },
].filter(Boolean) as Prisma.CourseWhereInput[],
},
orderBy: { createdAt: 'asc' },
include: {
weeks: {
orderBy: { week: 'asc' },
include: {
modules: {
orderBy: { sortOrder: 'asc' },
},
},
},
},
});
if (!courses.length && organizationName) {
const course = await this.prisma.course.findFirst({
where: {
slug: 'futuredrive-autonomy-basic',
organizationName,
isActive: true,
},
include: {
weeks: {
orderBy: { week: 'asc' },
include: {
modules: {
orderBy: { sortOrder: 'asc' },
},
},
},
},
});
if (course) courses = [course];
}
if (!courses.length) {
courses = await this.prisma.course.findMany({
where: { isActive: true },
orderBy: { createdAt: 'asc' },
include: {
weeks: {
orderBy: { week: 'asc' },
include: {
modules: {
orderBy: { sortOrder: 'asc' },
},
},
},
},
});
}
const weekIds = courses.flatMap((course) => course.weeks.map((week) => week.id));
const moduleIds = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.map((module) => module.id)));
const courseIds = courses.map((course) => course.id);
const teamWeekOverrides = organizationName && courseIds.length
? await this.prisma.instructorCurriculumWeekOverride.findMany({
where: {
organizationName,
courseId: { in: courseIds },
isTeamProject: true,
},
select: {
courseId: true,
week: true,
},
})
: [];
const teamWeekKeys = new Set(teamWeekOverrides.map((override) => `${override.courseId}:${override.week}`));
const isTeamProjectWeek = (courseId: string, week: number) => teamWeekKeys.has(`${courseId}:${week}`);
const studentGroupByStudentId = new Map(
enrollments
.map((enrollment) => [enrollment.user.id, enrollment.user.groupMemberships[0]?.group ?? null] as const)
.filter(([, group]) => group),
);
const teamGroupsById = new Map<string, {
id: string;
name: string;
members: Array<{ id: string; name: string; email: string }>;
}>();
students.forEach((student) => {
const group = studentGroupByStudentId.get(student.id);
if (!group) return;
const current = teamGroupsById.get(group.id) ?? { id: group.id, name: group.name, members: [] };
if (!current.members.some((member) => member.id === student.id)) {
current.members.push({ id: student.id, name: student.name, email: student.email });
}
teamGroupsById.set(group.id, current);
});
const teamGroups = [...teamGroupsById.values()];
const teamRepositories = teamGroups.length && moduleIds.length
? await this.prisma.teamStudioRepository.findMany({
where: {
groupId: { in: teamGroups.map((group) => group.id) },
moduleId: { in: moduleIds },
},
})
: [];
const repositories = studentIds.length && moduleIds.length
? await this.prisma.studioRepository.findMany({
where: {
userId: { in: studentIds },
},
})
: [];
const customRepositories = studentIds.length && courseIds.length
? await this.prisma.studentCustomModuleRepository.findMany({
where: {
userId: { in: studentIds },
customModule: { courseId: { in: courseIds } },
},
include: { customModule: true },
})
: [];
const teamCustomRepositories = teamGroups.length && courseIds.length
? await this.prisma.teamCustomModuleRepository.findMany({
where: {
groupId: { in: teamGroups.map((group) => group.id) },
customModule: { courseId: { in: courseIds } },
},
include: { customModule: true },
})
: [];
const repositoryByStudentModule = new Map(repositories.map((repository) => [`${repository.userId}:${repository.moduleId}`, repository]));
const repositoryByStudentRepoName = new Map(repositories.map((repository) => [`${repository.userId}:${repository.repoName}`, repository]));
const teamRepositoryByGroupModule = new Map(teamRepositories.map((repository) => [`${repository.groupId}:${repository.moduleId}`, repository]));
const progressByRepositoryId = new Map<string, SubmissionProgress>();
const progressByTeamRepositoryId = new Map<string, SubmissionProgress>();
const progressByCustomRepositoryId = new Map<string, SubmissionProgress>();
const progressByTeamCustomRepositoryId = new Map<string, SubmissionProgress>();
const scores = studentIds.length && weekIds.length
? await this.prisma.submissionTotalScore.findMany({
where: {
studentId: { in: studentIds },
weekId: { in: weekIds },
},
select: {
studentId: true,
weekId: true,
score: true,
},
})
: [];
const scoreByStudentWeek = new Map(scores.map((score) => [`${score.studentId}:${score.weekId}`, score.score]));
await Promise.all(repositories.map(async (repository) => {
const progress = await this.readSubmissionProgress(repository.repoOwner, repository.repoName).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByRepositoryId.set(repository.id, progress);
}));
await Promise.all(teamRepositories.map(async (repository) => {
const memberEmails = new Set(
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
);
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByTeamRepositoryId.set(repository.id, progress);
}));
await Promise.all(customRepositories.map(async (repository) => {
const progress = await this.readSubmissionProgress(repository.repoOwner, repository.repoName).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByCustomRepositoryId.set(repository.id, progress);
}));
await Promise.all(teamCustomRepositories.map(async (repository) => {
const memberEmails = new Set(
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
);
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByTeamCustomRepositoryId.set(repository.id, progress);
}));
const items = courses.flatMap((course) => course.weeks.flatMap((week) => {
if (isTeamProjectWeek(course.id, week.week)) return [];
return week.modules.flatMap((module) => students.map((student) => {
const expectedRepoName = this.buildStudioRepoName(student.email, week.week, module.name);
const repository = repositoryByStudentModule.get(`${student.id}:${module.id}`)
?? repositoryByStudentRepoName.get(`${student.id}:${expectedRepoName}`);
const progress = repository ? progressByRepositoryId.get(repository.id) : null;
const commits = progress?.commits ?? [];
const submittedAt = commits[0]?.date ?? null;
const status = !repository ? 'NOT_INSTALLED' : commits.length > 0 ? 'SUBMITTED' : 'PENDING';
const score = scoreByStudentWeek.get(`${student.id}:${week.id}`) ?? 0;
return {
id: `${student.id}:${module.id}`,
status,
submitted: status === 'SUBMITTED',
submittedAt,
commitCount: commits.length,
score,
course: {
id: course.id,
title: course.title,
slug: course.slug,
},
week: {
id: week.id,
week: week.week,
title: week.title,
isTeamProject: false,
},
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
},
student,
repository: repository
? {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
}
: null,
commits,
finalFiles: progress?.finalFiles ?? [],
};
}));
}));
const customItems = customRepositories.flatMap((repository) => {
const module = repository.customModule;
const course = courses.find((candidate) => candidate.id === module.courseId);
const week = course?.weeks.find((candidate) => candidate.week === module.week);
const student = students.find((candidate) => candidate.id === repository.userId);
if (!course || !week || !student || isTeamProjectWeek(course.id, week.week)) return [];
const progress = progressByCustomRepositoryId.get(repository.id);
const commits = progress?.commits ?? [];
const status = commits.length > 0 ? 'SUBMITTED' : 'PENDING';
return [{
id: `custom:${repository.userId}:${module.id}`,
status,
submitted: status === 'SUBMITTED',
submittedAt: commits[0]?.date ?? null,
commitCount: commits.length,
score: scoreByStudentWeek.get(`${student.id}:${week.id}`) ?? 0,
course: { id: course.id, title: course.title, slug: course.slug },
week: { id: week.id, week: week.week, title: week.title, isTeamProject: false },
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
sourceType: 'CUSTOM',
},
student,
repository: {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
},
commits,
finalFiles: progress?.finalFiles ?? [],
}];
});
const teamItems = courses.flatMap((course) => course.weeks.flatMap((week) => {
if (!isTeamProjectWeek(course.id, week.week)) return [];
return week.modules.flatMap((module) => teamGroups.map((group) => {
const repository = teamRepositoryByGroupModule.get(`${group.id}:${module.id}`);
const progress = repository ? progressByTeamRepositoryId.get(repository.id) : null;
const commits = progress?.commits ?? [];
const submittedAt = commits[0]?.date ?? null;
const status = !repository ? 'NOT_INSTALLED' : commits.length > 0 ? 'SUBMITTED' : 'PENDING';
const members = group.members.map((member) => {
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
return {
...member,
commitCount: memberCommits.length,
lastCommittedAt: memberCommits[0]?.date ?? null,
};
});
return {
id: `team:${group.id}:${module.id}`,
status,
submitted: status === 'SUBMITTED',
submittedAt,
commitCount: commits.length,
course: {
id: course.id,
title: course.title,
slug: course.slug,
},
week: {
id: week.id,
week: week.week,
title: week.title,
isTeamProject: true,
},
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
},
team: {
id: group.id,
name: group.name,
members,
},
student: {
id: group.id,
name: group.name,
email: '',
group: group.name,
},
repository: repository
? {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
}
: null,
commits,
finalFiles: progress?.finalFiles ?? [],
};
}));
}));
const teamCustomItems = teamCustomRepositories.flatMap((repository) => {
const module = repository.customModule;
const course = courses.find((candidate) => candidate.id === module.courseId);
const week = course?.weeks.find((candidate) => candidate.week === module.week);
const group = teamGroupsById.get(repository.groupId);
if (!course || !week || !group || !isTeamProjectWeek(course.id, week.week)) return [];
const progress = progressByTeamCustomRepositoryId.get(repository.id);
const commits = progress?.commits ?? [];
const members = group.members.map((member) => {
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
return {
...member,
commitCount: memberCommits.length,
lastCommittedAt: memberCommits[0]?.date ?? null,
};
});
return [{
id: `team-custom:${group.id}:${module.id}`,
status: commits.length > 0 ? 'SUBMITTED' : 'PENDING',
submitted: commits.length > 0,
submittedAt: commits[0]?.date ?? null,
commitCount: commits.length,
course: { id: course.id, title: course.title, slug: course.slug },
week: { id: week.id, week: week.week, title: week.title, isTeamProject: true },
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
sourceType: 'CUSTOM',
},
team: { id: group.id, name: group.name, members },
student: { id: group.id, name: group.name, email: '', group: group.name },
repository: {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
},
commits,
finalFiles: progress?.finalFiles ?? [],
}];
});
const allItems = [...items, ...customItems, ...teamItems, ...teamCustomItems];
return {
organization: organizationName
? {
id: organizationId,
name: organizationName,
}
: null,
summary: {
totalItems: allItems.length,
submittedItems: allItems.filter((item) => item.status === 'SUBMITTED').length,
pendingItems: allItems.filter((item) => item.status === 'PENDING').length,
notInstalledItems: allItems.filter((item) => item.status === 'NOT_INSTALLED').length,
totalCommits: allItems.reduce((count, item) => count + item.commitCount, 0),
},
courses: courses.map((course) => ({
id: course.id,
title: course.title,
slug: course.slug,
weeks: course.weeks.map((week) => ({
id: week.id,
week: week.week,
title: week.title,
isTeamProject: isTeamProjectWeek(course.id, week.week),
modules: week.modules.map((module) => ({
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
})),
})),
})),
students,
items: [...items, ...customItems],
teamItems: [...teamItems, ...teamCustomItems],
};
}
async getSubmissionFile(
accessToken: string | undefined,
input: {
owner?: string;
repoName?: string;
path?: string;
baseRef?: string;
headRef?: string;
},
) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
const owner = input.owner?.trim();
const repoName = input.repoName?.trim();
const path = input.path?.trim();
const baseRef = this.normalizeGitRef(input.baseRef);
const headRef = this.normalizeGitRef(input.headRef);
if (!owner || !repoName || !path || (!baseRef && !headRef)) {
throw new BadRequestException('저장소와 파일 정보를 확인할 수 없습니다.');
}
if (path.split('/').some((part) => !part || part === '.' || part === '..')) {
throw new BadRequestException('파일 경로가 올바르지 않습니다.');
}
await this.assertInstructorRepositoryAccess(organizationId, organizationName, owner, repoName);
const [original, modified] = await Promise.all([
baseRef ? this.readGiteaFileAtRef(owner, repoName, path, baseRef) : null,
headRef ? this.readGiteaFileAtRef(owner, repoName, path, headRef) : null,
]);
return {
original: original ?? '',
modified: modified ?? '',
originalAvailable: original !== null,
modifiedAvailable: modified !== null,
};
}
async saveSubmissionScore(accessToken: string | undefined, input: SaveSubmissionScoreDto) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
const score = Number(input.score);
if (!Number.isInteger(score) || score < 1 || score > 100) {
throw new BadRequestException('Score must be between 1 and 100');
}
const week = await this.prisma.courseWeek.findUnique({
where: { id: input.weekId },
include: {
course: true,
},
});
if (!week) throw new NotFoundException('Week not found');
const course = week.course;
if (course.teacherId !== instructor.id && course.organizationName !== organizationName) {
throw new ForbiddenException('Course is not accessible');
}
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: input.studentId,
courseId: course.id,
status: 'ACTIVE',
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[],
},
select: { id: true },
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
const saved = await this.prisma.submissionTotalScore.upsert({
where: {
studentId_weekId: {
studentId: input.studentId,
weekId: input.weekId,
},
},
create: {
courseId: course.id,
weekId: week.id,
studentId: input.studentId,
score,
},
update: {
courseId: course.id,
weekId: week.id,
score,
},
select: {
score: true,
},
});
const average = await this.prisma.submissionTotalScore.aggregate({
where: {
courseId: course.id,
studentId: input.studentId,
score: { gt: 0 },
},
_avg: { score: true },
});
const totalWeeks = await this.prisma.courseWeek.count({ where: { courseId: course.id } });
const averageScore = Math.round(average._avg.score ?? 0);
await this.prisma.courseStudentProgress.upsert({
where: {
courseId_userId: {
courseId: course.id,
userId: input.studentId,
},
},
create: {
courseId: course.id,
userId: input.studentId,
totalWeeks,
averageScore,
},
update: {
averageScore,
},
});
return { score: saved.score };
}
async createStudent(
accessToken: string | undefined,
input: CreateInstructorStudentDto,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationId || !organizationName) {
throw new BadRequestException('Instructor organization is required');
}
const email = input.email.trim().toLowerCase();
const name = input.name.trim();
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
let existingStudent = await this.prisma.user.findUnique({
where: { email },
});
const requestedGroupId = input.groupId?.trim();
const groupId = requestedGroupId && requestedGroupId !== 'unassigned'
? requestedGroupId
: null;
let group: { id: string } | null = null;
if (groupId) {
group = await this.prisma.studentGroup.findFirst({
where: {
id: groupId,
OR: [
{ organizationId },
{ organizationName },
],
},
select: { id: true },
});
if (!group) {
throw new BadRequestException('Invalid group');
}
}
if (!existingStudent) {
const createdAuthUser = await this.authService.createManagedUser(
accessToken,
{
email,
name,
password: this.configService.get<string>(
'STUDENT_DEFAULT_PASSWORD',
'qwer1234',
),
organizationId,
department: authUser.primaryAffiliation?.department ?? null,
position: '학생',
licenseCode: 'EDU',
},
);
existingStudent = await this.prisma.user.upsert({
where: { email },
update: this.toUserSyncData(createdAuthUser.user),
create: {
email,
...this.toUserSyncData(createdAuthUser.user),
},
});
}
await this.prisma.studentEnrollment.upsert({
where: {
userId_courseId: {
userId: existingStudent.id,
courseId: course.id,
},
},
update: {
organizationId,
organizationName,
status: 'ACTIVE',
removedAt: null,
},
create: {
userId: existingStudent.id,
courseId: course.id,
organizationId,
organizationName,
status: 'ACTIVE',
},
});
if (group) {
await this.prisma.studentGroupMember.upsert({
where: {
groupId_userId: {
groupId: group.id,
userId: existingStudent.id,
},
},
update: {},
create: {
groupId: group.id,
userId: existingStudent.id,
},
});
}
if (group && organizationName) {
this.queuePlatformAccessPolicy(organizationId, organizationName);
}
return this.listStudents(accessToken, course.id);
}
async removeStudent(accessToken: string | undefined, studentId: string) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationId && !organizationName) {
throw new BadRequestException('Instructor organization is required');
}
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: studentId,
status: 'ACTIVE',
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[],
},
select: {
id: true,
userId: true,
},
});
if (!enrollment || enrollment.userId === instructor.id) {
throw new NotFoundException('Student enrollment not found');
}
await this.prisma.$transaction([
this.prisma.studentEnrollment.update({
where: { id: enrollment.id },
data: {
status: 'REMOVED',
removedAt: new Date(),
},
}),
this.prisma.studentGroupMember.deleteMany({
where: {
userId: studentId,
group: {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
},
},
}),
]);
if (organizationName) {
this.queuePlatformAccessPolicy(organizationId, organizationName);
}
return this.listStudents(accessToken);
}
async listGroups(accessToken: string | undefined, courseId?: string | null) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationId && !organizationName) {
return {
summary: this.toGroupSummary([]),
groups: [],
};
}
const course = organizationName
? await this.findInstructorCourse(organizationName, instructor.id, courseId)
: null;
const enrollmentScope: Prisma.StudentEnrollmentWhereInput = course
? { courseId: course.id }
: { course: { teacherId: instructor.id } };
if (courseId?.trim() && !course) throw new NotFoundException('Course not found');
const groups = await this.prisma.studentGroup.findMany({
where: {
AND: [
{
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
},
groupCourseScope(instructor.id, course?.id),
],
},
orderBy: { name: 'asc' },
include: {
instructorPlatform: {
select: {
id: true,
displayName: true,
},
},
members: {
where: {
user: {
enrollments: {
some: {
status: 'ACTIVE',
...enrollmentScope,
},
},
},
},
include: {
user: {
include: {
progress: {
orderBy: { updatedAt: 'desc' },
take: 1,
},
},
},
},
},
},
});
const unassignedStudents = await this.findUnassignedStudents(organizationId, organizationName, instructor.id, course?.id ?? null);
const platformOptions = await this.findPlatformOptions(organizationId, organizationName);
const platformOptionNames = platformOptions.map((platform) => platform.name);
const responseGroups = groups.map((group) => {
const progresses = group.members
.map((member) => member.user.progress[0])
.filter(Boolean);
const progress = progresses.length
? Math.round(
progresses.reduce((sum, item) => {
return sum + (item.totalWeeks > 0 ? (item.completedWeeks / item.totalWeeks) * 100 : 0);
}, 0) / progresses.length,
)
: 0;
return {
id: group.id,
name: group.name,
week: `${group.week}주차`,
status: group.status,
members: group.members.map((member) => member.user.name ?? member.user.email.split('@')[0]),
membersDetailed: group.members.map((member) => ({
id: member.user.id,
name: member.user.name ?? member.user.email.split('@')[0],
email: member.user.email,
})),
instructorPlatformId: group.instructorPlatformId,
platform: group.instructorPlatform?.displayName ?? (group.platformName && platformOptionNames.includes(group.platformName) ? group.platformName : '미배정'),
progress,
completedMissions: progresses.reduce((sum, item) => sum + item.completedWeeks, 0),
activeUsers: 0,
};
});
return {
summary: this.toGroupSummary(responseGroups),
groups: responseGroups,
unassignedStudents,
platformOptions,
};
}
async listPlatforms(accessToken: string | undefined) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationId && !organizationName) {
return {
summary: this.toPlatformSummary([]),
platforms: [],
masterPlatforms: await this.findMasterPlatforms(),
agents: [],
};
}
const where = this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
);
const platforms = await this.prisma.instructorPlatform.findMany({
where: {
...where,
platform: {
is: {
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ platformKey: { contains: 'simulator', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
{ displayName: { contains: '시뮬', mode: 'insensitive' } },
{ displayName: { contains: 'simulator', mode: 'insensitive' } },
],
},
},
},
orderBy: { displayName: 'asc' },
include: { platform: true },
});
const platformIds = platforms.map((platform) => platform.id);
const assignedGroups = platformIds.length
? await this.prisma.studentGroup.findMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: { in: platformIds },
},
include: {
members: {
take: 1,
include: { user: true },
},
},
})
: [];
const groupByPlatform = new Map(
assignedGroups.map((group) => [group.instructorPlatformId, group]),
);
const activeControlSessions = platformIds.length
? await this.prisma.platformControlSession.findMany({
where: {
instructorPlatformId: { in: platformIds },
status: 'ACTIVE',
},
include: {
holderUser: {
select: {
name: true,
email: true,
},
},
},
})
: [];
const activeControlByPlatform = new Map(
activeControlSessions
.filter((session) => session.instructorPlatformId)
.map((session) => [session.instructorPlatformId, session]),
);
const boundAgentValues = platforms.flatMap((platform) => {
const agentNodeId = this.readStringSetting(platform.studioSettingsOverride, 'agentNodeId');
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail');
return [agentNodeId, agentHostname, agentOwnerEmail].filter((value): value is string => Boolean(value));
});
const agents = boundAgentValues.length
? await this.platformGatewayClient.listAgents(authUser.email).catch(() => [])
: [];
const agentConnectionByPlatform = new Map(await Promise.all(platforms.map(async (platform) => {
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail');
const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId')
?? agentHostname;
const connection = gatewayPlatformId
? await this.platformGatewayClient.getPlatformStatus(gatewayPlatformId, agentOwnerEmail ?? authUser.email).catch(() => null)
: null;
return [platform.id, connection] as const;
})));
const responsePlatforms = platforms.map((platform) => {
const assignedGroup = groupByPlatform.get(platform.id);
const activeControl = activeControlByPlatform.get(platform.id);
const agentNodeId = this.readStringSetting(platform.studioSettingsOverride, 'agentNodeId');
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail');
const agent = this.findBoundAgent(agents, {
nodeId: agentNodeId,
hostname: agentHostname,
ownerEmail: agentOwnerEmail,
});
const agentConnection = agentConnectionByPlatform.get(platform.id);
return {
id: platform.id,
masterPlatformId: platform.platformId,
name: platform.displayName,
type: this.toPlatformType(platform.platform.displayName),
status: activeControl ? '활성' : '대기',
battery: platform.battery,
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
: this.formatUptime(platform.uptimeMinutes),
speed: `${Number(platform.speedMeters).toFixed(1)} m/s`,
errors: platform.errorCount,
group: assignedGroup?.name ?? null,
user: activeControl?.holderUser.name ?? activeControl?.holderUser.email ?? null,
emergencyStop: platform.emergencyStop,
vehicleType: platform.vehicleType,
wheelbaseMeters: Number(platform.wheelbaseMeters),
maxSpeedMetersPerSecond: Number(platform.maxSpeedMetersPerSecond),
maxSteeringAngleDegrees: Number(platform.maxSteeringAngleDegrees),
sensors: platform.sensors,
drivers: platform.drivers,
algorithms: platform.algorithms,
controlDurationMinutes: platform.controlDurationMinutes,
controlWarningMinutes: platform.controlWarningMinutes,
agent: agentNodeId || agentHostname || agentOwnerEmail
? {
nodeId: agentNodeId,
hostname: agentHostname,
ownerEmail: agentOwnerEmail,
ip: agent?.ip ?? null,
connected: Boolean(agent?.connected && !agent.expired),
lastSeenAt: agent?.lastSeenAt ?? null,
}
: null,
};
});
return {
summary: this.toPlatformSummary(responsePlatforms),
platforms: responsePlatforms,
masterPlatforms: await this.findMasterPlatforms(),
agents: [],
};
}
async listPlatformAgents(accessToken: string | undefined) {
const { authUser } = await this.getInstructor(accessToken);
const agents = await this.platformGatewayClient.listAgents(authUser.email).catch(() => []);
return { agents };
}
async getCurriculum(accessToken: string | undefined, groupId?: string | null, courseId?: string | null) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) {
return {
course: null,
weeks: [],
setting: null,
};
}
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
if (!course) {
return {
course: null,
weeks: [],
setting: null,
};
}
const selectedGroup = await this.findOptionalInstructorGroup(groupId, organizationId, organizationName);
const setting = selectedGroup
? await this.ensureCurriculumSetting(
organizationId,
organizationName,
selectedGroup.id,
course.id,
selectedGroup.week ?? course.weeks[0]?.week ?? 1,
course.weeks[0]?.topics[0]?.title ?? null,
)
: null;
const overrides = await this.prisma.instructorCurriculumWeekOverride.findMany({
where: {
organizationName,
courseId: course.id,
},
});
const tagOptions = await this.findCurriculumTagOptions();
const courseMaps = await this.prisma.courseMap.findMany({
where: { courseId: course.id },
orderBy: { createdAt: 'asc' },
});
const weekMapSelections = await this.prisma.courseWeekMap.findMany({
where: { courseId: course.id },
orderBy: { sortOrder: 'asc' },
});
const mapKeysByWeek = new Map<number, string[]>();
weekMapSelections.forEach((selection) => {
const items = mapKeysByWeek.get(selection.week) ?? [];
items.push(selection.mapKey);
mapKeysByWeek.set(selection.week, items);
});
const environmentProfiles = await this.prisma.courseEnvironmentProfile.findMany({
where: { courseId: course.id, enabled: true },
orderBy: [{ platformKey: 'asc' }, { profileType: 'asc' }],
});
const overrideByWeek = new Map(overrides.map((override) => [override.week, override]));
const baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
const baseWeeks = course.weeks.map((week) => {
const override = overrideByWeek.get(week.week);
const learningSteps = this.resolveLearningSteps(
override?.learningSteps,
this.stringArray(override?.topics) ?? week.topics.map((topic) => topic.title),
this.stringArray(override?.topicDetails) ?? week.topics.map((topic) => topic.detail),
this.stringArray(override?.topicVideoUrls) ?? week.topics.map(() => ''),
);
const topics = learningSteps.map((step) => step.title);
const topicDetails = learningSteps.map((step) => step.description);
const topicVideoUrls = learningSteps.map((step) => step.videoUrl);
const disabledModuleIdSet = new Set(this.stringArray(override?.disabledModuleIds) ?? []);
const practiceContents = this.practiceContentsArray(override?.practiceContents)
?? week.practices.map((practice) => ({
title: practice.title,
description: practice.description,
}));
const progressState = this.resolveWeekProgressState(week.week, week.state, topics, setting);
return {
week: week.week,
title: override?.title ?? week.title,
canDelete: false,
tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state),
progress: progressState.progress,
state: progressState.state,
topics,
duration: override?.duration ?? week.duration,
github: week.github,
detail: {
overview: override?.overview ?? week.overview,
camera: readWeekCamera(override),
coverImage: week.references.filter((reference) => reference.kind === 'COVER_IMAGE')
.map((reference) => this.serializeWeekReference(reference, week.week))[0] ?? null,
learningSteps,
topicDetails,
topicVideoUrls,
practiceContents,
references: week.references.filter((reference) => reference.kind === 'REFERENCE').map((reference) => this.serializeWeekReference(reference, week.week)),
exampleCodes: week.references.filter((reference) => reference.kind === 'EXAMPLE_CODE').map((reference) => this.serializeWeekReference(reference, week.week)),
deliverable: override?.deliverable ?? week.deliverable,
},
isTeamProject: override?.isTeamProject ?? false,
modules: week.modules.map((module) => ({
id: module.id,
sortOrder: module.sortOrder,
category: module.category,
name: module.name,
folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
dockerImage: module.dockerImage,
defaultParameters: this.stringArray(module.defaultParameters) ?? [],
enabled: !disabledModuleIdSet.has(module.id),
})),
environmentProfiles: environmentProfiles
.filter((profile) => profile.week === week.week)
.map((profile) => ({
id: profile.id,
platformKey: profile.platformKey,
profileType: profile.profileType,
name: profile.name,
config: profile.config,
})),
mapKeys: mapKeysByWeek.get(week.week) ?? [],
};
});
const addedWeeks = overrides
.filter((override) => !baseWeekNumbers.has(override.week))
.map((override) => {
const learningSteps = this.resolveLearningSteps(
override.learningSteps,
this.stringArray(override.topics) ?? ['학습단계 1'],
this.stringArray(override.topicDetails) ?? [''],
this.stringArray(override.topicVideoUrls) ?? [''],
);
const topics = learningSteps.map((step) => step.title);
const progressState = this.resolveWeekProgressState(override.week, 'ready', topics, setting);
return {
week: override.week,
title: override.title ?? `${override.week}주차 커리큘럼`,
canDelete: true,
tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state),
progress: progressState.progress,
state: progressState.state,
topics,
duration: override.duration ?? '-',
github: '-',
detail: {
overview: override.overview ?? '',
camera: readWeekCamera(override),
learningSteps,
topicDetails: learningSteps.map((step) => step.description),
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
practiceContents: this.practiceContentsArray(override.practiceContents) ?? [],
references: [],
deliverable: override.deliverable ?? '',
},
isTeamProject: override.isTeamProject ?? false,
modules: [],
mapKeys: mapKeysByWeek.get(override.week) ?? [],
};
});
return {
course: {
id: course.id,
title: course.title,
subtitle: course.subtitle,
description: course.description,
organizationName: course.organizationName,
},
weeks: [...baseWeeks, ...addedWeeks].sort((left, right) => left.week - right.week),
tagOptions,
mapOptions: [
...NAV2_SYSTEM_MAPS,
...courseMaps.map((map) => ({
key: map.key,
platformKey: 'NAV2' as const,
name: map.name,
description: map.description ?? '강사가 구성한 학습 맵',
worldFile: map.worldFile,
viewerModel: map.viewerModel === 'turtlebot3_house' ? 'turtlebot3_house' as const : 'turtlebot3_world' as const,
custom: true,
layout: map.layout,
})),
],
setting: {
groupId: setting?.groupId ?? null,
activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? 1,
activeLearningStep: setting?.activeLearningStep ?? null,
},
};
}
async updateCurriculum(
accessToken: string | undefined,
input: UpdateInstructorCurriculumDto,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
const selectedGroup = await this.findOptionalInstructorGroup(input.groupId, organizationId, organizationName);
const targetWeek = course.weeks.find((week) => week.week === input.activeWeek);
const existingOverride = await this.prisma.instructorCurriculumWeekOverride.findUnique({
where: {
organizationName_courseId_week: {
organizationName,
courseId: course.id,
week: input.activeWeek,
},
},
});
let cameraViewUrl: string | undefined;
try {
cameraViewUrl = input.cameraViewUrl === undefined ? undefined : normalizeCameraUrl(input.cameraViewUrl);
} catch {
throw new BadRequestException('오버뷰 시청 URL에 올바른 room과 scene 또는 view를 지정해주세요.');
}
const cameraSettings = {
cameraViewUrl: cameraViewUrl ?? existingOverride?.cameraViewUrl ?? '',
cameraPassword: cameraViewUrl !== undefined ? '' : existingOverride?.cameraPassword ?? '',
};
const existingLearningSteps = this.resolveLearningSteps(
existingOverride?.learningSteps,
this.stringArray(existingOverride?.topics) ?? targetWeek?.topics.map((topic) => topic.title) ?? ['학습단계 1'],
this.stringArray(existingOverride?.topicDetails) ?? targetWeek?.topics.map((topic) => topic.detail) ?? [''],
this.stringArray(existingOverride?.topicVideoUrls) ?? targetWeek?.topics.map(() => '') ?? [''],
);
const inputLearningSteps = this.normalizeLearningSteps(
input.learningSteps,
input.topics,
input.topicDetails,
input.topicVideoUrls,
);
const learningSteps = inputLearningSteps ?? existingLearningSteps;
const topicTitles = learningSteps.map((step) => step.title).filter(Boolean);
const activeLearningStep = input.completed
? null
: input.activeLearningStep && topicTitles.includes(input.activeLearningStep)
? input.activeLearningStep
: topicTitles[0] ?? null;
const applyToStudents = input.applyToStudents !== false;
if (applyToStudents && !selectedGroup) {
throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.');
}
const practiceContents = this.normalizePracticeContents(input.practiceContents);
const disabledModuleIds = input.disabledModuleIds?.map((moduleId) => moduleId.trim()).filter(Boolean);
const moduleDefaultParameters = input.moduleDefaultParameters === undefined
? undefined
: input.moduleDefaultParameters.reduce<Array<{ moduleId: string; parameters: string[] }>>((items, entry) => {
const moduleId = entry?.moduleId?.trim();
if (!moduleId) return items;
items.push({
moduleId,
parameters: Array.isArray(entry.parameters)
? entry.parameters.map((parameter) => parameter.trim()).filter(Boolean)
: [],
});
return items;
}, []);
if (moduleDefaultParameters !== undefined) {
const moduleIdSet = new Set(targetWeek?.modules.map((module) => module.id) ?? []);
const invalidModule = moduleDefaultParameters.find((entry) => !moduleIdSet.has(entry.moduleId));
if (invalidModule) throw new BadRequestException('선택한 주차에 없는 모듈입니다.');
await this.prisma.$transaction(moduleDefaultParameters.map((entry) => (
this.prisma.courseWeekModule.update({
where: { id: entry.moduleId },
data: { defaultParameters: entry.parameters },
})
)));
}
if (input.environmentModuleSets !== undefined) {
const moduleIdSet = new Set(targetWeek?.modules.map((module) => module.id) ?? []);
const requestedSets = input.environmentModuleSets.reduce<Array<{
platformKey: string;
modules: Array<{ id: string; defaultParameters: string[]; dockerImage?: string | null }>;
}>>((items, entry) => {
const platformKey = entry?.platformKey?.trim();
if (!platformKey) return items;
const moduleMap = new Map<string, { defaultParameters: string[]; dockerImage?: string | null }>();
if (Array.isArray(entry.modules)) {
entry.modules.forEach((module) => {
const id = module?.id?.trim();
if (!id) return;
moduleMap.set(id, {
defaultParameters: Array.isArray(module.defaultParameters)
? module.defaultParameters.map((parameter) => parameter.trim()).filter(Boolean)
: [],
dockerImage: typeof module.dockerImage === 'string'
? module.dockerImage.trim() || null
: module.dockerImage === null ? null : undefined,
});
});
} else {
(entry.moduleIds ?? []).forEach((moduleId) => {
const id = moduleId.trim();
if (id) moduleMap.set(id, { defaultParameters: [] });
});
}
if ([...moduleMap.keys()].some((moduleId) => !moduleIdSet.has(moduleId))) {
throw new BadRequestException('선택한 주차에 없는 모듈입니다.');
}
items.push({
platformKey,
modules: [...moduleMap.entries()].map(([id, value]) => ({ id, ...value })),
});
return items;
}, []);
const profiles = await this.prisma.courseEnvironmentProfile.findMany({
where: { courseId: course.id, week: input.activeWeek },
});
const profilesByPlatformKey = new Map(profiles.map((profile) => [profile.platformKey, profile]));
await this.prisma.$transaction(requestedSets.map((item) => {
const logicalEnvironment = item.platformKey.toLowerCase();
const isLogicalEnvironment = logicalEnvironment === 'local' || logicalEnvironment === 'platform';
const isPlatformProfile = (profile: { profileType: string; config: Prisma.JsonValue | null }) => (
profile.profileType.toUpperCase() === 'PLATFORM'
|| (profile.config && typeof profile.config === 'object' && !Array.isArray(profile.config)
&& (profile.config as Prisma.JsonObject).target === 'remote-jetson')
);
const wantsPlatform = logicalEnvironment === 'platform';
const existingProfile = isLogicalEnvironment
? profiles.find((profile) => wantsPlatform === isPlatformProfile(profile))
: profilesByPlatformKey.get(item.platformKey);
if (!isLogicalEnvironment && !existingProfile) {
throw new BadRequestException('선택한 환경 프로필을 찾을 수 없습니다.');
}
const resolvedPlatformKey = existingProfile?.platformKey
?? (wantsPlatform ? 'futuredrive-platform' : 'futuredrive-local-pc');
const profileType = existingProfile?.profileType
?? (wantsPlatform ? 'PLATFORM' : 'LOCAL');
const profileName = existingProfile?.name
?? (wantsPlatform ? '플랫폼' : '로컬');
const existingConfig = existingProfile?.config && typeof existingProfile.config === 'object' && !Array.isArray(existingProfile.config)
? existingProfile.config as Prisma.JsonObject
: {};
const defaultConfig = wantsPlatform ? { target: 'remote-jetson' } : { target: 'local' };
const config = {
...defaultConfig,
...existingConfig,
moduleSet: {
target: wantsPlatform ? 'platform' : 'local',
includeUnlisted: false,
modules: item.modules.map((module) => ({
id: module.id,
...(module.defaultParameters.length ? { defaultParameters: module.defaultParameters } : {}),
...(module.dockerImage !== undefined ? { dockerImage: module.dockerImage } : {}),
})),
},
};
return this.prisma.courseEnvironmentProfile.upsert({
where: {
courseId_week_platformKey: {
courseId: course.id,
week: input.activeWeek,
platformKey: resolvedPlatformKey,
},
},
create: {
courseId: course.id,
week: input.activeWeek,
platformKey: resolvedPlatformKey,
profileType,
name: profileName,
config,
enabled: true,
},
update: {
profileType,
name: profileName,
config,
enabled: true,
},
});
}));
}
let selectedTagLabel: string | undefined;
if (input.tagLabel !== undefined) {
const tagLabel = input.tagLabel.trim();
const tagOptions = await this.findCurriculumTagOptions();
const tagOption = tagOptions.find((option) => option.label === tagLabel);
if (!tagOption) throw new BadRequestException('유효하지 않은 플랫폼 배지입니다.');
selectedTagLabel = tagOption.label;
}
if (!targetWeek || input.cameraViewUrl !== undefined || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
await this.prisma.instructorCurriculumWeekOverride.upsert({
where: {
organizationName_courseId_week: {
organizationName,
courseId: course.id,
week: input.activeWeek,
},
},
update: {
organizationId,
tagLabel: selectedTagLabel ?? existingOverride?.tagLabel ?? targetWeek?.tags[0] ?? '커리큘럼',
...cameraSettings,
title: this.optionalTrim(input.title) ?? existingOverride?.title ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`,
duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-',
overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '',
deliverable: this.optionalTrim(input.deliverable) ?? existingOverride?.deliverable ?? targetWeek?.deliverable ?? '',
isTeamProject: input.isTeamProject ?? existingOverride?.isTeamProject ?? false,
learningSteps,
topics: topicTitles,
topicDetails: learningSteps.map((step) => step.description),
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
practiceContents: practiceContents ?? this.practiceContentsArray(existingOverride?.practiceContents) ?? targetWeek?.practices.map((practice) => ({
title: practice.title,
description: practice.description,
})) ?? [],
disabledModuleIds: disabledModuleIds ?? this.stringArray(existingOverride?.disabledModuleIds) ?? [],
},
create: {
organizationId,
organizationName,
courseId: course.id,
week: input.activeWeek,
tagLabel: selectedTagLabel ?? targetWeek?.tags[0] ?? '커리큘럼',
...cameraSettings,
title: this.optionalTrim(input.title) ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`,
duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-',
overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '',
deliverable: this.optionalTrim(input.deliverable) ?? targetWeek?.deliverable ?? '',
isTeamProject: input.isTeamProject ?? false,
learningSteps,
topics: topicTitles,
topicDetails: learningSteps.map((step) => step.description),
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
practiceContents,
disabledModuleIds: disabledModuleIds ?? [],
},
});
}
if (input.mapKeys !== undefined) {
const selectedKeys = Array.from(new Set(input.mapKeys.map((key) => key.trim()).filter(Boolean)));
const customMaps = selectedKeys.length
? await this.prisma.courseMap.findMany({
where: { courseId: course.id, key: { in: selectedKeys }, platformKey: 'NAV2' },
select: { key: true },
})
: [];
const validKeys = new Set([
...NAV2_SYSTEM_MAPS.map((map) => map.key),
...customMaps.map((map) => map.key),
]);
const mapKeys = selectedKeys.filter((key) => validKeys.has(key));
await this.prisma.courseWeekMap.deleteMany({
where: {
courseId: course.id,
week: input.activeWeek,
mapKey: { notIn: mapKeys },
},
});
await Promise.all(mapKeys.map((mapKey, sortOrder) => this.prisma.courseWeekMap.upsert({
where: { courseId_week_mapKey: { courseId: course.id, week: input.activeWeek, mapKey } },
create: { courseId: course.id, week: input.activeWeek, mapKey, sortOrder },
update: { sortOrder },
})));
}
if (applyToStudents) {
const targetGroup = selectedGroup;
if (!targetGroup) throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.');
const existingSetting = await this.prisma.instructorCurriculumSetting.findFirst({
where: {
organizationName,
courseId: course.id,
groupId: targetGroup.id,
},
select: { id: true },
});
if (existingSetting) {
await this.prisma.instructorCurriculumSetting.update({
where: { id: existingSetting.id },
data: {
organizationId,
courseId: course.id,
activeWeek: input.activeWeek,
activeLearningStep,
},
});
} else {
await this.prisma.instructorCurriculumSetting.create({
data: {
organizationId,
organizationName,
groupId: targetGroup.id,
courseId: course.id,
activeWeek: input.activeWeek,
activeLearningStep,
},
});
}
await this.chatGateway.notifyGroupMembers(targetGroup.id, 'curriculum.updated', {
type: 'curriculum.updated',
organizationName,
groupId: targetGroup.id,
groupName: targetGroup.name,
activeWeek: input.activeWeek,
activeLearningStep,
updatedAt: new Date().toISOString(),
});
}
return this.getCurriculum(accessToken, selectedGroup?.id ?? input.groupId ?? null, course.id);
}
async createCurriculumMap(accessToken: string | undefined, input: CreateInstructorCourseMapDto) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
const name = input.name.trim();
if (!name) throw new BadRequestException('맵 이름을 입력해 주세요.');
const slug = name.toLowerCase().replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '') || 'map';
const key = `nav2-custom-${slug}-${randomUUID().slice(0, 8)}`;
return this.prisma.courseMap.create({
data: {
courseId: course.id,
key,
name,
description: this.optionalTrim(input.description) ?? '강사가 구성한 학습 맵',
layout: { version: 1, objects: [] },
},
});
}
async deleteCurriculumWeek(accessToken: string | undefined, week: number, courseId?: string | null) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
if (!course) throw new NotFoundException('Course not found');
const baseWeek = course.weeks.find((courseWeek) => courseWeek.week === week);
if (baseWeek) {
throw new ConflictException('Base curriculum week cannot be deleted');
}
const deleted = await this.prisma.instructorCurriculumWeekOverride.deleteMany({
where: {
organizationName,
courseId: course.id,
week,
},
});
if (deleted.count === 0) throw new NotFoundException('Curriculum week not found');
const setting = await this.ensureCurriculumSetting(
organizationId,
organizationName,
null,
course.id,
course.weeks[0]?.week ?? 1,
course.weeks[0]?.topics[0]?.title ?? null,
);
if (setting.activeWeek === week) {
const remainingOverrides = await this.prisma.instructorCurriculumWeekOverride.findMany({
where: {
organizationName,
courseId: course.id,
},
orderBy: { week: 'asc' },
});
const fallbackBaseWeek = course.weeks[0];
const fallbackOverride = remainingOverrides[0];
const fallbackWeek = fallbackBaseWeek?.week ?? fallbackOverride?.week ?? 1;
const fallbackLearningStep =
fallbackBaseWeek?.topics[0]?.title ??
this.stringArray(fallbackOverride?.topics)?.[0] ??
null;
await this.prisma.instructorCurriculumSetting.update({
where: { id: setting.id },
data: {
organizationId,
courseId: course.id,
activeWeek: fallbackWeek,
activeLearningStep: fallbackLearningStep,
},
});
this.chatGateway.notifyOrganization(organizationName, 'curriculum.updated', {
type: 'curriculum.updated',
organizationName,
activeWeek: fallbackWeek,
activeLearningStep: fallbackLearningStep,
updatedAt: new Date().toISOString(),
});
}
return this.getCurriculum(accessToken, null, course.id);
}
async uploadCurriculumCover(
accessToken: string | undefined,
input: { courseId?: string; week: number },
file: { originalname: string; buffer?: Buffer } | undefined,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
const week = course.weeks.find((item) => item.week === input.week);
if (!week) throw new NotFoundException('Course week not found');
if (!file?.buffer) throw new BadRequestException('이미지가 필요합니다.');
const format = validateWeekCover(file.buffer);
if (!this.r2PublicFileService.isConfigured()) throw new BadRequestException('이미지 저장소가 설정되지 않았습니다.');
const key = this.r2PublicFileService.createLmsCourseFileKey({
courseId: course.id, week: week.week, kind: 'COVER_IMAGE', fileId: randomUUID(), extension: format.extension,
});
await this.r2PublicFileService.uploadPublicFile({ key, body: file.buffer, contentType: format.contentType });
let previous: Array<{ storedFileName: string }> = [];
try {
await this.prisma.$transaction(async (tx) => {
// Serialize replacements so a week cannot acquire multiple covers.
await tx.$queryRaw`SELECT id FROM course_weeks WHERE id = ${week.id}::uuid FOR UPDATE`;
previous = await tx.courseWeekReference.findMany({ where: { weekId: week.id, kind: 'COVER_IMAGE' } });
await tx.courseWeekReference.deleteMany({ where: { weekId: week.id, kind: 'COVER_IMAGE' } });
await tx.courseWeekReference.create({ data: {
weekId: week.id, kind: 'COVER_IMAGE', title: '대표이미지',
originalFileName: this.normalizeUploadedFileName(file.originalname), storedFileName: key,
contentType: format.contentType, fileSizeBytes: file.buffer!.length, sortOrder: 0,
} });
});
} catch (error) {
await this.r2PublicFileService.deletePublicFile(key).catch(() => undefined);
throw error;
}
// Cloned courses may still reference the same object.
for (const old of previous) {
if (!await this.prisma.courseWeekReference.count({ where: { storedFileName: old.storedFileName } })) {
await this.r2PublicFileService.deletePublicFile(old.storedFileName).catch(() => undefined);
}
}
return this.getCurriculum(accessToken, null, course.id);
}
async uploadCurriculumReference(
accessToken: string | undefined,
input: { courseId?: string | null; week: number; title?: string | null; kind?: 'REFERENCE' | 'EXAMPLE_CODE' },
file: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer } | undefined,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
if (!file?.buffer) throw new BadRequestException('파일이 필요합니다.');
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
const targetWeek = course.weeks.find((courseWeek) => courseWeek.week === input.week);
if (!targetWeek) throw new NotFoundException('Course week not found');
const kind = input.kind ?? 'REFERENCE';
const originalFileName = this.normalizeUploadedFileName(file.originalname);
const fileId = randomUUID();
const extension = extname(originalFileName || '');
let storedFileName = `${fileId}${extension}`;
if (this.r2PublicFileService.isConfigured()) {
storedFileName = this.r2PublicFileService.createLmsCourseFileKey({
courseId: course.id,
week: targetWeek.week,
kind,
fileId,
extension,
});
await this.r2PublicFileService.uploadPublicFile({
key: storedFileName,
body: file.buffer,
contentType: file.mimetype,
});
} else {
await mkdir(COURSE_REFERENCE_UPLOAD_ROOT, { recursive: true });
await writeFile(join(COURSE_REFERENCE_UPLOAD_ROOT, storedFileName), file.buffer);
}
const maxSortOrder = await this.prisma.courseWeekReference.aggregate({
where: { weekId: targetWeek.id, kind },
_max: { sortOrder: true },
});
await this.prisma.courseWeekReference.create({
data: {
weekId: targetWeek.id,
kind,
title: this.optionalTrim(input.title) ?? originalFileName,
originalFileName,
storedFileName,
contentType: file.mimetype,
fileSizeBytes: file.size ?? file.buffer.length,
sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1,
},
});
return this.getCurriculum(accessToken, null, course.id);
}
async deleteCurriculumReference(
accessToken: string | undefined,
referenceId: string,
courseId?: string | null,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const reference = await this.prisma.courseWeekReference.findUnique({
where: { id: referenceId },
include: { week: { include: { course: true } } },
});
if (!reference) throw new NotFoundException('Reference not found');
if (
reference.week.course.teacherId !== instructor.id ||
reference.week.course.organizationName !== organizationName ||
(courseId?.trim() && reference.week.courseId !== courseId.trim())
) {
throw new ForbiddenException('Reference is not owned by instructor');
}
await this.prisma.courseWeekReference.delete({ where: { id: reference.id } });
const sharedCover = reference.kind === 'COVER_IMAGE'
&& await this.prisma.courseWeekReference.count({ where: { storedFileName: reference.storedFileName } }) > 0;
if (sharedCover) return this.getCurriculum(accessToken, null, reference.week.courseId);
if (this.r2PublicFileService.isPublicKey(reference.storedFileName)) {
await this.r2PublicFileService.deletePublicFile(reference.storedFileName).catch(() => undefined);
} else {
await unlink(join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName)).catch(() => undefined);
}
return this.getCurriculum(accessToken, null, reference.week.courseId);
}
async createPlatform(
accessToken: string | undefined,
input: CreateInstructorPlatformDto,
) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
if (!input.agentNodeId?.trim() || !input.agentHostname?.trim()) {
throw new BadRequestException('Agent binding is required');
}
const masterPlatform = await this.prisma.platform.findFirst({
where: {
id: input.platformId,
isActive: true,
curriculumTagOnly: false,
},
select: {
id: true,
displayName: true,
vehicleType: true,
wheelbaseMeters: true,
maxSpeedMetersPerSecond: true,
maxSteeringAngleDegrees: true,
sensors: true,
drivers: true,
algorithms: true,
defaultStudioSettings: true,
controlDurationMinutes: true,
controlWarningMinutes: true,
},
});
if (!masterPlatform) throw new NotFoundException('Platform master not found');
const baseName = this.toPlatformBaseName(masterPlatform.displayName);
const existing = await this.prisma.instructorPlatform.findMany({
where: {
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
platformId: masterPlatform.id,
},
select: {
displayName: true,
instanceNo: true,
},
});
const usedNumbers = new Set(
existing.map((platform) => platform.instanceNo || this.parsePlatformNo(platform.displayName)),
);
let nextNo = 1;
while (usedNumbers.has(nextNo)) nextNo += 1;
const displayName = this.normalizeDisplayName(input.displayName) ?? `${baseName} #${String(nextNo).padStart(2, '0')}`;
const studioSettingsOverride = {
...this.toJsonObject(masterPlatform.defaultStudioSettings),
...(input.agentNodeId ? { agentNodeId: input.agentNodeId } : {}),
...(input.agentHostname ? { agentHostname: input.agentHostname } : {}),
...(input.agentOwnerEmail ? { agentOwnerEmail: input.agentOwnerEmail } : {}),
...(input.agentHostname ? { gatewayPlatformId: input.agentHostname } : {}),
...(input.sshUser?.trim() ? { sshUser: input.sshUser.trim() } : {}),
...(input.sshPort?.trim() ? { sshPort: input.sshPort.trim() } : {}),
...(input.workspaceRoot?.trim() ? { workspaceRoot: input.workspaceRoot.trim() } : {}),
};
try {
await this.prisma.instructorPlatform.create({
data: {
organizationId,
organizationName,
platformId: masterPlatform.id,
displayName,
instanceNo: nextNo,
vehicleType: masterPlatform.vehicleType,
wheelbaseMeters: masterPlatform.wheelbaseMeters,
maxSpeedMetersPerSecond: masterPlatform.maxSpeedMetersPerSecond,
maxSteeringAngleDegrees: masterPlatform.maxSteeringAngleDegrees,
sensors: masterPlatform.sensors ?? [],
drivers: masterPlatform.drivers ?? [],
algorithms: masterPlatform.algorithms ?? {},
studioSettingsOverride: studioSettingsOverride as Prisma.InputJsonValue,
controlDurationMinutes: masterPlatform.controlDurationMinutes,
controlWarningMinutes: masterPlatform.controlWarningMinutes,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
const target = error.meta?.target;
if (Array.isArray(target) && target.includes('instance_no')) {
throw new ConflictException('플랫폼 등록번호가 중복되었습니다. 다시 등록해 주세요.');
}
throw new ConflictException('이미 사용 중인 플랫폼 이름입니다. 다른 이름을 입력해 주세요.');
}
throw error;
}
return this.listPlatforms(accessToken);
}
async issuePlatformRegistrationCode(accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const launcherApiBaseUrl = this.launcherApiBaseUrl();
let response: Response;
try {
response = await fetch(`${launcherApiBaseUrl}/provision/device-authorizations`, {
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({
usageType: 'class',
}),
});
} catch {
throw new BadGatewayException('Platform registration code request failed');
}
const payload = await response.json().catch((): Record<string, unknown> => ({}));
if (!response.ok || typeof payload.code !== 'string') {
throw new BadGatewayException(
typeof payload.message === 'string'
? payload.message
: 'Platform registration code request failed',
);
}
return {
code: payload.code,
expiresAt: typeof payload.expiresAt === 'string' ? payload.expiresAt : null,
};
}
async updatePlatform(
accessToken: string | undefined,
platformId: string,
input: UpdateInstructorPlatformDto,
) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const displayName = this.normalizeDisplayName(input.displayName);
if (!displayName) throw new BadRequestException('Platform name is required');
const controlDurationMinutes = this.toBoundedInt(input.controlDurationMinutes, 30, 1, 240);
const controlWarningMinutes = this.toBoundedInt(input.controlWarningMinutes, 5, 0, 60);
if (controlWarningMinutes >= controlDurationMinutes) {
throw new BadRequestException('Warning time must be shorter than control time');
}
const vehicleType = this.normalizeDisplayName(input.vehicleType) ?? 'MK-mini';
const wheelbaseMeters = this.toBoundedNumber(input.wheelbaseMeters, 0.325, 0, 10);
const maxSpeedMetersPerSecond = this.toBoundedNumber(input.maxSpeedMetersPerSecond, 7, 0, 100);
const maxSteeringAngleDegrees = this.toBoundedNumber(input.maxSteeringAngleDegrees, 30, 0, 90);
const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true });
const algorithms = this.normalizeAlgorithms(input.algorithms);
const platform = await this.prisma.instructorPlatform.findFirst({
where: {
id: platformId,
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new NotFoundException('Platform not found');
try {
await this.prisma.$transaction([
this.prisma.instructorPlatform.update({
where: { id: platform.id },
data: {
displayName,
vehicleType,
wheelbaseMeters,
maxSpeedMetersPerSecond,
maxSteeringAngleDegrees,
sensors,
algorithms,
controlDurationMinutes,
controlWarningMinutes,
},
}),
this.prisma.studentGroup.updateMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: platform.id,
},
data: {
platformName: displayName,
},
}),
]);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException('Platform name already exists');
}
throw error;
}
return this.listPlatforms(accessToken);
}
async assignPlatformToGroup(
accessToken: string | undefined,
platformId: string,
groupId: string,
force = false,
) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const platform = await this.prisma.instructorPlatform.findFirst({
where: {
id: platformId,
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new NotFoundException('Platform not found');
const unassign = groupId === 'unassigned';
const group = unassign ? null : await this.prisma.studentGroup.findFirst({
where: {
id: groupId,
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
},
select: {
id: true,
name: true,
instructorPlatformId: true,
},
});
if (!unassign && !group) throw new NotFoundException('Group not found');
if (group?.instructorPlatformId && group.instructorPlatformId !== platform.id) {
throw new BadRequestException('이미 플랫폼이 배정된 그룹입니다.');
}
const activeSession = await this.prisma.platformControlSession.findFirst({
where: {
instructorPlatformId: platform.id,
status: 'ACTIVE',
},
orderBy: { startedAt: 'desc' },
include: {
holderUser: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (activeSession && !force) {
throw new ConflictException({
code: 'PLATFORM_IN_USE',
message: '현재 학생이 플랫폼을 사용 중입니다.',
activeSession: {
id: activeSession.id,
holderUserId: activeSession.holderUserId,
holderName: activeSession.holderUser?.name ?? activeSession.holderUser?.email ?? null,
holderEmail: activeSession.holderUser?.email ?? null,
startedAt: activeSession.startedAt,
endsAt: activeSession.endsAt,
},
});
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.studentGroup.updateMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: platform.id,
...(group ? { NOT: { id: group.id } } : {}),
},
data: {
instructorPlatformId: null,
platformName: null,
},
});
if (group) {
await tx.studentGroup.update({
where: { id: group.id },
data: {
instructorPlatformId: platform.id,
platformName: platform.displayName,
},
});
}
if (activeSession && force) {
await tx.platformControlSession.updateMany({
where: {
id: activeSession.id,
status: 'ACTIVE',
},
data: {
status: 'RELEASED',
releasedAt: now,
endReason: 'GROUP_ASSIGNMENT_CHANGED',
},
});
await tx.platformControlQueueEntry.updateMany({
where: {
instructorPlatformId: platform.id,
status: 'WAITING',
},
data: {
status: 'CANCELED',
canceledAt: now,
},
});
}
});
if (activeSession && force) {
this.chatGateway.notifyUser(activeSession.holderUserId, {
type: 'platform.control.terminated',
sessionId: activeSession.id,
platformId: null,
instructorPlatformId: platform.id,
reason: group ? 'GROUP_ASSIGNMENT_CHANGED' : 'GROUP_ASSIGNMENT_REMOVED',
message: group
? `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`
: `${platform.displayName} 플랫폼 배정이 해제되어 사용이 종료되었습니다.`,
endedAt: now.toISOString(),
});
}
this.queuePlatformAccessPolicy(organizationId, organizationName);
return this.listPlatforms(accessToken);
}
async deletePlatform(accessToken: string | undefined, platformId: string, force = false) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const platform = await this.prisma.instructorPlatform.findFirst({
where: {
id: platformId,
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new NotFoundException('Platform not found');
const activeSession = await this.prisma.platformControlSession.findFirst({
where: {
instructorPlatformId: platform.id,
status: 'ACTIVE',
},
orderBy: { startedAt: 'desc' },
include: {
holderUser: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (activeSession && !force) {
throw new ConflictException({
code: 'PLATFORM_IN_USE',
message: '현재 학생이 플랫폼을 사용 중입니다.',
activeSession: {
id: activeSession.id,
holderUserId: activeSession.holderUserId,
holderName: activeSession.holderUser?.name ?? activeSession.holderUser?.email ?? null,
holderEmail: activeSession.holderUser?.email ?? null,
startedAt: activeSession.startedAt,
endsAt: activeSession.endsAt,
},
});
}
const now = new Date();
const terminatedSessionNotice = activeSession && force
? {
userId: activeSession.holderUserId,
sessionId: activeSession.id,
}
: null;
await this.prisma.$transaction([
this.prisma.studentGroup.updateMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: platform.id,
},
data: {
instructorPlatformId: null,
platformName: null,
},
}),
this.prisma.platformControlSession.updateMany({
where: {
instructorPlatformId: platform.id,
status: 'ACTIVE',
},
data: {
status: 'RELEASED',
releasedAt: now,
endReason: 'PLATFORM_DELETED',
},
}),
this.prisma.platformControlQueueEntry.updateMany({
where: {
instructorPlatformId: platform.id,
status: 'WAITING',
},
data: {
status: 'CANCELED',
canceledAt: now,
},
}),
this.prisma.platformControlSession.updateMany({
where: {
instructorPlatformId: platform.id,
},
data: {
instructorPlatformId: null,
},
}),
this.prisma.platformControlQueueEntry.updateMany({
where: {
instructorPlatformId: platform.id,
},
data: {
instructorPlatformId: null,
},
}),
this.prisma.instructorPlatform.delete({
where: { id: platform.id },
}),
]);
this.queuePlatformAccessPolicy(organizationId, organizationName);
if (terminatedSessionNotice) {
this.chatGateway.notifyUser(terminatedSessionNotice.userId, {
type: 'platform.control.terminated',
sessionId: terminatedSessionNotice.sessionId,
platformId: null,
instructorPlatformId: platform.id,
reason: 'PLATFORM_DELETED',
message: `${platform.displayName} 플랫폼 사용이 강사에 의해 종료되었습니다.`,
endedAt: now.toISOString(),
});
}
return this.listPlatforms(accessToken);
}
async createGroup(accessToken: string | undefined, input: SaveInstructorGroupDto) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
const name = input.name.trim();
if (!name) throw new BadRequestException('Group name is required');
const memberIds = await this.validateActiveStudentIds(
input.memberIds ?? [],
organizationId,
organizationName,
instructor.id,
input.courseId,
);
const assignedPlatform = await this.resolveInstructorPlatformAssignment(input, organizationId, organizationName);
try {
await this.prisma.$transaction(async (tx) => {
const group = await tx.studentGroup.create({
data: {
courseId: course.id,
organizationId,
organizationName,
name,
week: 1,
status: '진행중',
instructorPlatformId: assignedPlatform?.id ?? null,
platformName: assignedPlatform?.displayName ?? null,
},
select: { id: true },
});
if (memberIds.length > 0) {
await tx.studentGroupMember.deleteMany({
where: {
userId: { in: memberIds },
group: {
OR: [
organizationId ? { organizationId } : undefined,
{ organizationName },
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
},
},
});
await tx.studentGroupMember.createMany({
data: memberIds.map((userId) => ({
groupId: group.id,
userId,
})),
skipDuplicates: true,
});
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException('Group already exists');
}
throw error;
}
this.queuePlatformAccessPolicy(organizationId, organizationName);
return this.listGroups(accessToken, input.courseId);
}
async updateGroup(
accessToken: string | undefined,
groupId: string,
input: SaveInstructorGroupDto,
) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
if (!course) throw new NotFoundException('Course not found');
const group = await this.findInstructorGroup(groupId, organizationId, organizationName, instructor.id, course.id);
const name = input.name.trim();
if (!name) throw new BadRequestException('Group name is required');
const memberIds = await this.validateActiveStudentIds(
input.memberIds ?? [],
organizationId,
organizationName,
instructor.id,
input.courseId,
);
const assignedPlatform = await this.resolveInstructorPlatformAssignment(input, organizationId, organizationName);
const previousAssignment = await this.getGroupPlatformAssignment(group.id);
const termination = await this.prepareGroupPlatformTermination({
groupId: group.id,
previousPlatformId: previousAssignment.instructorPlatformId,
previousPlatformName: previousAssignment.platformName,
previousMemberIds: previousAssignment.memberIds,
nextPlatformId: assignedPlatform?.id ?? null,
nextMemberIds: memberIds,
reason: 'GROUP_ASSIGNMENT_CHANGED',
message: '플랫폼 배정이 변경되어 사용이 종료되었습니다.',
});
try {
await this.prisma.$transaction(async (tx) => {
await tx.studentGroup.update({
where: { id: group.id },
data: {
courseId: course.id,
name,
instructorPlatformId: assignedPlatform?.id ?? null,
platformName: assignedPlatform?.displayName ?? null,
},
});
await tx.studentGroupMember.deleteMany({
where: { groupId: group.id },
});
if (memberIds.length > 0) {
await tx.studentGroupMember.deleteMany({
where: {
userId: { in: memberIds },
group: {
OR: [
organizationId ? { organizationId } : undefined,
{ organizationName },
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
},
},
});
await tx.studentGroupMember.createMany({
data: memberIds.map((userId) => ({
groupId: group.id,
userId,
})),
skipDuplicates: true,
});
}
if (termination.sessionIds.length > 0) {
await tx.platformControlSession.updateMany({
where: {
id: { in: termination.sessionIds },
status: 'ACTIVE',
},
data: {
status: 'RELEASED',
releasedAt: termination.endedAt,
endReason: termination.reason,
},
});
}
if (termination.queueUserIds.length > 0 && previousAssignment.instructorPlatformId) {
await tx.platformControlQueueEntry.updateMany({
where: {
groupId: group.id,
instructorPlatformId: previousAssignment.instructorPlatformId,
status: 'WAITING',
userId: { in: termination.queueUserIds },
},
data: {
status: 'CANCELED',
canceledAt: termination.endedAt,
},
});
}
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException('Group already exists');
}
throw error;
}
this.queuePlatformAccessPolicy(organizationId, organizationName);
this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason);
return this.listGroups(accessToken, input.courseId);
}
async deleteGroup(accessToken: string | undefined, groupId: string, courseId?: string | null) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const course = await this.findInstructorCourse(organizationName, instructor.id, courseId);
if (!course) throw new NotFoundException('Course not found');
const group = await this.findInstructorGroup(groupId, organizationId, organizationName, instructor.id, course.id);
const previousAssignment = await this.getGroupPlatformAssignment(group.id);
const termination = await this.prepareGroupPlatformTermination({
groupId: group.id,
previousPlatformId: previousAssignment.instructorPlatformId,
previousPlatformName: previousAssignment.platformName,
previousMemberIds: previousAssignment.memberIds,
nextPlatformId: null,
nextMemberIds: [],
reason: 'GROUP_DELETED',
message: '그룹 배정이 삭제되어 플랫폼 사용이 종료되었습니다.',
});
await this.prisma.$transaction(async (tx) => {
if (termination.sessionIds.length > 0) {
await tx.platformControlSession.updateMany({
where: {
id: { in: termination.sessionIds },
status: 'ACTIVE',
},
data: {
status: 'RELEASED',
releasedAt: termination.endedAt,
endReason: termination.reason,
},
});
}
if (termination.queueUserIds.length > 0 && previousAssignment.instructorPlatformId) {
await tx.platformControlQueueEntry.updateMany({
where: {
groupId: group.id,
instructorPlatformId: previousAssignment.instructorPlatformId,
status: 'WAITING',
userId: { in: termination.queueUserIds },
},
data: {
status: 'CANCELED',
canceledAt: termination.endedAt,
},
});
}
await tx.studentGroupMember.deleteMany({
where: { groupId: group.id },
});
await tx.studentGroup.delete({
where: { id: group.id },
});
});
this.queuePlatformAccessPolicy(organizationId, organizationName);
this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason);
return this.listGroups(accessToken, courseId);
}
private async getGroupPlatformAssignment(groupId: string) {
const group = await this.prisma.studentGroup.findUnique({
where: { id: groupId },
select: {
instructorPlatformId: true,
instructorPlatform: {
select: { displayName: true },
},
members: {
select: { userId: true },
},
},
});
return {
instructorPlatformId: group?.instructorPlatformId ?? null,
platformName: group?.instructorPlatform?.displayName ?? null,
memberIds: group?.members.map((member) => member.userId) ?? [],
};
}
private async prepareGroupPlatformTermination(input: {
groupId: string;
previousPlatformId: string | null;
previousPlatformName: string | null;
previousMemberIds: string[];
nextPlatformId: string | null;
nextMemberIds: string[];
reason: string;
message: string;
}) {
const endedAt = new Date();
if (!input.previousPlatformId) {
return {
endedAt,
reason: input.reason,
sessionIds: [] as string[],
queueUserIds: [] as string[],
notices: [] as Array<{
userId: string;
sessionId: string;
instructorPlatformId: string;
message: string;
}>,
};
}
const nextMemberIds = new Set(input.nextMemberIds);
const platformChanged = input.previousPlatformId !== input.nextPlatformId;
const affectedUserIds = platformChanged
? input.previousMemberIds
: input.previousMemberIds.filter((userId) => !nextMemberIds.has(userId));
if (affectedUserIds.length === 0) {
return {
endedAt,
reason: input.reason,
sessionIds: [] as string[],
queueUserIds: [] as string[],
notices: [] as Array<{
userId: string;
sessionId: string;
instructorPlatformId: string;
message: string;
}>,
};
}
const sessions = await this.prisma.platformControlSession.findMany({
where: {
groupId: input.groupId,
instructorPlatformId: input.previousPlatformId,
holderUserId: { in: affectedUserIds },
status: 'ACTIVE',
},
select: {
id: true,
holderUserId: true,
instructorPlatformId: true,
},
});
const platformName = input.previousPlatformName ?? '플랫폼';
return {
endedAt,
reason: input.reason,
sessionIds: sessions.map((session) => session.id),
queueUserIds: affectedUserIds,
notices: sessions
.filter((session) => session.instructorPlatformId)
.map((session) => ({
userId: session.holderUserId,
sessionId: session.id,
instructorPlatformId: session.instructorPlatformId as string,
message: `${platformName} ${input.message}`,
})),
};
}
private notifyPlatformTerminatedSessions(
notices: Array<{
userId: string;
sessionId: string;
instructorPlatformId: string;
message: string;
}>,
endedAt: Date,
reason: string,
) {
for (const notice of notices) {
this.chatGateway.notifyUser(notice.userId, {
type: 'platform.control.terminated',
sessionId: notice.sessionId,
platformId: null,
instructorPlatformId: notice.instructorPlatformId,
reason,
message: notice.message,
endedAt: endedAt.toISOString(),
});
}
}
private queuePlatformAccessPolicy(
organizationId: string | undefined,
organizationName: string,
) {
void this.syncPlatformAccessPolicy(organizationId, organizationName).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Platform access sync failed in background: ${message}`);
});
}
async syncPlatformAccess(accessToken: string | undefined) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const assignmentCount = await this.syncPlatformAccessPolicy(organizationId, organizationName, true);
return { ok: true, assignmentCount, syncedAt: new Date().toISOString() };
}
private async syncPlatformAccessPolicy(
organizationId: string | undefined,
organizationName: string,
throwOnError = false,
) {
const [platforms, groups] = await Promise.all([
this.prisma.instructorPlatform.findMany({
where: this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
select: {
id: true,
displayName: true,
studioSettingsOverride: true,
},
}),
this.prisma.studentGroup.findMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: { not: null },
},
select: {
instructorPlatformId: true,
members: {
select: {
user: {
select: {
email: true,
},
},
},
},
},
}),
]);
const studentEmailsByPlatform = new Map<string, string[]>();
for (const group of groups) {
if (!group.instructorPlatformId) continue;
studentEmailsByPlatform.set(
group.instructorPlatformId,
group.members.map((member) => member.user.email).filter(Boolean),
);
}
// Include unassigned platforms with no students so the agent removes stale SSH keys.
const assignments: GatewayAccessAssignment[] = platforms.map((platform) => {
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
return {
platformId: this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id,
platformName: platform.displayName,
agentHostname,
agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'),
studentEmails: studentEmailsByPlatform.get(platform.id) ?? [],
};
});
await this.platformGatewayClient.syncAccess(assignments, { throwOnError });
return assignments.length;
}
private async findInstructorGroup(
groupId: string,
organizationId: string | undefined,
organizationName: string,
instructorId: string,
courseId: string,
) {
const group = await this.prisma.studentGroup.findFirst({
where: {
id: groupId,
AND: [groupCourseScope(instructorId, courseId)],
OR: [
organizationId ? { organizationId } : undefined,
{ organizationName },
].filter(Boolean) as Prisma.StudentGroupWhereInput[],
},
select: { id: true },
});
if (!group) throw new NotFoundException('Group not found');
return group;
}
private async validateActiveStudentIds(
memberIds: string[],
organizationId: string | undefined,
organizationName: string,
instructorId: string,
courseId?: string | null,
) {
const uniqueIds = [...new Set(memberIds.filter(Boolean))];
if (uniqueIds.length === 0) return [];
const course = await this.findInstructorCourse(organizationName, instructorId, courseId);
const activeStudents = await this.prisma.studentEnrollment.findMany({
where: {
userId: { in: uniqueIds },
status: 'ACTIVE',
...(course ? { courseId: course.id } : { course: { teacherId: instructorId } }),
},
select: { userId: true },
});
const activeIds = activeStudents.map((student) => student.userId);
if (activeIds.length !== uniqueIds.length) {
throw new BadRequestException('Invalid group member');
}
return activeIds;
}
private async findUnassignedStudents(
organizationId: string | undefined,
organizationName: string | undefined,
instructorId: string,
courseId?: string | null,
) {
if (!organizationName) return [];
const course = await this.findInstructorCourse(organizationName, instructorId, courseId);
const enrollmentScope: Prisma.StudentEnrollmentWhereInput = course
? { courseId: course.id }
: { course: { teacherId: instructorId } };
const enrollments = await this.prisma.studentEnrollment.findMany({
where: {
status: 'ACTIVE',
...enrollmentScope,
user: {
AND: [
{
OR: [
{ authRole: null },
{ authRole: 'STUDENT' },
{ authRole: 'USER' },
],
},
{
groupMemberships: {
none: {
group: {
members: {
some: {
user: {
enrollments: {
some: {
status: 'ACTIVE',
...enrollmentScope,
},
},
},
},
},
},
},
},
},
],
},
},
orderBy: { updatedAt: 'desc' },
include: { user: true },
});
return enrollments.map((enrollment) => ({
id: enrollment.user.id,
name: enrollment.user.name ?? enrollment.user.email.split('@')[0],
email: enrollment.user.email,
}));
}
private async findPlatformOptions(
organizationId: string | undefined,
organizationName: string | undefined,
) {
if (!organizationId && !organizationName) return [];
const platforms = await this.prisma.instructorPlatform.findMany({
where: {
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
platform: {
is: {
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ platformKey: { contains: 'simulator', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
{ displayName: { contains: '시뮬', mode: 'insensitive' } },
{ displayName: { contains: 'simulator', mode: 'insensitive' } },
],
},
},
},
orderBy: { displayName: 'asc' },
select: { id: true, displayName: true },
});
return platforms.map((platform) => ({
id: platform.id,
name: platform.displayName,
}));
}
private normalizePlatformName(platformName: string | null | undefined) {
const value = platformName?.trim();
return value && value !== 'unassigned' && value !== '미배정' ? value : null;
}
private async resolveInstructorPlatformAssignment(
input: Pick<SaveInstructorGroupDto, 'instructorPlatformId' | 'platformName'>,
organizationId: string | undefined,
organizationName: string,
) {
const instructorPlatformId = input.instructorPlatformId?.trim();
const platformName = this.normalizePlatformName(input.platformName);
if (!instructorPlatformId && !platformName) return null;
const platformSelector: Prisma.InstructorPlatformWhereInput = instructorPlatformId
? { id: instructorPlatformId }
: { displayName: platformName as string };
const platform = await this.prisma.instructorPlatform.findFirst({
where: {
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
...platformSelector,
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new BadRequestException('Assigned platform not found');
return platform;
}
private normalizeDisplayName(displayName: string | null | undefined) {
const value = displayName?.trim().replace(/\s+/g, ' ');
return value || null;
}
private readStringSetting(settings: unknown, key: string) {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null;
const value = (settings as Record<string, unknown>)[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private findBoundAgent(
agents: GatewayAgent[],
binding: { nodeId?: string | null; hostname?: string | null; ownerEmail?: string | null },
) {
// An owner can register multiple teaching devices. Bind an instructor
// platform to the saved node or hostname, never to the owner alone.
const bindingTokens = [binding.nodeId, binding.hostname]
.map((value) => this.normalizeAgentToken(value))
.filter(Boolean);
if (!bindingTokens.length) return null;
return agents.find((agent) => {
const agentTokens = [agent.id, agent.hostname, agent.name, agent.ip]
.map((value) => this.normalizeAgentToken(value))
.filter(Boolean);
return bindingTokens.some((bindingToken) => agentTokens.includes(bindingToken));
}) ?? null;
}
private normalizeAgentToken(value: string | null | undefined) {
return value?.trim().toLowerCase().replace(/\s+/g, '-') ?? '';
}
private toJsonObject(settings: unknown): Record<string, unknown> {
return settings && typeof settings === 'object' && !Array.isArray(settings)
? { ...(settings as Record<string, unknown>) }
: {};
}
private async findInstructorCourse(organizationName: string, instructorId: string, courseId?: string | null) {
return this.prisma.course.findFirst({
where: {
isActive: true,
teacherId: instructorId,
...(courseId?.trim()
? { id: courseId.trim(), organizationName }
: { organizationName }),
},
include: {
weeks: {
orderBy: { week: 'asc' },
include: {
topics: {
orderBy: { sortOrder: 'asc' },
},
practices: {
orderBy: { sortOrder: 'asc' },
},
references: {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
},
modules: {
orderBy: { sortOrder: 'asc' },
},
},
},
platforms: {
orderBy: { sortOrder: 'asc' },
include: {
platform: true,
},
},
environmentProfiles: {
where: { enabled: true },
orderBy: [{ platformKey: 'asc' }, { profileType: 'asc' }],
},
},
});
}
private async ensureCurriculumSetting(
organizationId: string | undefined,
organizationName: string,
groupId: string | null,
courseId: string,
activeWeek: number,
activeLearningStep: string | null,
) {
const existing = await this.prisma.instructorCurriculumSetting.findFirst({
where: {
organizationName,
courseId,
groupId,
},
});
if (existing) return existing;
return this.prisma.instructorCurriculumSetting.create({
data: {
organizationId,
organizationName,
groupId,
courseId,
activeWeek,
activeLearningStep,
},
});
}
private async findOptionalInstructorGroup(
groupId: string | null | undefined,
organizationId: string | undefined,
organizationName: string,
) {
const requestedGroupId = groupId?.trim();
if (!requestedGroupId || requestedGroupId === 'all' || requestedGroupId === 'default') return null;
const group = await this.prisma.studentGroup.findFirst({
where: {
id: requestedGroupId,
organizationName,
...(organizationId ? { organizationId } : {}),
},
select: {
id: true,
name: true,
week: true,
},
});
if (!group) throw new BadRequestException('Invalid group');
return group;
}
private stringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
return value.filter((item): item is string => typeof item === 'string');
}
private resolveLearningSteps(
value: unknown,
topics: string[],
topicDetails: string[] = [],
topicVideoUrls: string[] = [],
): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> {
return this.normalizeLearningSteps(value) ?? this.normalizeLearningSteps(undefined, topics, topicDetails, topicVideoUrls) ?? [];
}
private normalizeLearningSteps(
value?: unknown,
topics: string[] = [],
topicDetails: string[] = [],
topicVideoUrls: string[] = [],
): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> | null {
if (Array.isArray(value)) {
const steps = value
.map((item) => {
if (!item || typeof item !== 'object') return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === 'string' ? record.title.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
const videoUrl = typeof record.videoUrl === 'string' ? record.videoUrl.trim() : '';
const videoEnabled = typeof record.videoEnabled === 'boolean' ? record.videoEnabled : Boolean(videoUrl);
const concepts = Array.isArray(record.concepts)
? record.concepts.filter((concept): concept is string => typeof concept === 'string').map((concept) => concept.trim()).filter(Boolean)
: [];
return title || description || videoUrl || concepts.length
? { title: title || '학습단계', description, videoEnabled, videoUrl: videoEnabled ? videoUrl : '', concepts }
: null;
})
.filter((item): item is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(item));
return steps.length ? steps : null;
}
const titles = topics.map((topic) => topic.trim()).filter(Boolean);
if (!titles.length) return null;
return titles.map((title, index) => {
const videoUrl = topicVideoUrls[index]?.trim() ?? '';
return {
title,
description: topicDetails[index]?.trim() ?? '',
videoEnabled: Boolean(videoUrl),
videoUrl,
concepts: [],
};
});
}
private practiceContentsArray(value: unknown): Array<{ title: string; description: string }> | null {
if (!Array.isArray(value)) return null;
return value
.map((item) => {
if (!item || typeof item !== 'object') return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === 'string' ? record.title.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
return title || description ? { title, description } : null;
})
.filter((item): item is { title: string; description: string } => Boolean(item));
}
private normalizePracticeContents(value: Array<{ title?: string; description?: string }> | undefined) {
if (!value) return undefined;
return value
.map((item) => ({
title: item.title?.trim() ?? '',
description: item.description?.trim() ?? '',
}))
.filter((item) => item.title || item.description);
}
private normalizeUploadedFileName(fileName: string) {
if (!fileName) return 'reference';
const restored = Buffer.from(fileName, 'latin1').toString('utf8');
return restored.includes('\uFFFD') ? fileName : restored;
}
private serializeWeekReference(
reference: {
id: string;
kind: string;
title: string;
originalFileName: string;
storedFileName: string;
contentType: string | null;
fileSizeBytes: number;
createdAt: Date;
},
week: number,
) {
return {
id: reference.id,
kind: reference.kind,
title: reference.title,
description: reference.originalFileName,
fileName: reference.originalFileName,
contentType: reference.contentType,
fileSizeBytes: reference.fileSizeBytes,
week,
weekNumber: week,
downloadUrl: this.r2PublicFileService.publicUrl(reference.storedFileName) ?? `/curriculum/references/${reference.id}/download`,
createdAt: reference.createdAt.toISOString(),
};
}
private resolveWeekProgressState(
week: number,
baseState: string,
topics: string[],
setting: { activeWeek: number; activeLearningStep?: string | null } | null | undefined,
) {
if (!setting) return { state: baseState, progress: baseState === 'done' ? 100 : 0 };
if (week < setting.activeWeek) return { state: 'done', progress: 100 };
if (week > setting.activeWeek) return { state: 'locked', progress: 0 };
const progress = this.resolveLearningStepProgress(topics, setting.activeLearningStep);
return {
state: progress >= 100 ? 'done' : 'current',
progress,
};
}
private resolveLearningStepProgress(topics: string[], activeLearningStep?: string | null) {
if (!topics.length) return activeLearningStep === null ? 100 : 0;
if (activeLearningStep === null) return 100;
const activeIndex = topics.findIndex((topic) => topic === activeLearningStep);
if (activeIndex < 0) return 0;
return Math.min(99, Math.round((activeIndex / topics.length) * 100));
}
private resolveWeekTags(tags: string[], state: string) {
const nextTags = tags.length ? [...tags] : ['커리큘럼'];
nextTags[1] = state === 'done' ? '완료' : state === 'current' ? '진행중' : state === 'locked' ? '잠김' : '대기';
return nextTags;
}
private resolveBaseWeekTags(tags: string[], tagLabel?: string | null) {
const nextTags = tags.length ? [...tags] : ['커리큘럼'];
const label = tagLabel?.trim();
if (label) nextTags[0] = label;
return nextTags;
}
private async findCurriculumTagOptions() {
const platforms = await this.prisma.platform.findMany({
where: {
isActive: true,
curriculumTagOnly: false,
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
],
},
orderBy: { displayName: 'asc' },
select: {
platformKey: true,
displayName: true,
},
});
const options = new Map<string, { key: string; label: string }>();
options.set('none', { key: 'none', label: '없음' });
platforms.forEach((platform) => {
const label = this.toPlatformBaseName(platform.displayName);
if (!label || options.has(label)) return;
options.set(label, {
key: platform.platformKey,
label,
});
});
return Array.from(options.values());
}
private optionalTrim(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed || null;
}
private async findMasterPlatforms() {
const platforms = await this.prisma.platform.findMany({
where: {
isActive: true,
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ platformKey: { contains: 'simulator', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
{ displayName: { contains: '시뮬', mode: 'insensitive' } },
{ displayName: { contains: 'simulator', mode: 'insensitive' } },
],
},
orderBy: { displayName: 'asc' },
select: {
id: true,
displayName: true,
description: true,
iconKey: true,
vehicleType: true,
},
});
const byBaseName = new Map<string, {
id: string;
name: string;
description: string;
iconKey: string;
vehicleType: string;
}>();
platforms.forEach((platform) => {
const baseName = this.toPlatformBaseName(platform.displayName);
if (!byBaseName.has(baseName)) {
byBaseName.set(baseName, {
id: platform.id,
name: baseName,
description: platform.description,
iconKey: platform.iconKey,
vehicleType: platform.vehicleType,
});
}
});
return [...byBaseName.values()];
}
private organizationWhere<T>(
organizationId: string | undefined,
organizationName: string | undefined,
): T {
return {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean),
} as T;
}
private toPlatformBaseName(displayName: string) {
return displayName.replace(/\s*#\d+$/i, '').trim();
}
private parsePlatformNo(displayName: string) {
const match = displayName.match(/#(\d+)$/);
return match ? Number(match[1]) : 0;
}
private toPlatformType(displayName: string) {
return `${this.toPlatformBaseName(displayName)} 플랫폼`;
}
private formatUptime(minutes: number) {
if (minutes < 60) return `${minutes}분`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`;
}
private formatUptimeSeconds(seconds: number) {
return this.formatUptime(Math.floor(Math.max(0, seconds) / 60));
}
private toBoundedInt(
value: number | undefined,
fallback: number,
min: number,
max: number,
) {
const parsed = Number(value ?? fallback);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
private toBoundedNumber(
value: number | undefined,
fallback: number,
min: number,
max: number,
) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
private normalizeNameDescriptionList(
items: Array<{ title?: string; description?: string; enabled?: boolean }> | undefined,
options: { withEnabled?: boolean } = {},
) {
if (!Array.isArray(items)) return [];
return items
.map((item) => {
const normalized = {
title: this.normalizeDisplayName(item.title) ?? '',
description: this.normalizeDisplayName(item.description) ?? '',
};
return options.withEnabled
? { ...normalized, enabled: item.enabled !== false }
: normalized;
})
.filter((item) => item.title || item.description);
}
private normalizeAlgorithms(
algorithms: { slam?: string; planning?: string; control?: string } | undefined,
) {
return {
slam: this.normalizeDisplayName(algorithms?.slam) ?? 'Cartographer',
planning: this.normalizeDisplayName(algorithms?.planning) ?? 'A* + DWA',
control: this.normalizeDisplayName(algorithms?.control) ?? 'PID Control',
};
}
private async getInstructor(accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const { user } = await this.authService.me(accessToken);
const roles = new Set(
[user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()),
);
if (![...INSTRUCTOR_ROLES].some((role) => roles.has(role))) {
throw new ForbiddenException('Instructor role required');
}
const instructor = await this.prisma.user.upsert({
where: { email: user.email },
update: this.toUserSyncData(user),
create: {
email: user.email,
...this.toUserSyncData(user),
},
});
return { authUser: user, instructor };
}
async createInitialCourse(accessToken: string | undefined, title: string) {
const { authUser, instructor } = await this.getInstructor(accessToken);
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('강사 소속 조직을 확인할 수 없습니다.');
const course = await this.createBaseCourseForInstructor(instructor.id, organizationName, title);
return {
course: {
id: course.id,
slug: course.slug,
title: course.title,
subtitle: course.subtitle,
description: course.description,
organizationName: course.organizationName,
},
};
}
private async createBaseCourseForInstructor(instructorId: string, organizationName: string, title: string) {
const courseTitle = title.trim();
if (!courseTitle) throw new BadRequestException('강좌명을 입력해 주세요.');
const existingCourse = await this.prisma.course.findFirst({
where: { teacherId: instructorId, isActive: true },
select: { id: true },
});
if (existingCourse) throw new ConflictException('이미 생성된 강좌가 있습니다.');
const template = await this.prisma.course.findFirst({
where: { slug: { in: BASE_COURSE_SLUGS }, isActive: true },
include: {
weeks: {
orderBy: { week: 'asc' },
include: {
topics: { orderBy: { sortOrder: 'asc' } },
practices: { orderBy: { sortOrder: 'asc' } },
references: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] },
modules: { orderBy: { sortOrder: 'asc' } },
},
},
environmentProfiles: { orderBy: [{ week: 'asc' }, { platformKey: 'asc' }] },
maps: { orderBy: { key: 'asc' } },
weekMaps: { orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }] },
instructorCurriculumWeekOverrides: true,
},
});
if (!template) {
this.logger.error('Base NAV2 course was not found; skipping instructor course initialization.');
throw new NotFoundException('베이스 강좌를 찾을 수 없습니다.');
}
const courseSlug = `nav2-autonomy-${this.slug(instructorId).slice(0, 12)}`;
return this.prisma.$transaction(async (tx) => {
const courseAlreadyCreated = await tx.course.findFirst({
where: { teacherId: instructorId, isActive: true },
select: { id: true },
});
if (courseAlreadyCreated) throw new ConflictException('이미 생성된 강좌가 있습니다.');
const course = await tx.course.create({
data: {
slug: courseSlug,
title: courseTitle,
subtitle: template.subtitle,
description: template.description,
organizationName,
teacherId: instructorId,
isActive: true,
},
});
const moduleIds = new Map<string, string>();
for (const sourceWeek of template.weeks) {
const week = await tx.courseWeek.create({
data: {
courseId: course.id,
week: sourceWeek.week,
title: sourceWeek.title,
tags: sourceWeek.tags,
progress: sourceWeek.progress,
state: sourceWeek.state,
duration: sourceWeek.duration,
github: sourceWeek.github,
overview: sourceWeek.overview,
deliverable: sourceWeek.deliverable,
requiresSourceSubmission: sourceWeek.requiresSourceSubmission,
},
});
await Promise.all([
tx.courseWeekTopic.createMany({ data: sourceWeek.topics.map((topic) => ({ weekId: week.id, title: topic.title, detail: topic.detail, sortOrder: topic.sortOrder })) }),
tx.courseWeekPractice.createMany({ data: sourceWeek.practices.map((practice) => ({ weekId: week.id, title: practice.title, description: practice.description, sortOrder: practice.sortOrder })) }),
tx.courseWeekReference.createMany({ data: sourceWeek.references.map((reference) => ({ weekId: week.id, kind: reference.kind, title: reference.title, originalFileName: reference.originalFileName, storedFileName: reference.storedFileName, contentType: reference.contentType, fileSizeBytes: reference.fileSizeBytes, sortOrder: reference.sortOrder })) }),
]);
const learningOverride = template.instructorCurriculumWeekOverrides.find(
(override) => override.week === sourceWeek.week && override.organizationName === template.organizationName,
);
if (learningOverride?.learningSteps) {
const learningSteps = this.resolveLearningSteps(
learningOverride.learningSteps,
sourceWeek.topics.map((topic) => topic.title),
sourceWeek.topics.map((topic) => topic.detail),
);
await tx.instructorCurriculumWeekOverride.create({
data: {
courseId: course.id,
organizationName,
week: week.week,
learningSteps,
topics: learningSteps.map((step) => step.title),
topicDetails: learningSteps.map((step) => step.description),
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
},
});
}
for (const sourceModule of sourceWeek.modules) {
const module = await tx.courseWeekModule.create({
data: {
weekId: week.id,
sortOrder: sourceModule.sortOrder,
category: sourceModule.category,
name: sourceModule.name,
folderName: sourceModule.folderName,
templateRepositoryUrl: sourceModule.templateRepositoryUrl,
sourceType: sourceModule.sourceType,
prebuiltArtifacts: copyJson(sourceModule.prebuiltArtifacts),
defaultParameters: copyJson(sourceModule.defaultParameters),
dockerImage: sourceModule.dockerImage,
},
});
moduleIds.set(sourceModule.id, module.id);
}
}
await Promise.all([
...template.environmentProfiles.map((profile) => tx.courseEnvironmentProfile.create({
data: {
courseId: course.id,
week: profile.week,
platformKey: profile.platformKey,
profileType: profile.profileType,
name: profile.name,
config: remapProfileModuleIds(profile.config, moduleIds),
enabled: profile.enabled,
},
})),
...template.maps.map((map) => tx.courseMap.create({
data: {
courseId: course.id,
key: map.key,
name: map.name,
description: map.description,
platformKey: map.platformKey,
worldFile: map.worldFile,
viewerModel: map.viewerModel,
layout: copyJson(map.layout),
},
})),
...template.weekMaps.map((weekMap) => tx.courseWeekMap.create({
data: { courseId: course.id, week: weekMap.week, mapKey: weekMap.mapKey, sortOrder: weekMap.sortOrder },
})),
]);
return course;
});
}
private toUserSyncData(user: AuthResponseUser) {
return {
authUserId: user.id,
name: user.name,
authRole: user.role,
primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null,
primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null,
primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null,
department: user.primaryAffiliation?.department ?? null,
position: user.primaryAffiliation?.position ?? null,
};
}
private async readSubmissionProgress(owner: string, repoName: string): Promise<SubmissionProgress> {
const commits = await this.readSubmissionCommits(owner, repoName);
return {
commits,
finalFiles: await this.readFinalSubmissionFiles(owner, repoName, commits),
};
}
private async readTeamSubmissionProgress(
owner: string,
repoName: string,
memberEmails: Set<string>,
): Promise<SubmissionProgress> {
const commits = await this.readSubmissionCommits(owner, repoName, (commit) => (
memberEmails.has(commit.authorEmail.trim().toLowerCase()) || commit.message.includes('팀 반영')
));
return {
commits,
finalFiles: await this.readFinalSubmissionFiles(owner, repoName, commits),
};
}
private buildStudioRepoName(studentEmail: string, week: number, moduleName: string) {
return `${this.slug(studentEmail.split('@')[0] || studentEmail)}-week-${week}-${this.slug(moduleName)}`;
}
private slug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'workspace';
}
private async readSubmissionCommits(
owner: string,
repoName: string,
includeCommit: (commit: Omit<SubmissionCommit, 'files'>) => boolean = (commit) => this.isSubmissionCommit(commit),
): Promise<SubmissionCommit[]> {
const commits = await this.fetchGiteaJson<unknown[]>(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits?limit=50`,
).catch(() => []);
const submissionCommits = commits
.map((commit) => this.normalizeGiteaCommit(commit))
.filter((commit): commit is Omit<SubmissionCommit, 'files'> => Boolean(commit))
.filter(includeCommit)
.slice(0, 20);
return Promise.all(submissionCommits.map(async (commit) => ({
...commit,
files: await this.readGiteaCommitFiles(owner, repoName, commit.hash),
})));
}
private async readFinalSubmissionFiles(owner: string, repoName: string, commits: SubmissionCommit[]): Promise<SubmissionCommitFile[]> {
if (!commits.length) return [];
if (commits.length === 1) return commits[0].files;
const latest = commits[0];
const oldest = commits[commits.length - 1];
const baseHash = await this.readGiteaCommitParent(owner, repoName, oldest.hash).catch(() => null);
if (baseHash) {
const diffText = await this.fetchGiteaText([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
]).catch(() => '');
const diffFiles = this.parseDiffFiles(diffText)
.filter((file) => !this.isIgnoredSubmissionFile(file.path))
.map((file) => ({
...file,
originalRef: baseHash,
modifiedRef: latest.hash,
}));
if (diffFiles.length) return diffFiles;
}
const byPath = new Map<string, SubmissionCommitFile>();
[...commits].reverse().forEach((commit) => {
commit.files.forEach((file) => {
if (file.status.toLowerCase() === 'deleted') byPath.delete(file.path);
else byPath.set(file.path, file);
});
});
return Array.from(byPath.values());
}
private async readGiteaCommitParent(owner: string, repoName: string, hash: string): Promise<string | null> {
const detail = await this.fetchFirstGiteaJson<Record<string, unknown>>([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`,
]);
const parents = Array.isArray(detail.parents) ? detail.parents : [];
const parent = parents[0];
if (!parent || typeof parent !== 'object') return null;
const item = parent as Record<string, unknown>;
return String(item.sha ?? item.id ?? '') || null;
}
private async readGiteaCommitFiles(owner: string, repoName: string, hash: string): Promise<SubmissionCommitFile[]> {
const detail = await this.fetchFirstGiteaJson<Record<string, unknown>>([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`,
]).catch(() => null);
const files = Array.isArray(detail?.files) ? detail.files : [];
const parent = Array.isArray(detail?.parents) ? detail.parents[0] : null;
const parentHash = parent && typeof parent === 'object'
? String((parent as Record<string, unknown>).sha ?? (parent as Record<string, unknown>).id ?? '') || null
: null;
const diffText = await this.fetchGiteaText([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}.diff`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(hash)}.patch`,
]).catch(() => '');
const diffFiles = this.parseDiffFiles(diffText);
const diffByPath = new Map(diffFiles.map((file) => [file.path, file]));
if (files.length) {
return files.map((file) => {
const item = file as Record<string, unknown>;
const path = String(item.filename ?? item.path ?? '');
const diffFile = diffByPath.get(path);
return {
path,
status: String(item.status ?? diffFile?.status ?? 'modified'),
patch: String(item.patch ?? '') || diffFile?.patch || '',
originalRef: parentHash,
modifiedRef: hash,
};
}).filter((file) => file.path && !this.isIgnoredSubmissionFile(file.path));
}
return diffFiles.filter((file) => !this.isIgnoredSubmissionFile(file.path));
}
private isIgnoredSubmissionFile(path: string): boolean {
return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json';
}
private normalizeGiteaCommit(commit: unknown): Omit<SubmissionCommit, 'files'> | null {
if (!commit || typeof commit !== 'object') return null;
const item = commit as Record<string, unknown>;
const nested = (item.commit && typeof item.commit === 'object' ? item.commit : {}) as Record<string, unknown>;
const author = (nested.author && typeof nested.author === 'object' ? nested.author : {}) as Record<string, unknown>;
const committer = (nested.committer && typeof nested.committer === 'object' ? nested.committer : {}) as Record<string, unknown>;
const userAuthor = (item.author && typeof item.author === 'object' ? item.author : {}) as Record<string, unknown>;
const hash = String(item.sha ?? item.id ?? '');
if (!hash) return null;
const message = String(nested.message ?? item.message ?? '').split('\n')[0] ?? '';
return {
hash,
shortHash: hash.slice(0, 8),
message,
author: String(author.name ?? userAuthor.login ?? 'unknown'),
authorEmail: String(author.email ?? ''),
date: String(author.date ?? committer.date ?? item.created_at ?? '') || null,
};
}
private isSubmissionCommit(commit: Omit<SubmissionCommit, 'files'>): boolean {
const botEmail = this.configService
.get<string>('STUDIO_GIT_AUTHOR_EMAIL', DEFAULT_SUBMISSION_BOT_EMAIL)
.toLowerCase();
return commit.authorEmail.toLowerCase() === botEmail || commit.message.includes('제출');
}
private async fetchGiteaJson<T>(path: string): Promise<T> {
const response = await this.fetchGitea(path);
return response.json() as Promise<T>;
}
private async fetchFirstGiteaJson<T>(paths: string[]): Promise<T> {
let lastError: unknown = null;
for (const path of paths) {
try {
return await this.fetchGiteaJson<T>(path);
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new BadGatewayException('Gitea API request failed');
}
private async fetchGiteaText(paths: string[]): Promise<string> {
for (const path of paths) {
const response = await this.fetchGitea(path).catch(() => null);
if (response?.ok) return response.text();
}
return '';
}
private normalizeGitRef(value: string | undefined): string | null {
const ref = value?.trim() ?? '';
return /^[a-f0-9]{7,64}$/i.test(ref) ? ref : null;
}
private async assertInstructorRepositoryAccess(
organizationId: string | undefined,
organizationName: string | undefined,
repoOwner: string,
repoName: string,
) {
if (!organizationId && !organizationName) {
throw new ForbiddenException('소속 기관 정보를 확인할 수 없습니다.');
}
const organizationFilter = [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[];
const groupFilter = [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[];
const repositoryFilter = { repoOwner, repoName };
const [studentRepository, studentCustomRepository, teamRepository, teamCustomRepository] = await Promise.all([
this.prisma.studioRepository.findFirst({
where: {
...repositoryFilter,
user: { enrollments: { some: { OR: organizationFilter } } },
},
select: { id: true },
}),
this.prisma.studentCustomModuleRepository.findFirst({
where: {
...repositoryFilter,
user: { enrollments: { some: { OR: organizationFilter } } },
},
select: { id: true },
}),
this.prisma.teamStudioRepository.findFirst({
where: {
...repositoryFilter,
group: { OR: groupFilter },
},
select: { id: true },
}),
this.prisma.teamCustomModuleRepository.findFirst({
where: {
...repositoryFilter,
group: { OR: groupFilter },
},
select: { id: true },
}),
]);
if (!studentRepository && !studentCustomRepository && !teamRepository && !teamCustomRepository) {
throw new ForbiddenException('제출 저장소에 접근할 수 없습니다.');
}
}
private async readGiteaFileAtRef(owner: string, repoName: string, path: string, ref: string): Promise<string | null> {
const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/');
const response = await this.fetchGitea(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/raw/${encodedPath}?ref=${encodeURIComponent(ref)}`,
).catch(() => null);
return response ? response.text() : null;
}
private async fetchGitea(path: string): Promise<Response> {
const giteaBaseUrl = this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/+$/, '');
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN', '');
if (!token) throw new BadGatewayException('GITEA_ADMIN_TOKEN is not configured');
const response = await fetch(`${giteaBaseUrl}${path}`, {
headers: {
Authorization: `token ${token}`,
},
});
if (!response.ok) {
throw new BadGatewayException(await response.text());
}
return response;
}
private parseDiffFiles(diffText: string): SubmissionCommitFile[] {
const files: SubmissionCommitFile[] = [];
let current: SubmissionCommitFile | null = null;
diffText.split('\n').forEach((line) => {
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
if (match) {
current = {
path: match[2],
status: 'modified',
patch: '',
};
files.push(current);
return;
}
if (!current) return;
if (line.startsWith('new file mode ')) current.status = 'added';
if (line.startsWith('deleted file mode ')) current.status = 'deleted';
current.patch = `${current.patch}${current.patch ? '\n' : ''}${line}`;
});
return files.filter((file) => file.path);
}
private launcherApiBaseUrl() {
return this.configService
.get<string>('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api')
.replace(/\/$/, '');
}
private toSummary(students: Array<{ progress: number; online: boolean; issues: number }>) {
const total = students.length;
const averageProgress = total
? Math.round(students.reduce((sum, student) => sum + student.progress, 0) / total)
: 0;
return {
totalStudents: total,
onlineStudents: students.filter((student) => student.online).length,
averageProgress,
unresolvedIssues: students.reduce((sum, student) => sum + student.issues, 0),
};
}
private toGroupSummary(
groups: Array<{ members: string[]; platform: string; progress: number }>,
) {
const totalGroups = groups.length;
const assignedPlatforms = groups.filter((group) => group.platform !== '미배정').length;
const totalStudents = groups.reduce((sum, group) => sum + group.members.length, 0);
const averageProgress = totalGroups
? Math.round(groups.reduce((sum, group) => sum + group.progress, 0) / totalGroups)
: 0;
return {
totalGroups,
assignedPlatforms,
totalStudents,
averageProgress,
};
}
private toPlatformSummary(
platforms: Array<{ status: string; errors: number }>,
) {
return {
totalPlatforms: platforms.length,
activePlatforms: platforms.filter((platform) => platform.status === '활성').length,
standbyPlatforms: platforms.filter((platform) => platform.status === '대기').length,
errorPlatforms: platforms.filter((platform) => platform.errors > 0 || platform.status === '오류').length,
};
}
private formatRelativeTime(date: Date) {
const diffMs = Date.now() - date.getTime();
const minutes = Math.max(0, Math.floor(diffMs / 60000));
if (minutes < 1) return '방금 전';
if (minutes < 60) return `${minutes}분 전`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 전`;
const days = Math.floor(hours / 24);
return `${days}일 전`;
}
}