import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { join } from 'node:path'; import { AuthService } from '../auth/auth.service'; import { PlatformControlService } from '../platform-control/platform-control.service'; import { PrismaService } from '../prisma/prisma.service'; const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references'); const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; @Injectable() export class CurriculumService { constructor( private readonly authService: AuthService, private readonly platformControlService: PlatformControlService, private readonly prisma: PrismaService, ) {} async listCourses(accessToken?: string) { const authUser = accessToken ? await this.authService.me(accessToken).then((response) => response.user).catch(() => null) : null; const lmsUser = authUser?.email ? await this.prisma.user.findUnique({ where: { email: authUser.email } }) : null; if (!lmsUser) { const guideCourse = await this.findPublicGuideCourse(); return { courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [], }; } const enrollments = await this.prisma.studentEnrollment.findMany({ where: { userId: lmsUser.id, status: 'ACTIVE', course: { isActive: true }, }, include: { course: { include: { teacher: { select: { id: true, email: true, name: true, primaryOrganizationName: true, }, }, studentProgress: { where: { userId: lmsUser.id }, take: 1, }, }, }, }, orderBy: { updatedAt: 'desc' }, }); if (enrollments.length) { return { courses: enrollments.map((enrollment) => this.serializeCourseListItem(enrollment.course)), }; } const guideCourse = await this.findPublicGuideCourse(lmsUser.id); return { courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [], }; } async getActiveCurriculum(accessToken?: string, courseId?: string | null) { const authUser = accessToken ? await this.authService.me(accessToken).then((response) => response.user).catch(() => null) : null; const lmsUser = authUser?.email ? await this.prisma.user.findUnique({ where: { email: authUser.email } }) : null; const requestedCourseId = courseId?.trim(); const enrolledCourse = !requestedCourseId && authUser?.email ? await this.prisma.course.findFirst({ where: { isActive: true, enrollments: { some: { user: { email: authUser.email }, status: 'ACTIVE', }, }, }, select: { id: true }, orderBy: { updatedAt: 'desc' }, }) : null; const course = await this.prisma.course.findFirst({ where: { ...(requestedCourseId ? { id: requestedCourseId, ...(authUser?.email ? { OR: [ { slug: PUBLIC_GUIDE_COURSE_SLUG }, { enrollments: { some: { user: { email: authUser.email }, status: 'ACTIVE', }, }, }, ], } : { slug: PUBLIC_GUIDE_COURSE_SLUG }), } : enrolledCourse ? { id: enrolledCourse.id } : { slug: PUBLIC_GUIDE_COURSE_SLUG }), isActive: true, }, include: { teacher: { select: { id: true, email: true, name: true, primaryOrganizationName: true, }, }, 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 }, }, studentProgress: { where: { user: { email: authUser?.email ?? '__NO_AUTH_USER__', }, }, take: 1, }, }, }); if (!course) { return emptyCurriculumResponse(); } const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({ where: { courseId: course.id, enabled: true, }, include: { platform: true, }, }); const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile])); const visualizationProfilesByWeek = new Map>(); visualizationSelections.forEach((selection) => { const profileType = selection.platform.type.toUpperCase(); const environmentProfile = environmentProfileByWeekType.get(`${selection.week}:${profileType}`); const items = visualizationProfilesByWeek.get(selection.week) ?? []; items.push({ id: environmentProfile?.id ?? selection.id, week: selection.week, platformKey: environmentProfile?.platformKey ?? 'futuredrive-local-pc', profileType: selection.platform.type, name: environmentProfile?.name ?? selection.platform.displayName, config: environmentProfile?.config ?? selection.platform.defaultConfig, }); visualizationProfilesByWeek.set(selection.week, items); }); const progress = course.studentProgress[0]; const enrollment = lmsUser ? await this.prisma.studentEnrollment.findFirst({ where: { userId: lmsUser.id, courseId: course.id, status: 'ACTIVE', }, }) : null; const organizationName = enrollment?.organizationName ?? course.organizationName; const organizationId = enrollment?.organizationId ?? undefined; const studentGroup = lmsUser ? await this.prisma.studentGroup.findFirst({ where: { organizationName, members: { some: { userId: lmsUser.id, user: { enrollments: { some: { courseId: course.id, status: 'ACTIVE', }, }, }, }, }, }, select: { id: true, name: true, }, }) : null; const assignedInstructorPlatformIds = await this.findStudentAssignedInstructorPlatformIds( organizationId, organizationName, authUser?.email, authUser?.id, ); const instructorPlatforms = await this.findStudentPlatformOptions( organizationId, organizationName, assignedInstructorPlatformIds, ); const environmentProfilesByPlatformKey = new Map>(); Array.from(visualizationProfilesByWeek.values()).flat().forEach((profile) => { const items = environmentProfilesByPlatformKey.get(profile.platformKey) ?? []; items.push({ id: profile.id, week: profile.week, platformKey: profile.platformKey, profileType: profile.profileType, name: profile.name, config: profile.config, }); environmentProfilesByPlatformKey.set(profile.platformKey, items); }); const fixedPlatforms = course.platforms .filter((coursePlatform) => { return this.isFixedPlatform(coursePlatform.platform); }) .map((coursePlatform) => { const isLocal = this.isFixedPlatform(coursePlatform.platform); return { id: coursePlatform.platform.id, platformKey: coursePlatform.platform.platformKey, title: coursePlatform.platform.displayName, description: coursePlatform.platform.description, organizationName: coursePlatform.platform.organizationName, iconKey: coursePlatform.platform.iconKey, sourceSettings: coursePlatform.platform.defaultStudioSettings, environmentProfiles: environmentProfilesByPlatformKey.get(coursePlatform.platform.platformKey) ?? [], status: isLocal ? '사용 가능' : coursePlatform.status, action: isLocal ? '연결' : coursePlatform.action, state: coursePlatform.state, local: isLocal, }; }); const controlStates = await this.platformControlService.getControlStates( { organizationId, organizationName }, lmsUser?.id ?? null, [ ...fixedPlatforms.filter((platform) => !platform.local).map((platform) => this.platformControlService.makePlatformResource(platform.id, null), ), ...instructorPlatforms.map((platform) => this.platformControlService.makePlatformResource(null, platform.id), ), ], ); const platforms = [ ...fixedPlatforms.map((platform) => { const { local: _local, ...payload } = platform; return platform.local ? payload : this.withControlState( payload, controlStates.get(this.platformControlService.resourceKeyForIds(platform.id, null)), ); }), ...instructorPlatforms.map((platform) => this.withControlState( platform, controlStates.get(this.platformControlService.resourceKeyForIds(null, platform.id)), )), ]; const groupSetting = studentGroup ? await this.prisma.instructorCurriculumSetting.findFirst({ where: { organizationName, courseId: course.id, groupId: studentGroup.id, }, select: { activeWeek: true, activeLearningStep: true, }, }) : null; const defaultSetting = await this.prisma.instructorCurriculumSetting.findFirst({ where: { organizationName, courseId: course.id, groupId: null, }, select: { activeWeek: true, activeLearningStep: true, }, }); const setting = groupSetting ?? defaultSetting; const overrides = await this.prisma.instructorCurriculumWeekOverride.findMany({ where: { organizationName, courseId: course.id, }, }); const overrideByWeek = new Map(overrides.map((override) => [override.week, override])); const weekProgressItems = lmsUser ? await this.prisma.courseStudentWeekProgress.findMany({ where: { courseId: course.id, userId: lmsUser.id, }, }) : []; const weekProgressByWeek = new Map(weekProgressItems.map((item) => [item.week, item])); const baseWeekNumbers = new Set(course.weeks.map((week) => week.week)); const baseWeeks = course.weeks.map((week) => { const override = overrideByWeek.get(week.week); const weekProgress = weekProgressByWeek.get(week.week); const disabledModuleIdSet = new Set(this.stringArray(override?.disabledModuleIds) ?? []); 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 practiceContents = this.practiceContentsArray(override?.practiceContents) ?? (week.practices.length ? week.practices.map((practice) => ({ title: practice.title, description: practice.description, })) : undefined); const progressState = this.resolveWeekProgressState(week.week, week.state, topics, setting); return { week: week.week, title: override?.title ?? week.title, tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state), progress: progressState.progress, state: progressState.state, topics, duration: override?.duration ?? week.duration, github: week.github, detail: { overview: override?.overview ?? week.overview, 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, requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission, sourceSubmitted: Boolean(weekProgress?.submittedAt), sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null, }, modules: week.modules .filter((module) => !disabledModuleIdSet.has(module.id)) .map((module) => { const moduleWithArtifacts = module as typeof module & { sourceType?: string | null; prebuiltArtifacts?: unknown; }; return { id: module.id, sortOrder: module.sortOrder, category: module.category, name: module.name, folderName: module.folderName, templateRepositoryUrl: module.templateRepositoryUrl, sourceType: moduleWithArtifacts.sourceType ?? 'GIT', prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [], dockerImage: module.dockerImage, }; }), environmentProfiles: visualizationProfilesByWeek.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); const weekProgress = weekProgressByWeek.get(override.week); return { week: override.week, title: override.title ?? `${override.week}주차 커리큘럼`, tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state), progress: progressState.progress, state: progressState.state, topics, duration: override.duration ?? '-', github: '-', detail: { overview: override.overview ?? '', learningSteps, topicDetails: learningSteps.map((step) => step.description), topicVideoUrls: learningSteps.map((step) => step.videoUrl), practiceContents: this.practiceContentsArray(override.practiceContents) ?? undefined, references: [], deliverable: override.deliverable ?? '', requiresSourceSubmission: override.requiresSourceSubmission ?? true, sourceSubmitted: Boolean(weekProgress?.submittedAt), sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null, }, environmentProfiles: visualizationProfilesByWeek.get(override.week) ?? [], }; }); return { course: { id: course.id, slug: course.slug, title: course.title, subtitle: course.subtitle, description: course.description, organizationName: course.organizationName, teacher: course.teacher, }, weeks: [...baseWeeks, ...addedWeeks].sort((left, right) => left.week - right.week), activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? null, activeLearningStep: setting?.activeLearningStep ?? null, learningStats: this.serializeLearningStats(progress, course.weeks.length), platforms, }; } async getReferenceDownload(accessToken: string | undefined, referenceId: string) { const { user: authUser } = await this.authService.me(accessToken); if (!authUser?.email) throw new UnauthorizedException('Invalid user'); const reference = await this.prisma.courseWeekReference.findUnique({ where: { id: referenceId }, include: { week: { include: { course: { include: { enrollments: { where: { status: 'ACTIVE', user: { email: authUser.email }, }, take: 1, }, }, }, }, }, }, }); if (!reference || !reference.week.course.enrollments.length) { throw new NotFoundException('Reference not found'); } return { path: join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName), fileName: reference.originalFileName, contentType: reference.contentType ?? 'application/octet-stream', }; } private withControlState( platform: { id: string; platformKey: string; title: string; description: string; organizationName: string; iconKey: string; status: string; action: string; state: string; }, controlState: | { status: string; action: string; state: string; session: unknown; queue: unknown; warning: boolean; } | undefined, ) { if (!controlState) return platform; return { ...platform, status: controlState.status === 'AVAILABLE' ? platform.status : this.toControlStatusLabel(controlState.status), action: controlState.status === 'AVAILABLE' ? platform.action : controlState.action, state: controlState.state, control: controlState, }; } private async findPublicGuideCourse(userId?: string) { return this.prisma.course.findFirst({ where: { slug: PUBLIC_GUIDE_COURSE_SLUG, isActive: true, }, include: { teacher: { select: { id: true, email: true, name: true, primaryOrganizationName: true, }, }, studentProgress: { where: { userId: userId ?? '00000000-0000-0000-0000-000000000000' }, take: 1, }, }, }); } private serializeCourseListItem( course: { id: string; slug: string; title: string; subtitle: string | null; description: string | null; organizationName: string; teacher: { id: string; email: string; name: string | null; primaryOrganizationName: string | null; }; studentProgress: Array<{ completedWeeks: number; totalWeeks: number; }>; }, publicGuide = false, ) { const progress = course.studentProgress[0]; return { id: course.id, slug: course.slug, title: course.title, subtitle: course.subtitle, description: course.description, organizationName: course.organizationName, teacher: course.teacher, progress: progress && progress.totalWeeks > 0 ? Math.round((progress.completedWeeks / progress.totalWeeks) * 100) : 0, publicGuide, }; } private serializeLearningStats( progress: { completedWeeks: number; totalWeeks: number; learningHours: number; commitCount: number; averageScore: number; } | null | undefined, fallbackTotalWeeks: number, ) { const completedWeeks = progress?.completedWeeks ?? 0; const totalWeeks = progress?.totalWeeks ?? fallbackTotalWeeks; const learningHours = progress?.learningHours ?? 0; const commitCount = progress?.commitCount ?? 0; const averageScore = progress?.averageScore ?? 0; return [ { key: 'completedWeeks', value: `${completedWeeks}/${totalWeeks}`, label: '완료한 주차', iconKey: 'check', }, { key: 'learningHours', value: this.formatLearningHours(learningHours), label: '학습 시간', iconKey: 'clock', }, { key: 'commitCount', value: String(commitCount), label: '커밋 수', iconKey: 'trending', }, { key: 'averageScore', value: String(averageScore), label: '평균 점수', iconKey: 'gauge', }, ]; } private toControlStatusLabel(status: string) { if (status === 'ACTIVE') return '제어 중'; if (status === 'QUEUED') return '대기 예약됨'; if (status === 'IN_USE') return '다른 수강생 사용 중'; return '사용 가능'; } private async findStudentAssignedInstructorPlatformIds( organizationId: string | undefined, organizationName: string, email: string | undefined, authUserId: string | undefined, ) { const userConditions = [ email ? { email } : undefined, authUserId ? { authUserId } : undefined, ].filter(Boolean) as Prisma.UserWhereInput[]; if (!userConditions.length) return []; const groups = await this.prisma.studentGroup.findMany({ where: { ...this.organizationWhere( organizationId, organizationName, ), instructorPlatformId: { not: null }, members: { some: { user: { OR: userConditions, }, }, }, }, select: { instructorPlatformId: true, }, }); const assignedInstructorPlatformIds = [ ...new Set( groups .map((group) => group.instructorPlatformId) .filter((platformId): platformId is string => Boolean(platformId)), ), ]; return assignedInstructorPlatformIds; } private async findStudentPlatformOptions( organizationId: string | undefined, organizationName: string, assignedInstructorPlatformIds: string[], ) { if (!assignedInstructorPlatformIds.length) return []; const platforms = await this.prisma.instructorPlatform.findMany({ where: { ...this.organizationWhere( organizationId, organizationName, ), id: { in: assignedInstructorPlatformIds }, platform: { is: { isActive: true, NOT: [ { platformKey: { contains: 'local', mode: 'insensitive' } }, { displayName: { contains: '로컬', mode: 'insensitive' } }, ], }, }, }, orderBy: [{ displayName: 'asc' }], include: { platform: true }, }); return platforms.map((platform) => { const blocked = platform.emergencyStop || platform.errorCount > 0; return { id: platform.id, platformKey: platform.platform.platformKey, title: platform.displayName, description: platform.platform.description, organizationName: platform.organizationName, iconKey: platform.platform.iconKey, sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings, status: blocked ? platform.emergencyStop ? '비상 정지' : '오류 발생' : '상태 확인 중', action: blocked ? '연결 불가' : '제어권 요청', state: blocked ? 'locked' : 'ready', }; }); } private organizationWhere( organizationId: string | undefined, organizationName: string | undefined, ): T { return { OR: [ organizationId ? { organizationId } : undefined, organizationName ? { organizationName } : undefined, ].filter(Boolean), } as T; } private isFixedPlatform(platform: { platformKey: string; displayName: string }) { const platformKey = platform.platformKey.toLowerCase(); const displayName = platform.displayName.toLowerCase(); return ( platformKey.includes('local') || displayName.includes('로컬') ); } private stringArray(value: unknown): string[] | null { if (!Array.isArray(value)) return null; return value.filter((item): item is string => typeof item === 'string'); } private resolveLearningSteps( value: unknown, topics: string[], topicDetails: string[] = [], topicVideoUrls: string[] = [], ): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> { return this.normalizeLearningSteps(value) ?? this.normalizeLearningSteps(undefined, topics, topicDetails, topicVideoUrls) ?? []; } private normalizeLearningSteps( value?: unknown, topics: string[] = [], topicDetails: string[] = [], topicVideoUrls: string[] = [], ): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> | null { if (Array.isArray(value)) { const steps = value .map((item) => { if (!item || typeof item !== 'object') return null; const record = item as Record; const title = typeof record.title === 'string' ? record.title.trim() : ''; const description = typeof record.description === 'string' ? record.description.trim() : ''; const videoUrl = typeof record.videoUrl === 'string' ? record.videoUrl.trim() : ''; const videoEnabled = typeof record.videoEnabled === 'boolean' ? record.videoEnabled : Boolean(videoUrl); const concepts = Array.isArray(record.concepts) ? record.concepts.filter((concept): concept is string => typeof concept === 'string').map((concept) => concept.trim()).filter(Boolean) : []; return title || description || videoUrl || concepts.length ? { title: title || '학습단계', description, videoEnabled, videoUrl: videoEnabled ? videoUrl : '', concepts } : null; }) .filter((item): item is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(item)); return steps.length ? steps : null; } const titles = topics.map((topic) => topic.trim()).filter(Boolean); if (!titles.length) return null; return titles.map((title, index) => { const videoUrl = topicVideoUrls[index]?.trim() ?? ''; return { title, description: topicDetails[index]?.trim() ?? '', videoEnabled: Boolean(videoUrl), videoUrl, concepts: [], }; }); } private practiceContentsArray(value: unknown): Array<{ title: string; description: string }> | null { if (!Array.isArray(value)) return null; return value .map((item) => { if (!item || typeof item !== 'object') return null; const record = item as Record; const title = typeof record.title === 'string' ? record.title.trim() : ''; const description = typeof record.description === 'string' ? record.description.trim() : ''; return title || description ? { title, description } : null; }) .filter((item): item is { title: string; description: string } => Boolean(item)); } private serializeWeekReference( reference: { id: string; kind: string; title: string; originalFileName: string; contentType: string | null; fileSizeBytes: number; createdAt: Date; }, week: number, ) { return { id: reference.id, kind: reference.kind, title: reference.title, description: reference.originalFileName, fileName: reference.originalFileName, contentType: reference.contentType, fileSizeBytes: reference.fileSizeBytes, week, weekNumber: week, downloadUrl: `/curriculum/references/${reference.id}/download`, createdAt: reference.createdAt.toISOString(), }; } private resolveWeekProgressState( week: number, baseState: string, topics: string[], setting: { activeWeek: number; activeLearningStep?: string | null } | null | undefined, ) { if (!setting) return { state: baseState, progress: baseState === 'done' ? 100 : 0 }; if (week < setting.activeWeek) return { state: 'done', progress: 100 }; if (week > setting.activeWeek) return { state: 'locked', progress: 0 }; const progress = this.resolveLearningStepProgress(topics, setting.activeLearningStep); return { state: progress >= 100 ? 'done' : 'current', progress, }; } private resolveLearningStepProgress(topics: string[], activeLearningStep?: string | null) { if (!topics.length) return activeLearningStep === null ? 100 : 0; if (activeLearningStep === null) return 100; const activeIndex = topics.findIndex((topic) => topic === activeLearningStep); if (activeIndex < 0) return 0; return Math.min(99, Math.round((activeIndex / topics.length) * 100)); } private resolveWeekTags(tags: string[], state: string) { const nextTags = tags.length ? [...tags] : ['커리큘럼']; nextTags[1] = state === 'done' ? '완료' : state === 'current' ? '진행중' : state === 'locked' ? '잠김' : '대기'; return nextTags; } private resolveBaseWeekTags(tags: string[], tagLabel?: string | null) { const nextTags = tags.length ? [...tags] : ['커리큘럼']; const label = tagLabel?.trim(); if (label) nextTags[0] = label; return nextTags; } private formatLearningHours(learningMinutes: number) { const totalMinutes = Math.max(0, Math.floor(learningMinutes)); if (totalMinutes < 60) return `${totalMinutes}m`; const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; } } function emptyCurriculumResponse() { return { course: null, weeks: [], learningStats: [], platforms: [], }; }