feat: enable hybrid local and remote module execution

This commit is contained in:
2026-08-06 16:17:41 +09:00
parent e06b853bcb
commit f8ccd8b0a3
18 changed files with 1312 additions and 123 deletions
@@ -0,0 +1,12 @@
ALTER TABLE "student_saved_maps"
ADD COLUMN "runtime_kind" TEXT NOT NULL DEFAULT 'SIMULATION';
UPDATE "student_saved_maps"
SET "runtime_kind" = CASE
WHEN "database_path" IS NOT NULL THEN 'PLATFORM_3D'
WHEN "source_map_key" = 'nav2-platform-slam' THEN 'PLATFORM_2D'
ELSE 'SIMULATION'
END;
CREATE INDEX "student_saved_maps_user_id_course_id_week_runtime_kind_idx"
ON "student_saved_maps"("user_id", "course_id", "week", "runtime_kind");
@@ -0,0 +1,4 @@
ALTER TABLE "student_saved_maps"
ADD COLUMN "yaml_url" TEXT,
ADD COLUMN "pgm_url" TEXT,
ADD COLUMN "database_url" TEXT;
@@ -0,0 +1,10 @@
ALTER TABLE "student_custom_modules"
ADD COLUMN "runtime_target" TEXT NOT NULL DEFAULT 'LOCAL';
DROP INDEX "student_custom_modules_user_id_course_id_week_folder_name_key";
CREATE UNIQUE INDEX "student_custom_modules_user_id_course_id_week_folder_name_runtime_target_key"
ON "student_custom_modules"("user_id", "course_id", "week", "folder_name", "runtime_target");
CREATE INDEX "student_custom_modules_user_id_course_id_week_runtime_target_sort_order_idx"
ON "student_custom_modules"("user_id", "course_id", "week", "runtime_target", "sort_order");
@@ -0,0 +1,64 @@
ALTER TABLE "instructor_curriculum_week_overrides"
ADD COLUMN "is_team_project" BOOLEAN DEFAULT false;
CREATE TABLE "team_studio_repositories" (
"id" UUID NOT NULL,
"group_id" UUID NOT NULL,
"week_id" UUID NOT NULL,
"module_id" UUID NOT NULL,
"repo_owner" TEXT NOT NULL,
"repo_name" TEXT NOT NULL,
"repository_url" TEXT NOT NULL,
"template_repository_url" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "team_studio_repositories_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "team_studio_repositories_group_id_module_id_key"
ON "team_studio_repositories"("group_id", "module_id");
CREATE INDEX "team_studio_repositories_week_id_idx"
ON "team_studio_repositories"("week_id");
CREATE INDEX "team_studio_repositories_repo_owner_repo_name_idx"
ON "team_studio_repositories"("repo_owner", "repo_name");
ALTER TABLE "team_studio_repositories"
ADD CONSTRAINT "team_studio_repositories_group_id_fkey"
FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "team_studio_repositories"
ADD CONSTRAINT "team_studio_repositories_module_id_fkey"
FOREIGN KEY ("module_id") REFERENCES "course_week_modules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "team_custom_modules" (
"id" UUID NOT NULL,
"group_id" UUID NOT NULL,
"creator_id" UUID NOT NULL,
"course_id" UUID NOT NULL,
"week" INTEGER NOT NULL,
"sort_order" INTEGER NOT NULL,
"category" TEXT NOT NULL,
"name" TEXT NOT NULL,
"folder_name" TEXT NOT NULL,
"docker_image" TEXT,
"package_name" TEXT NOT NULL,
"launch_file" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "team_custom_modules_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "team_custom_modules_group_id_course_id_week_folder_name_key"
ON "team_custom_modules"("group_id", "course_id", "week", "folder_name");
CREATE INDEX "team_custom_modules_group_id_course_id_week_sort_order_idx"
ON "team_custom_modules"("group_id", "course_id", "week", "sort_order");
ALTER TABLE "team_custom_modules"
ADD CONSTRAINT "team_custom_modules_group_id_fkey"
FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "team_custom_modules"
ADD CONSTRAINT "team_custom_modules_creator_id_fkey"
FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "team_custom_modules"
ADD CONSTRAINT "team_custom_modules_course_id_fkey"
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,45 @@
CREATE TABLE "student_custom_module_repositories" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"custom_module_id" UUID NOT NULL,
"repo_owner" TEXT NOT NULL,
"repo_name" TEXT NOT NULL,
"repository_url" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "student_custom_module_repositories_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "team_custom_module_repositories" (
"id" UUID NOT NULL,
"group_id" UUID NOT NULL,
"custom_module_id" UUID NOT NULL,
"repo_owner" TEXT NOT NULL,
"repo_name" TEXT NOT NULL,
"repository_url" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "team_custom_module_repositories_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "student_custom_module_repositories_custom_module_id_key" ON "student_custom_module_repositories"("custom_module_id");
CREATE INDEX "student_custom_module_repositories_user_id_idx" ON "student_custom_module_repositories"("user_id");
CREATE INDEX "student_custom_module_repositories_repo_owner_repo_name_idx" ON "student_custom_module_repositories"("repo_owner", "repo_name");
CREATE UNIQUE INDEX "team_custom_module_repositories_custom_module_id_key" ON "team_custom_module_repositories"("custom_module_id");
CREATE INDEX "team_custom_module_repositories_group_id_idx" ON "team_custom_module_repositories"("group_id");
CREATE INDEX "team_custom_module_repositories_repo_owner_repo_name_idx" ON "team_custom_module_repositories"("repo_owner", "repo_name");
ALTER TABLE "student_custom_module_repositories"
ADD CONSTRAINT "student_custom_module_repositories_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "student_custom_module_repositories"
ADD CONSTRAINT "student_custom_module_repositories_custom_module_id_fkey"
FOREIGN KEY ("custom_module_id") REFERENCES "student_custom_modules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "team_custom_module_repositories"
ADD CONSTRAINT "team_custom_module_repositories_group_id_fkey"
FOREIGN KEY ("group_id") REFERENCES "student_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "team_custom_module_repositories"
ADD CONSTRAINT "team_custom_module_repositories_custom_module_id_fkey"
FOREIGN KEY ("custom_module_id") REFERENCES "team_custom_modules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,33 @@
-- Align the LMS platform record with the checked-in NAV2-MKMini profile.
-- The speed is the real YHS CAN safety limit, not the higher simulation-only cap.
UPDATE "platforms"
SET
"vehicle_type" = 'MK-mini',
"wheelbase_meters" = 0.600,
"max_speed_meters_per_second" = 0.300,
"max_steering_angle_degrees" = 28.6479
WHERE "platform_key" ILIKE '%mkmini%';
UPDATE "instructor_platforms" AS instance
SET
"vehicle_type" = 'MK-mini',
"wheelbase_meters" = 0.600,
"max_speed_meters_per_second" = 0.300,
"max_steering_angle_degrees" = 28.6479
FROM "platforms" AS platform
WHERE instance."platform_id" = platform."id"
AND platform."platform_key" ILIKE '%mkmini%';
UPDATE "course_week_modules"
SET "prebuilt_artifacts" = jsonb_build_array(
jsonb_build_object(
'type', 'build-install',
'fileName', 'lms-nav2-mkmini-build-install-amd64.tar.zst',
'url', 'https://pub-ca0a1bc004d8409da3329dbb9ac32bcf.r2.dev/studio-artifacts/modules/lms-nav2-mkmini/1.0.0/prebuilt/amd64/lms-nav2-mkmini-build-install-amd64.tar.zst',
'checksumSha256', '8b36d3fda6a81a49da140529d9874e99b734d05e01e12a49e719e2b26f8803d9',
'architecture', 'amd64',
'required', false,
'extractPath', '.'
)
)
WHERE "folder_name" = '[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws';
+96 -1
View File
@@ -31,8 +31,10 @@ model User {
platformControlSessions PlatformControlSession[]
platformControlQueueEntries PlatformControlQueueEntry[]
studioRepositories StudioRepository[]
customModuleRepositories StudentCustomModuleRepository[]
savedMaps StudentSavedMap[]
customModules StudentCustomModule[]
teamCustomModules TeamCustomModule[] @relation("TeamCustomModuleCreator")
submissionScores SubmissionScore[]
submissionTotalScores SubmissionTotalScore[]
@@ -77,6 +79,9 @@ model StudentGroup {
platformControlSessions PlatformControlSession[]
platformControlQueueEntries PlatformControlQueueEntry[]
curriculumSettings InstructorCurriculumSetting[]
teamStudioRepositories TeamStudioRepository[]
teamCustomModuleRepositories TeamCustomModuleRepository[]
teamCustomModules TeamCustomModule[]
@@unique([organizationName, name])
@@index([organizationId])
@@ -162,6 +167,7 @@ model Course {
weekMaps CourseWeekMap[]
savedMaps StudentSavedMap[]
customModules StudentCustomModule[]
teamCustomModules TeamCustomModule[]
submissionScores SubmissionScore[]
submissionTotalScores SubmissionTotalScore[]
@@ -267,8 +273,12 @@ model StudentSavedMap {
viewerModel String @map("viewer_model")
yamlPath String @map("yaml_path")
pgmPath String @map("pgm_path")
yamlUrl String? @map("yaml_url")
pgmUrl String? @map("pgm_url")
previewPgm String? @map("preview_pgm")
databasePath String? @map("database_path")
databaseUrl String? @map("database_url")
runtimeKind String @default("SIMULATION") @map("runtime_kind")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -276,6 +286,7 @@ model StudentSavedMap {
@@unique([userId, courseId, week, yamlPath])
@@index([userId, courseId, week, createdAt])
@@index([userId, courseId, week, runtimeKind])
@@map("student_saved_maps")
}
@@ -291,16 +302,78 @@ model StudentCustomModule {
dockerImage String? @map("docker_image")
packageName String @map("package_name")
launchFile String @map("launch_file")
runtimeTarget String @default("LOCAL") @map("runtime_target")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
repository StudentCustomModuleRepository?
@@unique([userId, courseId, week, folderName])
@@unique([userId, courseId, week, folderName, runtimeTarget])
@@index([userId, courseId, week, sortOrder])
@@index([userId, courseId, week, runtimeTarget, sortOrder])
@@map("student_custom_modules")
}
model TeamCustomModule {
id String @id @default(uuid()) @db.Uuid
groupId String @map("group_id") @db.Uuid
creatorId String @map("creator_id") @db.Uuid
courseId String @map("course_id") @db.Uuid
week Int
sortOrder Int @map("sort_order")
category String
name String
folderName String @map("folder_name")
dockerImage String? @map("docker_image")
packageName String @map("package_name")
launchFile String @map("launch_file")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
group StudentGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
creator User @relation("TeamCustomModuleCreator", fields: [creatorId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
repository TeamCustomModuleRepository?
@@unique([groupId, courseId, week, folderName])
@@index([groupId, courseId, week, sortOrder])
@@map("team_custom_modules")
}
model StudentCustomModuleRepository {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
customModuleId String @unique @map("custom_module_id") @db.Uuid
repoOwner String @map("repo_owner")
repoName String @map("repo_name")
repositoryUrl String @map("repository_url")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
customModule StudentCustomModule @relation(fields: [customModuleId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([repoOwner, repoName])
@@map("student_custom_module_repositories")
}
model TeamCustomModuleRepository {
id String @id @default(uuid()) @db.Uuid
groupId String @map("group_id") @db.Uuid
customModuleId String @unique @map("custom_module_id") @db.Uuid
repoOwner String @map("repo_owner")
repoName String @map("repo_name")
repositoryUrl String @map("repository_url")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
group StudentGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
customModule TeamCustomModule @relation(fields: [customModuleId], references: [id], onDelete: Cascade)
@@index([groupId])
@@index([repoOwner, repoName])
@@map("team_custom_module_repositories")
}
model InstructorCurriculumSetting {
id String @id @default(uuid()) @db.Uuid
organizationId String? @map("organization_id") @db.Uuid
@@ -333,6 +406,7 @@ model InstructorCurriculumWeekOverride {
overview String?
deliverable String?
requiresSourceSubmission Boolean? @default(true) @map("requires_source_submission")
isTeamProject Boolean? @default(false) @map("is_team_project")
learningSteps Json? @map("learning_steps")
topics Json?
topicDetails Json? @map("topic_details")
@@ -540,6 +614,7 @@ model CourseWeekModule {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
week CourseWeek @relation(fields: [weekId], references: [id], onDelete: Cascade)
studioRepositories StudioRepository[]
teamStudioRepositories TeamStudioRepository[]
submissionScores SubmissionScore[]
@@unique([weekId, folderName])
@@ -568,6 +643,26 @@ model StudioRepository {
@@map("studio_repositories")
}
model TeamStudioRepository {
id String @id @default(uuid()) @db.Uuid
groupId String @map("group_id") @db.Uuid
weekId String @map("week_id") @db.Uuid
moduleId String @map("module_id") @db.Uuid
repoOwner String @map("repo_owner")
repoName String @map("repo_name")
repositoryUrl String @map("repository_url")
templateRepositoryUrl String @map("template_repository_url")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
group StudentGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
module CourseWeekModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
@@unique([groupId, moduleId])
@@index([weekId])
@@index([repoOwner, repoName])
@@map("team_studio_repositories")
}
model SubmissionScore {
id String @id @default(uuid()) @db.Uuid
courseId String @map("course_id") @db.Uuid
+1 -1
View File
@@ -169,7 +169,7 @@ WITH module_definitions(category, name, folder_name, template_repository_url, pr
('Control', 'NAV2-Control', '[lms-nav2-gazebo-control]nav2-control_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-control.git',
jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-control/1.0.0/prebuilt/latest/lms-nav2-gazebo-control-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-control-build-install.tar.zst', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true', 'robot:=turtlebot3', 'level:=2d'), 'future-nav2-gazebo:humble'),
('Vehicle', 'NAV2-MKMini', '[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-mkmini.git',
'[]'::jsonb, jsonb_build_array('sim_mode:=false', 'platform:=mkmini', 'robot:=mkmini', 'level:=2d'), 'future-nav2:humble-jetson_orin')
jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-mkmini/1.0.0/prebuilt/amd64/lms-nav2-mkmini-build-install-amd64.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-mkmini-build-install-amd64.tar.zst', 'checksumSha256', '8b36d3fda6a81a49da140529d9874e99b734d05e01e12a49e719e2b26f8803d9', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=false', 'platform:=mkmini', 'robot:=mkmini', 'level:=2d'), 'future-nav2:humble-jetson_orin')
)
INSERT INTO course_week_modules (week_id, sort_order, category, name, folder_name, template_repository_url, source_type, prebuilt_artifacts, default_parameters, docker_image)
SELECT w.id, row_number() OVER (PARTITION BY w.id ORDER BY module_definitions.folder_name), category, name, folder_name, template_repository_url, 'GIT', prebuilt_artifacts, default_parameters, docker_image
+1
View File
@@ -26,6 +26,7 @@ export class CurriculumController {
dockerImage?: string;
packageName?: string;
launchFile?: string;
runtimeTarget?: string;
},
) {
return this.curriculumService.createCustomModule(this.getBearerToken(request), body);
+148 -33
View File
@@ -212,7 +212,6 @@ export class CurriculumService {
items.push(selection.mapKey);
mapKeysByWeek.set(selection.week, items);
});
const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile]));
const visualizationProfilesByWeek = new Map<number, Array<{
id: string;
week: number;
@@ -221,17 +220,28 @@ export class CurriculumService {
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) ?? [];
course.environmentProfiles.forEach((profile) => {
const items = visualizationProfilesByWeek.get(profile.week) ?? [];
items.push({
id: environmentProfile?.id ?? selection.id,
id: profile.id,
week: profile.week,
platformKey: profile.platformKey,
profileType: profile.profileType,
name: profile.name,
config: profile.config,
});
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: environmentProfile?.platformKey ?? 'futuredrive-local-pc',
platformKey: selection.platformKey,
profileType: selection.platform.type,
name: environmentProfile?.name ?? selection.platform.displayName,
config: environmentProfile?.config ?? selection.platform.defaultConfig,
name: selection.platform.displayName,
config: selection.platform.defaultConfig,
});
visualizationProfilesByWeek.set(selection.week, items);
});
@@ -271,6 +281,18 @@ export class CurriculumService {
},
})
: null;
const teamCustomModules = studentGroup
? await this.prisma.teamCustomModule.findMany({
where: { groupId: studentGroup.id, courseId: course.id },
orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
})
: [];
const teamCustomModulesByWeek = new Map<number, typeof teamCustomModules>();
teamCustomModules.forEach((module) => {
const items = teamCustomModulesByWeek.get(module.week) ?? [];
items.push(module);
teamCustomModulesByWeek.set(module.week, items);
});
const assignedInstructorPlatformIds = await this.findStudentAssignedInstructorPlatformIds(
organizationId,
organizationName,
@@ -316,6 +338,7 @@ export class CurriculumService {
organizationName: coursePlatform.platform.organizationName,
iconKey: coursePlatform.platform.iconKey,
sourceSettings: coursePlatform.platform.defaultStudioSettings,
vehicleRuntimeProfile: this.toVehicleRuntimeProfile(coursePlatform.platform),
environmentProfiles: environmentProfilesByPlatformKey.get(coursePlatform.platform.platformKey) ?? [],
status: isLocal ? '사용 가능' : coursePlatform.status,
action: isLocal ? '연결' : coursePlatform.action,
@@ -433,10 +456,10 @@ export class CurriculumService {
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,
},
isTeamProject: Boolean(override?.isTeamProject),
modules: [
...week.modules
.filter((module) => !disabledModuleIdSet.has(module.id))
@@ -459,7 +482,7 @@ export class CurriculumService {
dockerImage: module.dockerImage,
};
}),
...(customModulesByWeek.get(week.week) ?? []).map((module) => ({
...((override?.isTeamProject ? teamCustomModulesByWeek.get(week.week) : customModulesByWeek.get(week.week)) ?? []).map((module) => ({
id: module.id,
sortOrder: module.sortOrder,
category: module.category,
@@ -472,6 +495,9 @@ export class CurriculumService {
dockerImage: module.dockerImage,
packageName: module.packageName,
launchFile: module.launchFile,
runtimeTarget: override?.isTeamProject
? 'SHARED'
: ('runtimeTarget' in module ? module.runtimeTarget : 'LOCAL'),
})),
],
environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [],
@@ -507,10 +533,10 @@ export class CurriculumService {
practiceContents: this.practiceContentsArray(override.practiceContents) ?? undefined,
references: [],
deliverable: override.deliverable ?? '',
requiresSourceSubmission: override.requiresSourceSubmission ?? true,
sourceSubmitted: Boolean(weekProgress?.submittedAt),
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
},
isTeamProject: Boolean(override.isTeamProject),
environmentProfiles: visualizationProfilesByWeek.get(override.week) ?? [],
maps: serializeCourseMaps(mapKeysByWeek.get(override.week) ?? [], course.maps),
};
@@ -530,6 +556,7 @@ export class CurriculumService {
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),
studentGroup: studentGroup ? { id: studentGroup.id, name: studentGroup.name } : null,
platforms,
};
}
@@ -589,6 +616,7 @@ export class CurriculumService {
dockerImage?: string;
packageName?: string;
launchFile?: string;
runtimeTarget?: string;
},
) {
const { user: authUser } = await this.authService.me(accessToken);
@@ -603,6 +631,7 @@ export class CurriculumService {
const dockerImage = input.dockerImage?.trim() || null;
const packageName = input.packageName?.trim() ?? '';
const launchFile = input.launchFile?.trim() ?? '';
const runtimeTarget = input.runtimeTarget?.trim().toUpperCase() || 'LOCAL';
if (!courseId || !Number.isInteger(week) || week < 1 || !name || !packageName || !launchFile) {
throw new BadRequestException('모듈 정보가 올바르지 않습니다.');
}
@@ -615,10 +644,13 @@ export class CurriculumService {
if (dockerImage && (!/^[a-zA-Z0-9_./:@-]+$/.test(dockerImage) || dockerImage.length > 180)) {
throw new BadRequestException('실행 이미지명이 올바르지 않습니다.');
}
if (runtimeTarget !== 'LOCAL' && runtimeTarget !== 'PLATFORM') {
throw new BadRequestException('모듈 실행 위치가 올바르지 않습니다.');
}
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: { userId: user.id, courseId, status: 'ACTIVE' },
select: { id: true },
select: { id: true, organizationName: true },
});
const courseWeek = await this.prisma.courseWeek.findFirst({
where: { courseId, week },
@@ -626,28 +658,68 @@ export class CurriculumService {
});
if (!enrollment || !courseWeek) throw new NotFoundException('수강 중인 강좌 주차를 찾을 수 없습니다.');
const folderName = `[lms-nav2-custom]${packageName}_v1.0.0_ws`;
const duplicateName = await this.prisma.studentCustomModule.findFirst({
where: { userId: user.id, courseId, week, name },
select: { id: true },
const weekOverride = await this.prisma.instructorCurriculumWeekOverride.findFirst({
where: { courseId, week, organizationName: enrollment.organizationName },
select: { isTeamProject: true },
});
const teamProject = Boolean(weekOverride?.isTeamProject);
const studentGroup = teamProject
? await this.prisma.studentGroup.findFirst({
where: {
organizationName: enrollment.organizationName,
members: { some: { userId: user.id } },
},
select: { id: true },
})
: null;
if (teamProject && !studentGroup) throw new BadRequestException('팀 프로젝트 주차는 그룹에 배정된 수강생만 사용할 수 있습니다.');
const folderName = `[lms-nav2-custom]${packageName}_v1.0.0_ws`;
const duplicateName = teamProject
? await this.prisma.teamCustomModule.findFirst({
where: { groupId: studentGroup!.id, courseId, week, name },
select: { id: true },
})
: await this.prisma.studentCustomModule.findFirst({
where: { userId: user.id, courseId, week, name, runtimeTarget },
select: { id: true },
});
if (duplicateName) throw new ConflictException('같은 이름의 커스텀 모듈이 이미 있습니다.');
const count = await this.prisma.studentCustomModule.count({ where: { userId: user.id, courseId, week } });
const count = teamProject
? await this.prisma.teamCustomModule.count({ where: { groupId: studentGroup!.id, courseId, week } })
: await this.prisma.studentCustomModule.count({ where: { userId: user.id, courseId, week, runtimeTarget } });
try {
const module = await this.prisma.studentCustomModule.create({
data: {
userId: user.id,
courseId,
week,
sortOrder: 10_000 + count,
category,
name,
folderName,
dockerImage,
packageName,
launchFile,
},
});
const module = teamProject
? await this.prisma.teamCustomModule.create({
data: {
groupId: studentGroup!.id,
creatorId: user.id,
courseId,
week,
sortOrder: 10_000 + count,
category,
name,
folderName,
dockerImage,
packageName,
launchFile,
},
})
: await this.prisma.studentCustomModule.create({
data: {
userId: user.id,
courseId,
week,
sortOrder: 10_000 + count,
category,
name,
folderName,
dockerImage,
packageName,
launchFile,
runtimeTarget,
},
});
return {
id: module.id,
sortOrder: module.sortOrder,
@@ -657,6 +729,9 @@ export class CurriculumService {
dockerImage: module.dockerImage,
packageName: module.packageName,
launchFile: module.launchFile,
runtimeTarget: teamProject
? 'SHARED'
: ('runtimeTarget' in module ? module.runtimeTarget : 'LOCAL'),
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
@@ -679,7 +754,15 @@ export class CurriculumService {
...(courseId?.trim() ? { courseId: courseId.trim() } : {}),
},
});
if (!deleted.count) throw new NotFoundException('커스텀 모듈을 찾을 수 없습니다.');
if (deleted.count) return { ok: true };
const teamDeleted = await this.prisma.teamCustomModule.deleteMany({
where: {
id: moduleId,
creatorId: user.id,
...(courseId?.trim() ? { courseId: courseId.trim() } : {}),
},
});
if (!teamDeleted.count) throw new NotFoundException('커스텀 모듈을 찾을 수 없습니다.');
return { ok: true };
}
@@ -911,6 +994,13 @@ export class CurriculumService {
organizationName: platform.organizationName,
iconKey: platform.platform.iconKey,
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
vehicleRuntimeProfile: this.toVehicleRuntimeProfile({
platformKey: platform.platform.platformKey,
vehicleType: platform.vehicleType,
wheelbaseMeters: platform.wheelbaseMeters,
maxSpeedMetersPerSecond: platform.maxSpeedMetersPerSecond,
maxSteeringAngleDegrees: platform.maxSteeringAngleDegrees,
}),
status: blocked
? platform.emergencyStop
? '비상 정지'
@@ -922,6 +1012,31 @@ export class CurriculumService {
});
}
private toVehicleRuntimeProfile(platform: {
platformKey: string;
vehicleType: string;
wheelbaseMeters: unknown;
maxSpeedMetersPerSecond: unknown;
maxSteeringAngleDegrees: unknown;
}) {
const identity = `${platform.platformKey} ${platform.vehicleType}`.toLowerCase();
const profileKey = identity.includes('turtlebot')
? 'turtlebot3'
: identity.includes('mkmini') || identity.includes('mk-mini')
? 'mkmini'
: null;
if (!profileKey) return null;
const maxSteeringAngleRadians = Number(platform.maxSteeringAngleDegrees) * (Math.PI / 180);
return {
profileKey,
driveType: profileKey === 'turtlebot3' ? 'differential' : 'ackermann',
wheelbaseMeters: Number(platform.wheelbaseMeters),
maxSpeedMetersPerSecond: Number(platform.maxSpeedMetersPerSecond),
maxSteeringAngleRadians: Number.isFinite(maxSteeringAngleRadians) ? maxSteeringAngleRadians : 0,
};
}
private organizationWhere<T>(
organizationId: string | undefined,
organizationName: string | undefined,
@@ -48,7 +48,7 @@ export class UpdateInstructorCurriculumDto {
@IsOptional()
@IsBoolean()
requiresSourceSubmission?: boolean;
isTeamProject?: boolean;
@IsOptional()
@IsArray()
+15
View File
@@ -31,6 +31,21 @@ export class InstructorController {
return this.instructorService.listSubmissions(this.getBearerToken(request));
}
@Get('submissions/file')
getSubmissionFile(
@Req() request: Request,
@Query('owner') owner?: string,
@Query('repo') repoName?: string,
@Query('path') path?: string,
@Query('baseRef') baseRef?: string,
@Query('headRef') headRef?: string,
) {
return this.instructorService.getSubmissionFile(
this.getBearerToken(request),
{ owner, repoName, path, baseRef, headRef },
);
}
@Put('submissions/score')
saveSubmissionScore(@Req() request: Request, @Body() body: SaveSubmissionScoreDto) {
return this.instructorService.saveSubmissionScore(this.getBearerToken(request), body);
+472 -53
View File
@@ -36,6 +36,8 @@ type SubmissionCommitFile = {
path: string;
status: string;
patch: string;
originalRef?: string | null;
modifiedRef?: string | null;
};
type SubmissionCommit = {
@@ -245,7 +247,7 @@ export class InstructorService {
const isSubmitted = Boolean(weekProgressByUserWeek.get(`${user.id}:${week.week}`)?.submittedAt);
const isPastByProgress = (progress?.completedWeeks ?? 0) >= week.week;
const isCompletedByGroup = completedWeekNumbers.includes(week.week);
return week.requiresSourceSubmission && !isSubmitted && (isPastByProgress || isCompletedByGroup);
return !isSubmitted && (isPastByProgress || isCompletedByGroup);
})
.forEach((week) => {
if (issues.some((issue) => issue.week === week.week && issue.type === 'COMPLETED_WITHOUT_SUBMISSION')) return;
@@ -419,6 +421,50 @@ export class InstructorService {
}
const weekIds = courses.flatMap((course) => course.weeks.map((week) => week.id));
const moduleIds = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.map((module) => module.id)));
const courseIds = courses.map((course) => course.id);
const teamWeekOverrides = organizationName && courseIds.length
? await this.prisma.instructorCurriculumWeekOverride.findMany({
where: {
organizationName,
courseId: { in: courseIds },
isTeamProject: true,
},
select: {
courseId: true,
week: true,
},
})
: [];
const teamWeekKeys = new Set(teamWeekOverrides.map((override) => `${override.courseId}:${override.week}`));
const isTeamProjectWeek = (courseId: string, week: number) => teamWeekKeys.has(`${courseId}:${week}`);
const studentGroupByStudentId = new Map(
enrollments
.map((enrollment) => [enrollment.user.id, enrollment.user.groupMemberships[0]?.group ?? null] as const)
.filter(([, group]) => group),
);
const teamGroupsById = new Map<string, {
id: string;
name: string;
members: Array<{ id: string; name: string; email: string }>;
}>();
students.forEach((student) => {
const group = studentGroupByStudentId.get(student.id);
if (!group) return;
const current = teamGroupsById.get(group.id) ?? { id: group.id, name: group.name, members: [] };
if (!current.members.some((member) => member.id === student.id)) {
current.members.push({ id: student.id, name: student.name, email: student.email });
}
teamGroupsById.set(group.id, current);
});
const teamGroups = [...teamGroupsById.values()];
const teamRepositories = teamGroups.length && moduleIds.length
? await this.prisma.teamStudioRepository.findMany({
where: {
groupId: { in: teamGroups.map((group) => group.id) },
moduleId: { in: moduleIds },
},
})
: [];
const repositories = studentIds.length && moduleIds.length
? await this.prisma.studioRepository.findMany({
@@ -427,9 +473,31 @@ export class InstructorService {
},
})
: [];
const customRepositories = studentIds.length && courseIds.length
? await this.prisma.studentCustomModuleRepository.findMany({
where: {
userId: { in: studentIds },
customModule: { courseId: { in: courseIds } },
},
include: { customModule: true },
})
: [];
const teamCustomRepositories = teamGroups.length && courseIds.length
? await this.prisma.teamCustomModuleRepository.findMany({
where: {
groupId: { in: teamGroups.map((group) => group.id) },
customModule: { courseId: { in: courseIds } },
},
include: { customModule: true },
})
: [];
const repositoryByStudentModule = new Map(repositories.map((repository) => [`${repository.userId}:${repository.moduleId}`, repository]));
const repositoryByStudentRepoName = new Map(repositories.map((repository) => [`${repository.userId}:${repository.repoName}`, repository]));
const teamRepositoryByGroupModule = new Map(teamRepositories.map((repository) => [`${repository.groupId}:${repository.moduleId}`, repository]));
const progressByRepositoryId = new Map<string, SubmissionProgress>();
const progressByTeamRepositoryId = new Map<string, SubmissionProgress>();
const progressByCustomRepositoryId = new Map<string, SubmissionProgress>();
const progressByTeamCustomRepositoryId = new Map<string, SubmissionProgress>();
const scores = studentIds.length && weekIds.length
? await this.prisma.submissionTotalScore.findMany({
where: {
@@ -452,8 +520,37 @@ export class InstructorService {
}));
progressByRepositoryId.set(repository.id, progress);
}));
await Promise.all(teamRepositories.map(async (repository) => {
const memberEmails = new Set(
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
);
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByTeamRepositoryId.set(repository.id, progress);
}));
await Promise.all(customRepositories.map(async (repository) => {
const progress = await this.readSubmissionProgress(repository.repoOwner, repository.repoName).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByCustomRepositoryId.set(repository.id, progress);
}));
await Promise.all(teamCustomRepositories.map(async (repository) => {
const memberEmails = new Set(
(teamGroupsById.get(repository.groupId)?.members ?? []).map((member) => member.email.trim().toLowerCase()),
);
const progress = await this.readTeamSubmissionProgress(repository.repoOwner, repository.repoName, memberEmails).catch(() => ({
commits: [],
finalFiles: [],
}));
progressByTeamCustomRepositoryId.set(repository.id, progress);
}));
const items = courses.flatMap((course) => course.weeks.flatMap((week) => week.modules.flatMap((module) => students.map((student) => {
const items = courses.flatMap((course) => course.weeks.flatMap((week) => {
if (isTeamProjectWeek(course.id, week.week)) return [];
return week.modules.flatMap((module) => students.map((student) => {
const expectedRepoName = this.buildStudioRepoName(student.email, week.week, module.name);
const repository = repositoryByStudentModule.get(`${student.id}:${module.id}`)
?? repositoryByStudentRepoName.get(`${student.id}:${expectedRepoName}`);
@@ -479,6 +576,7 @@ export class InstructorService {
id: week.id,
week: week.week,
title: week.title,
isTeamProject: false,
},
module: {
id: module.id,
@@ -498,7 +596,152 @@ export class InstructorService {
commits,
finalFiles: progress?.finalFiles ?? [],
};
}))));
}));
}));
const customItems = customRepositories.flatMap((repository) => {
const module = repository.customModule;
const course = courses.find((candidate) => candidate.id === module.courseId);
const week = course?.weeks.find((candidate) => candidate.week === module.week);
const student = students.find((candidate) => candidate.id === repository.userId);
if (!course || !week || !student || isTeamProjectWeek(course.id, week.week)) return [];
const progress = progressByCustomRepositoryId.get(repository.id);
const commits = progress?.commits ?? [];
const status = commits.length > 0 ? 'SUBMITTED' : 'PENDING';
return [{
id: `custom:${repository.userId}:${module.id}`,
status,
submitted: status === 'SUBMITTED',
submittedAt: commits[0]?.date ?? null,
commitCount: commits.length,
score: scoreByStudentWeek.get(`${student.id}:${week.id}`) ?? 0,
course: { id: course.id, title: course.title, slug: course.slug },
week: { id: week.id, week: week.week, title: week.title, isTeamProject: false },
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
sourceType: 'CUSTOM',
},
student,
repository: {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
},
commits,
finalFiles: progress?.finalFiles ?? [],
}];
});
const teamItems = courses.flatMap((course) => course.weeks.flatMap((week) => {
if (!isTeamProjectWeek(course.id, week.week)) return [];
return week.modules.flatMap((module) => teamGroups.map((group) => {
const repository = teamRepositoryByGroupModule.get(`${group.id}:${module.id}`);
const progress = repository ? progressByTeamRepositoryId.get(repository.id) : null;
const commits = progress?.commits ?? [];
const submittedAt = commits[0]?.date ?? null;
const status = !repository ? 'NOT_INSTALLED' : commits.length > 0 ? 'SUBMITTED' : 'PENDING';
const members = group.members.map((member) => {
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
return {
...member,
commitCount: memberCommits.length,
lastCommittedAt: memberCommits[0]?.date ?? null,
};
});
return {
id: `team:${group.id}:${module.id}`,
status,
submitted: status === 'SUBMITTED',
submittedAt,
commitCount: commits.length,
course: {
id: course.id,
title: course.title,
slug: course.slug,
},
week: {
id: week.id,
week: week.week,
title: week.title,
isTeamProject: true,
},
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
},
team: {
id: group.id,
name: group.name,
members,
},
student: {
id: group.id,
name: group.name,
email: '',
group: group.name,
},
repository: repository
? {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
}
: null,
commits,
finalFiles: progress?.finalFiles ?? [],
};
}));
}));
const teamCustomItems = teamCustomRepositories.flatMap((repository) => {
const module = repository.customModule;
const course = courses.find((candidate) => candidate.id === module.courseId);
const week = course?.weeks.find((candidate) => candidate.week === module.week);
const group = teamGroupsById.get(repository.groupId);
if (!course || !week || !group || !isTeamProjectWeek(course.id, week.week)) return [];
const progress = progressByTeamCustomRepositoryId.get(repository.id);
const commits = progress?.commits ?? [];
const members = group.members.map((member) => {
const memberCommits = commits.filter((commit) => commit.authorEmail.trim().toLowerCase() === member.email.trim().toLowerCase());
return {
...member,
commitCount: memberCommits.length,
lastCommittedAt: memberCommits[0]?.date ?? null,
};
});
return [{
id: `team-custom:${group.id}:${module.id}`,
status: commits.length > 0 ? 'SUBMITTED' : 'PENDING',
submitted: commits.length > 0,
submittedAt: commits[0]?.date ?? null,
commitCount: commits.length,
course: { id: course.id, title: course.title, slug: course.slug },
week: { id: week.id, week: week.week, title: week.title, isTeamProject: true },
module: {
id: module.id,
name: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder,
sourceType: 'CUSTOM',
},
team: { id: group.id, name: group.name, members },
student: { id: group.id, name: group.name, email: '', group: group.name },
repository: {
id: repository.id,
owner: repository.repoOwner,
name: repository.repoName,
url: repository.repositoryUrl,
},
commits,
finalFiles: progress?.finalFiles ?? [],
}];
});
const allItems = [...items, ...customItems, ...teamItems, ...teamCustomItems];
return {
organization: organizationName
@@ -508,20 +751,21 @@ export class InstructorService {
}
: null,
summary: {
totalItems: items.length,
submittedItems: items.filter((item) => item.status === 'SUBMITTED').length,
pendingItems: items.filter((item) => item.status === 'PENDING').length,
notInstalledItems: items.filter((item) => item.status === 'NOT_INSTALLED').length,
totalCommits: items.reduce((count, item) => count + item.commitCount, 0),
totalItems: allItems.length,
submittedItems: allItems.filter((item) => item.status === 'SUBMITTED').length,
pendingItems: allItems.filter((item) => item.status === 'PENDING').length,
notInstalledItems: allItems.filter((item) => item.status === 'NOT_INSTALLED').length,
totalCommits: allItems.reduce((count, item) => count + item.commitCount, 0),
},
courses: courses.map((course) => ({
id: course.id,
title: course.title,
slug: course.slug,
weeks: course.weeks.map((week) => ({
id: week.id,
week: week.week,
title: week.title,
id: course.id,
title: course.title,
slug: course.slug,
weeks: course.weeks.map((week) => ({
id: week.id,
week: week.week,
title: week.title,
isTeamProject: isTeamProjectWeek(course.id, week.week),
modules: week.modules.map((module) => ({
id: module.id,
name: module.name,
@@ -531,7 +775,48 @@ export class InstructorService {
})),
})),
students,
items,
items: [...items, ...customItems],
teamItems: [...teamItems, ...teamCustomItems],
};
}
async getSubmissionFile(
accessToken: string | undefined,
input: {
owner?: string;
repoName?: string;
path?: string;
baseRef?: string;
headRef?: string;
},
) {
const { authUser } = await this.getInstructor(accessToken);
const organizationId = authUser.primaryAffiliation?.organizationId;
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
const owner = input.owner?.trim();
const repoName = input.repoName?.trim();
const path = input.path?.trim();
const baseRef = this.normalizeGitRef(input.baseRef);
const headRef = this.normalizeGitRef(input.headRef);
if (!owner || !repoName || !path || (!baseRef && !headRef)) {
throw new BadRequestException('저장소와 파일 정보를 확인할 수 없습니다.');
}
if (path.split('/').some((part) => !part || part === '.' || part === '..')) {
throw new BadRequestException('파일 경로가 올바르지 않습니다.');
}
await this.assertInstructorRepositoryAccess(organizationId, organizationName, owner, repoName);
const [original, modified] = await Promise.all([
baseRef ? this.readGiteaFileAtRef(owner, repoName, path, baseRef) : null,
headRef ? this.readGiteaFileAtRef(owner, repoName, path, headRef) : null,
]);
return {
original: original ?? '',
modified: modified ?? '',
originalAvailable: original !== null,
modifiedAvailable: modified !== null,
};
}
@@ -1009,6 +1294,16 @@ export class InstructorService {
const agents = boundAgentValues.length
? await this.platformGatewayClient.listAgents(authUser.email).catch(() => [])
: [];
const agentConnectionByPlatform = new Map(await Promise.all(platforms.map(async (platform) => {
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
const agentOwnerEmail = this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail');
const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId')
?? agentHostname;
const connection = gatewayPlatformId
? await this.platformGatewayClient.getPlatformStatus(gatewayPlatformId, agentOwnerEmail ?? authUser.email).catch(() => null)
: null;
return [platform.id, connection] as const;
})));
const responsePlatforms = platforms.map((platform) => {
const assignedGroup = groupByPlatform.get(platform.id);
@@ -1021,6 +1316,7 @@ export class InstructorService {
hostname: agentHostname,
ownerEmail: agentOwnerEmail,
});
const agentConnection = agentConnectionByPlatform.get(platform.id);
return {
id: platform.id,
masterPlatformId: platform.platformId,
@@ -1028,7 +1324,9 @@ export class InstructorService {
type: this.toPlatformType(platform.platform.displayName),
status: activeControl ? '활성' : '대기',
battery: platform.battery,
uptime: this.formatUptime(platform.uptimeMinutes),
uptime: typeof agentConnection?.agentUptimeSeconds === 'number'
? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds)
: this.formatUptime(platform.uptimeMinutes),
speed: `${Number(platform.speedMeters).toFixed(1)} m/s`,
errors: platform.errorCount,
group: assignedGroup?.name ?? null,
@@ -1146,9 +1444,6 @@ export class InstructorService {
where: { courseId: course.id, enabled: true },
orderBy: [{ platformKey: 'asc' }, { profileType: 'asc' }],
});
const environmentProfileByWeekPlatformKey = new Map(
environmentProfiles.map((profile) => [`${profile.week}:${profile.platformKey}`, profile]),
);
const overrideByWeek = new Map(overrides.map((override) => [override.week, override]));
const baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
const baseWeeks = course.weeks.map((week) => {
@@ -1188,8 +1483,8 @@ export class InstructorService {
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,
},
isTeamProject: override?.isTeamProject ?? false,
modules: week.modules.map((module) => ({
id: module.id,
sortOrder: module.sortOrder,
@@ -1201,16 +1496,15 @@ export class InstructorService {
defaultParameters: this.stringArray(module.defaultParameters) ?? [],
enabled: !disabledModuleIdSet.has(module.id),
})),
environmentProfiles: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => {
const profile = environmentProfileByWeekPlatformKey.get(`${week.week}:${selection.platformKey}`);
return {
id: profile?.id ?? `${week.week}:${selection.platformKey}`,
platformKey: selection.platformKey,
profileType: profile?.profileType ?? selection.platform.type,
name: profile?.name ?? selection.platform.displayName,
config: profile?.config ?? selection.platform.defaultConfig,
};
}),
environmentProfiles: environmentProfiles
.filter((profile) => profile.week === week.week)
.map((profile) => ({
id: profile.id,
platformKey: profile.platformKey,
profileType: profile.profileType,
name: profile.name,
config: profile.config,
})),
visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({
key: selection.platform.key,
type: selection.platform.type,
@@ -1249,8 +1543,8 @@ export class InstructorService {
practiceContents: this.practiceContentsArray(override.practiceContents) ?? [],
references: [],
deliverable: override.deliverable ?? '',
requiresSourceSubmission: override.requiresSourceSubmission ?? true,
},
isTeamProject: override.isTeamProject ?? false,
modules: [],
visualizationPlatforms: (visualizationSelectionsByWeek.get(override.week) ?? []).map((selection) => ({
key: selection.platform.key,
@@ -1421,32 +1715,55 @@ export class InstructorService {
courseId: course.id,
week: input.activeWeek,
enabled: true,
platformKey: { in: requestedSets.map((item) => item.platformKey) },
},
include: { platform: true },
});
const selectionsByPlatformKey = new Map(selections.map((selection) => [selection.platformKey, selection]));
if (requestedSets.some((item) => !selectionsByPlatformKey.has(item.platformKey))) {
throw new BadRequestException('선택한 환경 프로필을 찾을 수 없습니다.');
}
const profiles = await this.prisma.courseEnvironmentProfile.findMany({
where: { courseId: course.id, week: input.activeWeek },
});
const profilesByPlatformKey = new Map(profiles.map((profile) => [profile.platformKey, profile]));
await this.prisma.$transaction(requestedSets.map((item) => {
const selection = selectionsByPlatformKey.get(item.platformKey)!;
const existingProfile = profilesByPlatformKey.get(item.platformKey);
const logicalEnvironment = item.platformKey.toLowerCase();
const isLogicalEnvironment = logicalEnvironment === 'local' || logicalEnvironment === 'platform';
const isPlatformProfile = (profile: { profileType: string; config: Prisma.JsonValue | null }) => (
profile.profileType.toUpperCase() === 'PLATFORM'
|| (profile.config && typeof profile.config === 'object' && !Array.isArray(profile.config)
&& (profile.config as Prisma.JsonObject).target === 'remote-jetson')
);
const wantsPlatform = logicalEnvironment === 'platform';
const 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) {
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)
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 config = {
...defaultConfig,
...existingConfig,
moduleSet: {
target: defaultConfig.target === 'remote-jetson' ? 'platform' : 'local',
target: wantsPlatform ? 'platform' : 'local',
includeUnlisted: false,
modules: item.modules.map((module) => ({
id: module.id,
@@ -1460,21 +1777,21 @@ export class InstructorService {
courseId_week_platformKey: {
courseId: course.id,
week: input.activeWeek,
platformKey: item.platformKey,
platformKey: resolvedPlatformKey,
},
},
create: {
courseId: course.id,
week: input.activeWeek,
platformKey: item.platformKey,
profileType: selection.platform.type,
name: selection.platform.displayName,
platformKey: resolvedPlatformKey,
profileType,
name: profileName,
config,
enabled: true,
},
update: {
profileType: selection.platform.type,
name: selection.platform.displayName,
profileType,
name: profileName,
config,
enabled: true,
},
@@ -1490,7 +1807,7 @@ export class InstructorService {
selectedTagLabel = tagOption.label;
}
if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.requiresSourceSubmission !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) {
await this.prisma.instructorCurriculumWeekOverride.upsert({
where: {
organizationName_courseId_week: {
@@ -1506,7 +1823,7 @@ export class InstructorService {
duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-',
overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '',
deliverable: this.optionalTrim(input.deliverable) ?? existingOverride?.deliverable ?? targetWeek?.deliverable ?? '',
requiresSourceSubmission: input.requiresSourceSubmission ?? existingOverride?.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true,
isTeamProject: input.isTeamProject ?? existingOverride?.isTeamProject ?? false,
learningSteps,
topics: topicTitles,
topicDetails: learningSteps.map((step) => step.description),
@@ -1527,7 +1844,7 @@ export class InstructorService {
duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-',
overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '',
deliverable: this.optionalTrim(input.deliverable) ?? targetWeek?.deliverable ?? '',
requiresSourceSubmission: input.requiresSourceSubmission ?? targetWeek?.requiresSourceSubmission ?? true,
isTeamProject: input.isTeamProject ?? false,
learningSteps,
topics: topicTitles,
topicDetails: learningSteps.map((step) => step.description),
@@ -3320,6 +3637,10 @@ export class InstructorService {
return rest > 0 ? `${hours}시간 ${rest}분` : `${hours}시간`;
}
private formatUptimeSeconds(seconds: number) {
return this.formatUptime(Math.floor(Math.max(0, seconds) / 60));
}
private toBoundedInt(
value: number | undefined,
fallback: number,
@@ -3415,6 +3736,20 @@ export class InstructorService {
};
}
private async readTeamSubmissionProgress(
owner: string,
repoName: string,
memberEmails: Set<string>,
): Promise<SubmissionProgress> {
const commits = await this.readSubmissionCommits(owner, repoName, (commit) => (
memberEmails.has(commit.authorEmail.trim().toLowerCase()) || commit.message.includes('팀 반영')
));
return {
commits,
finalFiles: await this.readFinalSubmissionFiles(owner, repoName, commits),
};
}
private buildStudioRepoName(studentEmail: string, week: number, moduleName: string) {
return `${this.slug(studentEmail.split('@')[0] || studentEmail)}-week-${week}-${this.slug(moduleName)}`;
}
@@ -3427,7 +3762,11 @@ export class InstructorService {
.replace(/^-+|-+$/g, '') || 'workspace';
}
private async readSubmissionCommits(owner: string, repoName: string): Promise<SubmissionCommit[]> {
private async readSubmissionCommits(
owner: string,
repoName: string,
includeCommit: (commit: Omit<SubmissionCommit, 'files'>) => boolean = (commit) => this.isSubmissionCommit(commit),
): Promise<SubmissionCommit[]> {
const commits = await this.fetchGiteaJson<unknown[]>(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits?limit=50`,
).catch(() => []);
@@ -3435,7 +3774,7 @@ export class InstructorService {
const submissionCommits = commits
.map((commit) => this.normalizeGiteaCommit(commit))
.filter((commit): commit is Omit<SubmissionCommit, 'files'> => Boolean(commit))
.filter((commit) => this.isSubmissionCommit(commit))
.filter(includeCommit)
.slice(0, 20);
return Promise.all(submissionCommits.map(async (commit) => ({
@@ -3456,7 +3795,13 @@ export class InstructorService {
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
`/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/compare/${encodeURIComponent(baseHash)}...${encodeURIComponent(latest.hash)}.diff`,
]).catch(() => '');
const diffFiles = this.parseDiffFiles(diffText).filter((file) => !this.isIgnoredSubmissionFile(file.path));
const diffFiles = this.parseDiffFiles(diffText)
.filter((file) => !this.isIgnoredSubmissionFile(file.path))
.map((file) => ({
...file,
originalRef: baseHash,
modifiedRef: latest.hash,
}));
if (diffFiles.length) return diffFiles;
}
@@ -3488,6 +3833,10 @@ export class InstructorService {
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}`,
]).catch(() => null);
const files = Array.isArray(detail?.files) ? detail.files : [];
const parent = Array.isArray(detail?.parents) ? detail.parents[0] : null;
const parentHash = parent && typeof parent === 'object'
? String((parent as Record<string, unknown>).sha ?? (parent as Record<string, unknown>).id ?? '') || null
: null;
const diffText = await this.fetchGiteaText([
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/git/commits/${encodeURIComponent(hash)}.diff`,
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/commits/${encodeURIComponent(hash)}.diff`,
@@ -3506,6 +3855,8 @@ export class InstructorService {
path,
status: String(item.status ?? diffFile?.status ?? 'modified'),
patch: String(item.patch ?? '') || diffFile?.patch || '',
originalRef: parentHash,
modifiedRef: hash,
};
}).filter((file) => file.path && !this.isIgnoredSubmissionFile(file.path));
}
@@ -3570,6 +3921,74 @@ export class InstructorService {
return '';
}
private normalizeGitRef(value: string | undefined): string | null {
const ref = value?.trim() ?? '';
return /^[a-f0-9]{7,64}$/i.test(ref) ? ref : null;
}
private async assertInstructorRepositoryAccess(
organizationId: string | undefined,
organizationName: string | undefined,
repoOwner: string,
repoName: string,
) {
if (!organizationId && !organizationName) {
throw new ForbiddenException('소속 기관 정보를 확인할 수 없습니다.');
}
const organizationFilter = [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentEnrollmentWhereInput[];
const groupFilter = [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean) as Prisma.StudentGroupWhereInput[];
const repositoryFilter = { repoOwner, repoName };
const [studentRepository, studentCustomRepository, teamRepository, teamCustomRepository] = await Promise.all([
this.prisma.studioRepository.findFirst({
where: {
...repositoryFilter,
user: { enrollments: { some: { OR: organizationFilter } } },
},
select: { id: true },
}),
this.prisma.studentCustomModuleRepository.findFirst({
where: {
...repositoryFilter,
user: { enrollments: { some: { OR: organizationFilter } } },
},
select: { id: true },
}),
this.prisma.teamStudioRepository.findFirst({
where: {
...repositoryFilter,
group: { OR: groupFilter },
},
select: { id: true },
}),
this.prisma.teamCustomModuleRepository.findFirst({
where: {
...repositoryFilter,
group: { OR: groupFilter },
},
select: { id: true },
}),
]);
if (!studentRepository && !studentCustomRepository && !teamRepository && !teamCustomRepository) {
throw new ForbiddenException('제출 저장소에 접근할 수 없습니다.');
}
}
private async readGiteaFileAtRef(owner: string, repoName: string, path: string, ref: string): Promise<string | null> {
const encodedPath = path.split('/').map((part) => encodeURIComponent(part)).join('/');
const response = await this.fetchGitea(
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/raw/${encodedPath}?ref=${encodeURIComponent(ref)}`,
).catch(() => null);
return response ? response.text() : null;
}
private async fetchGitea(path: string): Promise<Response> {
const giteaBaseUrl = this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
@@ -37,6 +37,8 @@ export type GatewayPlatformConnectionStatus = {
sshUser?: string | null;
workspaceRoot?: string | null;
lastSeenAt: string | null;
agentStartedAt?: string | null;
agentUptimeSeconds?: number | null;
state: string;
message: string | null;
};
+20
View File
@@ -43,6 +43,26 @@ export class R2PublicFileService {
].filter(Boolean).join('/');
}
createLmsSavedMapFileKey(input: {
userId: string;
courseId: string;
week: number;
mapId: string;
fileName: string;
}) {
const fileName = input.fileName.replace(/[^A-Za-z0-9._-]/g, '_');
return [
this.publicPrefix,
'lms',
'saved-maps',
input.courseId,
`week-${input.week}`,
input.userId,
input.mapId,
fileName,
].filter(Boolean).join('/');
}
async uploadPublicFile(input: UploadPublicFileInput) {
if (!this.isConfigured()) return null;
+30 -2
View File
@@ -1,4 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, Query, Req } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post, Query, Req, UploadedFiles, UseInterceptors } from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { Request } from 'express';
import { StudioService } from './studio.service';
@@ -22,6 +23,14 @@ export class StudioController {
return this.studioService.prepareRepository(this.getBearerToken(request), body);
}
@Post('custom-module-repositories/prepare')
prepareCustomModuleRepository(
@Req() request: Request,
@Body() body: { courseId?: string; week?: number; moduleId?: string },
) {
return this.studioService.prepareCustomModuleRepository(this.getBearerToken(request), body);
}
@Post('submissions/record')
recordSubmission(
@Req() request: Request,
@@ -32,6 +41,7 @@ export class StudioController {
completedWeeks?: number;
totalWeeks?: number;
commitCount?: number;
completionSubmitted?: boolean;
},
) {
return this.studioService.recordSubmission(this.getBearerToken(request), body);
@@ -73,11 +83,29 @@ export class StudioController {
@Post('maps')
recordSavedMap(
@Req() request: Request,
@Body() body: { courseId?: string; week?: number; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string },
@Body() body: { courseId?: string; week?: number; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string },
) {
return this.studioService.recordSavedMap(this.getBearerToken(request), body);
}
@Post('maps/upload')
@UseInterceptors(FileFieldsInterceptor([
{ name: 'yaml', maxCount: 1 },
{ name: 'pgm', maxCount: 1 },
{ name: 'database', maxCount: 1 },
]))
uploadSavedMap(
@Req() request: Request,
@UploadedFiles() files: Record<string, Array<{ originalname: string; mimetype?: string; size?: number; buffer?: Buffer }>>,
@Body() body: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string },
) {
return this.studioService.uploadSavedMap(this.getBearerToken(request), body, {
yaml: files?.yaml?.[0],
pgm: files?.pgm?.[0],
database: files?.database?.[0],
});
}
@Delete('maps/:id')
deleteSavedMap(@Req() request: Request, @Param('id') id: string) {
return this.studioService.deleteSavedMap(this.getBearerToken(request), id);
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { R2PublicFileService } from '../storage/r2-public-file.service';
import { StudioController } from './studio.controller';
import { StudioService } from './studio.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [StudioController],
providers: [StudioService],
providers: [StudioService, R2PublicFileService],
})
export class StudioModule {}
+356 -31
View File
@@ -6,9 +6,11 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomUUID } from 'crypto';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { R2PublicFileService } from '../storage/r2-public-file.service';
@Injectable()
export class StudioService {
@@ -20,6 +22,7 @@ export class StudioService {
private readonly authService: AuthService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
private readonly r2PublicFileService: R2PublicFileService,
) {
this.giteaBaseUrl = this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
@@ -56,6 +59,7 @@ export class StudioService {
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: {
@@ -63,8 +67,10 @@ export class StudioService {
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({
@@ -102,40 +108,82 @@ export class StudioService {
throw new BadRequestException('templateRepositoryUrl is required');
}
const existingRepository = await this.prisma.studioRepository.findUnique({
where: {
userId_moduleId: {
userId: lmsUser.id,
moduleId: module.id,
},
},
});
const organizationName = enrollmentOrganizationName ?? lmsUser.primaryOrganizationName ?? undefined;
const teamProject = await this.isTeamProjectWeek(courseWeek.courseId, courseWeek.week, organizationName);
const studentGroup = teamProject
? await this.findStudentGroup(lmsUser.id, courseWeek.courseId, organizationName)
: null;
if (teamProject && !studentGroup) {
throw new BadRequestException('팀 프로젝트 주차는 그룹에 배정된 수강생만 사용할 수 있습니다.');
}
const existingRepository = teamProject
? await this.prisma.teamStudioRepository.findUnique({
where: {
groupId_moduleId: {
groupId: studentGroup!.id,
moduleId: module.id,
},
},
})
: await this.prisma.studioRepository.findUnique({
where: {
userId_moduleId: {
userId: lmsUser.id,
moduleId: module.id,
},
},
});
const courseIdPrefix = courseWeek.courseId.replace(/-/g, '').slice(0, 8);
const repoName = existingRepository?.repoName
?? `${this.slug(authUser.email.split('@')[0] || authUser.email)}-course-${courseIdPrefix}-week-${courseWeek.week}-${this.slug(module.name)}`;
?? (teamProject
? `team-${this.slug(studentGroup!.name)}-${studentGroup!.id.slice(0, 8)}-course-${courseIdPrefix}-week-${courseWeek.week}-${this.slug(module.name)}`
: `${this.slug(authUser.email.split('@')[0] || authUser.email)}-course-${courseIdPrefix}-week-${courseWeek.week}-${this.slug(module.name)}`);
const repositoryUrl = existingRepository?.repositoryUrl
?? `${this.giteaBaseUrl}/${this.studentOwner}/${repoName}.git`;
if (!existingRepository) {
await this.ensureGiteaRepository(repoName, `Studio workspace for ${authUser.email} ${courseWeek.week}W ${module.name}`);
await this.ensureGiteaRepository(
repoName,
teamProject
? `Team studio workspace for ${studentGroup!.name} ${courseWeek.week}W ${module.name}`
: `Studio workspace for ${authUser.email} ${courseWeek.week}W ${module.name}`,
);
}
const repository = existingRepository
? await this.prisma.studioRepository.update({
where: { id: existingRepository.id },
data: { templateRepositoryUrl },
})
: await this.prisma.studioRepository.create({
data: {
userId: lmsUser.id,
weekId: courseWeek.id,
moduleId: module.id,
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
templateRepositoryUrl,
},
});
const repository = teamProject
? (existingRepository
? await this.prisma.teamStudioRepository.update({
where: { id: existingRepository.id },
data: { templateRepositoryUrl },
})
: await this.prisma.teamStudioRepository.create({
data: {
groupId: studentGroup!.id,
weekId: courseWeek.id,
moduleId: module.id,
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
templateRepositoryUrl,
},
}))
: (existingRepository
? await this.prisma.studioRepository.update({
where: { id: existingRepository.id },
data: { templateRepositoryUrl },
})
: await this.prisma.studioRepository.create({
data: {
userId: lmsUser.id,
weekId: courseWeek.id,
moduleId: module.id,
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
templateRepositoryUrl,
},
}));
const moduleWithArtifacts = module as typeof module & {
sourceType?: string | null;
@@ -152,6 +200,106 @@ export class StudioService {
templateRepositoryUrlWithToken: this.withToken(templateRepositoryUrl),
sourceType: moduleWithArtifacts.sourceType ?? 'GIT',
prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [],
workspaceScope: teamProject ? 'TEAM' : 'PERSONAL',
groupId: studentGroup?.id ?? null,
};
}
async prepareCustomModuleRepository(
accessToken: string | undefined,
input: { courseId?: string; week?: number; moduleId?: string },
) {
if (!this.giteaToken) {
throw new BadGatewayException('GITEA_ADMIN_TOKEN is not configured');
}
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const courseId = input.courseId?.trim();
const moduleId = input.moduleId?.trim();
const week = Number(input.week);
if (!courseId || !moduleId || !Number.isInteger(week) || week < 1) {
throw new BadRequestException('courseId, week, and moduleId are required');
}
const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } });
if (!lmsUser) throw new NotFoundException('LMS user not found');
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');
const courseWeek = await this.prisma.courseWeek.findFirst({
where: { courseId, week, course: { isActive: true } },
select: { id: true, courseId: true, week: true },
});
if (!courseWeek) throw new NotFoundException('Course week not found');
const teamProject = await this.isTeamProjectWeek(courseId, week, enrollment.organizationName);
const studentGroup = teamProject
? await this.findStudentGroup(lmsUser.id, courseId, enrollment.organizationName)
: null;
if (teamProject && !studentGroup) {
throw new BadRequestException('팀 프로젝트 주차는 그룹에 배정된 수강생만 사용할 수 있습니다.');
}
const customModule = teamProject
? await this.prisma.teamCustomModule.findFirst({
where: { id: moduleId, groupId: studentGroup!.id, courseId, week },
})
: await this.prisma.studentCustomModule.findFirst({
where: { id: moduleId, userId: lmsUser.id, courseId, week },
});
if (!customModule) throw new NotFoundException('Custom module not found');
const existingRepository = teamProject
? await this.prisma.teamCustomModuleRepository.findUnique({ where: { customModuleId: moduleId } })
: await this.prisma.studentCustomModuleRepository.findUnique({ where: { customModuleId: moduleId } });
const courseIdPrefix = courseId.replace(/-/g, '').slice(0, 8);
const repoName = existingRepository?.repoName
?? (teamProject
? `team-${this.slug(studentGroup!.name)}-${studentGroup!.id.slice(0, 8)}-course-${courseIdPrefix}-week-${week}-custom-${this.slug(customModule.name)}`
: `${this.slug(authUser.email.split('@')[0] || authUser.email)}-course-${courseIdPrefix}-week-${week}-custom-${this.slug(customModule.name)}`);
const repositoryUrl = existingRepository?.repositoryUrl
?? `${this.giteaBaseUrl}/${this.studentOwner}/${repoName}.git`;
if (!existingRepository) {
await this.ensureGiteaRepository(
repoName,
teamProject
? `Team custom module for ${studentGroup!.name} ${week}W ${customModule.name}`
: `Custom module for ${authUser.email} ${week}W ${customModule.name}`,
);
}
const repository = teamProject
? (existingRepository ?? await this.prisma.teamCustomModuleRepository.create({
data: {
groupId: studentGroup!.id,
customModuleId: moduleId,
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
},
}))
: (existingRepository ?? await this.prisma.studentCustomModuleRepository.create({
data: {
userId: lmsUser.id,
customModuleId: moduleId,
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
},
}));
return {
id: repository.id,
owner: repository.repoOwner,
repoName: repository.repoName,
repositoryUrl: repository.repositoryUrl,
repositoryUrlWithToken: this.withToken(repository.repositoryUrl),
workspaceScope: teamProject ? 'TEAM' : 'PERSONAL',
groupId: studentGroup?.id ?? null,
};
}
@@ -163,6 +311,7 @@ export class StudioService {
completedWeeks?: number;
totalWeeks?: number;
commitCount?: number;
completionSubmitted?: boolean;
},
) {
const { user: authUser } = await this.authService.me(accessToken);
@@ -195,6 +344,30 @@ export class StudioService {
const totalWeeks = Math.max(0, input.totalWeeks ?? enrollment.course.weeks.length);
const completedWeeks = Math.max(0, Math.min(totalWeeks, input.completedWeeks ?? Math.max(0, (input.week ?? 1) - 1)));
const commitCount = Math.max(0, input.commitCount ?? 0);
const week = Math.max(0, Math.floor(input.week ?? 0));
if (input.completionSubmitted && week > 0) {
const existingSubmission = await this.prisma.courseStudentWeekProgress.findUnique({
where: {
courseId_userId_week: {
courseId,
userId: lmsUser.id,
week,
},
},
select: { submittedAt: true },
});
if (existingSubmission?.submittedAt) {
return {
ok: true,
alreadySubmitted: true,
progress: {
completedWeeks,
totalWeeks,
commitCount: 0,
},
};
}
}
const progress = await this.prisma.courseStudentProgress.upsert({
where: {
@@ -219,8 +392,7 @@ export class StudioService {
},
});
const week = Math.max(0, Math.floor(input.week ?? 0));
if (week > 0 && commitCount > 0) {
if (week > 0 && (commitCount > 0 || input.completionSubmitted)) {
await this.prisma.courseStudentWeekProgress.upsert({
where: {
courseId_userId_week: {
@@ -313,6 +485,14 @@ export class StudioService {
});
if (!module) throw new NotFoundException('Course week module not found');
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: { userId: lmsUser.id, courseId, status: 'ACTIVE' },
select: { organizationName: true },
});
const teamProject = await this.isTeamProjectWeek(courseWeek.courseId, courseWeek.week, enrollment?.organizationName ?? lmsUser.primaryOrganizationName ?? undefined);
if (teamProject) {
throw new BadRequestException('팀 프로젝트의 공동 저장소는 개인 모듈 삭제로 제거할 수 없습니다.');
}
const repository = await this.prisma.studioRepository.findUnique({
where: {
userId_moduleId: {
@@ -427,7 +607,7 @@ export class StudioService {
async recordSavedMap(
accessToken: string | undefined,
input: { courseId?: string; week?: number; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string },
input: { courseId?: string; week?: number; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string },
) {
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
@@ -437,6 +617,7 @@ export class StudioService {
const previewPgm = input.previewPgm?.trim() || null;
const databasePath = input.databasePath?.trim() || null;
const sourceMapKey = input.sourceMapKey?.trim();
const runtimeKind = input.runtimeKind?.trim().toUpperCase() || 'SIMULATION';
// Platform SLAM maps do not originate from a Gazebo world file.
const worldFile = input.worldFile?.trim() ?? '';
const viewerModel = input.viewerModel?.trim();
@@ -447,6 +628,7 @@ export class StudioService {
if (previewPgm && (!/^[A-Za-z0-9+/=]+$/.test(previewPgm) || previewPgm.length > 70_000)) throw new BadRequestException('previewPgm is invalid');
if (databasePath && (!databasePath.startsWith('/workspace/maps/') || !databasePath.endsWith('.db'))) throw new BadRequestException('databasePath is invalid');
if (!sourceMapKey || !viewerModel) throw new BadRequestException('source map is required');
if (!['SIMULATION', 'PLATFORM_2D', 'PLATFORM_3D'].includes(runtimeKind)) throw new BadRequestException('runtimeKind is invalid');
const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } });
if (!lmsUser) throw new NotFoundException('LMS user not found');
const enrollment = await this.prisma.studentEnrollment.findFirst({
@@ -455,8 +637,120 @@ export class StudioService {
if (!enrollment) throw new NotFoundException('Student enrollment not found');
return this.prisma.studentSavedMap.upsert({
where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week: input.week!, yamlPath } },
update: { name: input.name?.trim() || 'SLAM 결과 맵', pgmPath, previewPgm, databasePath, sourceMapKey, worldFile, viewerModel },
create: { userId: lmsUser.id, courseId, week: input.week!, name: input.name?.trim() || 'SLAM 결과 맵', sourceMapKey, worldFile, viewerModel, yamlPath, pgmPath, previewPgm, databasePath },
update: { name: input.name?.trim() || 'SLAM 결과 맵', pgmPath, previewPgm, databasePath, sourceMapKey, worldFile, viewerModel, runtimeKind },
create: { userId: lmsUser.id, courseId, week: input.week!, name: input.name?.trim() || 'SLAM 결과 맵', sourceMapKey, worldFile, viewerModel, yamlPath, pgmPath, previewPgm, databasePath, runtimeKind },
});
}
async uploadSavedMap(
accessToken: string | undefined,
input: { courseId?: string; week?: string; name?: string; sourceMapKey?: string; worldFile?: string; viewerModel?: string; yamlPath?: string; pgmPath?: string; previewPgm?: string; databasePath?: string; runtimeKind?: string },
files: {
yaml?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer };
pgm?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer };
database?: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer };
},
) {
if (!this.r2PublicFileService.isConfigured()) {
throw new BadGatewayException('R2 public storage is not configured');
}
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const courseId = input.courseId?.trim();
const week = Number(input.week);
const yamlPath = input.yamlPath?.trim();
const pgmPath = input.pgmPath?.trim();
const databasePath = input.databasePath?.trim() || null;
const previewPgm = input.previewPgm?.trim() || null;
const sourceMapKey = input.sourceMapKey?.trim();
const worldFile = input.worldFile?.trim() ?? '';
const viewerModel = input.viewerModel?.trim();
const runtimeKind = input.runtimeKind?.trim().toUpperCase() || 'SIMULATION';
if (!courseId) throw new BadRequestException('courseId is required');
if (!Number.isInteger(week) || week < 1) throw new BadRequestException('week is required');
if (!yamlPath?.startsWith('/workspace/maps/') || !yamlPath.endsWith('.yaml')) throw new BadRequestException('yamlPath is invalid');
if (!pgmPath?.startsWith('/workspace/maps/') || !pgmPath.endsWith('.pgm')) throw new BadRequestException('pgmPath is invalid');
if (databasePath && (!databasePath.startsWith('/workspace/maps/') || !databasePath.endsWith('.db'))) throw new BadRequestException('databasePath is invalid');
if (previewPgm && (!/^[A-Za-z0-9+/=]+$/.test(previewPgm) || previewPgm.length > 70_000)) throw new BadRequestException('previewPgm is invalid');
if (!sourceMapKey || !viewerModel) throw new BadRequestException('source map is required');
if (!['SIMULATION', 'PLATFORM_2D', 'PLATFORM_3D'].includes(runtimeKind)) throw new BadRequestException('runtimeKind is invalid');
if (!files.yaml?.buffer || !files.pgm?.buffer) throw new BadRequestException('yaml and pgm files are required');
if (databasePath && !files.database?.buffer) throw new BadRequestException('database file is required');
const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } });
if (!lmsUser) throw new NotFoundException('LMS user not found');
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: { userId: lmsUser.id, courseId, status: 'ACTIVE' },
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
const existing = await this.prisma.studentSavedMap.findUnique({
where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week, yamlPath } },
});
const mapId = existing?.id ?? randomUUID();
const makeKey = (fileName: string) => this.r2PublicFileService.createLmsSavedMapFileKey({
userId: lmsUser.id,
courseId,
week,
mapId,
fileName,
});
const yamlUrl = await this.r2PublicFileService.uploadPublicFile({
key: makeKey(files.yaml.originalname),
body: files.yaml.buffer,
contentType: files.yaml.mimetype || 'application/x-yaml',
});
const pgmUrl = await this.r2PublicFileService.uploadPublicFile({
key: makeKey(files.pgm.originalname),
body: files.pgm.buffer,
contentType: files.pgm.mimetype || 'image/x-portable-graymap',
});
const databaseUrl = files.database?.buffer
? await this.r2PublicFileService.uploadPublicFile({
key: makeKey(files.database.originalname),
body: files.database.buffer,
contentType: files.database.mimetype || 'application/octet-stream',
})
: null;
if (!yamlUrl || !pgmUrl || (databasePath && !databaseUrl)) {
throw new BadGatewayException('R2 saved map upload failed');
}
return this.prisma.studentSavedMap.upsert({
where: { userId_courseId_week_yamlPath: { userId: lmsUser.id, courseId, week, yamlPath } },
update: {
name: input.name?.trim() || 'SLAM 결과 맵',
pgmPath,
yamlUrl,
pgmUrl,
previewPgm,
databasePath,
databaseUrl,
sourceMapKey,
worldFile,
viewerModel,
runtimeKind,
},
create: {
id: mapId,
userId: lmsUser.id,
courseId,
week,
name: input.name?.trim() || 'SLAM 결과 맵',
sourceMapKey,
worldFile,
viewerModel,
yamlPath,
pgmPath,
yamlUrl,
pgmUrl,
previewPgm,
databasePath,
databaseUrl,
runtimeKind,
},
});
}
@@ -472,6 +766,37 @@ export class StudioService {
return { ok: true };
}
private async isTeamProjectWeek(courseId: string, week: number, organizationName?: string): Promise<boolean> {
const override = await this.prisma.instructorCurriculumWeekOverride.findFirst({
where: {
courseId,
week,
...(organizationName ? { organizationName } : {}),
},
select: { isTeamProject: true },
});
return Boolean(override?.isTeamProject);
}
private async findStudentGroup(userId: string, courseId: string, organizationName?: string) {
return this.prisma.studentGroup.findFirst({
where: {
...(organizationName ? { organizationName } : {}),
members: {
some: {
userId,
user: {
enrollments: {
some: { courseId, status: 'ACTIVE' },
},
},
},
},
},
select: { id: true, name: true },
});
}
private async ensureGiteaRepository(repoName: string, description: string) {
const existingResponse = await fetch(`${this.giteaBaseUrl}/api/v1/repos/${encodeURIComponent(this.studentOwner)}/${encodeURIComponent(repoName)}`, {
headers: this.giteaHeaders(),