import { BadGatewayException, BadRequestException, ConflictException, ForbiddenException, Injectable, 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 { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto'; import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto'; import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.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'); type SubmissionCommitFile = { path: string; status: string; patch: string; }; 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 { constructor( private readonly authService: AuthService, private readonly chatGateway: ChatGateway, private readonly prisma: PrismaService, private readonly configService: ConfigService, private readonly platformGatewayClient: PlatformGatewayClient, ) {} 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 week.requiresSourceSubmission && !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: { AND: [ { OR: [ organizationId ? { primaryOrganizationId: organizationId } : undefined, organizationName ? { primaryOrganizationName: organizationName } : undefined, ].filter(Boolean) as Prisma.UserWhereInput[], }, { 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 repositories = studentIds.length && moduleIds.length ? await this.prisma.studioRepository.findMany({ where: { userId: { in: studentIds }, }, }) : []; 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 progressByRepositoryId = 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); })); const items = courses.flatMap((course) => course.weeks.flatMap((week) => 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, }, 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 ?? [], }; })))); return { organization: organizationName ? { id: organizationId, name: organizationName, } : null, summary: { totalItems: items.length, submittedItems: items.filter((item) => item.status === 'SUBMITTED').length, pendingItems: items.filter((item) => item.status === 'PENDING').length, notInstalledItems: items.filter((item) => item.status === 'NOT_INSTALLED').length, totalCommits: items.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, modules: week.modules.map((module) => ({ id: module.id, name: module.name, folderName: module.folderName, sortOrder: module.sortOrder, })), })), })), students, items, }; } 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: 'EDUCATION', }, ); await this.grantLmsAppPermission(accessToken, createdAuthUser.user.id); 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) { await this.syncPlatformAccessPolicy(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) { await this.syncPlatformAccessPolicy(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 } }; const groups = await this.prisma.studentGroup.findMany({ where: { AND: [ { OR: [ organizationId ? { organizationId } : undefined, organizationName ? { organizationName } : undefined, ].filter(Boolean) as Prisma.StudentGroupWhereInput[], }, { members: { some: { user: { enrollments: { some: { status: 'ACTIVE', ...enrollmentScope, }, }, }, }, }, }, ], }, 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 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, }); return { id: platform.id, masterPlatformId: platform.platformId, name: platform.displayName, type: this.toPlatformType(platform.platform.displayName), status: activeControl ? '활성' : '대기', battery: platform.battery, uptime: 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 visualizationPlatforms = await this.prisma.visualizationPlatform.findMany({ where: { enabled: true }, orderBy: { sortOrder: 'asc' }, }); const tagOptions = await this.findCurriculumTagOptions(); const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({ where: { courseId: course.id, enabled: true, }, include: { platform: true, }, }); const visualizationSelectionsByWeek = new Map(); visualizationSelections.forEach((selection) => { const items = visualizationSelectionsByWeek.get(selection.week) ?? []; items.push(selection); visualizationSelectionsByWeek.set(selection.week, items); }); 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 topics = this.stringArray(override?.topics) ?? week.topics.map((topic) => topic.title); const topicDetails = this.stringArray(override?.topicDetails) ?? week.topics.map((topic) => topic.detail); const topicVideoUrls = this.stringArray(override?.topicVideoUrls) ?? week.topics.map(() => ''); 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, 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, 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, requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission, }, 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, enabled: !disabledModuleIdSet.has(module.id), })), visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({ key: selection.platform.key, type: selection.platform.type, displayName: selection.platform.displayName, description: selection.platform.description, config: selection.platform.defaultConfig, })), }; }); const addedWeeks = overrides .filter((override) => !baseWeekNumbers.has(override.week)) .map((override) => { const topics = this.stringArray(override.topics) ?? ['학습단계 1']; const progressState = this.resolveWeekProgressState(override.week, 'ready', topics, setting); return { week: override.week, title: override.title ?? `${override.week}주차 커리큘럼`, tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state), progress: progressState.progress, state: progressState.state, topics, duration: override.duration ?? '-', github: '-', detail: { overview: override.overview ?? '', topicDetails: this.stringArray(override.topicDetails) ?? [''], topicVideoUrls: this.stringArray(override.topicVideoUrls) ?? [''], practiceContents: this.practiceContentsArray(override.practiceContents) ?? [], references: [], deliverable: override.deliverable ?? '', requiresSourceSubmission: override.requiresSourceSubmission ?? true, }, modules: [], visualizationPlatforms: (visualizationSelectionsByWeek.get(override.week) ?? []).map((selection) => ({ key: selection.platform.key, type: selection.platform.type, displayName: selection.platform.displayName, description: selection.platform.description, config: selection.platform.defaultConfig, })), }; }); 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, visualizationPlatforms: visualizationPlatforms.map((platform) => ({ key: platform.key, type: platform.type, displayName: platform.displayName, description: platform.description, config: platform.defaultConfig, })), 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, }, }, }); const topicTitles = input.topics?.map((topic) => topic.trim()).filter(Boolean) ?? this.stringArray(existingOverride?.topics) ?? targetWeek?.topics.map((topic) => topic.title) ?? ['학습단계 1']; 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); 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.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.requiresSourceSubmission !== undefined || 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] ?? '커리큘럼', 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 ?? '', requiresSourceSubmission: input.requiresSourceSubmission ?? existingOverride?.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true, topics: topicTitles, topicDetails: input.topicDetails?.map((detail) => detail.trim()) ?? this.stringArray(existingOverride?.topicDetails) ?? targetWeek?.topics.map((topic) => topic.detail) ?? [], topicVideoUrls: input.topicVideoUrls?.map((url) => url.trim()) ?? this.stringArray(existingOverride?.topicVideoUrls) ?? targetWeek?.topics.map(() => '') ?? [], 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] ?? '커리큘럼', 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 ?? '', requiresSourceSubmission: input.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true, topics: topicTitles, topicDetails: input.topicDetails?.map((detail) => detail.trim()) ?? [], topicVideoUrls: input.topicVideoUrls?.map((url) => url.trim()) ?? [], practiceContents, disabledModuleIds: disabledModuleIds ?? [], }, }); } if (input.visualizationPlatformKeys) { const selectedKeys = Array.from(new Set(input.visualizationPlatformKeys.map((key) => key.trim().toUpperCase()).filter(Boolean))); const validPlatforms = selectedKeys.length ? await this.prisma.visualizationPlatform.findMany({ where: { key: { in: selectedKeys }, enabled: true, }, select: { key: true }, }) : []; const validKeySet = new Set(validPlatforms.map((platform) => platform.key)); await this.prisma.courseWeekVisualizationPlatform.deleteMany({ where: { courseId: course.id, week: input.activeWeek, platformKey: { notIn: selectedKeys }, }, }); await Promise.all(selectedKeys.filter((key) => validKeySet.has(key)).map((key) => ( this.prisma.courseWeekVisualizationPlatform.upsert({ where: { courseId_week_platformKey: { courseId: course.id, week: input.activeWeek, platformKey: key, }, }, create: { courseId: course.id, week: input.activeWeek, platformKey: key, enabled: true, }, update: { enabled: true, }, }) ))); } 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 deleteCurriculumWeek(accessToken: string | undefined, week: number) { 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); 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 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); await mkdir(COURSE_REFERENCE_UPLOAD_ROOT, { recursive: true }); const storedFileName = `${randomUUID()}${extname(originalFileName || '')}`; 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 } }); 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, ), displayName: { startsWith: baseName, }, }, 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') { throw new ConflictException('Platform name already exists'); } throw error; } return this.listPlatforms(accessToken); } 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 group = await this.prisma.studentGroup.findFirst({ where: { id: groupId, ...this.organizationWhere( organizationId, organizationName, ), }, select: { id: true, name: true, instructorPlatformId: true, }, }); if (!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, NOT: { id: group.id }, }, data: { instructorPlatformId: null, platformName: null, }, }); 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_ASSIGNMENT_CHANGED', message: `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`, endedAt: now.toISOString(), }); } await this.syncPlatformAccessPolicy(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 }, }), ]); await this.syncPlatformAccessPolicy(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 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: { 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; } await this.syncPlatformAccessPolicy(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 group = await this.findInstructorGroup(groupId, organizationId, organizationName); 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: { 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; } await this.syncPlatformAccessPolicy(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 } = 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 group = await this.findInstructorGroup(groupId, 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: 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 }, }); }); await this.syncPlatformAccessPolicy(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 async syncPlatformAccessPolicy( organizationId: string | undefined, organizationName: string, ) { const groups = await this.prisma.studentGroup.findMany({ where: { ...this.organizationWhere( organizationId, organizationName, ), instructorPlatformId: { not: null }, }, include: { instructorPlatform: { select: { id: true, displayName: true, studioSettingsOverride: true, }, }, members: { include: { user: { select: { email: true, }, }, }, }, }, }); const assignments = groups .map((group): GatewayAccessAssignment | null => { const platform = group.instructorPlatform; if (!platform) return null; const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname'); const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id; const studentEmails = group.members .map((member) => member.user.email) .filter(Boolean); if (!studentEmails.length) return null; return { platformId: gatewayPlatformId, platformName: platform.displayName, agentHostname, agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'), studentEmails, }; }) .filter((assignment): assignment is GatewayAccessAssignment => Boolean(assignment)); await this.platformGatewayClient.syncAccess(assignments); } private async findInstructorGroup( groupId: string, organizationId: string | undefined, organizationName: string, ) { const group = await this.prisma.studentGroup.findFirst({ where: { id: groupId, 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 }, ) { const bindingTokens = [binding.nodeId, binding.hostname, binding.ownerEmail] .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, agent.user] .map((value) => this.normalizeAgentToken(value)) .filter(Boolean); return bindingTokens.some((bindingToken) => agentTokens.some((agentToken) => ( agentToken === bindingToken || agentToken.includes(bindingToken) || bindingToken.includes(agentToken) ))); }) ?? 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, }, }, }, }); } 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 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; 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: `/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, }, }); 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, }); } }); 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 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 }; } 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 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): 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((commit) => this.isSubmissionCommit(commit)) .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)); 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 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 || '', }; }).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 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 async grantLmsAppPermission(accessToken: string, authUserId: string) { const launcherApiBaseUrl = this.configService .get('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api') .replace(/\/$/, ''); const response = await fetch( `${launcherApiBaseUrl}/apps/${encodeURIComponent('LMS')}/users/${encodeURIComponent(authUserId)}/permission`, { method: 'POST', headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json', }, body: JSON.stringify({ canView: true, canLaunch: true, }), }, ).catch(() => null); if (!response?.ok) { const payload = await response?.json().catch((): null => null); throw new BadGatewayException({ message: 'Launcher app permission request failed', statusCode: response?.status, error: payload, }); } } 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}일 전`; } }