Sync LMS backend source
This commit is contained in:
@@ -0,0 +1,929 @@
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user