From 38ab528d24057657708b4553af1431f93609ed1b Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 12 Aug 2026 14:46:57 +0900 Subject: [PATCH] add commits.. --- .../migration.sql | 16 ++ .../migration.sql | 17 ++ .../migration.sql | 18 ++ .../migration.sql | 27 +++ .../migration.sql | 20 ++ .../migration.sql | 7 + .../migration.sql | 39 ++++ scripts/reseed-base-nav2-curriculum.sql | 152 ++++++++++++---- .../create-initial-instructor-course.dto.ts | 8 + src/instructor/instructor.controller.ts | 6 + src/instructor/instructor.service.ts | 171 ++++++++++++++++++ 11 files changed, 450 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/20260811150000_replace_legacy_nav2_slam_runtime/migration.sql create mode 100644 prisma/migrations/20260811162500_refresh_nav2_gazebo_simulator_artifact/migration.sql create mode 100644 prisma/migrations/20260811173000_fix_nav2_visualization_bridge_context/migration.sql create mode 100644 prisma/migrations/20260811174500_refresh_navigation_module_artifacts/migration.sql create mode 100644 prisma/migrations/20260811181000_default_localization_mode_amcl/migration.sql create mode 100644 prisma/migrations/20260812114500_remove_profile_viewer_modes/migration.sql create mode 100644 prisma/migrations/20260812123000_consolidate_platform_environment/migration.sql create mode 100644 src/instructor/dto/create-initial-instructor-course.dto.ts diff --git a/prisma/migrations/20260811150000_replace_legacy_nav2_slam_runtime/migration.sql b/prisma/migrations/20260811150000_replace_legacy_nav2_slam_runtime/migration.sql new file mode 100644 index 0000000..9899667 --- /dev/null +++ b/prisma/migrations/20260811150000_replace_legacy_nav2_slam_runtime/migration.sql @@ -0,0 +1,16 @@ +UPDATE "course_environment_profiles" +SET "config" = replace( + replace( + replace( + "config"::text, + 'nav2-slam:humble', + 'future-nav2-gazebo:humble' + ), + 'runtime-images/nav2/humble/latest/nav2-slam-humble.tar.zst', + 'runtime-images/nav2/gazebo/latest/future-nav2-gazebo-humble.tar.zst' + ), + 'nav2-slam-humble.tar.zst', + 'future-nav2-gazebo-humble.tar.zst' +)::jsonb +WHERE "config"::text LIKE '%nav2-slam:humble%' + OR "config"::text LIKE '%nav2-slam-humble.tar.zst%'; diff --git a/prisma/migrations/20260811162500_refresh_nav2_gazebo_simulator_artifact/migration.sql b/prisma/migrations/20260811162500_refresh_nav2_gazebo_simulator_artifact/migration.sql new file mode 100644 index 0000000..20c997a --- /dev/null +++ b/prisma/migrations/20260811162500_refresh_nav2_gazebo_simulator_artifact/migration.sql @@ -0,0 +1,17 @@ +UPDATE "course_week_modules" AS module +SET "prebuilt_artifacts" = ( + SELECT jsonb_agg( + CASE + WHEN artifact->>'fileName' = 'lms-nav2-gazebo-simulator-build-install.tar.zst' + OR artifact->>'url' LIKE '%/lms-nav2-gazebo-simulator-build-install.tar.zst' + THEN jsonb_set( + artifact, + '{checksumSha256}', + to_jsonb('8432100a17d85047ab4ac79445d5eacf236ccc79a785db61d14270104af270b7'::text) + ) + ELSE artifact + END + ) + FROM jsonb_array_elements(module."prebuilt_artifacts") AS artifact +) +WHERE module."folder_name" = '[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws'; diff --git a/prisma/migrations/20260811173000_fix_nav2_visualization_bridge_context/migration.sql b/prisma/migrations/20260811173000_fix_nav2_visualization_bridge_context/migration.sql new file mode 100644 index 0000000..cf0a667 --- /dev/null +++ b/prisma/migrations/20260811173000_fix_nav2_visualization_bridge_context/migration.sql @@ -0,0 +1,18 @@ +-- The NAV2 viewer expects the unified slam-ros-bridge API +-- (/state, /ws/state and /ws/control). The older nav2-ros-bridge context +-- does not expose those endpoints, so remove that override from every +-- local NAV2 course environment profile. +UPDATE "course_environment_profiles" +SET + "config" = jsonb_set( + ("config"::jsonb #- '{bridge,buildContext}'), + '{bridge,image}', + '"slam-ros-bridge:latest"'::jsonb, + true + ), + "updated_at" = CURRENT_TIMESTAMP +WHERE "platform_key" = 'NAV2' + AND ( + "config"::jsonb #>> '{bridge,buildContext}' = 'resources/nav2-ros-bridge' + OR "config"::jsonb #>> '{bridge,image}' = 'nav2-ros-bridge' + ); diff --git a/prisma/migrations/20260811174500_refresh_navigation_module_artifacts/migration.sql b/prisma/migrations/20260811174500_refresh_navigation_module_artifacts/migration.sql new file mode 100644 index 0000000..8c9ff05 --- /dev/null +++ b/prisma/migrations/20260811174500_refresh_navigation_module_artifacts/migration.sql @@ -0,0 +1,27 @@ +UPDATE "course_week_modules" AS module +SET "prebuilt_artifacts" = ( + SELECT jsonb_agg( + CASE + WHEN artifact->>'fileName' = 'lms-nav2-gazebo-planning-build-install.tar.zst' + OR artifact->>'url' LIKE '%/lms-nav2-gazebo-planning-build-install.tar.zst' + THEN jsonb_set( + artifact, + '{checksumSha256}', + to_jsonb('6087b12b35a4c2ea3073c6b9fad5e43175d7425c2f4a042fa654e8c42b2bf29d'::text) + ) + WHEN artifact->>'fileName' = 'lms-nav2-gazebo-control-build-install.tar.zst' + OR artifact->>'url' LIKE '%/lms-nav2-gazebo-control-build-install.tar.zst' + THEN jsonb_set( + artifact, + '{checksumSha256}', + to_jsonb('ce8949b8a14f6d29b3924b82d4819923368fb67029f45cc50a07175afa659a7a'::text) + ) + ELSE artifact + END + ) + FROM jsonb_array_elements(module."prebuilt_artifacts") AS artifact +) +WHERE module."folder_name" IN ( + '[lms-nav2-gazebo-planning]nav2-planning_v1.0.0_ws', + '[lms-nav2-gazebo-control]nav2-control_v1.0.0_ws' +); diff --git a/prisma/migrations/20260811181000_default_localization_mode_amcl/migration.sql b/prisma/migrations/20260811181000_default_localization_mode_amcl/migration.sql new file mode 100644 index 0000000..7feff7b --- /dev/null +++ b/prisma/migrations/20260811181000_default_localization_mode_amcl/migration.sql @@ -0,0 +1,20 @@ +UPDATE "course_week_modules" AS module +SET "default_parameters" = CASE + WHEN EXISTS ( + SELECT 1 + FROM jsonb_array_elements(module."default_parameters") AS parameter + WHERE parameter #>> '{}' LIKE 'localization_mode:=%' + ) THEN ( + SELECT jsonb_agg( + CASE + WHEN parameter #>> '{}' LIKE 'localization_mode:=%' + THEN to_jsonb('localization_mode:=amcl'::text) + ELSE parameter + END + ) + FROM jsonb_array_elements(module."default_parameters") AS parameter + ) + ELSE COALESCE(module."default_parameters", '[]'::jsonb) + || jsonb_build_array('localization_mode:=amcl') +END +WHERE module."folder_name" = '[lms-nav2-gazebo-localization]nav2-localization_v1.0.0_ws'; diff --git a/prisma/migrations/20260812114500_remove_profile_viewer_modes/migration.sql b/prisma/migrations/20260812114500_remove_profile_viewer_modes/migration.sql new file mode 100644 index 0000000..b8f26bd --- /dev/null +++ b/prisma/migrations/20260812114500_remove_profile_viewer_modes/migration.sql @@ -0,0 +1,7 @@ +-- The selected map determines SLAM/navigation and 2D/3D viewer mode. +-- Environment profiles retain only runtime connection configuration. +UPDATE "course_environment_profiles" +SET + "config" = "config" #- '{viewer,mode}', + "updated_at" = CURRENT_TIMESTAMP +WHERE "config" #> '{viewer,mode}' IS NOT NULL; diff --git a/prisma/migrations/20260812123000_consolidate_platform_environment/migration.sql b/prisma/migrations/20260812123000_consolidate_platform_environment/migration.sql new file mode 100644 index 0000000..b4d5656 --- /dev/null +++ b/prisma/migrations/20260812123000_consolidate_platform_environment/migration.sql @@ -0,0 +1,39 @@ +-- SLAM/navigation is selected from the map. Keep one runtime configuration +-- for the remote platform instead of separate visualization profiles. +DELETE FROM "course_environment_profiles" legacy +USING "course_environment_profiles" platform +WHERE legacy."course_id" = platform."course_id" + AND legacy."week" = platform."week" + AND platform."platform_key" = 'PLATFORM' + AND legacy."platform_key" IN ('REAL_SLAM', 'REAL_SLAM_3D', 'REAL_NAVIGATION'); + +WITH canonical AS ( + SELECT id + FROM ( + SELECT + id, + row_number() OVER ( + PARTITION BY "course_id", "week" + ORDER BY CASE "platform_key" + WHEN 'REAL_SLAM' THEN 1 + WHEN 'REAL_NAVIGATION' THEN 2 + WHEN 'REAL_SLAM_3D' THEN 3 + END + ) AS rank + FROM "course_environment_profiles" + WHERE "platform_key" IN ('REAL_SLAM', 'REAL_SLAM_3D', 'REAL_NAVIGATION') + ) ranked + WHERE rank = 1 +) +UPDATE "course_environment_profiles" profile +SET + "platform_key" = 'PLATFORM', + "profile_type" = 'PLATFORM', + "name" = '플랫폼', + "config" = jsonb_set(profile."config" #- '{viewer,mode}', '{type}', '"PLATFORM"'::jsonb, true), + "updated_at" = CURRENT_TIMESTAMP +FROM canonical +WHERE profile.id = canonical.id; + +DELETE FROM "course_environment_profiles" +WHERE "platform_key" IN ('REAL_SLAM', 'REAL_SLAM_3D', 'REAL_NAVIGATION'); diff --git a/scripts/reseed-base-nav2-curriculum.sql b/scripts/reseed-base-nav2-curriculum.sql index 2f899e7..e62f628 100644 --- a/scripts/reseed-base-nav2-curriculum.sql +++ b/scripts/reseed-base-nav2-curriculum.sql @@ -25,9 +25,6 @@ WHERE course_id IN (SELECT id FROM target_course); DELETE FROM course_environment_profiles WHERE course_id IN (SELECT id FROM target_course); -DELETE FROM course_week_visualization_platforms -WHERE course_id IN (SELECT id FROM target_course); - DELETE FROM course_week_maps WHERE course_id IN (SELECT id FROM target_course); @@ -153,23 +150,23 @@ WHERE w.course_id IN (SELECT id FROM target_course) AND w.week = 2; WITH module_definitions(category, name, folder_name, template_repository_url, prebuilt_artifacts, default_parameters, docker_image) AS ( VALUES ('Simulator', 'NAV2-Gazebo', '[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-simulator.git', - jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-simulator/1.0.0/prebuilt/latest/lms-nav2-gazebo-simulator-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-simulator-build-install.tar.zst', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('gui:=false'), 'future-nav2-gazebo:humble'), + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-simulator/1.0.0/prebuilt/latest/lms-nav2-gazebo-simulator-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-simulator-build-install.tar.zst', 'checksumSha256', '7d2cfe092e81ef15a6e55525410d6f30a803d8c81bee20541dd1d7b964b1fe53', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('gui:=false'), 'future-nav2-gazebo:humble'), ('Vehicle', 'NAV2-TurtleBot3Burger', '[lms-nav2-gazebo-vehicle]nav2-vehicle_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-vehicle.git', - jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-vehicle/1.0.0/prebuilt/latest/lms-nav2-gazebo-vehicle-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-vehicle-build-install.tar.zst', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true', 'platform:=turtlebot3', 'level:=2d'), 'future-nav2-gazebo:humble'), + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-vehicle/1.0.0/prebuilt/latest/lms-nav2-gazebo-vehicle-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-vehicle-build-install.tar.zst', 'checksumSha256', '42e8179172c178978e5ae5d48e189164f6b8a5030fdaa0eba497ca86a97e96bb', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true', 'platform:=turtlebot3', 'level:=2d'), 'future-nav2-gazebo:humble'), ('Map', 'NAV2-Map', '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-map.git', - jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-map/1.0.0/prebuilt/latest/lms-nav2-gazebo-map-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-map-build-install.tar.zst', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-map/1.0.0/prebuilt/latest/lms-nav2-gazebo-map-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-map-build-install.tar.zst', 'checksumSha256', 'f409ce54c40aeeed8cf3fc06251c2e321887b3ec5f5827dfb74897a62780582f', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), ('Localization', 'NAV2-SLAM', '[lms-nav2-gazebo-slam]nav2-slam_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-slam.git', - jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-slam/1.0.0/prebuilt/latest/lms-nav2-gazebo-slam-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-slam-build-install.tar.zst', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-slam/1.0.0/prebuilt/latest/lms-nav2-gazebo-slam-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-slam-build-install.tar.zst', 'checksumSha256', '186f2aa926eb41140b2783507b521083536aee3be716cf55c591a32250ec0fce', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), ('Localization', 'NAV2-Localization', '[lms-nav2-gazebo-localization]nav2-localization_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-localization.git', - jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-localization/1.0.0/prebuilt/latest/lms-nav2-gazebo-localization-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-localization-build-install.tar.zst', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('localization_mode:=slam'), 'future-nav2-gazebo:humble'), + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-localization/1.0.0/prebuilt/latest/lms-nav2-gazebo-localization-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-localization-build-install.tar.zst', 'checksumSha256', '44c8d50205f001a196870d03f44d435582b96c51eb4445e50cc8db6203b2c0c9', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('localization_mode:=amcl'), 'future-nav2-gazebo:humble'), ('Perception', 'NAV2-Perception', '[lms-nav2-gazebo-perception]nav2-perception_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-perception.git', - jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-perception/1.0.0/prebuilt/latest/lms-nav2-gazebo-perception-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-perception-build-install.tar.zst', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true'), 'future-nav2-gazebo:humble'), + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-perception/1.0.0/prebuilt/latest/lms-nav2-gazebo-perception-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-perception-build-install.tar.zst', 'checksumSha256', '23c6523d98506caee9e982741c1c806f9f58581aba777af58eadc7cd5fca1432', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true'), 'future-nav2-gazebo:humble'), ('Planning', 'NAV2-Planning', '[lms-nav2-gazebo-planning]nav2-planning_v1.0.0_ws', 'http://localhost:3000/templates/lms-nav2-gazebo-planning.git', - jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-planning/1.0.0/prebuilt/latest/lms-nav2-gazebo-planning-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-planning-build-install.tar.zst', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), + jsonb_build_array(jsonb_build_object('url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/modules/lms-nav2-gazebo-planning/1.0.0/prebuilt/latest/lms-nav2-gazebo-planning-build-install.tar.zst', 'type', 'build-install', 'fileName', 'lms-nav2-gazebo-planning-build-install.tar.zst', 'checksumSha256', 'b0c698acf54ef71915e2707868726b998fbdbb2e2ad15fe65cefc931a9afaa78', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), '[]'::jsonb, 'future-nav2-gazebo:humble'), ('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', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=true', 'robot:=turtlebot3', 'level:=2d'), 'future-nav2-gazebo:humble'), + 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', 'checksumSha256', '63b3b59377c19288f47aa5619fef9b10938f400771df1805d8ca973648ac0ed8', 'architecture', 'amd64', '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_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', 'a11ac9e920fcbe5c5a54e086b72d453932ece32e0c7976858435c0a0ba73a8f9', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), 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', '6094d2aa5188a28153452aec08e28870984b36c1bc0c06fec8091901f6d26856', 'architecture', 'amd64', 'required', false, 'extractPath', '.')), jsonb_build_array('sim_mode:=false', 'platform:=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 @@ -177,19 +174,92 @@ FROM course_weeks w CROSS JOIN module_definitions WHERE w.course_id IN (SELECT id FROM target_course) AND w.week IN (1, 2); -INSERT INTO course_week_visualization_platforms (course_id, week, platform_key, enabled, created_at, updated_at) -SELECT target_course.id, weeks.week, platform.platform_key, true, now(), now() -FROM target_course -CROSS JOIN (VALUES (1), (2)) AS weeks(week) -JOIN visualization_platforms platform ON platform.platform_key IN ('NAV2', 'REAL_SLAM', 'REAL_NAVIGATION'); - INSERT INTO course_environment_profiles (course_id, week, platform_key, profile_type, name, config, enabled, created_at, updated_at) -SELECT target_course.id, weeks.week, platform.platform_key, platform.type, - CASE WHEN platform.platform_key = 'NAV2' THEN '로컬' ELSE platform.display_name END, - platform.default_config, true, now(), now() +SELECT target_course.id, weeks.week, environment.platform_key, environment.profile_type, + environment.name, environment.config, true, now(), now() FROM target_course CROSS JOIN (VALUES (1), (2)) AS weeks(week) -JOIN visualization_platforms platform ON platform.platform_key IN ('NAV2', 'REAL_SLAM', 'REAL_NAVIGATION'); +CROSS JOIN ( + VALUES + ( + 'NAV2', + 'NAV2', + '로컬', + jsonb_build_object( + 'type', 'NAV2', + 'target', 'local', + 'viewer', jsonb_build_object('stack', 'nav2'), + 'bridge', jsonb_build_object( + 'name', 'nav2-ros-bridge', + 'port', 8765, + 'image', 'slam-ros-bridge:latest', + 'env', jsonb_build_object('ROBOT_STACK', 'nav2') + ), + 'runtimeImages', jsonb_build_array(jsonb_build_object( + 'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/gazebo/latest/future-nav2-gazebo-humble.tar.zst', + 'image', 'future-nav2-gazebo:humble', + 'fileName', 'future-nav2-gazebo-humble.tar.zst', + 'required', true, + 'checksumSha256', '54230e039c1fa5dc82615cef3828c5acae7b8bea8e921b70cfdc5351d8301644' + )) + ) + ), + ( + 'PLATFORM', + 'PLATFORM', + '플랫폼', + jsonb_build_object( + 'type', 'PLATFORM', + 'target', 'remote-jetson', + 'runtimeKind', 'real', + 'viewer', jsonb_build_object('stack', 'nav2'), + 'bridge', jsonb_build_object( + 'name', 'nav2-ros-bridge', + 'port', 8765, + 'image', 'slam-ros-bridge:latest', + 'env', jsonb_build_object('ROBOT_STACK', 'nav2') + ), + 'runtimeImages', jsonb_build_array( + jsonb_build_object( + 'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/latest/slam-ros-bridge-latest.tar.zst', + 'image', 'slam-ros-bridge:latest', + 'fileName', 'slam-ros-bridge-latest.tar.zst', + 'required', true + ), + jsonb_build_object( + 'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/jetson-orin/latest/future-nav2-humble-jetson-orin.tar.zst', + 'image', 'future-nav2:humble-jetson_orin', + 'fileName', 'future-nav2-humble-jetson-orin.tar.zst', + 'required', true + ) + ) + ) + ) +) AS environment(platform_key, profile_type, name, config); + +-- Viewer mode belongs to the selected map, not the platform environment. +UPDATE course_environment_profiles profile +SET config = profile.config::jsonb #- '{viewer,mode}' +WHERE profile.course_id IN (SELECT id FROM target_course); + +-- Real-platform modules need the Jetson NAV2 runtime in addition to the +-- visualization bridge image. The desktop app downloads this artifact on +-- demand before starting a remote module. +UPDATE course_environment_profiles profile +SET config = jsonb_set( + profile.config::jsonb, + '{runtimeImages}', + COALESCE(profile.config::jsonb->'runtimeImages', '[]'::jsonb) || jsonb_build_array(jsonb_build_object( + 'url', 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/jetson-orin/latest/future-nav2-humble-jetson-orin.tar.zst', + 'image', 'future-nav2:humble-jetson_orin', + 'fileName', 'future-nav2-humble-jetson-orin.tar.zst', + 'required', true + )), + true +) +WHERE profile.course_id IN (SELECT id FROM target_course) + AND profile.platform_key = 'PLATFORM' + AND NOT (COALESCE(profile.config::jsonb->'runtimeImages', '[]'::jsonb) @> '[{"image":"future-nav2:humble-jetson_orin"}]'::jsonb); -- Local simulation gets all Gazebo modules. Platform profiles only receive modules applicable to their workflow. UPDATE course_environment_profiles profile @@ -204,19 +274,16 @@ SET config = profile.config::jsonb || jsonb_build_object('moduleSet', jsonb_buil AND week.week = profile.week AND ( (profile.platform_key = 'NAV2' AND module.folder_name LIKE '[lms-nav2-gazebo-%') - OR (profile.platform_key IN ('REAL_SLAM', 'REAL_NAVIGATION') AND module.folder_name IN ( - '[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', - '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', - '[lms-nav2-gazebo-perception]nav2-perception_v1.0.0_ws', - '[lms-nav2-gazebo-localization]nav2-localization_v1.0.0_ws', - '[lms-nav2-gazebo-planning]nav2-planning_v1.0.0_ws', - '[lms-nav2-gazebo-control]nav2-control_v1.0.0_ws' + OR (profile.platform_key = 'PLATFORM' AND module.folder_name NOT IN ( + '[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws', + '[lms-nav2-gazebo-vehicle]nav2-vehicle_v1.0.0_ws', + '[lms-nav2-gazebo-gui]nav2-rviz2_v1.0.0_ws' )) ) ), '[]'::jsonb) )) WHERE profile.course_id IN (SELECT id FROM target_course) - AND profile.platform_key IN ('NAV2', 'REAL_SLAM', 'REAL_NAVIGATION'); + AND profile.platform_key IN ('NAV2', 'PLATFORM'); -- Control runs with TurtleBot3/Gazebo defaults locally, but uses MK-mini and the ARM64 image on a platform. UPDATE course_environment_profiles profile @@ -238,7 +305,30 @@ SET config = jsonb_set(profile.config::jsonb, '{moduleSet,modules}', ( ) AS control_module ), true) WHERE profile.course_id IN (SELECT id FROM target_course) - AND profile.platform_key IN ('REAL_SLAM', 'REAL_NAVIGATION'); + AND profile.platform_key = 'PLATFORM'; + +-- The 2D perception module defaults to Gazebo in the course module list. +-- Override only that value for the real platform; the other added modules +-- retain their own compatible base parameters. +UPDATE course_environment_profiles profile +SET config = jsonb_set(profile.config::jsonb, '{moduleSet,modules}', ( + SELECT jsonb_agg( + CASE WHEN item->>'id' = perception_module.id::text THEN item || jsonb_build_object( + 'defaultParameters', jsonb_build_array('sim_mode:=false') + ) ELSE item END + ) + FROM jsonb_array_elements(profile.config->'moduleSet'->'modules') AS item + CROSS JOIN LATERAL ( + SELECT module.id + FROM course_weeks week + JOIN course_week_modules module ON module.week_id = week.id + WHERE week.course_id = profile.course_id + AND week.week = profile.week + AND module.name = 'NAV2-Perception' + ) AS perception_module +), true) +WHERE profile.course_id IN (SELECT id FROM target_course) + AND profile.platform_key = 'PLATFORM'; INSERT INTO course_week_maps (id, course_id, week, map_key, sort_order, created_at, updated_at) SELECT gen_random_uuid(), target_course.id, weeks.week, map.map_key, map.sort_order, now(), now() diff --git a/src/instructor/dto/create-initial-instructor-course.dto.ts b/src/instructor/dto/create-initial-instructor-course.dto.ts new file mode 100644 index 0000000..fe01d2d --- /dev/null +++ b/src/instructor/dto/create-initial-instructor-course.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class CreateInitialInstructorCourseDto { + @IsString() + @IsNotEmpty() + @MaxLength(120) + title!: string; +} diff --git a/src/instructor/instructor.controller.ts b/src/instructor/instructor.controller.ts index 76549dd..65a4bc6 100644 --- a/src/instructor/instructor.controller.ts +++ b/src/instructor/instructor.controller.ts @@ -6,6 +6,7 @@ import { InstructorService } from './instructor.service'; import { AssignInstructorPlatformDto } from './dto/assign-instructor-platform.dto'; import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto'; import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map.dto'; +import { CreateInitialInstructorCourseDto } from './dto/create-initial-instructor-course.dto'; import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; @@ -26,6 +27,11 @@ export class InstructorController { return this.instructorService.listCourses(this.getBearerToken(request)); } + @Post('courses/initial') + createInitialCourse(@Req() request: Request, @Body() body: CreateInitialInstructorCourseDto) { + return this.instructorService.createInitialCourse(this.getBearerToken(request), body.title); + } + @Get('submissions') listSubmissions(@Req() request: Request) { return this.instructorService.listSubmissions(this.getBearerToken(request)); diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index 7ec7a41..e841cd0 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -31,6 +31,31 @@ import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps'; const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']); const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net'; const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references'); +const BASE_COURSE_SLUGS = ['futuredrive-autonomy-basic', 'yeungnam-autonomy-basic']; + +function copyJson(value: Prisma.JsonValue): Prisma.InputJsonValue | Prisma.JsonNullValueInput { + return value === null ? Prisma.JsonNull : JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +} + +function remapProfileModuleIds(value: Prisma.JsonValue, moduleIds: Map) { + const config = copyJson(value); + if (!config || typeof config !== 'object' || Array.isArray(config)) return config; + + const moduleSet = (config as Record).moduleSet; + if (!moduleSet || typeof moduleSet !== 'object' || Array.isArray(moduleSet)) return config; + + const modules = (moduleSet as Record).modules; + if (!Array.isArray(modules)) return config; + + for (const module of modules) { + if (!module || typeof module !== 'object' || Array.isArray(module)) continue; + const id = (module as Record).id; + if (typeof id === 'string' && moduleIds.has(id)) { + (module as Record).id = moduleIds.get(id)!; + } + } + return config; +} type SubmissionCommitFile = { path: string; @@ -3612,6 +3637,152 @@ export class InstructorService { return { authUser: user, instructor }; } + async createInitialCourse(accessToken: string | undefined, title: string) { + const { authUser, instructor } = await this.getInstructor(accessToken); + const organizationName = authUser.primaryAffiliation?.organizationName?.trim(); + if (!organizationName) throw new BadRequestException('강사 소속 조직을 확인할 수 없습니다.'); + + const course = await this.createBaseCourseForInstructor(instructor.id, organizationName, title); + return { + course: { + id: course.id, + slug: course.slug, + title: course.title, + subtitle: course.subtitle, + description: course.description, + organizationName: course.organizationName, + }, + }; + } + + private async createBaseCourseForInstructor(instructorId: string, organizationName: string, title: string) { + const courseTitle = title.trim(); + if (!courseTitle) throw new BadRequestException('강좌명을 입력해 주세요.'); + + const existingCourse = await this.prisma.course.findFirst({ + where: { teacherId: instructorId, isActive: true }, + select: { id: true }, + }); + if (existingCourse) throw new ConflictException('이미 생성된 강좌가 있습니다.'); + + const template = await this.prisma.course.findFirst({ + where: { slug: { in: BASE_COURSE_SLUGS }, isActive: true }, + include: { + weeks: { + orderBy: { week: 'asc' }, + include: { + topics: { orderBy: { sortOrder: 'asc' } }, + practices: { orderBy: { sortOrder: 'asc' } }, + references: { orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }] }, + modules: { orderBy: { sortOrder: 'asc' } }, + }, + }, + environmentProfiles: { orderBy: [{ week: 'asc' }, { platformKey: 'asc' }] }, + maps: { orderBy: { key: 'asc' } }, + weekMaps: { orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }] }, + }, + }); + if (!template) { + this.logger.error('Base NAV2 course was not found; skipping instructor course initialization.'); + throw new NotFoundException('베이스 강좌를 찾을 수 없습니다.'); + } + + const courseSlug = `nav2-autonomy-${this.slug(instructorId).slice(0, 12)}`; + return this.prisma.$transaction(async (tx) => { + const courseAlreadyCreated = await tx.course.findFirst({ + where: { teacherId: instructorId, isActive: true }, + select: { id: true }, + }); + if (courseAlreadyCreated) throw new ConflictException('이미 생성된 강좌가 있습니다.'); + + const course = await tx.course.create({ + data: { + slug: courseSlug, + title: courseTitle, + subtitle: template.subtitle, + description: template.description, + organizationName, + teacherId: instructorId, + isActive: true, + }, + }); + + const moduleIds = new Map(); + for (const sourceWeek of template.weeks) { + const week = await tx.courseWeek.create({ + data: { + courseId: course.id, + week: sourceWeek.week, + title: sourceWeek.title, + tags: sourceWeek.tags, + progress: sourceWeek.progress, + state: sourceWeek.state, + duration: sourceWeek.duration, + github: sourceWeek.github, + overview: sourceWeek.overview, + deliverable: sourceWeek.deliverable, + requiresSourceSubmission: sourceWeek.requiresSourceSubmission, + }, + }); + + await Promise.all([ + tx.courseWeekTopic.createMany({ data: sourceWeek.topics.map((topic) => ({ weekId: week.id, title: topic.title, detail: topic.detail, sortOrder: topic.sortOrder })) }), + tx.courseWeekPractice.createMany({ data: sourceWeek.practices.map((practice) => ({ weekId: week.id, title: practice.title, description: practice.description, sortOrder: practice.sortOrder })) }), + tx.courseWeekReference.createMany({ data: sourceWeek.references.map((reference) => ({ weekId: week.id, kind: reference.kind, title: reference.title, originalFileName: reference.originalFileName, storedFileName: reference.storedFileName, contentType: reference.contentType, fileSizeBytes: reference.fileSizeBytes, sortOrder: reference.sortOrder })) }), + ]); + + for (const sourceModule of sourceWeek.modules) { + const module = await tx.courseWeekModule.create({ + data: { + weekId: week.id, + sortOrder: sourceModule.sortOrder, + category: sourceModule.category, + name: sourceModule.name, + folderName: sourceModule.folderName, + templateRepositoryUrl: sourceModule.templateRepositoryUrl, + sourceType: sourceModule.sourceType, + prebuiltArtifacts: copyJson(sourceModule.prebuiltArtifacts), + defaultParameters: copyJson(sourceModule.defaultParameters), + dockerImage: sourceModule.dockerImage, + }, + }); + moduleIds.set(sourceModule.id, module.id); + } + } + + await Promise.all([ + ...template.environmentProfiles.map((profile) => tx.courseEnvironmentProfile.create({ + data: { + courseId: course.id, + week: profile.week, + platformKey: profile.platformKey, + profileType: profile.profileType, + name: profile.name, + config: remapProfileModuleIds(profile.config, moduleIds), + enabled: profile.enabled, + }, + })), + ...template.maps.map((map) => tx.courseMap.create({ + data: { + courseId: course.id, + key: map.key, + name: map.name, + description: map.description, + platformKey: map.platformKey, + worldFile: map.worldFile, + viewerModel: map.viewerModel, + layout: copyJson(map.layout), + }, + })), + ...template.weekMaps.map((weekMap) => tx.courseWeekMap.create({ + data: { courseId: course.id, week: weekMap.week, mapKey: weekMap.mapKey, sortOrder: weekMap.sortOrder }, + })), + ]); + + return course; + }); + } + private toUserSyncData(user: AuthResponseUser) { return { authUserId: user.id,