diff --git a/prisma/migrations/20260804160000_add_student_saved_map_runtime_kind/migration.sql b/prisma/migrations/20260804160000_add_student_saved_map_runtime_kind/migration.sql new file mode 100644 index 0000000..85d3c1c --- /dev/null +++ b/prisma/migrations/20260804160000_add_student_saved_map_runtime_kind/migration.sql @@ -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"); diff --git a/prisma/migrations/20260804170000_add_student_saved_map_r2_urls/migration.sql b/prisma/migrations/20260804170000_add_student_saved_map_r2_urls/migration.sql new file mode 100644 index 0000000..58985ed --- /dev/null +++ b/prisma/migrations/20260804170000_add_student_saved_map_r2_urls/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "student_saved_maps" + ADD COLUMN "yaml_url" TEXT, + ADD COLUMN "pgm_url" TEXT, + ADD COLUMN "database_url" TEXT; diff --git a/prisma/migrations/20260805000000_add_student_custom_module_runtime_target/migration.sql b/prisma/migrations/20260805000000_add_student_custom_module_runtime_target/migration.sql new file mode 100644 index 0000000..0344472 --- /dev/null +++ b/prisma/migrations/20260805000000_add_student_custom_module_runtime_target/migration.sql @@ -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"); diff --git a/prisma/migrations/20260805090000_add_team_project_workspaces/migration.sql b/prisma/migrations/20260805090000_add_team_project_workspaces/migration.sql new file mode 100644 index 0000000..bf7fda5 --- /dev/null +++ b/prisma/migrations/20260805090000_add_team_project_workspaces/migration.sql @@ -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; diff --git a/prisma/migrations/20260806000000_add_custom_module_repositories/migration.sql b/prisma/migrations/20260806000000_add_custom_module_repositories/migration.sql new file mode 100644 index 0000000..66f294d --- /dev/null +++ b/prisma/migrations/20260806000000_add_custom_module_repositories/migration.sql @@ -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; diff --git a/prisma/migrations/20260806110000_align_mkmini_runtime_spec/migration.sql b/prisma/migrations/20260806110000_align_mkmini_runtime_spec/migration.sql new file mode 100644 index 0000000..78973b8 --- /dev/null +++ b/prisma/migrations/20260806110000_align_mkmini_runtime_spec/migration.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1325fbf..f77e6e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/scripts/reseed-base-nav2-curriculum.sql b/scripts/reseed-base-nav2-curriculum.sql index adac18a..a841815 100644 --- a/scripts/reseed-base-nav2-curriculum.sql +++ b/scripts/reseed-base-nav2-curriculum.sql @@ -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 diff --git a/src/curriculum/curriculum.controller.ts b/src/curriculum/curriculum.controller.ts index 2a594e2..53af87d 100644 --- a/src/curriculum/curriculum.controller.ts +++ b/src/curriculum/curriculum.controller.ts @@ -26,6 +26,7 @@ export class CurriculumController { dockerImage?: string; packageName?: string; launchFile?: string; + runtimeTarget?: string; }, ) { return this.curriculumService.createCustomModule(this.getBearerToken(request), body); diff --git a/src/curriculum/curriculum.service.ts b/src/curriculum/curriculum.service.ts index 9caa811..9cfab6b 100644 --- a/src/curriculum/curriculum.service.ts +++ b/src/curriculum/curriculum.service.ts @@ -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>(); - 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(); + 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( organizationId: string | undefined, organizationName: string | undefined, diff --git a/src/instructor/dto/update-instructor-curriculum.dto.ts b/src/instructor/dto/update-instructor-curriculum.dto.ts index 33f98e6..a1cb018 100644 --- a/src/instructor/dto/update-instructor-curriculum.dto.ts +++ b/src/instructor/dto/update-instructor-curriculum.dto.ts @@ -48,7 +48,7 @@ export class UpdateInstructorCurriculumDto { @IsOptional() @IsBoolean() - requiresSourceSubmission?: boolean; + isTeamProject?: boolean; @IsOptional() @IsArray() diff --git a/src/instructor/instructor.controller.ts b/src/instructor/instructor.controller.ts index 3f9d74d..76549dd 100644 --- a/src/instructor/instructor.controller.ts +++ b/src/instructor/instructor.controller.ts @@ -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); diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index b54d925..0893fe2 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -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; + }>(); + 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(); + const progressByTeamRepositoryId = new Map(); + const progressByCustomRepositoryId = new Map(); + const progressByTeamCustomRepositoryId = new Map(); 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, + ): Promise { + 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 { + private async readSubmissionCommits( + owner: string, + repoName: string, + includeCommit: (commit: Omit) => boolean = (commit) => this.isSubmissionCommit(commit), + ): Promise { const commits = await this.fetchGiteaJson( `/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 => 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).sha ?? (parent as Record).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 { + 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 { const giteaBaseUrl = this.configService .get('GITEA_BASE_URL', 'http://localhost:3000') diff --git a/src/platform-control/platform-gateway.client.ts b/src/platform-control/platform-gateway.client.ts index 20d979c..58bbd09 100644 --- a/src/platform-control/platform-gateway.client.ts +++ b/src/platform-control/platform-gateway.client.ts @@ -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; }; diff --git a/src/storage/r2-public-file.service.ts b/src/storage/r2-public-file.service.ts index 321ddee..e4fa4b2 100644 --- a/src/storage/r2-public-file.service.ts +++ b/src/storage/r2-public-file.service.ts @@ -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; diff --git a/src/studio/studio.controller.ts b/src/studio/studio.controller.ts index 8a7b749..0ab6128 100644 --- a/src/studio/studio.controller.ts +++ b/src/studio/studio.controller.ts @@ -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>, + @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); diff --git a/src/studio/studio.module.ts b/src/studio/studio.module.ts index 4088ddd..3472434 100644 --- a/src/studio/studio.module.ts +++ b/src/studio/studio.module.ts @@ -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 {} diff --git a/src/studio/studio.service.ts b/src/studio/studio.service.ts index b9a74da..0b39d37 100644 --- a/src/studio/studio.service.ts +++ b/src/studio/studio.service.ts @@ -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('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 { + 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(),