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