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) { const config = copyJson(value); if (!config || typeof config !== 'object' || Array.isArray(config)) return config; const moduleSet = (config as Record).moduleSet; if (!moduleSet || typeof moduleSet !== 'object' || Array.isArray(moduleSet)) return config; const modules = (moduleSet as Record).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).id; if (typeof id === 'string' && moduleIds.has(id)) { (module as Record).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(); 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; }>(); 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(); const progressByTeamRepositoryId = new Map(); const progressByCustomRepositoryId = new Map(); const progressByTeamCustomRepositoryId = new Map(); 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( '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( 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( 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(); 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>((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; }>>((items, entry) => { const platformKey = entry?.platformKey?.trim(); if (!platformKey) return items; const moduleMap = new Map(); 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( 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 => ({})); 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( 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( 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( 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( 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( 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( 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( 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( organizationId, organizationName, ), select: { id: true, displayName: true, studioSettingsOverride: true, }, }), this.prisma.studentGroup.findMany({ where: { ...this.organizationWhere( organizationId, organizationName, ), instructorPlatformId: { not: null }, }, select: { instructorPlatformId: true, members: { select: { user: { select: { email: true, }, }, }, }, }, }), ]); const studentEmailsByPlatform = new Map(); 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( 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, 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( 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)[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 { return settings && typeof settings === 'object' && !Array.isArray(settings) ? { ...(settings as Record) } : {}; } 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; 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; 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(); 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(); 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( 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(); 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 { 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, ): Promise { 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) => boolean = (commit) => this.isSubmissionCommit(commit), ): Promise { const commits = await this.fetchGiteaJson( `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits?limit=50`, ).catch(() => []); const submissionCommits = commits .map((commit) => this.normalizeGiteaCommit(commit)) .filter((commit): commit is Omit => 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 { 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(); [...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 { const detail = await this.fetchFirstGiteaJson>([ `/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; return String(item.sha ?? item.id ?? '') || null; } private async readGiteaCommitFiles(owner: string, repoName: string, hash: string): Promise { const detail = await this.fetchFirstGiteaJson>([ `/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).sha ?? (parent as Record).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; 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 | null { if (!commit || typeof commit !== 'object') return null; const item = commit as Record; const nested = (item.commit && typeof item.commit === 'object' ? item.commit : {}) as Record; const author = (nested.author && typeof nested.author === 'object' ? nested.author : {}) as Record; const committer = (nested.committer && typeof nested.committer === 'object' ? nested.committer : {}) as Record; const userAuthor = (item.author && typeof item.author === 'object' ? item.author : {}) as Record; 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): boolean { const botEmail = this.configService .get('STUDIO_GIT_AUTHOR_EMAIL', DEFAULT_SUBMISSION_BOT_EMAIL) .toLowerCase(); return commit.authorEmail.toLowerCase() === botEmail || commit.message.includes('제출'); } private async fetchGiteaJson(path: string): Promise { const response = await this.fetchGitea(path); return response.json() as Promise; } private async fetchFirstGiteaJson(paths: string[]): Promise { let lastError: unknown = null; for (const path of paths) { try { return await this.fetchGiteaJson(path); } catch (error) { lastError = error; } } throw lastError instanceof Error ? lastError : new BadGatewayException('Gitea API request failed'); } private async fetchGiteaText(paths: string[]): Promise { 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 { 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 { const giteaBaseUrl = this.configService .get('GITEA_BASE_URL', 'http://localhost:3000') .replace(/\/+$/, ''); const token = this.configService.get('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('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}일 전`; } }