patch curriculum..

This commit is contained in:
2026-08-07 10:27:19 +09:00
parent f8ccd8b0a3
commit 196ba51ec6
9 changed files with 353 additions and 199 deletions
+10
View File
@@ -46,6 +46,16 @@ export class CurriculumController {
return this.curriculumService.listCourses(this.getBearerToken(request));
}
@Get('course-catalog')
searchCourseCatalog(@Req() request: Request, @Query('query') query?: string) {
return this.curriculumService.searchCourseCatalog(this.getBearerToken(request), query);
}
@Post('course-catalog/:courseId/enroll')
enrollCourse(@Req() request: Request, @Param('courseId') courseId: string) {
return this.curriculumService.enrollCourse(this.getBearerToken(request), courseId);
}
@Get('references/:referenceId/download')
async downloadReference(
@Req() request: Request,
+137 -36
View File
@@ -2,7 +2,7 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException,
import { Prisma } from '@prisma/client';
import { join } from 'node:path';
import { AuthService } from '../auth/auth.service';
import { AuthResponseUser, AuthService } from '../auth/auth.service';
import { PlatformControlService } from '../platform-control/platform-control.service';
import { PrismaService } from '../prisma/prisma.service';
import { R2PublicFileService } from '../storage/r2-public-file.service';
@@ -30,20 +30,21 @@ export class CurriculumService {
const lmsUser = authUser?.email
? await this.prisma.user.findUnique({ where: { email: authUser.email } })
: null;
const guideCourse = await this.findPublicGuideCourse(lmsUser?.id);
const guideCourseItem = guideCourse ? this.serializeCourseListItem(guideCourse, true) : null;
if (!lmsUser) {
const guideCourse = await this.findPublicGuideCourse();
return {
courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [],
};
return { courses: [], guideCourse: guideCourseItem };
}
const enrollments = await this.prisma.studentEnrollment.findMany({
where: {
userId: lmsUser.id,
status: 'ACTIVE',
course: { isActive: true },
course: {
isActive: true,
slug: { not: PUBLIC_GUIDE_COURSE_SLUG },
},
},
include: {
course: {
@@ -66,19 +67,128 @@ export class CurriculumService {
orderBy: { updatedAt: 'desc' },
});
if (enrollments.length) {
return {
courses: enrollments.map((enrollment) => this.serializeCourseListItem(enrollment.course)),
};
}
return {
courses: enrollments.map((enrollment) => this.serializeCourseListItem(enrollment.course)),
guideCourse: guideCourseItem,
};
}
const guideCourse = await this.findPublicGuideCourse(lmsUser.id);
async searchCourseCatalog(accessToken: string | undefined, query?: string) {
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const keyword = query?.trim() ?? '';
const courses = await this.prisma.course.findMany({
where: {
isActive: true,
slug: { not: PUBLIC_GUIDE_COURSE_SLUG },
...(keyword
? {
OR: [
{ title: { contains: keyword, mode: 'insensitive' } },
{ teacher: { name: { contains: keyword, mode: 'insensitive' } } },
{ teacher: { email: { contains: keyword, mode: 'insensitive' } } },
],
}
: {}),
},
select: {
id: true,
title: true,
subtitle: true,
organizationName: true,
teacher: {
select: {
name: true,
email: true,
primaryOrganizationName: true,
},
},
},
orderBy: [{ title: 'asc' }],
take: 20,
});
const lmsUser = await this.prisma.user.findUnique({
where: { email: authUser.email },
select: { id: true },
});
const enrolledIds = lmsUser
? new Set((await this.prisma.studentEnrollment.findMany({
where: {
userId: lmsUser.id,
courseId: { in: courses.map((course) => course.id) },
status: 'ACTIVE',
},
select: { courseId: true },
})).map((enrollment) => enrollment.courseId))
: new Set<string>();
return {
courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [],
courses: courses.map((course) => ({
...course,
enrolled: enrolledIds.has(course.id),
})),
};
}
async enrollCourse(accessToken: string | undefined, courseId: string) {
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const normalizedCourseId = courseId.trim();
if (!normalizedCourseId) throw new BadRequestException('courseId is required');
const course = await this.prisma.course.findFirst({
where: {
id: normalizedCourseId,
isActive: true,
slug: { not: PUBLIC_GUIDE_COURSE_SLUG },
},
include: {
teacher: {
select: {
primaryOrganizationId: true,
},
},
},
});
if (!course) throw new NotFoundException('수강 신청할 강좌를 찾을 수 없습니다.');
const lmsUser = await this.prisma.user.upsert({
where: { email: authUser.email },
update: this.toUserSyncData(authUser),
create: {
email: authUser.email,
...this.toUserSyncData(authUser),
},
});
await this.prisma.studentEnrollment.upsert({
where: {
userId_courseId: {
userId: lmsUser.id,
courseId: course.id,
},
},
update: {
organizationId: course.teacher.primaryOrganizationId,
organizationName: course.organizationName,
status: 'ACTIVE',
removedAt: null,
},
create: {
userId: lmsUser.id,
courseId: course.id,
organizationId: course.teacher.primaryOrganizationId,
organizationName: course.organizationName,
status: 'ACTIVE',
},
});
return { enrolled: true, courseId: course.id };
}
async getActiveCurriculum(accessToken?: string, courseId?: string | null) {
const authUser = accessToken
? await this.authService.me(accessToken).then((response) => response.user).catch(() => null)
@@ -197,15 +307,6 @@ export class CurriculumService {
customModulesByWeek.set(module.week, items);
});
const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({
where: {
courseId: course.id,
enabled: true,
},
include: {
platform: true,
},
});
const mapKeysByWeek = new Map<number, string[]>();
course.weekMaps.forEach((selection) => {
const items = mapKeysByWeek.get(selection.week) ?? [];
@@ -232,19 +333,6 @@ export class CurriculumService {
});
visualizationProfilesByWeek.set(profile.week, items);
});
visualizationSelections.forEach((selection) => {
const items = visualizationProfilesByWeek.get(selection.week) ?? [];
if (items.some((profile) => profile.platformKey === selection.platformKey)) return;
items.push({
id: selection.id,
week: selection.week,
platformKey: selection.platformKey,
profileType: selection.platform.type,
name: selection.platform.displayName,
config: selection.platform.defaultConfig,
});
visualizationProfilesByWeek.set(selection.week, items);
});
const progress = course.studentProgress[0];
const enrollment = lmsUser
? await this.prisma.studentEnrollment.findFirst({
@@ -861,6 +949,19 @@ export class CurriculumService {
};
}
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 serializeLearningStats(
progress: {
completedWeeks: number;
@@ -106,11 +106,6 @@ export class UpdateInstructorCurriculumDto {
}>;
}>;
@IsOptional()
@IsArray()
@IsString({ each: true })
visualizationPlatformKeys?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
+2 -105
View File
@@ -1406,26 +1406,7 @@ export class InstructorService {
courseId: course.id,
},
});
const visualizationPlatforms = await this.prisma.visualizationPlatform.findMany({
where: { enabled: true },
orderBy: { sortOrder: 'asc' },
});
const tagOptions = await this.findCurriculumTagOptions();
const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({
where: {
courseId: course.id,
enabled: true,
},
include: {
platform: true,
},
});
const visualizationSelectionsByWeek = new Map<number, typeof visualizationSelections>();
visualizationSelections.forEach((selection) => {
const items = visualizationSelectionsByWeek.get(selection.week) ?? [];
items.push(selection);
visualizationSelectionsByWeek.set(selection.week, items);
});
const courseMaps = await this.prisma.courseMap.findMany({
where: { courseId: course.id },
orderBy: { createdAt: 'asc' },
@@ -1505,13 +1486,6 @@ export class InstructorService {
name: profile.name,
config: profile.config,
})),
visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({
key: selection.platform.key,
type: selection.platform.type,
displayName: selection.platform.displayName,
description: selection.platform.description,
config: selection.platform.defaultConfig,
})),
mapKeys: mapKeysByWeek.get(week.week) ?? [],
};
});
@@ -1546,13 +1520,6 @@ export class InstructorService {
},
isTeamProject: override.isTeamProject ?? false,
modules: [],
visualizationPlatforms: (visualizationSelectionsByWeek.get(override.week) ?? []).map((selection) => ({
key: selection.platform.key,
type: selection.platform.type,
displayName: selection.platform.displayName,
description: selection.platform.description,
config: selection.platform.defaultConfig,
})),
mapKeys: mapKeysByWeek.get(override.week) ?? [],
};
});
@@ -1567,13 +1534,6 @@ export class InstructorService {
},
weeks: [...baseWeeks, ...addedWeeks].sort((left, right) => left.week - right.week),
tagOptions,
visualizationPlatforms: visualizationPlatforms.map((platform) => ({
key: platform.key,
type: platform.type,
displayName: platform.displayName,
description: platform.description,
config: platform.defaultConfig,
})),
mapOptions: [
...NAV2_SYSTEM_MAPS,
...courseMaps.map((map) => ({
@@ -1710,15 +1670,6 @@ export class InstructorService {
});
return items;
}, []);
const selections = await this.prisma.courseWeekVisualizationPlatform.findMany({
where: {
courseId: course.id,
week: input.activeWeek,
enabled: true,
},
include: { platform: true },
});
const selectionsByPlatformKey = new Map(selections.map((selection) => [selection.platformKey, selection]));
const profiles = await this.prisma.courseEnvironmentProfile.findMany({
where: { courseId: course.id, week: input.activeWeek },
});
@@ -1732,33 +1683,22 @@ export class InstructorService {
&& (profile.config as Prisma.JsonObject).target === 'remote-jetson')
);
const wantsPlatform = logicalEnvironment === 'platform';
const selection = isLogicalEnvironment
? selections.find((candidate) => wantsPlatform === isPlatformProfile({
profileType: candidate.platform.type,
config: candidate.platform.defaultConfig,
}))
: selectionsByPlatformKey.get(item.platformKey);
const existingProfile = isLogicalEnvironment
? profiles.find((profile) => wantsPlatform === isPlatformProfile(profile))
: profilesByPlatformKey.get(item.platformKey);
if (!isLogicalEnvironment && !selection && !existingProfile) {
if (!isLogicalEnvironment && !existingProfile) {
throw new BadRequestException('선택한 환경 프로필을 찾을 수 없습니다.');
}
const resolvedPlatformKey = existingProfile?.platformKey
?? selection?.platformKey
?? (wantsPlatform ? 'futuredrive-platform' : 'futuredrive-local-pc');
const profileType = existingProfile?.profileType
?? selection?.platform.type
?? (wantsPlatform ? 'PLATFORM' : 'LOCAL');
const profileName = existingProfile?.name
?? selection?.platform.displayName
?? (wantsPlatform ? '플랫폼' : '로컬');
const existingConfig = existingProfile?.config && typeof existingProfile.config === 'object' && !Array.isArray(existingProfile.config)
? existingProfile.config as Prisma.JsonObject
: {};
const defaultConfig = selection?.platform.defaultConfig && typeof selection.platform.defaultConfig === 'object' && !Array.isArray(selection.platform.defaultConfig)
? selection.platform.defaultConfig as Prisma.JsonObject
: wantsPlatform ? { target: 'remote-jetson' } : { target: 'local' };
const defaultConfig = wantsPlatform ? { target: 'remote-jetson' } : { target: 'local' };
const config = {
...defaultConfig,
...existingConfig,
@@ -1855,49 +1795,6 @@ export class InstructorService {
});
}
if (input.visualizationPlatformKeys !== undefined) {
const selectedKeys = Array.from(new Set(input.visualizationPlatformKeys.map((key) => key.trim().toUpperCase()).filter(Boolean)));
const validPlatforms = selectedKeys.length
? await this.prisma.visualizationPlatform.findMany({
where: {
key: { in: selectedKeys },
enabled: true,
},
select: { key: true },
})
: [];
const validKeySet = new Set(validPlatforms.map((platform) => platform.key));
await this.prisma.courseWeekVisualizationPlatform.deleteMany({
where: {
courseId: course.id,
week: input.activeWeek,
platformKey: { notIn: selectedKeys },
},
});
await Promise.all(selectedKeys.filter((key) => validKeySet.has(key)).map((key) => (
this.prisma.courseWeekVisualizationPlatform.upsert({
where: {
courseId_week_platformKey: {
courseId: course.id,
week: input.activeWeek,
platformKey: key,
},
},
create: {
courseId: course.id,
week: input.activeWeek,
platformKey: key,
enabled: true,
},
update: {
enabled: true,
},
})
)));
}
if (input.mapKeys !== undefined) {
const selectedKeys = Array.from(new Set(input.mapKeys.map((key) => key.trim()).filter(Boolean)));
const customMaps = selectedKeys.length
+43 -19
View File
@@ -12,6 +12,8 @@ import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { R2PublicFileService } from '../storage/r2-public-file.service';
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
@Injectable()
export class StudioService {
private readonly giteaBaseUrl: string;
@@ -53,26 +55,7 @@ export class StudioService {
const folderName = input.folderName?.trim();
if (!moduleName && !folderName) throw new BadRequestException('moduleName or folderName is required');
const lmsUser = await this.prisma.user.findUnique({
where: { email: authUser.email },
});
if (!lmsUser) throw new NotFoundException('LMS user not found');
const courseId = input.courseId?.trim();
let enrollmentOrganizationName: string | undefined;
if (courseId) {
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
select: { organizationName: true },
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
enrollmentOrganizationName = enrollment.organizationName;
}
const courseWeek = await this.prisma.courseWeek.findFirst({
where: courseId
? {
@@ -87,6 +70,11 @@ export class StudioService {
isActive: true,
},
},
include: {
course: {
select: { slug: true },
},
},
});
if (!courseWeek) throw new NotFoundException('Course week not found');
@@ -108,6 +96,42 @@ export class StudioService {
throw new BadRequestException('templateRepositoryUrl is required');
}
const isPublicGuide = courseWeek.course.slug === PUBLIC_GUIDE_COURSE_SLUG;
if (isPublicGuide) {
return {
id: `guide-${module.id}`,
owner: null,
repoName: null,
repositoryUrl: null,
repositoryUrlWithToken: null,
templateRepositoryUrl,
templateRepositoryUrlWithToken: this.withToken(templateRepositoryUrl),
sourceType: module.sourceType ?? 'GIT',
prebuiltArtifacts: module.prebuiltArtifacts ?? [],
workspaceScope: 'PERSONAL',
groupId: null,
};
}
const lmsUser = await this.prisma.user.findUnique({
where: { email: authUser.email },
});
if (!lmsUser) throw new NotFoundException('LMS user not found');
let enrollmentOrganizationName: string | undefined;
if (courseId) {
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
select: { organizationName: true },
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
enrollmentOrganizationName = enrollment.organizationName;
}
const organizationName = enrollmentOrganizationName ?? lmsUser.primaryOrganizationName ?? undefined;
const teamProject = await this.isTeamProjectWeek(courseWeek.courseId, courseWeek.week, organizationName);
const studentGroup = teamProject