feat: enable hybrid local and remote module execution
This commit is contained in:
@@ -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;
|
||||
+10
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ export class CurriculumController {
|
||||
dockerImage?: string;
|
||||
packageName?: string;
|
||||
launchFile?: string;
|
||||
runtimeTarget?: string;
|
||||
},
|
||||
) {
|
||||
return this.curriculumService.createCustomModule(this.getBearerToken(request), body);
|
||||
|
||||
@@ -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,15 +658,54 @@ export class CurriculumService {
|
||||
});
|
||||
if (!enrollment || !courseWeek) throw new NotFoundException('수강 중인 강좌 주차를 찾을 수 없습니다.');
|
||||
|
||||
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 = await this.prisma.studentCustomModule.findFirst({
|
||||
where: { userId: user.id, courseId, week, name },
|
||||
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({
|
||||
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,
|
||||
@@ -646,6 +717,7 @@ export class CurriculumService {
|
||||
dockerImage,
|
||||
packageName,
|
||||
launchFile,
|
||||
runtimeTarget,
|
||||
},
|
||||
});
|
||||
return {
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,11 +751,11 @@ 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,
|
||||
@@ -522,6 +765,7 @@ export class InstructorService {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,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 {}
|
||||
|
||||
+335
-10
@@ -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,7 +108,25 @@ export class StudioService {
|
||||
throw new BadRequestException('templateRepositoryUrl is required');
|
||||
}
|
||||
|
||||
const existingRepository = await this.prisma.studioRepository.findUnique({
|
||||
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,
|
||||
@@ -112,15 +136,39 @@ export class StudioService {
|
||||
});
|
||||
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
|
||||
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 },
|
||||
@@ -135,7 +183,7 @@ export class StudioService {
|
||||
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(),
|
||||
|
||||
Reference in New Issue
Block a user