930 lines
31 KiB
TypeScript
930 lines
31 KiB
TypeScript
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<number, Array<{
|
|
id: string;
|
|
week: number;
|
|
platformKey: string;
|
|
profileType: string;
|
|
name: string;
|
|
config: Prisma.JsonValue;
|
|
}>>();
|
|
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<string, Array<{
|
|
id: string;
|
|
week: number;
|
|
platformKey: string;
|
|
profileType: string;
|
|
name: string;
|
|
config: Prisma.JsonValue;
|
|
}>>();
|
|
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<Prisma.StudentGroupWhereInput>(
|
|
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<Prisma.InstructorPlatformWhereInput>(
|
|
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<T>(
|
|
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<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 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: [],
|
|
};
|
|
}
|