patch..
This commit is contained in:
+4
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "course_week_modules"
|
||||||
|
ADD COLUMN IF NOT EXISTS "default_parameters" JSONB NOT NULL DEFAULT '[]';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN "course_week_modules"."default_parameters" IS '주차 모듈 실행 시 적용하는 기본 ROS 파라미터 배열.';
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
CREATE TABLE "course_maps" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"course_id" UUID NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"platform_key" TEXT NOT NULL DEFAULT 'NAV2',
|
||||||
|
"world_file" TEXT NOT NULL DEFAULT 'empty_world.world',
|
||||||
|
"viewer_model" TEXT NOT NULL DEFAULT 'turtlebot3_world',
|
||||||
|
"layout" JSONB NOT NULL DEFAULT '{"version":1,"objects":[]}',
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "course_maps_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "course_week_maps" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"course_id" UUID NOT NULL,
|
||||||
|
"week" INTEGER NOT NULL,
|
||||||
|
"map_key" TEXT NOT NULL,
|
||||||
|
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "course_week_maps_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "course_maps_course_id_key_key" ON "course_maps"("course_id", "key");
|
||||||
|
CREATE INDEX "course_maps_course_id_platform_key_idx" ON "course_maps"("course_id", "platform_key");
|
||||||
|
CREATE UNIQUE INDEX "course_week_maps_course_id_week_map_key_key" ON "course_week_maps"("course_id", "week", "map_key");
|
||||||
|
CREATE INDEX "course_week_maps_course_id_week_idx" ON "course_week_maps"("course_id", "week");
|
||||||
|
|
||||||
|
ALTER TABLE "course_maps" ADD CONSTRAINT "course_maps_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "course_week_maps" ADD CONSTRAINT "course_week_maps_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
INSERT INTO "course_week_maps" ("id", "course_id", "week", "map_key", "sort_order", "updated_at")
|
||||||
|
SELECT gen_random_uuid(), "course_id", "week", 'nav2-turtlebot3-world', 0, CURRENT_TIMESTAMP
|
||||||
|
FROM "course_week_visualization_platforms"
|
||||||
|
WHERE "platform_key" = 'NAV2' AND "enabled" = true
|
||||||
|
ON CONFLICT ("course_id", "week", "map_key") DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO "course_week_maps" ("id", "course_id", "week", "map_key", "sort_order", "updated_at")
|
||||||
|
SELECT gen_random_uuid(), "course_id", "week", 'nav2-turtlebot3-house', 1, CURRENT_TIMESTAMP
|
||||||
|
FROM "course_week_visualization_platforms"
|
||||||
|
WHERE "platform_key" = 'NAV2' AND "enabled" = true
|
||||||
|
ON CONFLICT ("course_id", "week", "map_key") DO NOTHING;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE "student_saved_maps" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"course_id" UUID NOT NULL,
|
||||||
|
"week" INTEGER NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"source_map_key" TEXT NOT NULL,
|
||||||
|
"world_file" TEXT NOT NULL,
|
||||||
|
"viewer_model" TEXT NOT NULL,
|
||||||
|
"yaml_path" TEXT NOT NULL,
|
||||||
|
"pgm_path" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
CONSTRAINT "student_saved_maps_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "student_saved_maps_user_id_course_id_week_yaml_path_key"
|
||||||
|
ON "student_saved_maps"("user_id", "course_id", "week", "yaml_path");
|
||||||
|
CREATE INDEX "student_saved_maps_user_id_course_id_week_created_at_idx"
|
||||||
|
ON "student_saved_maps"("user_id", "course_id", "week", "created_at");
|
||||||
|
|
||||||
|
ALTER TABLE "student_saved_maps"
|
||||||
|
ADD CONSTRAINT "student_saved_maps_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "student_saved_maps"
|
||||||
|
ADD CONSTRAINT "student_saved_maps_course_id_fkey"
|
||||||
|
FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
INSERT INTO "visualization_platforms" (
|
||||||
|
"platform_key", "type", "display_name", "description", "default_config", "sort_order", "enabled", "created_at", "updated_at"
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
'REAL_SLAM',
|
||||||
|
'REAL_SLAM',
|
||||||
|
'실차 SLAM',
|
||||||
|
'원격 플랫폼의 실제 센서로 지도를 생성합니다.',
|
||||||
|
'{"type":"REAL_SLAM","target":"remote-jetson","runtimeKind":"real","viewer":{"stack":"nav2","mode":"slam"},"bridge":{"name":"nav2-ros-bridge","port":8765,"image":"slam-ros-bridge:latest","env":{"ROBOT_STACK":"nav2"}},"runtimeImages":[{"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,
|
||||||
|
30, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'REAL_NAVIGATION',
|
||||||
|
'REAL_NAVIGATION',
|
||||||
|
'실차 자율주행',
|
||||||
|
'실차 SLAM 결과 맵에서 초기 위치와 목표점을 지정해 주행합니다.',
|
||||||
|
'{"type":"REAL_NAVIGATION","target":"remote-jetson","runtimeKind":"real","viewer":{"stack":"nav2","mode":"navigation"},"bridge":{"name":"nav2-ros-bridge","port":8765,"image":"slam-ros-bridge:latest","env":{"ROBOT_STACK":"nav2"}},"runtimeImages":[{"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,
|
||||||
|
31, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
ON CONFLICT ("platform_key") DO UPDATE SET
|
||||||
|
"type" = EXCLUDED."type",
|
||||||
|
"display_name" = EXCLUDED."display_name",
|
||||||
|
"description" = EXCLUDED."description",
|
||||||
|
"default_config" = EXCLUDED."default_config",
|
||||||
|
"sort_order" = EXCLUDED."sort_order",
|
||||||
|
"enabled" = EXCLUDED."enabled",
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
UPDATE "visualization_platforms"
|
||||||
|
SET
|
||||||
|
"display_name" = CASE "platform_key"
|
||||||
|
WHEN 'REAL_SLAM' THEN '플랫폼 SLAM'
|
||||||
|
WHEN 'REAL_NAVIGATION' THEN '플랫폼 자율주행'
|
||||||
|
END,
|
||||||
|
"description" = CASE "platform_key"
|
||||||
|
WHEN 'REAL_SLAM' THEN '원격 플랫폼의 센서로 지도를 생성합니다.'
|
||||||
|
WHEN 'REAL_NAVIGATION' THEN '플랫폼 SLAM 결과 맵에서 초기 위치와 목표점을 지정해 주행합니다.'
|
||||||
|
END,
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP
|
||||||
|
WHERE "platform_key" IN ('REAL_SLAM', 'REAL_NAVIGATION');
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "platform_control_sessions"
|
||||||
|
ADD COLUMN "extension_count" INTEGER NOT NULL DEFAULT 0;
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
INSERT INTO "visualization_platforms" (
|
||||||
|
"platform_key", "type", "display_name", "description", "default_config", "sort_order", "enabled", "created_at", "updated_at"
|
||||||
|
) VALUES (
|
||||||
|
'REAL_SLAM_3D',
|
||||||
|
'REAL_SLAM_3D',
|
||||||
|
'플랫폼 3D SLAM',
|
||||||
|
'플랫폼의 3D LiDAR 포인트클라우드와 ICP 위치를 읽기 전용으로 표시합니다.',
|
||||||
|
'{"type":"REAL_SLAM_3D","target":"remote-jetson","runtimeKind":"real","viewer":{"stack":"nav2","mode":"slam3d"},"bridge":{"name":"nav2-ros-bridge","port":8765,"image":"slam-ros-bridge:jetson_orin-3d","env":{"ROBOT_STACK":"nav2","READ_ONLY":"true","ODOM_TOPIC":"/odom","ICP_ODOM_TOPIC":"/icp_odom","SCAN_TOPIC":"/scan","POINT_CLOUD_TOPIC":"/cloud_map","FALLBACK_POINT_CLOUD_TOPIC":"/lidar_filtered"}},"runtimeImages":[{"url":"https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge/jetson-orin-3d/slam-ros-bridge-jetson-orin-3d.tar.zst","image":"slam-ros-bridge:jetson_orin-3d","fileName":"slam-ros-bridge-jetson-orin-3d.tar.zst","required":true}]}'::jsonb,
|
||||||
|
32, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
ON CONFLICT ("platform_key") DO UPDATE SET
|
||||||
|
"type" = EXCLUDED."type",
|
||||||
|
"display_name" = EXCLUDED."display_name",
|
||||||
|
"description" = EXCLUDED."description",
|
||||||
|
"default_config" = EXCLUDED."default_config",
|
||||||
|
"sort_order" = EXCLUDED."sort_order",
|
||||||
|
"enabled" = EXCLUDED."enabled",
|
||||||
|
"updated_at" = CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "student_saved_maps"
|
||||||
|
ADD COLUMN "database_path" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "student_saved_maps"
|
||||||
|
ADD COLUMN "preview_pgm" TEXT;
|
||||||
@@ -31,6 +31,7 @@ model User {
|
|||||||
platformControlSessions PlatformControlSession[]
|
platformControlSessions PlatformControlSession[]
|
||||||
platformControlQueueEntries PlatformControlQueueEntry[]
|
platformControlQueueEntries PlatformControlQueueEntry[]
|
||||||
studioRepositories StudioRepository[]
|
studioRepositories StudioRepository[]
|
||||||
|
savedMaps StudentSavedMap[]
|
||||||
submissionScores SubmissionScore[]
|
submissionScores SubmissionScore[]
|
||||||
submissionTotalScores SubmissionTotalScore[]
|
submissionTotalScores SubmissionTotalScore[]
|
||||||
|
|
||||||
@@ -156,6 +157,9 @@ model Course {
|
|||||||
instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[]
|
instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[]
|
||||||
environmentProfiles CourseEnvironmentProfile[]
|
environmentProfiles CourseEnvironmentProfile[]
|
||||||
visualizationSelections CourseWeekVisualizationPlatform[]
|
visualizationSelections CourseWeekVisualizationPlatform[]
|
||||||
|
maps CourseMap[]
|
||||||
|
weekMaps CourseWeekMap[]
|
||||||
|
savedMaps StudentSavedMap[]
|
||||||
submissionScores SubmissionScore[]
|
submissionScores SubmissionScore[]
|
||||||
submissionTotalScores SubmissionTotalScore[]
|
submissionTotalScores SubmissionTotalScore[]
|
||||||
|
|
||||||
@@ -216,6 +220,63 @@ model CourseWeekVisualizationPlatform {
|
|||||||
@@map("course_week_visualization_platforms")
|
@@map("course_week_visualization_platforms")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model CourseMap {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
courseId String @map("course_id") @db.Uuid
|
||||||
|
key String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
platformKey String @default("NAV2") @map("platform_key")
|
||||||
|
worldFile String @default("empty_world.world") @map("world_file")
|
||||||
|
viewerModel String @default("turtlebot3_world") @map("viewer_model")
|
||||||
|
layout Json @default("{\"version\":1,\"objects\":[]}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([courseId, key])
|
||||||
|
@@index([courseId, platformKey])
|
||||||
|
@@map("course_maps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model CourseWeekMap {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
courseId String @map("course_id") @db.Uuid
|
||||||
|
week Int
|
||||||
|
mapKey String @map("map_key")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([courseId, week, mapKey])
|
||||||
|
@@index([courseId, week])
|
||||||
|
@@map("course_week_maps")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StudentSavedMap {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
courseId String @map("course_id") @db.Uuid
|
||||||
|
week Int
|
||||||
|
name String
|
||||||
|
sourceMapKey String @map("source_map_key")
|
||||||
|
worldFile String @map("world_file")
|
||||||
|
viewerModel String @map("viewer_model")
|
||||||
|
yamlPath String @map("yaml_path")
|
||||||
|
pgmPath String @map("pgm_path")
|
||||||
|
previewPgm String? @map("preview_pgm")
|
||||||
|
databasePath String? @map("database_path")
|
||||||
|
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)
|
||||||
|
|
||||||
|
@@unique([userId, courseId, week, yamlPath])
|
||||||
|
@@index([userId, courseId, week, createdAt])
|
||||||
|
@@map("student_saved_maps")
|
||||||
|
}
|
||||||
|
|
||||||
model InstructorCurriculumSetting {
|
model InstructorCurriculumSetting {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
organizationId String? @map("organization_id") @db.Uuid
|
organizationId String? @map("organization_id") @db.Uuid
|
||||||
@@ -328,6 +389,7 @@ model PlatformControlSession {
|
|||||||
endsAt DateTime @map("ends_at") @db.Timestamptz(3)
|
endsAt DateTime @map("ends_at") @db.Timestamptz(3)
|
||||||
warningAt DateTime @map("warning_at") @db.Timestamptz(3)
|
warningAt DateTime @map("warning_at") @db.Timestamptz(3)
|
||||||
warningSentAt DateTime? @map("warning_sent_at") @db.Timestamptz(3)
|
warningSentAt DateTime? @map("warning_sent_at") @db.Timestamptz(3)
|
||||||
|
extensionCount Int @default(0) @map("extension_count")
|
||||||
releasedAt DateTime? @map("released_at") @db.Timestamptz(3)
|
releasedAt DateTime? @map("released_at") @db.Timestamptz(3)
|
||||||
endReason String? @map("end_reason")
|
endReason String? @map("end_reason")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
@@ -448,6 +510,7 @@ model CourseWeekModule {
|
|||||||
templateRepositoryUrl String? @map("template_repository_url")
|
templateRepositoryUrl String? @map("template_repository_url")
|
||||||
sourceType String @default("GIT") @map("source_type")
|
sourceType String @default("GIT") @map("source_type")
|
||||||
prebuiltArtifacts Json @default("[]") @map("prebuilt_artifacts")
|
prebuiltArtifacts Json @default("[]") @map("prebuilt_artifacts")
|
||||||
|
defaultParameters Json @default("[]") @map("default_parameters")
|
||||||
dockerImage String? @map("docker_image")
|
dockerImage String? @map("docker_image")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
export type CourseMapPayload = {
|
||||||
|
key: string;
|
||||||
|
platformKey: 'NAV2';
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
worldFile: string;
|
||||||
|
viewerModel: 'turtlebot3_world' | 'turtlebot3_house';
|
||||||
|
custom?: boolean;
|
||||||
|
layout?: Prisma.JsonValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NAV2_SYSTEM_MAPS: CourseMapPayload[] = [
|
||||||
|
{
|
||||||
|
key: 'nav2-turtlebot3-world',
|
||||||
|
platformKey: 'NAV2',
|
||||||
|
name: '기본 월드',
|
||||||
|
description: 'TurtleBot3 기본 장애물 환경',
|
||||||
|
worldFile: 'turtlebot3_world.world',
|
||||||
|
viewerModel: 'turtlebot3_world',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'nav2-turtlebot3-house',
|
||||||
|
platformKey: 'NAV2',
|
||||||
|
name: '실내 환경',
|
||||||
|
description: '방과 복도로 구성된 TurtleBot3 House',
|
||||||
|
worldFile: 'turtlebot3_house.world',
|
||||||
|
viewerModel: 'turtlebot3_house',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function serializeCourseMaps(
|
||||||
|
selectedKeys: string[],
|
||||||
|
customMaps: Array<{
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
platformKey: string;
|
||||||
|
worldFile: string;
|
||||||
|
viewerModel: string;
|
||||||
|
layout: Prisma.JsonValue;
|
||||||
|
}>,
|
||||||
|
): CourseMapPayload[] {
|
||||||
|
const systemByKey = new Map(NAV2_SYSTEM_MAPS.map((map) => [map.key, map]));
|
||||||
|
const customByKey = new Map(customMaps.map((map) => [map.key, map]));
|
||||||
|
|
||||||
|
return selectedKeys.flatMap((key) => {
|
||||||
|
const systemMap = systemByKey.get(key);
|
||||||
|
if (systemMap) return [systemMap];
|
||||||
|
const customMap = customByKey.get(key);
|
||||||
|
if (!customMap || customMap.platformKey.toUpperCase() !== 'NAV2') return [];
|
||||||
|
return [{
|
||||||
|
key: customMap.key,
|
||||||
|
platformKey: 'NAV2' as const,
|
||||||
|
name: customMap.name,
|
||||||
|
description: customMap.description ?? '강사가 구성한 학습 맵',
|
||||||
|
worldFile: customMap.worldFile,
|
||||||
|
viewerModel: customMap.viewerModel === 'turtlebot3_house' ? 'turtlebot3_house' as const : 'turtlebot3_world' as const,
|
||||||
|
custom: true,
|
||||||
|
layout: customMap.layout,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { AuthService } from '../auth/auth.service';
|
|||||||
import { PlatformControlService } from '../platform-control/platform-control.service';
|
import { PlatformControlService } from '../platform-control/platform-control.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
import { R2PublicFileService } from '../storage/r2-public-file.service';
|
||||||
|
import { serializeCourseMaps } from './course-maps';
|
||||||
|
|
||||||
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
||||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
||||||
@@ -159,6 +160,12 @@ export class CurriculumService {
|
|||||||
environmentProfiles: {
|
environmentProfiles: {
|
||||||
where: { enabled: true },
|
where: { enabled: true },
|
||||||
},
|
},
|
||||||
|
maps: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
},
|
||||||
|
weekMaps: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
},
|
||||||
studentProgress: {
|
studentProgress: {
|
||||||
where: {
|
where: {
|
||||||
user: {
|
user: {
|
||||||
@@ -183,6 +190,12 @@ export class CurriculumService {
|
|||||||
platform: true,
|
platform: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const mapKeysByWeek = new Map<number, string[]>();
|
||||||
|
course.weekMaps.forEach((selection) => {
|
||||||
|
const items = mapKeysByWeek.get(selection.week) ?? [];
|
||||||
|
items.push(selection.mapKey);
|
||||||
|
mapKeysByWeek.set(selection.week, items);
|
||||||
|
});
|
||||||
const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile]));
|
const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile]));
|
||||||
const visualizationProfilesByWeek = new Map<number, Array<{
|
const visualizationProfilesByWeek = new Map<number, Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -414,6 +427,7 @@ export class CurriculumService {
|
|||||||
const moduleWithArtifacts = module as typeof module & {
|
const moduleWithArtifacts = module as typeof module & {
|
||||||
sourceType?: string | null;
|
sourceType?: string | null;
|
||||||
prebuiltArtifacts?: unknown;
|
prebuiltArtifacts?: unknown;
|
||||||
|
defaultParameters?: unknown;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
id: module.id,
|
id: module.id,
|
||||||
@@ -424,10 +438,12 @@ export class CurriculumService {
|
|||||||
templateRepositoryUrl: module.templateRepositoryUrl,
|
templateRepositoryUrl: module.templateRepositoryUrl,
|
||||||
sourceType: moduleWithArtifacts.sourceType ?? 'GIT',
|
sourceType: moduleWithArtifacts.sourceType ?? 'GIT',
|
||||||
prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [],
|
prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [],
|
||||||
|
defaultParameters: moduleWithArtifacts.defaultParameters ?? [],
|
||||||
dockerImage: module.dockerImage,
|
dockerImage: module.dockerImage,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [],
|
environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [],
|
||||||
|
maps: serializeCourseMaps(mapKeysByWeek.get(week.week) ?? [], course.maps),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const addedWeeks = overrides
|
const addedWeeks = overrides
|
||||||
@@ -464,6 +480,7 @@ export class CurriculumService {
|
|||||||
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
|
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
|
||||||
},
|
},
|
||||||
environmentProfiles: visualizationProfilesByWeek.get(override.week) ?? [],
|
environmentProfiles: visualizationProfilesByWeek.get(override.week) ?? [],
|
||||||
|
maps: serializeCourseMaps(mapKeysByWeek.get(override.week) ?? [], course.maps),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -763,7 +780,7 @@ export class CurriculumService {
|
|||||||
? '비상 정지'
|
? '비상 정지'
|
||||||
: '오류 발생'
|
: '오류 발생'
|
||||||
: '상태 확인 중',
|
: '상태 확인 중',
|
||||||
action: blocked ? '연결 불가' : '제어권 요청',
|
action: blocked ? '연결 불가' : '제어요청',
|
||||||
state: blocked ? 'locked' : 'ready',
|
state: blocked ? 'locked' : 'ready',
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -925,10 +942,9 @@ export class CurriculumService {
|
|||||||
|
|
||||||
private formatLearningHours(learningMinutes: number) {
|
private formatLearningHours(learningMinutes: number) {
|
||||||
const totalMinutes = Math.max(0, Math.floor(learningMinutes));
|
const totalMinutes = Math.max(0, Math.floor(learningMinutes));
|
||||||
if (totalMinutes < 60) return `${totalMinutes}m`;
|
|
||||||
const hours = Math.floor(totalMinutes / 60);
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateInstructorCourseMapDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
courseId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(240)
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
@@ -87,8 +87,32 @@ export class UpdateInstructorCurriculumDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
disabledModuleIds?: string[];
|
disabledModuleIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
moduleDefaultParameters?: Array<{
|
||||||
|
moduleId?: string;
|
||||||
|
parameters?: string[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
environmentModuleSets?: Array<{
|
||||||
|
platformKey?: string;
|
||||||
|
moduleIds?: string[];
|
||||||
|
modules?: Array<{
|
||||||
|
id?: string;
|
||||||
|
defaultParameters?: string[];
|
||||||
|
dockerImage?: string | null;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
visualizationPlatformKeys?: string[];
|
visualizationPlatformKeys?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
mapKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Request } from 'express';
|
|||||||
import { InstructorService } from './instructor.service';
|
import { InstructorService } from './instructor.service';
|
||||||
import { AssignInstructorPlatformDto } from './dto/assign-instructor-platform.dto';
|
import { AssignInstructorPlatformDto } from './dto/assign-instructor-platform.dto';
|
||||||
import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto';
|
import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto';
|
||||||
|
import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map.dto';
|
||||||
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
|
||||||
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
||||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||||
@@ -85,6 +86,17 @@ export class InstructorController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('curriculum/maps')
|
||||||
|
createCurriculumMap(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Body() body: CreateInstructorCourseMapDto,
|
||||||
|
) {
|
||||||
|
return this.instructorService.createCurriculumMap(
|
||||||
|
this.getBearerToken(request),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('curriculum/weeks/:week')
|
@Delete('curriculum/weeks/:week')
|
||||||
deleteCurriculumWeek(
|
deleteCurriculumWeek(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@@ -152,6 +164,15 @@ export class InstructorController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('platform-registration-code')
|
||||||
|
issuePlatformRegistrationCode(
|
||||||
|
@Req() request: Request,
|
||||||
|
) {
|
||||||
|
return this.instructorService.issuePlatformRegistrationCode(
|
||||||
|
this.getBearerToken(request),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Put('platforms/:platformId')
|
@Put('platforms/:platformId')
|
||||||
updatePlatform(
|
updatePlatform(
|
||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -24,6 +25,8 @@ import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
|||||||
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
||||||
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
|
||||||
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
|
||||||
|
import { CreateInstructorCourseMapDto } from './dto/create-instructor-course-map.dto';
|
||||||
|
import { NAV2_SYSTEM_MAPS } from '../curriculum/course-maps';
|
||||||
|
|
||||||
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
|
const INSTRUCTOR_ROLES = new Set(['INSTRUCTOR', 'LMS_ADMIN']);
|
||||||
const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net';
|
const DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net';
|
||||||
@@ -59,6 +62,8 @@ type StudentIssueItem = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InstructorService {
|
export class InstructorService {
|
||||||
|
private readonly logger = new Logger(InstructorService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly chatGateway: ChatGateway,
|
private readonly chatGateway: ChatGateway,
|
||||||
@@ -734,7 +739,7 @@ export class InstructorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (group && organizationName) {
|
if (group && organizationName) {
|
||||||
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
||||||
}
|
}
|
||||||
return this.listStudents(accessToken, course.id);
|
return this.listStudents(accessToken, course.id);
|
||||||
}
|
}
|
||||||
@@ -789,7 +794,7 @@ export class InstructorService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (organizationName) {
|
if (organizationName) {
|
||||||
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
||||||
}
|
}
|
||||||
return this.listStudents(accessToken);
|
return this.listStudents(accessToken);
|
||||||
}
|
}
|
||||||
@@ -1123,6 +1128,27 @@ export class InstructorService {
|
|||||||
items.push(selection);
|
items.push(selection);
|
||||||
visualizationSelectionsByWeek.set(selection.week, items);
|
visualizationSelectionsByWeek.set(selection.week, items);
|
||||||
});
|
});
|
||||||
|
const courseMaps = await this.prisma.courseMap.findMany({
|
||||||
|
where: { courseId: course.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
const weekMapSelections = await this.prisma.courseWeekMap.findMany({
|
||||||
|
where: { courseId: course.id },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
});
|
||||||
|
const mapKeysByWeek = new Map<number, string[]>();
|
||||||
|
weekMapSelections.forEach((selection) => {
|
||||||
|
const items = mapKeysByWeek.get(selection.week) ?? [];
|
||||||
|
items.push(selection.mapKey);
|
||||||
|
mapKeysByWeek.set(selection.week, items);
|
||||||
|
});
|
||||||
|
const environmentProfiles = await this.prisma.courseEnvironmentProfile.findMany({
|
||||||
|
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 overrideByWeek = new Map(overrides.map((override) => [override.week, override]));
|
||||||
const baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
|
const baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
|
||||||
const baseWeeks = course.weeks.map((week) => {
|
const baseWeeks = course.weeks.map((week) => {
|
||||||
@@ -1172,8 +1198,19 @@ export class InstructorService {
|
|||||||
folderName: module.folderName,
|
folderName: module.folderName,
|
||||||
templateRepositoryUrl: module.templateRepositoryUrl,
|
templateRepositoryUrl: module.templateRepositoryUrl,
|
||||||
dockerImage: module.dockerImage,
|
dockerImage: module.dockerImage,
|
||||||
|
defaultParameters: this.stringArray(module.defaultParameters) ?? [],
|
||||||
enabled: !disabledModuleIdSet.has(module.id),
|
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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({
|
visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({
|
||||||
key: selection.platform.key,
|
key: selection.platform.key,
|
||||||
type: selection.platform.type,
|
type: selection.platform.type,
|
||||||
@@ -1181,6 +1218,7 @@ export class InstructorService {
|
|||||||
description: selection.platform.description,
|
description: selection.platform.description,
|
||||||
config: selection.platform.defaultConfig,
|
config: selection.platform.defaultConfig,
|
||||||
})),
|
})),
|
||||||
|
mapKeys: mapKeysByWeek.get(week.week) ?? [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const addedWeeks = overrides
|
const addedWeeks = overrides
|
||||||
@@ -1221,6 +1259,7 @@ export class InstructorService {
|
|||||||
description: selection.platform.description,
|
description: selection.platform.description,
|
||||||
config: selection.platform.defaultConfig,
|
config: selection.platform.defaultConfig,
|
||||||
})),
|
})),
|
||||||
|
mapKeys: mapKeysByWeek.get(override.week) ?? [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1241,6 +1280,19 @@ export class InstructorService {
|
|||||||
description: platform.description,
|
description: platform.description,
|
||||||
config: platform.defaultConfig,
|
config: platform.defaultConfig,
|
||||||
})),
|
})),
|
||||||
|
mapOptions: [
|
||||||
|
...NAV2_SYSTEM_MAPS,
|
||||||
|
...courseMaps.map((map) => ({
|
||||||
|
key: map.key,
|
||||||
|
platformKey: 'NAV2' as const,
|
||||||
|
name: map.name,
|
||||||
|
description: map.description ?? '강사가 구성한 학습 맵',
|
||||||
|
worldFile: map.worldFile,
|
||||||
|
viewerModel: map.viewerModel === 'turtlebot3_house' ? 'turtlebot3_house' as const : 'turtlebot3_world' as const,
|
||||||
|
custom: true,
|
||||||
|
layout: map.layout,
|
||||||
|
})),
|
||||||
|
],
|
||||||
setting: {
|
setting: {
|
||||||
groupId: setting?.groupId ?? null,
|
groupId: setting?.groupId ?? null,
|
||||||
activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? 1,
|
activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? 1,
|
||||||
@@ -1300,6 +1352,135 @@ export class InstructorService {
|
|||||||
const practiceContents = this.normalizePracticeContents(input.practiceContents);
|
const practiceContents = this.normalizePracticeContents(input.practiceContents);
|
||||||
|
|
||||||
const disabledModuleIds = input.disabledModuleIds?.map((moduleId) => moduleId.trim()).filter(Boolean);
|
const disabledModuleIds = input.disabledModuleIds?.map((moduleId) => moduleId.trim()).filter(Boolean);
|
||||||
|
const moduleDefaultParameters = input.moduleDefaultParameters === undefined
|
||||||
|
? undefined
|
||||||
|
: input.moduleDefaultParameters.reduce<Array<{ moduleId: string; parameters: string[] }>>((items, entry) => {
|
||||||
|
const moduleId = entry?.moduleId?.trim();
|
||||||
|
if (!moduleId) return items;
|
||||||
|
items.push({
|
||||||
|
moduleId,
|
||||||
|
parameters: Array.isArray(entry.parameters)
|
||||||
|
? entry.parameters.map((parameter) => parameter.trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (moduleDefaultParameters !== undefined) {
|
||||||
|
const moduleIdSet = new Set(targetWeek?.modules.map((module) => module.id) ?? []);
|
||||||
|
const invalidModule = moduleDefaultParameters.find((entry) => !moduleIdSet.has(entry.moduleId));
|
||||||
|
if (invalidModule) throw new BadRequestException('선택한 주차에 없는 모듈입니다.');
|
||||||
|
|
||||||
|
await this.prisma.$transaction(moduleDefaultParameters.map((entry) => (
|
||||||
|
this.prisma.courseWeekModule.update({
|
||||||
|
where: { id: entry.moduleId },
|
||||||
|
data: { defaultParameters: entry.parameters },
|
||||||
|
})
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.environmentModuleSets !== undefined) {
|
||||||
|
const moduleIdSet = new Set(targetWeek?.modules.map((module) => module.id) ?? []);
|
||||||
|
const requestedSets = input.environmentModuleSets.reduce<Array<{
|
||||||
|
platformKey: string;
|
||||||
|
modules: Array<{ id: string; defaultParameters: string[]; dockerImage?: string | null }>;
|
||||||
|
}>>((items, entry) => {
|
||||||
|
const platformKey = entry?.platformKey?.trim();
|
||||||
|
if (!platformKey) return items;
|
||||||
|
const moduleMap = new Map<string, { defaultParameters: string[]; dockerImage?: string | null }>();
|
||||||
|
if (Array.isArray(entry.modules)) {
|
||||||
|
entry.modules.forEach((module) => {
|
||||||
|
const id = module?.id?.trim();
|
||||||
|
if (!id) return;
|
||||||
|
moduleMap.set(id, {
|
||||||
|
defaultParameters: Array.isArray(module.defaultParameters)
|
||||||
|
? module.defaultParameters.map((parameter) => parameter.trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
dockerImage: typeof module.dockerImage === 'string'
|
||||||
|
? module.dockerImage.trim() || null
|
||||||
|
: module.dockerImage === null ? null : undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
(entry.moduleIds ?? []).forEach((moduleId) => {
|
||||||
|
const id = moduleId.trim();
|
||||||
|
if (id) moduleMap.set(id, { defaultParameters: [] });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ([...moduleMap.keys()].some((moduleId) => !moduleIdSet.has(moduleId))) {
|
||||||
|
throw new BadRequestException('선택한 주차에 없는 모듈입니다.');
|
||||||
|
}
|
||||||
|
items.push({
|
||||||
|
platformKey,
|
||||||
|
modules: [...moduleMap.entries()].map(([id, value]) => ({ id, ...value })),
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}, []);
|
||||||
|
const selections = await this.prisma.courseWeekVisualizationPlatform.findMany({
|
||||||
|
where: {
|
||||||
|
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 existingConfig = existingProfile?.config && typeof existingProfile.config === 'object' && !Array.isArray(existingProfile.config)
|
||||||
|
? existingProfile.config as Prisma.JsonObject
|
||||||
|
: {};
|
||||||
|
const defaultConfig = selection.platform.defaultConfig && typeof selection.platform.defaultConfig === 'object' && !Array.isArray(selection.platform.defaultConfig)
|
||||||
|
? selection.platform.defaultConfig as Prisma.JsonObject
|
||||||
|
: {};
|
||||||
|
const config = {
|
||||||
|
...defaultConfig,
|
||||||
|
...existingConfig,
|
||||||
|
moduleSet: {
|
||||||
|
target: defaultConfig.target === 'remote-jetson' ? 'platform' : 'local',
|
||||||
|
includeUnlisted: false,
|
||||||
|
modules: item.modules.map((module) => ({
|
||||||
|
id: module.id,
|
||||||
|
...(module.defaultParameters.length ? { defaultParameters: module.defaultParameters } : {}),
|
||||||
|
...(module.dockerImage !== undefined ? { dockerImage: module.dockerImage } : {}),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return this.prisma.courseEnvironmentProfile.upsert({
|
||||||
|
where: {
|
||||||
|
courseId_week_platformKey: {
|
||||||
|
courseId: course.id,
|
||||||
|
week: input.activeWeek,
|
||||||
|
platformKey: item.platformKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
courseId: course.id,
|
||||||
|
week: input.activeWeek,
|
||||||
|
platformKey: item.platformKey,
|
||||||
|
profileType: selection.platform.type,
|
||||||
|
name: selection.platform.displayName,
|
||||||
|
config,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
profileType: selection.platform.type,
|
||||||
|
name: selection.platform.displayName,
|
||||||
|
config,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
let selectedTagLabel: string | undefined;
|
let selectedTagLabel: string | undefined;
|
||||||
if (input.tagLabel !== undefined) {
|
if (input.tagLabel !== undefined) {
|
||||||
const tagLabel = input.tagLabel.trim();
|
const tagLabel = input.tagLabel.trim();
|
||||||
@@ -1357,7 +1538,7 @@ export class InstructorService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.visualizationPlatformKeys) {
|
if (input.visualizationPlatformKeys !== undefined) {
|
||||||
const selectedKeys = Array.from(new Set(input.visualizationPlatformKeys.map((key) => key.trim().toUpperCase()).filter(Boolean)));
|
const selectedKeys = Array.from(new Set(input.visualizationPlatformKeys.map((key) => key.trim().toUpperCase()).filter(Boolean)));
|
||||||
const validPlatforms = selectedKeys.length
|
const validPlatforms = selectedKeys.length
|
||||||
? await this.prisma.visualizationPlatform.findMany({
|
? await this.prisma.visualizationPlatform.findMany({
|
||||||
@@ -1400,6 +1581,34 @@ export class InstructorService {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.mapKeys !== undefined) {
|
||||||
|
const selectedKeys = Array.from(new Set(input.mapKeys.map((key) => key.trim()).filter(Boolean)));
|
||||||
|
const customMaps = selectedKeys.length
|
||||||
|
? await this.prisma.courseMap.findMany({
|
||||||
|
where: { courseId: course.id, key: { in: selectedKeys }, platformKey: 'NAV2' },
|
||||||
|
select: { key: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const validKeys = new Set([
|
||||||
|
...NAV2_SYSTEM_MAPS.map((map) => map.key),
|
||||||
|
...customMaps.map((map) => map.key),
|
||||||
|
]);
|
||||||
|
const mapKeys = selectedKeys.filter((key) => validKeys.has(key));
|
||||||
|
|
||||||
|
await this.prisma.courseWeekMap.deleteMany({
|
||||||
|
where: {
|
||||||
|
courseId: course.id,
|
||||||
|
week: input.activeWeek,
|
||||||
|
mapKey: { notIn: mapKeys },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await Promise.all(mapKeys.map((mapKey, sortOrder) => this.prisma.courseWeekMap.upsert({
|
||||||
|
where: { courseId_week_mapKey: { courseId: course.id, week: input.activeWeek, mapKey } },
|
||||||
|
create: { courseId: course.id, week: input.activeWeek, mapKey, sortOrder },
|
||||||
|
update: { sortOrder },
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
if (applyToStudents) {
|
if (applyToStudents) {
|
||||||
const targetGroup = selectedGroup;
|
const targetGroup = selectedGroup;
|
||||||
if (!targetGroup) throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.');
|
if (!targetGroup) throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.');
|
||||||
@@ -1449,6 +1658,28 @@ export class InstructorService {
|
|||||||
return this.getCurriculum(accessToken, selectedGroup?.id ?? input.groupId ?? null, course.id);
|
return this.getCurriculum(accessToken, selectedGroup?.id ?? input.groupId ?? null, course.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createCurriculumMap(accessToken: string | undefined, input: CreateInstructorCourseMapDto) {
|
||||||
|
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||||
|
const organizationName = authUser.primaryAffiliation?.organizationName?.trim();
|
||||||
|
if (!organizationName) throw new BadRequestException('Instructor organization is required');
|
||||||
|
const course = await this.findInstructorCourse(organizationName, instructor.id, input.courseId);
|
||||||
|
if (!course) throw new NotFoundException('Course not found');
|
||||||
|
|
||||||
|
const name = input.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('맵 이름을 입력해 주세요.');
|
||||||
|
const slug = name.toLowerCase().replace(/[^a-z0-9가-힣]+/g, '-').replace(/^-+|-+$/g, '') || 'map';
|
||||||
|
const key = `nav2-custom-${slug}-${randomUUID().slice(0, 8)}`;
|
||||||
|
return this.prisma.courseMap.create({
|
||||||
|
data: {
|
||||||
|
courseId: course.id,
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
description: this.optionalTrim(input.description) ?? '강사가 구성한 학습 맵',
|
||||||
|
layout: { version: 1, objects: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async deleteCurriculumWeek(accessToken: string | undefined, week: number) {
|
async deleteCurriculumWeek(accessToken: string | undefined, week: number) {
|
||||||
const { authUser, instructor } = await this.getInstructor(accessToken);
|
const { authUser, instructor } = await this.getInstructor(accessToken);
|
||||||
const organizationId = authUser.primaryAffiliation?.organizationId;
|
const organizationId = authUser.primaryAffiliation?.organizationId;
|
||||||
@@ -1708,6 +1939,40 @@ export class InstructorService {
|
|||||||
return this.listPlatforms(accessToken);
|
return this.listPlatforms(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async issuePlatformRegistrationCode(accessToken: string | undefined) {
|
||||||
|
if (!accessToken) throw new UnauthorizedException('Bearer token required');
|
||||||
|
const launcherApiBaseUrl = this.launcherApiBaseUrl();
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${launcherApiBaseUrl}/provision/device-authorizations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
usageType: 'class',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BadGatewayException('Platform registration code request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json().catch((): Record<string, unknown> => ({}));
|
||||||
|
if (!response.ok || typeof payload.code !== 'string') {
|
||||||
|
throw new BadGatewayException(
|
||||||
|
typeof payload.message === 'string'
|
||||||
|
? payload.message
|
||||||
|
: 'Platform registration code request failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: payload.code,
|
||||||
|
expiresAt: typeof payload.expiresAt === 'string' ? payload.expiresAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async updatePlatform(
|
async updatePlatform(
|
||||||
accessToken: string | undefined,
|
accessToken: string | undefined,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
@@ -1812,7 +2077,8 @@ export class InstructorService {
|
|||||||
});
|
});
|
||||||
if (!platform) throw new NotFoundException('Platform not found');
|
if (!platform) throw new NotFoundException('Platform not found');
|
||||||
|
|
||||||
const group = await this.prisma.studentGroup.findFirst({
|
const unassign = groupId === 'unassigned';
|
||||||
|
const group = unassign ? null : await this.prisma.studentGroup.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: groupId,
|
id: groupId,
|
||||||
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
|
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
|
||||||
@@ -1826,8 +2092,8 @@ export class InstructorService {
|
|||||||
instructorPlatformId: true,
|
instructorPlatformId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!group) throw new NotFoundException('Group not found');
|
if (!unassign && !group) throw new NotFoundException('Group not found');
|
||||||
if (group.instructorPlatformId && group.instructorPlatformId !== platform.id) {
|
if (group?.instructorPlatformId && group.instructorPlatformId !== platform.id) {
|
||||||
throw new BadRequestException('이미 플랫폼이 배정된 그룹입니다.');
|
throw new BadRequestException('이미 플랫폼이 배정된 그룹입니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1871,13 +2137,14 @@ export class InstructorService {
|
|||||||
organizationName,
|
organizationName,
|
||||||
),
|
),
|
||||||
instructorPlatformId: platform.id,
|
instructorPlatformId: platform.id,
|
||||||
NOT: { id: group.id },
|
...(group ? { NOT: { id: group.id } } : {}),
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
instructorPlatformId: null,
|
instructorPlatformId: null,
|
||||||
platformName: null,
|
platformName: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (group) {
|
||||||
await tx.studentGroup.update({
|
await tx.studentGroup.update({
|
||||||
where: { id: group.id },
|
where: { id: group.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -1885,6 +2152,7 @@ export class InstructorService {
|
|||||||
platformName: platform.displayName,
|
platformName: platform.displayName,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
if (activeSession && force) {
|
if (activeSession && force) {
|
||||||
await tx.platformControlSession.updateMany({
|
await tx.platformControlSession.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -1916,12 +2184,14 @@ export class InstructorService {
|
|||||||
sessionId: activeSession.id,
|
sessionId: activeSession.id,
|
||||||
platformId: null,
|
platformId: null,
|
||||||
instructorPlatformId: platform.id,
|
instructorPlatformId: platform.id,
|
||||||
reason: 'GROUP_ASSIGNMENT_CHANGED',
|
reason: group ? 'GROUP_ASSIGNMENT_CHANGED' : 'GROUP_ASSIGNMENT_REMOVED',
|
||||||
message: `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`,
|
message: group
|
||||||
|
? `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`
|
||||||
|
: `${platform.displayName} 플랫폼 배정이 해제되어 사용이 종료되었습니다.`,
|
||||||
endedAt: now.toISOString(),
|
endedAt: now.toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
||||||
return this.listPlatforms(accessToken);
|
return this.listPlatforms(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2040,7 +2310,7 @@ export class InstructorService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
||||||
if (terminatedSessionNotice) {
|
if (terminatedSessionNotice) {
|
||||||
this.chatGateway.notifyUser(terminatedSessionNotice.userId, {
|
this.chatGateway.notifyUser(terminatedSessionNotice.userId, {
|
||||||
type: 'platform.control.terminated',
|
type: 'platform.control.terminated',
|
||||||
@@ -2116,7 +2386,7 @@ export class InstructorService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
||||||
return this.listGroups(accessToken, input.courseId);
|
return this.listGroups(accessToken, input.courseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2221,7 +2491,7 @@ export class InstructorService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
||||||
this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason);
|
this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason);
|
||||||
return this.listGroups(accessToken, input.courseId);
|
return this.listGroups(accessToken, input.courseId);
|
||||||
}
|
}
|
||||||
@@ -2281,7 +2551,7 @@ export class InstructorService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
||||||
this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason);
|
this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason);
|
||||||
return this.listGroups(accessToken, courseId);
|
return this.listGroups(accessToken, courseId);
|
||||||
}
|
}
|
||||||
@@ -2408,11 +2678,33 @@ export class InstructorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private queuePlatformAccessPolicy(
|
||||||
|
organizationId: string | undefined,
|
||||||
|
organizationName: string,
|
||||||
|
) {
|
||||||
|
void this.syncPlatformAccessPolicy(organizationId, organizationName).catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.warn(`Platform access sync failed in background: ${message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async syncPlatformAccessPolicy(
|
private async syncPlatformAccessPolicy(
|
||||||
organizationId: string | undefined,
|
organizationId: string | undefined,
|
||||||
organizationName: string,
|
organizationName: string,
|
||||||
) {
|
) {
|
||||||
const groups = await this.prisma.studentGroup.findMany({
|
const [platforms, groups] = await Promise.all([
|
||||||
|
this.prisma.instructorPlatform.findMany({
|
||||||
|
where: this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
|
||||||
|
organizationId,
|
||||||
|
organizationName,
|
||||||
|
),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
studioSettingsOverride: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.studentGroup.findMany({
|
||||||
where: {
|
where: {
|
||||||
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
|
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
|
||||||
organizationId,
|
organizationId,
|
||||||
@@ -2420,16 +2712,10 @@ export class InstructorService {
|
|||||||
),
|
),
|
||||||
instructorPlatformId: { not: null },
|
instructorPlatformId: { not: null },
|
||||||
},
|
},
|
||||||
include: {
|
|
||||||
instructorPlatform: {
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
instructorPlatformId: true,
|
||||||
displayName: true,
|
|
||||||
studioSettingsOverride: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
members: {
|
members: {
|
||||||
include: {
|
select: {
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
email: true,
|
email: true,
|
||||||
@@ -2438,27 +2724,29 @@ export class InstructorService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
const assignments = groups
|
const studentEmailsByPlatform = new Map<string, string[]>();
|
||||||
.map((group): GatewayAccessAssignment | null => {
|
for (const group of groups) {
|
||||||
const platform = group.instructorPlatform;
|
if (!group.instructorPlatformId) continue;
|
||||||
if (!platform) return null;
|
studentEmailsByPlatform.set(
|
||||||
|
group.instructorPlatformId,
|
||||||
|
group.members.map((member) => member.user.email).filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include unassigned platforms with no students so the agent removes stale SSH keys.
|
||||||
|
const assignments: GatewayAccessAssignment[] = platforms.map((platform) => {
|
||||||
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
|
const agentHostname = this.readStringSetting(platform.studioSettingsOverride, 'agentHostname');
|
||||||
const gatewayPlatformId = this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id;
|
|
||||||
const studentEmails = group.members
|
|
||||||
.map((member) => member.user.email)
|
|
||||||
.filter(Boolean);
|
|
||||||
if (!studentEmails.length) return null;
|
|
||||||
return {
|
return {
|
||||||
platformId: gatewayPlatformId,
|
platformId: this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id,
|
||||||
platformName: platform.displayName,
|
platformName: platform.displayName,
|
||||||
agentHostname,
|
agentHostname,
|
||||||
agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'),
|
agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'),
|
||||||
studentEmails,
|
studentEmails: studentEmailsByPlatform.get(platform.id) ?? [],
|
||||||
};
|
};
|
||||||
})
|
});
|
||||||
.filter((assignment): assignment is GatewayAccessAssignment => Boolean(assignment));
|
|
||||||
|
|
||||||
await this.platformGatewayClient.syncAccess(assignments);
|
await this.platformGatewayClient.syncAccess(assignments);
|
||||||
}
|
}
|
||||||
@@ -2654,20 +2942,18 @@ export class InstructorService {
|
|||||||
agents: GatewayAgent[],
|
agents: GatewayAgent[],
|
||||||
binding: { nodeId?: string | null; hostname?: string | null; ownerEmail?: string | null },
|
binding: { nodeId?: string | null; hostname?: string | null; ownerEmail?: string | null },
|
||||||
) {
|
) {
|
||||||
const bindingTokens = [binding.nodeId, binding.hostname, binding.ownerEmail]
|
// An owner can register multiple teaching devices. Bind an instructor
|
||||||
|
// platform to the saved node or hostname, never to the owner alone.
|
||||||
|
const bindingTokens = [binding.nodeId, binding.hostname]
|
||||||
.map((value) => this.normalizeAgentToken(value))
|
.map((value) => this.normalizeAgentToken(value))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
if (!bindingTokens.length) return null;
|
if (!bindingTokens.length) return null;
|
||||||
|
|
||||||
return agents.find((agent) => {
|
return agents.find((agent) => {
|
||||||
const agentTokens = [agent.id, agent.hostname, agent.name, agent.ip, agent.user]
|
const agentTokens = [agent.id, agent.hostname, agent.name, agent.ip]
|
||||||
.map((value) => this.normalizeAgentToken(value))
|
.map((value) => this.normalizeAgentToken(value))
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return bindingTokens.some((bindingToken) => agentTokens.some((agentToken) => (
|
return bindingTokens.some((bindingToken) => agentTokens.includes(bindingToken));
|
||||||
agentToken === bindingToken ||
|
|
||||||
agentToken.includes(bindingToken) ||
|
|
||||||
bindingToken.includes(agentToken)
|
|
||||||
)));
|
|
||||||
}) ?? null;
|
}) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2714,6 +3000,10 @@ export class InstructorService {
|
|||||||
platform: true,
|
platform: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
environmentProfiles: {
|
||||||
|
where: { enabled: true },
|
||||||
|
orderBy: [{ platformKey: 'asc' }, { profileType: 'asc' }],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3323,9 +3613,7 @@ export class InstructorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async grantLmsAppPermission(accessToken: string, authUserId: string) {
|
private async grantLmsAppPermission(accessToken: string, authUserId: string) {
|
||||||
const launcherApiBaseUrl = this.configService
|
const launcherApiBaseUrl = this.launcherApiBaseUrl();
|
||||||
.get<string>('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api')
|
|
||||||
.replace(/\/$/, '');
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${launcherApiBaseUrl}/apps/${encodeURIComponent('LMS')}/users/${encodeURIComponent(authUserId)}/permission`,
|
`${launcherApiBaseUrl}/apps/${encodeURIComponent('LMS')}/users/${encodeURIComponent(authUserId)}/permission`,
|
||||||
{
|
{
|
||||||
@@ -3351,6 +3639,13 @@ export class InstructorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private launcherApiBaseUrl() {
|
||||||
|
return this.configService
|
||||||
|
.get<string>('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private toSummary(students: Array<{ progress: number; online: boolean; issues: number }>) {
|
private toSummary(students: Array<{ progress: number; online: boolean; issues: number }>) {
|
||||||
const total = students.length;
|
const total = students.length;
|
||||||
const averageProgress = total
|
const averageProgress = total
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ export class PlatformControlController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('platforms/:platformId/extend')
|
||||||
|
extendControl(@Req() request: Request, @Param('platformId') platformId: string) {
|
||||||
|
return this.platformControlService.extendControl(
|
||||||
|
this.getBearerToken(request),
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('platforms/:platformId/connect')
|
@Post('platforms/:platformId/connect')
|
||||||
connectPlatform(@Req() request: Request, @Param('platformId') platformId: string) {
|
connectPlatform(@Req() request: Request, @Param('platformId') platformId: string) {
|
||||||
return this.platformControlService.connectPlatform(
|
return this.platformControlService.connectPlatform(
|
||||||
@@ -84,6 +92,29 @@ export class PlatformControlController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('platforms/:platformId/visualization/status')
|
||||||
|
getRemoteVisualizationStatus(@Req() request: Request, @Param('platformId') platformId: string) {
|
||||||
|
return this.platformControlService.getRemoteVisualizationStatus(this.getBearerToken(request), platformId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('platforms/:platformId/visualization/start')
|
||||||
|
startRemoteVisualization(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Body() body: { stack?: string; rosDomainId?: string; profile?: unknown },
|
||||||
|
) {
|
||||||
|
return this.platformControlService.startRemoteVisualization(this.getBearerToken(request), platformId, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('platforms/:platformId/visualization/stop')
|
||||||
|
stopRemoteVisualization(
|
||||||
|
@Req() request: Request,
|
||||||
|
@Param('platformId') platformId: string,
|
||||||
|
@Body() body: { stack?: string; rosDomainId?: string; profile?: unknown },
|
||||||
|
) {
|
||||||
|
return this.platformControlService.stopRemoteVisualization(this.getBearerToken(request), platformId, body);
|
||||||
|
}
|
||||||
|
|
||||||
private getBearerToken(request: Request): string | undefined {
|
private getBearerToken(request: Request): string | undefined {
|
||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
return type === 'Bearer' ? token : undefined;
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
OnModuleDestroy,
|
OnModuleDestroy,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
|
ServiceUnavailableException,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
@@ -205,7 +206,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
? '사용 가능'
|
? '사용 가능'
|
||||||
: this.toControlStatusLabel(control.status),
|
: this.toControlStatusLabel(control.status),
|
||||||
action: control.status === 'AVAILABLE'
|
action: control.status === 'AVAILABLE'
|
||||||
? '제어권 요청'
|
? '제어요청'
|
||||||
: control.action,
|
: control.action,
|
||||||
state: control.state,
|
state: control.state,
|
||||||
control,
|
control,
|
||||||
@@ -233,7 +234,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const group = await this.findStudentGroup(user.id, context);
|
const group = await this.findStudentGroup(user.id, context);
|
||||||
const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null);
|
const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null);
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
const active = await tx.platformControlSession.findFirst({
|
const active = await tx.platformControlSession.findFirst({
|
||||||
where: {
|
where: {
|
||||||
...this.resourceWhere(resource),
|
...this.resourceWhere(resource),
|
||||||
@@ -258,6 +259,15 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const next = await this.grantNextQueued(tx, resource, context);
|
const next = await this.grantNextQueued(tx, resource, context);
|
||||||
if (next) this.notifyGranted(next);
|
if (next) this.notifyGranted(next);
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: 'RELEASED',
|
||||||
|
platformId: platformOptionId,
|
||||||
|
nextSession: next ? this.serializeSession(next) : null,
|
||||||
|
};
|
||||||
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||||
|
|
||||||
|
// The gateway call can wait for a remote agent. Keep it outside the
|
||||||
|
// interactive transaction so its timeout cannot invalidate the DB commit.
|
||||||
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
|
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
|
||||||
if (gatewayPlatformId) {
|
if (gatewayPlatformId) {
|
||||||
await this.platformGatewayClient.release({
|
await this.platformGatewayClient.release({
|
||||||
@@ -271,12 +281,78 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return result;
|
||||||
result: 'RELEASED',
|
}
|
||||||
platformId: platformOptionId,
|
|
||||||
nextSession: next ? this.serializeSession(next) : null,
|
async extendControl(accessToken: string | undefined, platformOptionId: string) {
|
||||||
};
|
const { authUser, user } = await this.getStudent(accessToken);
|
||||||
|
const context = this.organizationContext(authUser);
|
||||||
|
const group = await this.findStudentGroup(user.id, context);
|
||||||
|
const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null);
|
||||||
|
|
||||||
|
await this.applyExpiredSessions(context);
|
||||||
|
|
||||||
|
const session = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const active = await tx.platformControlSession.findFirst({
|
||||||
|
where: {
|
||||||
|
...this.resourceWhere(resource),
|
||||||
|
holderUserId: user.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!active) throw new NotFoundException('Active control session not found');
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (now < active.warningAt) {
|
||||||
|
throw new BadRequestException('제어 시간 경고 구간에서만 연장할 수 있습니다.');
|
||||||
|
}
|
||||||
|
if (active.extensionCount >= 1) {
|
||||||
|
throw new BadRequestException('제어 시간은 한 번만 연장할 수 있습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitingCount = await tx.platformControlQueueEntry.count({
|
||||||
|
where: {
|
||||||
|
...this.resourceWhere(resource),
|
||||||
|
status: 'WAITING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (waitingCount > 0) {
|
||||||
|
throw new BadRequestException('대기 중인 예약이 있어 제어 시간을 연장할 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const endsAt = new Date(active.endsAt.getTime() + resource.durationMinutes * 60_000);
|
||||||
|
const warningAt = new Date(endsAt.getTime() - resource.warningMinutes * 60_000);
|
||||||
|
return tx.platformControlSession.update({
|
||||||
|
where: { id: active.id },
|
||||||
|
data: {
|
||||||
|
endsAt,
|
||||||
|
warningAt,
|
||||||
|
warningSentAt: null,
|
||||||
|
extensionCount: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
|
||||||
|
|
||||||
|
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
|
||||||
|
if (gatewayPlatformId) {
|
||||||
|
const ttlSeconds = Math.max(60, Math.floor((session.endsAt.getTime() - Date.now()) / 1000));
|
||||||
|
await this.platformGatewayClient.extendSession({
|
||||||
|
platformId: gatewayPlatformId,
|
||||||
|
ttlSeconds,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.authRole,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: 'EXTENDED',
|
||||||
|
platformId: platformOptionId,
|
||||||
|
session: this.serializeSession(session),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectPlatform(accessToken: string | undefined, platformOptionId: string) {
|
async connectPlatform(accessToken: string | undefined, platformOptionId: string) {
|
||||||
@@ -304,11 +380,15 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource);
|
const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource);
|
||||||
|
const assignedStudentEmails = group?.members
|
||||||
|
.map((member) => member.user.email)
|
||||||
|
.filter((email): email is string => Boolean(email))
|
||||||
|
?? (authUser.email ? [authUser.email] : []);
|
||||||
await this.platformGatewayClient.syncAccess([{
|
await this.platformGatewayClient.syncAccess([{
|
||||||
platformId: gatewayPlatformId,
|
platformId: gatewayPlatformId,
|
||||||
platformName: resource.title,
|
platformName: resource.title,
|
||||||
agentOwnerEmail,
|
agentOwnerEmail,
|
||||||
studentEmails: authUser.email ? [authUser.email] : [],
|
studentEmails: assignedStudentEmails,
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
const ttlSeconds = Math.max(60, Math.floor((active.endsAt.getTime() - Date.now()) / 1000));
|
const ttlSeconds = Math.max(60, Math.floor((active.endsAt.getTime() - Date.now()) / 1000));
|
||||||
@@ -323,13 +403,19 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
role: user.authRole || undefined,
|
role: user.authRole || undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch((error: unknown) => {
|
||||||
|
const detail = error instanceof Error ? error.message : '원격 플랫폼 Gateway에 연결하지 못했습니다.';
|
||||||
|
throw new ServiceUnavailableException(`원격 플랫폼 연결에 실패했습니다. ${detail}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!gateway?.connection) {
|
||||||
|
throw new ServiceUnavailableException('원격 플랫폼 연결 정보를 받지 못했습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mode: gateway?.connection ? 'remote' : 'gateway-unavailable',
|
mode: 'remote',
|
||||||
gateway,
|
gateway,
|
||||||
environmentConfigOverride: gateway?.connection
|
environmentConfigOverride: {
|
||||||
? {
|
|
||||||
target: 'remote-jetson',
|
target: 'remote-jetson',
|
||||||
agent: {
|
agent: {
|
||||||
url: gateway.connection.agentUrl,
|
url: gateway.connection.agentUrl,
|
||||||
@@ -342,11 +428,51 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
BRIDGE_WS_URL: gateway.connection.bridgeWsUrl,
|
BRIDGE_WS_URL: gateway.connection.bridgeWsUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRemoteVisualizationStatus(accessToken: string | undefined, platformOptionId: string) {
|
||||||
|
const { authUser, user } = await this.getStudent(accessToken);
|
||||||
|
const context = this.organizationContext(authUser);
|
||||||
|
const group = await this.findStudentGroup(user.id, context);
|
||||||
|
const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null);
|
||||||
|
await this.assertActiveControl(resource, user.id);
|
||||||
|
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
|
||||||
|
if (!gatewayPlatformId) throw new BadRequestException('원격 플랫폼이 아닙니다.');
|
||||||
|
return this.platformGatewayClient.getVisualizationStatus(gatewayPlatformId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startRemoteVisualization(
|
||||||
|
accessToken: string | undefined,
|
||||||
|
platformOptionId: string,
|
||||||
|
input: { stack?: string; rosDomainId?: string; profile?: unknown },
|
||||||
|
) {
|
||||||
|
const { authUser, user } = await this.getStudent(accessToken);
|
||||||
|
const context = this.organizationContext(authUser);
|
||||||
|
const group = await this.findStudentGroup(user.id, context);
|
||||||
|
const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null);
|
||||||
|
await this.assertActiveControl(resource, user.id);
|
||||||
|
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
|
||||||
|
if (!gatewayPlatformId) throw new BadRequestException('원격 플랫폼이 아닙니다.');
|
||||||
|
return this.platformGatewayClient.startVisualization(gatewayPlatformId, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopRemoteVisualization(
|
||||||
|
accessToken: string | undefined,
|
||||||
|
platformOptionId: string,
|
||||||
|
input: { stack?: string; rosDomainId?: string; profile?: unknown },
|
||||||
|
) {
|
||||||
|
const { authUser, user } = await this.getStudent(accessToken);
|
||||||
|
const context = this.organizationContext(authUser);
|
||||||
|
const group = await this.findStudentGroup(user.id, context);
|
||||||
|
const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null);
|
||||||
|
await this.assertActiveControl(resource, user.id);
|
||||||
|
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
|
||||||
|
if (!gatewayPlatformId) throw new BadRequestException('원격 플랫폼이 아닙니다.');
|
||||||
|
return this.platformGatewayClient.stopVisualization(gatewayPlatformId, input);
|
||||||
|
}
|
||||||
|
|
||||||
async listRemoteModules(accessToken: string | undefined, platformOptionId: string) {
|
async listRemoteModules(accessToken: string | undefined, platformOptionId: string) {
|
||||||
const { authUser, user } = await this.getStudent(accessToken);
|
const { authUser, user } = await this.getStudent(accessToken);
|
||||||
const context = this.organizationContext(authUser);
|
const context = this.organizationContext(authUser);
|
||||||
@@ -782,6 +908,15 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
some: { userId },
|
some: { userId },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { email: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { updatedAt: 'desc' },
|
orderBy: { updatedAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -832,7 +967,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private emptyState(status: 'AVAILABLE'): PlatformControlState {
|
private emptyState(status: 'AVAILABLE'): PlatformControlState {
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
action: '제어권 요청',
|
action: '제어요청',
|
||||||
state: 'ready',
|
state: 'ready',
|
||||||
session: null,
|
session: null,
|
||||||
queue: null,
|
queue: null,
|
||||||
@@ -853,6 +988,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
startedAt: Date;
|
startedAt: Date;
|
||||||
endsAt: Date;
|
endsAt: Date;
|
||||||
warningAt: Date;
|
warningAt: Date;
|
||||||
|
extensionCount?: number;
|
||||||
holderUser?: { name: string | null; email: string } | null;
|
holderUser?: { name: string | null; email: string } | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
@@ -862,6 +998,7 @@ export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
|
|||||||
startedAt: session.startedAt.toISOString(),
|
startedAt: session.startedAt.toISOString(),
|
||||||
endsAt: session.endsAt.toISOString(),
|
endsAt: session.endsAt.toISOString(),
|
||||||
warningAt: session.warningAt.toISOString(),
|
warningAt: session.warningAt.toISOString(),
|
||||||
|
extensionCount: session.extensionCount ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,9 +66,27 @@ export class PlatformGatewayClient {
|
|||||||
ttlSeconds: number;
|
ttlSeconds: number;
|
||||||
}): Promise<GatewaySessionResponse | null> {
|
}): Promise<GatewaySessionResponse | null> {
|
||||||
if (!this.enabled) return null;
|
if (!this.enabled) return null;
|
||||||
|
const body = {
|
||||||
|
platformId: input.platformId,
|
||||||
|
user: input.user,
|
||||||
|
ttlSeconds: input.ttlSeconds,
|
||||||
|
...(input.agentOwnerEmail ? { agentOwnerEmail: input.agentOwnerEmail } : {}),
|
||||||
|
};
|
||||||
return this.request<GatewaySessionResponse>('/api/lms/platform-sessions', {
|
return this.request<GatewaySessionResponse>('/api/lms/platform-sessions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async extendSession(input: {
|
||||||
|
platformId: string;
|
||||||
|
user: { id: string; email?: string; name?: string | null; role?: string | null };
|
||||||
|
ttlSeconds: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
await this.request(`/api/lms/platform-sessions/${encodeURIComponent(input.platformId)}/extend`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ user: input.user, ttlSeconds: input.ttlSeconds }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +153,29 @@ export class PlatformGatewayClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getVisualizationStatus(platformId: string): Promise<unknown> {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/visualization/status`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async startVisualization(platformId: string, input: { stack?: string | null; rosDomainId?: string | null; profile?: unknown }): Promise<unknown> {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/visualization/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopVisualization(platformId: string, input: { stack?: string | null; rosDomainId?: string | null; profile?: unknown } = {}): Promise<unknown> {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/visualization/stop`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async syncAccess(assignments: GatewayAccessAssignment[]): Promise<void> {
|
async syncAccess(assignments: GatewayAccessAssignment[]): Promise<void> {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
await this.request('/api/lms/access-sync', {
|
await this.request('/api/lms/access-sync', {
|
||||||
@@ -157,8 +198,10 @@ export class PlatformGatewayClient {
|
|||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = String(payload?.error ?? payload?.message ?? `platform_gateway_failed:${response.status}`);
|
const message = String(payload?.error ?? payload?.message ?? `platform_gateway_failed:${response.status}`);
|
||||||
this.logger.warn(`Platform gateway request failed. path=${path} status=${response.status} message=${message}`);
|
const issues = payload?.issues ? ` issues=${JSON.stringify(payload.issues)}` : '';
|
||||||
throw new Error(message);
|
const detail = payload?.result?.error ? ` detail=${String(payload.result.error)}` : '';
|
||||||
|
this.logger.warn(`Platform gateway request failed. path=${path} status=${response.status} message=${message}${issues}${detail}`);
|
||||||
|
throw new Error(`${message}${issues}${detail}`);
|
||||||
}
|
}
|
||||||
return payload as T;
|
return payload as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Delete, Post, Req } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Query, Req } from '@nestjs/common';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
|
||||||
import { StudioService } from './studio.service';
|
import { StudioService } from './studio.service';
|
||||||
@@ -65,6 +65,24 @@ export class StudioController {
|
|||||||
return this.studioService.recordLearningTime(this.getBearerToken(request), body);
|
return this.studioService.recordLearningTime(this.getBearerToken(request), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('maps')
|
||||||
|
listSavedMaps(@Req() request: Request, @Query('courseId') courseId?: string, @Query('week') week?: string) {
|
||||||
|
return this.studioService.listSavedMaps(this.getBearerToken(request), courseId, Number(week));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 },
|
||||||
|
) {
|
||||||
|
return this.studioService.recordSavedMap(this.getBearerToken(request), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('maps/:id')
|
||||||
|
deleteSavedMap(@Req() request: Request, @Param('id') id: string) {
|
||||||
|
return this.studioService.deleteSavedMap(this.getBearerToken(request), id);
|
||||||
|
}
|
||||||
|
|
||||||
private getBearerToken(request: Request): string | undefined {
|
private getBearerToken(request: Request): string | undefined {
|
||||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
return type === 'Bearer' ? token : undefined;
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
|||||||
@@ -412,6 +412,66 @@ export class StudioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listSavedMaps(accessToken: string | undefined, courseId: string | undefined, week: number) {
|
||||||
|
const { user: authUser } = await this.authService.me(accessToken);
|
||||||
|
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
|
||||||
|
if (!courseId?.trim()) throw new BadRequestException('courseId is required');
|
||||||
|
if (!Number.isInteger(week) || week < 1) throw new BadRequestException('week is required');
|
||||||
|
const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } });
|
||||||
|
if (!lmsUser) throw new NotFoundException('LMS user not found');
|
||||||
|
return this.prisma.studentSavedMap.findMany({
|
||||||
|
where: { userId: lmsUser.id, courseId: courseId.trim(), week },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
) {
|
||||||
|
const { user: authUser } = await this.authService.me(accessToken);
|
||||||
|
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
|
||||||
|
const courseId = input.courseId?.trim();
|
||||||
|
const yamlPath = input.yamlPath?.trim();
|
||||||
|
const pgmPath = input.pgmPath?.trim();
|
||||||
|
const previewPgm = input.previewPgm?.trim() || null;
|
||||||
|
const databasePath = input.databasePath?.trim() || null;
|
||||||
|
const sourceMapKey = input.sourceMapKey?.trim();
|
||||||
|
// Platform SLAM maps do not originate from a Gazebo world file.
|
||||||
|
const worldFile = input.worldFile?.trim() ?? '';
|
||||||
|
const viewerModel = input.viewerModel?.trim();
|
||||||
|
if (!courseId) throw new BadRequestException('courseId is required');
|
||||||
|
if (!Number.isInteger(input.week) || input.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 (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');
|
||||||
|
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');
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSavedMap(accessToken: string | undefined, id: string) {
|
||||||
|
const { user: authUser } = await this.authService.me(accessToken);
|
||||||
|
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
|
||||||
|
const lmsUser = await this.prisma.user.findUnique({ where: { email: authUser.email } });
|
||||||
|
if (!lmsUser) throw new NotFoundException('LMS user not found');
|
||||||
|
const result = await this.prisma.studentSavedMap.deleteMany({
|
||||||
|
where: { id, userId: lmsUser.id },
|
||||||
|
});
|
||||||
|
if (!result.count) throw new NotFoundException('Saved map not found');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureGiteaRepository(repoName: string, description: string) {
|
private async ensureGiteaRepository(repoName: string, description: string) {
|
||||||
const existingResponse = await fetch(`${this.giteaBaseUrl}/api/v1/repos/${encodeURIComponent(this.studentOwner)}/${encodeURIComponent(repoName)}`, {
|
const existingResponse = await fetch(`${this.giteaBaseUrl}/api/v1/repos/${encodeURIComponent(this.studentOwner)}/${encodeURIComponent(repoName)}`, {
|
||||||
headers: this.giteaHeaders(),
|
headers: this.giteaHeaders(),
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# launch 파일 예제
|
||||||
|
from launch import LaunchDescription
|
||||||
|
from launch_ros.actions import Node
|
||||||
|
|
||||||
|
def generate_launch_description():
|
||||||
|
return LaunchDescription([
|
||||||
|
Node(
|
||||||
|
package='slam_pkg',
|
||||||
|
executable='slam_node',
|
||||||
|
name='slam',
|
||||||
|
output='screen'
|
||||||
|
)
|
||||||
|
])
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import rclpy
|
||||||
|
from rclpy.node import Node
|
||||||
|
from sensor_msgs.msg import LaserScan
|
||||||
|
|
||||||
|
class SlamNode(Node):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__('slam_node')
|
||||||
|
self.subscription = \
|
||||||
|
self.create_subscription(
|
||||||
|
LaserScan,
|
||||||
|
'/scan',
|
||||||
|
self.listener_callback,
|
||||||
|
10)
|
||||||
|
|
||||||
|
def listener_callback(self, msg):
|
||||||
|
self.get_logger().info(
|
||||||
|
'Received scan data'
|
||||||
|
)
|
||||||
|
# SLAM 알고리즘 구현
|
||||||
|
pass
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
rclpy.init(args=args)
|
||||||
|
node = SlamNode()
|
||||||
|
rclpy.spin(node)
|
||||||
|
rclpy.shutdown()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Autonomy Studio LMS demo file
|
||||||
Reference in New Issue
Block a user