Files
lms-be/src/instructor/instructor.service.ts
T
2026-06-22 18:17:02 +09:00

3322 lines
114 KiB
TypeScript

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<string, number[]>();
if (course && organizationName) {
const groupSettings = await this.prisma.instructorCurriculumSetting.findMany({
where: {
organizationName,
courseId: course.id,
groupId: { not: null },
},
include: {
group: true,
},
});
groupSettings.forEach((setting) => {
const completedThroughWeek = setting.activeLearningStep ? setting.activeWeek - 1 : setting.activeWeek;
completedWeekNumbersByGroupName.set(
setting.group?.name ?? '미배정',
courseWeeks.filter((week) => week.week <= completedThroughWeek).map((week) => week.week),
);
});
}
const students = enrollments
.filter((enrollment) => enrollment.user.id !== instructor.id)
.map((enrollment) => {
const user = enrollment.user;
const progress = user.progress[0];
const group = user.groupMemberships[0]?.group;
const groupName = group?.name ?? '미배정';
const progressPercent = progress && progress.totalWeeks > 0
? Math.round((progress.completedWeeks / progress.totalWeeks) * 100)
: 0;
const issues: StudentIssueItem[] = [];
const completedWeekNumbers = completedWeekNumbersByGroupName.get(groupName) ?? [];
completedWeekNumbers.forEach((weekNumber) => {
if (weekProgressByUserWeek.get(`${user.id}:${weekNumber}`)?.submittedAt) return;
const weekTitle = courseWeeks.find((week) => week.week === weekNumber)?.title ?? `${weekNumber}주차`;
issues.push({
type: 'COMPLETED_WITHOUT_SUBMISSION',
title: `${weekNumber}주차 완료 후 미제출`,
detail: `${weekTitle} 주차가 완료 처리되었지만 제출 기록이 없습니다.`,
week: weekNumber,
});
});
courseWeeks
.filter((week) => {
const isSubmitted = Boolean(weekProgressByUserWeek.get(`${user.id}:${week.week}`)?.submittedAt);
const isPastByProgress = (progress?.completedWeeks ?? 0) >= week.week;
const isCompletedByGroup = completedWeekNumbers.includes(week.week);
return 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<string, SubmissionProgress>();
const scores = studentIds.length && weekIds.length
? await this.prisma.submissionTotalScore.findMany({
where: {
studentId: { in: studentIds },
weekId: { in: weekIds },
},
select: {
studentId: true,
weekId: true,
score: true,
},
})
: [];
const scoreByStudentWeek = new Map(scores.map((score) => [`${score.studentId}:${score.weekId}`, score.score]));
await Promise.all(repositories.map(async (repository) => {
const progress = await this.readSubmissionProgress(repository.repoOwner, repository.repoName).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByRepositoryId.set(repository.id, progress);
}));
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<string>(
'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<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
);
const platforms = await this.prisma.instructorPlatform.findMany({
where: {
...where,
platform: {
is: {
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ platformKey: { contains: 'simulator', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
{ displayName: { contains: '시뮬', mode: 'insensitive' } },
{ displayName: { contains: 'simulator', mode: 'insensitive' } },
],
},
},
},
orderBy: { displayName: 'asc' },
include: { platform: true },
});
const platformIds = platforms.map((platform) => platform.id);
const assignedGroups = platformIds.length
? await this.prisma.studentGroup.findMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: { in: platformIds },
},
include: {
members: {
take: 1,
include: { user: true },
},
},
})
: [];
const groupByPlatform = new Map(
assignedGroups.map((group) => [group.instructorPlatformId, group]),
);
const activeControlSessions = platformIds.length
? await this.prisma.platformControlSession.findMany({
where: {
instructorPlatformId: { in: platformIds },
status: 'ACTIVE',
},
include: {
holderUser: {
select: {
name: true,
email: true,
},
},
},
})
: [];
const activeControlByPlatform = new Map(
activeControlSessions
.filter((session) => session.instructorPlatformId)
.map((session) => [session.instructorPlatformId, session]),
);
const boundAgentValues = platforms.flatMap((platform) => {
const agentNodeId = this.readStringSetting(platform.studioSettingsOverride, 'agentNodeId');
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail');
return [agentNodeId, agentHostname, agentOwnerEmail].filter((value): value is string => Boolean(value));
});
const agents = boundAgentValues.length
? await this.platformGatewayClient.listAgents(authUser.email).catch(() => [])
: [];
const 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<number, typeof visualizationSelections>();
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<Prisma.InstructorPlatformWhereInput>(
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<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new NotFoundException('Platform not found');
try {
await this.prisma.$transaction([
this.prisma.instructorPlatform.update({
where: { id: platform.id },
data: {
displayName,
vehicleType,
wheelbaseMeters,
maxSpeedMetersPerSecond,
maxSteeringAngleDegrees,
sensors,
algorithms,
controlDurationMinutes,
controlWarningMinutes,
},
}),
this.prisma.studentGroup.updateMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: platform.id,
},
data: {
platformName: displayName,
},
}),
]);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException('Platform name already exists');
}
throw error;
}
return this.listPlatforms(accessToken);
}
async assignPlatformToGroup(
accessToken: string | undefined,
platformId: string,
groupId: string,
force = false,
) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
if (!organizationName) throw new BadRequestException('Instructor organization is required');
const platform = await this.prisma.instructorPlatform.findFirst({
where: {
id: platformId,
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new NotFoundException('Platform not found');
const group = await this.prisma.studentGroup.findFirst({
where: {
id: groupId,
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
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<Prisma.StudentGroupWhereInput>(
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<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new NotFoundException('Platform not found');
const activeSession = await this.prisma.platformControlSession.findFirst({
where: {
instructorPlatformId: platform.id,
status: 'ACTIVE',
},
orderBy: { startedAt: 'desc' },
include: {
holderUser: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
if (activeSession && !force) {
throw new ConflictException({
code: 'PLATFORM_IN_USE',
message: '현재 학생이 플랫폼을 사용 중입니다.',
activeSession: {
id: activeSession.id,
holderUserId: activeSession.holderUserId,
holderName: activeSession.holderUser?.name ?? activeSession.holderUser?.email ?? null,
holderEmail: activeSession.holderUser?.email ?? null,
startedAt: activeSession.startedAt,
endsAt: activeSession.endsAt,
},
});
}
const now = new Date();
const terminatedSessionNotice = activeSession && force
? {
userId: activeSession.holderUserId,
sessionId: activeSession.id,
}
: null;
await this.prisma.$transaction([
this.prisma.studentGroup.updateMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: platform.id,
},
data: {
instructorPlatformId: null,
platformName: null,
},
}),
this.prisma.platformControlSession.updateMany({
where: {
instructorPlatformId: platform.id,
status: 'ACTIVE',
},
data: {
status: 'RELEASED',
releasedAt: now,
endReason: 'PLATFORM_DELETED',
},
}),
this.prisma.platformControlQueueEntry.updateMany({
where: {
instructorPlatformId: platform.id,
status: 'WAITING',
},
data: {
status: 'CANCELED',
canceledAt: now,
},
}),
this.prisma.platformControlSession.updateMany({
where: {
instructorPlatformId: platform.id,
},
data: {
instructorPlatformId: null,
},
}),
this.prisma.platformControlQueueEntry.updateMany({
where: {
instructorPlatformId: platform.id,
},
data: {
instructorPlatformId: null,
},
}),
this.prisma.instructorPlatform.delete({
where: { id: platform.id },
}),
]);
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<Prisma.StudentGroupWhereInput>(
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<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
platform: {
is: {
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ platformKey: { contains: 'simulator', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
{ displayName: { contains: '시뮬', mode: 'insensitive' } },
{ displayName: { contains: 'simulator', mode: 'insensitive' } },
],
},
},
},
orderBy: { displayName: 'asc' },
select: { id: true, displayName: true },
});
return platforms.map((platform) => ({
id: platform.id,
name: platform.displayName,
}));
}
private normalizePlatformName(platformName: string | null | undefined) {
const value = platformName?.trim();
return value && value !== 'unassigned' && value !== '미배정' ? value : null;
}
private async resolveInstructorPlatformAssignment(
input: Pick<SaveInstructorGroupDto, 'instructorPlatformId' | 'platformName'>,
organizationId: string | undefined,
organizationName: string,
) {
const instructorPlatformId = input.instructorPlatformId?.trim();
const platformName = this.normalizePlatformName(input.platformName);
if (!instructorPlatformId && !platformName) return null;
const platformSelector: Prisma.InstructorPlatformWhereInput = instructorPlatformId
? { id: instructorPlatformId }
: { displayName: platformName as string };
const platform = await this.prisma.instructorPlatform.findFirst({
where: {
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
...platformSelector,
},
select: {
id: true,
displayName: true,
},
});
if (!platform) throw new BadRequestException('Assigned platform not found');
return platform;
}
private normalizeDisplayName(displayName: string | null | undefined) {
const value = displayName?.trim().replace(/\s+/g, ' ');
return value || null;
}
private readStringSetting(settings: unknown, key: string) {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null;
const value = (settings as Record<string, unknown>)[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private findBoundAgent(
agents: GatewayAgent[],
binding: { nodeId?: string | null; hostname?: string | null; ownerEmail?: string | null },
) {
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<string, unknown> {
return settings && typeof settings === 'object' && !Array.isArray(settings)
? { ...(settings as Record<string, unknown>) }
: {};
}
private async findInstructorCourse(organizationName: string, instructorId: string, courseId?: string | null) {
return this.prisma.course.findFirst({
where: {
isActive: true,
teacherId: instructorId,
...(courseId?.trim()
? { id: courseId.trim(), organizationName }
: { organizationName }),
},
include: {
weeks: {
orderBy: { week: 'asc' },
include: {
topics: {
orderBy: { sortOrder: 'asc' },
},
practices: {
orderBy: { sortOrder: 'asc' },
},
references: {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
},
modules: {
orderBy: { sortOrder: 'asc' },
},
},
},
platforms: {
orderBy: { sortOrder: 'asc' },
include: {
platform: true,
},
},
},
});
}
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<string, unknown>;
const title = typeof record.title === 'string' ? record.title.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
return title || description ? { title, description } : null;
})
.filter((item): item is { title: string; description: string } => Boolean(item));
}
private normalizePracticeContents(value: Array<{ title?: string; description?: string }> | undefined) {
if (!value) return undefined;
return value
.map((item) => ({
title: item.title?.trim() ?? '',
description: item.description?.trim() ?? '',
}))
.filter((item) => item.title || item.description);
}
private normalizeUploadedFileName(fileName: string) {
if (!fileName) return 'reference';
const restored = Buffer.from(fileName, 'latin1').toString('utf8');
return restored.includes('\uFFFD') ? fileName : restored;
}
private serializeWeekReference(
reference: {
id: string;
kind: string;
title: string;
originalFileName: string;
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<string, { key: string; label: string }>();
options.set('none', { key: 'none', label: '없음' });
platforms.forEach((platform) => {
const label = this.toPlatformBaseName(platform.displayName);
if (!label || options.has(label)) return;
options.set(label, {
key: platform.platformKey,
label,
});
});
return Array.from(options.values());
}
private optionalTrim(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed || null;
}
private async findMasterPlatforms() {
const platforms = await this.prisma.platform.findMany({
where: {
isActive: true,
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ platformKey: { contains: 'simulator', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
{ displayName: { contains: '시뮬', mode: 'insensitive' } },
{ displayName: { contains: 'simulator', mode: 'insensitive' } },
],
},
orderBy: { displayName: 'asc' },
select: {
id: true,
displayName: true,
description: true,
iconKey: true,
},
});
const byBaseName = new Map<string, {
id: string;
name: string;
description: string;
iconKey: string;
}>();
platforms.forEach((platform) => {
const baseName = this.toPlatformBaseName(platform.displayName);
if (!byBaseName.has(baseName)) {
byBaseName.set(baseName, {
id: platform.id,
name: baseName,
description: platform.description,
iconKey: platform.iconKey,
});
}
});
return [...byBaseName.values()];
}
private organizationWhere<T>(
organizationId: string | undefined,
organizationName: string | undefined,
): T {
return {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean),
} as T;
}
private toPlatformBaseName(displayName: string) {
return displayName.replace(/\s*#\d+$/i, '').trim();
}
private parsePlatformNo(displayName: string) {
const match = displayName.match(/#(\d+)$/);
return match ? Number(match[1]) : 0;
}
private toPlatformType(displayName: string) {
return `${this.toPlatformBaseName(displayName)} 플랫폼`;
}
private formatUptime(minutes: number) {
if (minutes < 60) return `${minutes}분`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`;
}
private 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<SubmissionProgress> {
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<SubmissionCommit[]> {
const commits = await this.fetchGiteaJson<unknown[]>(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits?limit=50`,
).catch(() => []);
const submissionCommits = commits
.map((commit) => this.normalizeGiteaCommit(commit))
.filter((commit): commit is Omit<SubmissionCommit, 'files'> => Boolean(commit))
.filter((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<SubmissionCommitFile[]> {
if (!commits.length) return [];
if (commits.length === 1) return commits[0].files;
const latest = commits[0];
const oldest = commits[commits.length - 1];
const baseHash = await this.readGiteaCommitParent(owner, repoName, oldest.hash).catch(() => null);
if (baseHash) {
const diffText = await this.fetchGiteaText([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
]).catch(() => '');
const diffFiles = this.parseDiffFiles(diffText).filter((file) => !this.isIgnoredSubmissionFile(file.path));
if (diffFiles.length) return diffFiles;
}
const byPath = new Map<string, SubmissionCommitFile>();
[...commits].reverse().forEach((commit) => {
commit.files.forEach((file) => {
if (file.status.toLowerCase() === 'deleted') byPath.delete(file.path);
else byPath.set(file.path, file);
});
});
return Array.from(byPath.values());
}
private async readGiteaCommitParent(owner: string, repoName: string, hash: string): Promise<string | null> {
const detail = await this.fetchFirstGiteaJson<Record<string, unknown>>([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`,
]);
const parents = Array.isArray(detail.parents) ? detail.parents : [];
const parent = parents[0];
if (!parent || typeof parent !== 'object') return null;
const item = parent as Record<string, unknown>;
return String(item.sha ?? item.id ?? '') || null;
}
private async readGiteaCommitFiles(owner: string, repoName: string, hash: string): Promise<SubmissionCommitFile[]> {
const detail = await this.fetchFirstGiteaJson<Record<string, unknown>>([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`,
]).catch(() => null);
const files = Array.isArray(detail?.files) ? detail.files : [];
const diffText = await this.fetchGiteaText([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}.diff`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(hash)}.patch`,
]).catch(() => '');
const diffFiles = this.parseDiffFiles(diffText);
const diffByPath = new Map(diffFiles.map((file) => [file.path, file]));
if (files.length) {
return files.map((file) => {
const item = file as Record<string, unknown>;
const path = String(item.filename ?? item.path ?? '');
const diffFile = diffByPath.get(path);
return {
path,
status: String(item.status ?? diffFile?.status ?? 'modified'),
patch: String(item.patch ?? '') || diffFile?.patch || '',
};
}).filter((file) => file.path && !this.isIgnoredSubmissionFile(file.path));
}
return diffFiles.filter((file) => !this.isIgnoredSubmissionFile(file.path));
}
private isIgnoredSubmissionFile(path: string): boolean {
return path === '.gitignore' || path.endsWith('/.gitignore') || path === '.autonomy-studio-git.json';
}
private normalizeGiteaCommit(commit: unknown): Omit<SubmissionCommit, 'files'> | null {
if (!commit || typeof commit !== 'object') return null;
const item = commit as Record<string, unknown>;
const nested = (item.commit && typeof item.commit === 'object' ? item.commit : {}) as Record<string, unknown>;
const author = (nested.author && typeof nested.author === 'object' ? nested.author : {}) as Record<string, unknown>;
const committer = (nested.committer && typeof nested.committer === 'object' ? nested.committer : {}) as Record<string, unknown>;
const userAuthor = (item.author && typeof item.author === 'object' ? item.author : {}) as Record<string, unknown>;
const hash = String(item.sha ?? item.id ?? '');
if (!hash) return null;
const message = String(nested.message ?? item.message ?? '').split('\n')[0] ?? '';
return {
hash,
shortHash: hash.slice(0, 8),
message,
author: String(author.name ?? userAuthor.login ?? 'unknown'),
authorEmail: String(author.email ?? ''),
date: String(author.date ?? committer.date ?? item.created_at ?? '') || null,
};
}
private isSubmissionCommit(commit: Omit<SubmissionCommit, 'files'>): boolean {
const botEmail = this.configService
.get<string>('STUDIO_GIT_AUTHOR_EMAIL', DEFAULT_SUBMISSION_BOT_EMAIL)
.toLowerCase();
return commit.authorEmail.toLowerCase() === botEmail || commit.message.includes('제출');
}
private async fetchGiteaJson<T>(path: string): Promise<T> {
const response = await this.fetchGitea(path);
return response.json() as Promise<T>;
}
private async fetchFirstGiteaJson<T>(paths: string[]): Promise<T> {
let lastError: unknown = null;
for (const path of paths) {
try {
return await this.fetchGiteaJson<T>(path);
} catch (error) {
lastError = error;
}
}
throw lastError instanceof Error ? lastError : new BadGatewayException('Gitea API request failed');
}
private async fetchGiteaText(paths: string[]): Promise<string> {
for (const path of paths) {
const response = await this.fetchGitea(path).catch(() => null);
if (response?.ok) return response.text();
}
return '';
}
private async fetchGitea(path: string): Promise<Response> {
const giteaBaseUrl = this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/+$/, '');
const token = this.configService.get<string>('GITEA_ADMIN_TOKEN', '');
if (!token) throw new BadGatewayException('GITEA_ADMIN_TOKEN is not configured');
const response = await fetch(`${giteaBaseUrl}${path}`, {
headers: {
Authorization: `token ${token}`,
},
});
if (!response.ok) {
throw new BadGatewayException(await response.text());
}
return response;
}
private parseDiffFiles(diffText: string): SubmissionCommitFile[] {
const files: SubmissionCommitFile[] = [];
let current: SubmissionCommitFile | null = null;
diffText.split('\n').forEach((line) => {
const match = line.match(/^diff --git a\/(.+) b\/(.+)$/);
if (match) {
current = {
path: match[2],
status: 'modified',
patch: '',
};
files.push(current);
return;
}
if (!current) return;
if (line.startsWith('new file mode ')) current.status = 'added';
if (line.startsWith('deleted file mode ')) current.status = 'deleted';
current.patch = `${current.patch}${current.patch ? '\n' : ''}${line}`;
});
return files.filter((file) => file.path);
}
private async grantLmsAppPermission(accessToken: string, authUserId: string) {
const launcherApiBaseUrl = this.configService
.get<string>('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}일 전`;
}
}