From 420a7ecf2b6eb43d0f22b07322f32a244756226a Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 22 Sep 2026 17:49:24 +0900 Subject: [PATCH] feat: configure overview camera per curriculum week --- .../migration.sql | 3 ++ prisma/schema.prisma | 2 ++ src/curriculum/curriculum.service.ts | 5 +-- .../week-camera.spec.ts} | 8 ++--- .../week-camera.ts} | 4 +-- .../dto/update-instructor-curriculum.dto.ts | 8 +++++ .../dto/update-instructor-platform.dto.ts | 8 ----- src/instructor/instructor.service.ts | 35 +++++++++---------- 8 files changed, 39 insertions(+), 34 deletions(-) create mode 100644 prisma/migrations/20260922110000_week_overview_camera/migration.sql rename src/{instructor/platform-camera.spec.ts => curriculum/week-camera.spec.ts} (74%) rename src/{instructor/platform-camera.ts => curriculum/week-camera.ts} (86%) diff --git a/prisma/migrations/20260922110000_week_overview_camera/migration.sql b/prisma/migrations/20260922110000_week_overview_camera/migration.sql new file mode 100644 index 0000000..58a4aad --- /dev/null +++ b/prisma/migrations/20260922110000_week_overview_camera/migration.sql @@ -0,0 +1,3 @@ +ALTER TABLE "instructor_curriculum_week_overrides" +ADD COLUMN "camera_view_url" TEXT NOT NULL DEFAULT '', +ADD COLUMN "camera_password" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 77026b5..f440ad1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -365,6 +365,8 @@ model InstructorCurriculumSetting { } model InstructorCurriculumWeekOverride { + cameraViewUrl String @default("") @map("camera_view_url") + cameraPassword String @default("") @map("camera_password") id String @id @default(uuid()) @db.Uuid organizationId String? @map("organization_id") @db.Uuid organizationName String @map("organization_name") diff --git a/src/curriculum/curriculum.service.ts b/src/curriculum/curriculum.service.ts index 3ef0323..5011f1f 100644 --- a/src/curriculum/curriculum.service.ts +++ b/src/curriculum/curriculum.service.ts @@ -7,7 +7,7 @@ import { PlatformControlService } from '../platform-control/platform-control.ser import { PrismaService } from '../prisma/prisma.service'; import { R2PublicFileService } from '../storage/r2-public-file.service'; import { serializeCourseMaps } from './course-maps'; -import { readPlatformCamera } from '../instructor/platform-camera'; +import { readWeekCamera } from './week-camera'; const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references'); const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; @@ -542,6 +542,7 @@ export class CurriculumService { github: week.github, detail: { overview: override?.overview ?? week.overview, + camera: readWeekCamera(override), learningSteps, topicDetails, topicVideoUrls, @@ -620,6 +621,7 @@ export class CurriculumService { github: '-', detail: { overview: override.overview ?? '', + camera: readWeekCamera(override), learningSteps, topicDetails: learningSteps.map((step) => step.description), topicVideoUrls: learningSteps.map((step) => step.videoUrl), @@ -1100,7 +1102,6 @@ export class CurriculumService { organizationName: platform.organizationName, iconKey: platform.platform.iconKey, sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings, - camera: readPlatformCamera(platform.studioSettingsOverride), vehicleRuntimeProfile: this.toVehicleRuntimeProfile({ platformKey: platform.platform.platformKey, vehicleType: platform.vehicleType, diff --git a/src/instructor/platform-camera.spec.ts b/src/curriculum/week-camera.spec.ts similarity index 74% rename from src/instructor/platform-camera.spec.ts rename to src/curriculum/week-camera.spec.ts index a9bd9e9..b9af83e 100644 --- a/src/instructor/platform-camera.spec.ts +++ b/src/curriculum/week-camera.spec.ts @@ -1,6 +1,6 @@ -import { normalizeCameraUrl, readPlatformCamera } from './platform-camera'; +import { normalizeCameraUrl, readWeekCamera } from './week-camera'; -describe('platform camera settings', () => { +describe('week camera settings', () => { it('keeps only viewing parameters', () => { const url = new URL(normalizeCameraUrl('https://vdo.ninja/?room=track&view=camera&push=bad&director=bad&password=secret')); expect(url.searchParams.get('room')).toBe('track'); @@ -17,7 +17,7 @@ describe('platform camera settings', () => { it('supports unset camera settings and preserves password whitespace', () => { expect(normalizeCameraUrl(' ')).toBe(''); - expect(readPlatformCamera(null)).toEqual({ url: '', password: '' }); - expect(readPlatformCamera({ cameraViewUrl: 'url', cameraPassword: ' a&b ' })).toEqual({ url: 'url', password: ' a&b ' }); + expect(readWeekCamera(null)).toEqual({ url: '', password: '' }); + expect(readWeekCamera({ cameraViewUrl: 'url', cameraPassword: ' a&b ' })).toEqual({ url: 'url', password: ' a&b ' }); }); }); diff --git a/src/instructor/platform-camera.ts b/src/curriculum/week-camera.ts similarity index 86% rename from src/instructor/platform-camera.ts rename to src/curriculum/week-camera.ts index 6c5f1a2..3e34ee3 100644 --- a/src/instructor/platform-camera.ts +++ b/src/curriculum/week-camera.ts @@ -1,4 +1,4 @@ -export function readPlatformCamera(settings: unknown): { url: string; password: string } { +export function readWeekCamera(settings: unknown): { url: string; password: string } { const value = settings && typeof settings === 'object' ? settings as Record : {}; return { url: typeof value.cameraViewUrl === 'string' ? value.cameraViewUrl : '', @@ -10,7 +10,7 @@ export function normalizeCameraUrl(input: string): string { if (!input.trim()) return ''; const url = new URL(input.trim()); if (url.protocol !== 'https:' || url.hostname !== 'vdo.ninja' || url.port || url.username || url.password || url.pathname !== '/') { - throw new Error('VDO.Ninja 시청 URL을 입력해주세요.'); + throw new Error('올바른 오버뷰 시청 URL을 입력해주세요.'); } if (!url.searchParams.get('room')?.trim() || !url.searchParams.get('view')?.trim()) { throw new Error('시청 URL에 room과 view가 필요합니다.'); diff --git a/src/instructor/dto/update-instructor-curriculum.dto.ts b/src/instructor/dto/update-instructor-curriculum.dto.ts index 6b21b73..ab97138 100644 --- a/src/instructor/dto/update-instructor-curriculum.dto.ts +++ b/src/instructor/dto/update-instructor-curriculum.dto.ts @@ -1,6 +1,14 @@ import { IsArray, IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; export class UpdateInstructorCurriculumDto { + @IsOptional() + @IsString() + cameraViewUrl?: string; + + @IsOptional() + @IsString() + cameraPassword?: string; + @IsOptional() @IsString() courseId?: string | null; diff --git a/src/instructor/dto/update-instructor-platform.dto.ts b/src/instructor/dto/update-instructor-platform.dto.ts index 53c56ad..b58bc16 100644 --- a/src/instructor/dto/update-instructor-platform.dto.ts +++ b/src/instructor/dto/update-instructor-platform.dto.ts @@ -1,14 +1,6 @@ import { IsArray, IsInt, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator'; export class UpdateInstructorPlatformDto { - @IsOptional() - @IsString() - cameraViewUrl?: string; - - @IsOptional() - @IsString() - cameraPassword?: string; - @IsString() displayName: string; diff --git a/src/instructor/instructor.service.ts b/src/instructor/instructor.service.ts index 5081dad..804fa79 100644 --- a/src/instructor/instructor.service.ts +++ b/src/instructor/instructor.service.ts @@ -23,7 +23,7 @@ import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dt import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto'; import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto'; import { groupCourseScope } from './group-course-scope'; -import { normalizeCameraUrl, readPlatformCamera } from './platform-camera'; +import { normalizeCameraUrl, readWeekCamera } from '../curriculum/week-camera'; import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto'; import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto'; import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto'; @@ -1338,7 +1338,6 @@ export class InstructorService { type: this.toPlatformType(platform.platform.displayName), status: activeControl ? '활성' : '대기', battery: platform.battery, - camera: readPlatformCamera(platform.studioSettingsOverride), uptime: typeof agentConnection?.agentUptimeSeconds === 'number' ? this.formatUptimeSeconds(agentConnection.agentUptimeSeconds) : this.formatUptime(platform.uptimeMinutes), @@ -1473,6 +1472,7 @@ export class InstructorService { github: week.github, detail: { overview: override?.overview ?? week.overview, + camera: readWeekCamera(override), learningSteps, topicDetails, topicVideoUrls, @@ -1528,6 +1528,7 @@ export class InstructorService { github: '-', detail: { overview: override.overview ?? '', + camera: readWeekCamera(override), learningSteps, topicDetails: learningSteps.map((step) => step.description), topicVideoUrls: learningSteps.map((step) => step.videoUrl), @@ -1596,6 +1597,17 @@ export class InstructorService { }, }); + let cameraViewUrl: string | undefined; + try { + cameraViewUrl = input.cameraViewUrl === undefined ? undefined : normalizeCameraUrl(input.cameraViewUrl); + } catch { + throw new BadRequestException('오버뷰 시청 URL에 올바른 room과 view를 지정해주세요.'); + } + const cameraSettings = { + cameraViewUrl: cameraViewUrl ?? existingOverride?.cameraViewUrl ?? '', + cameraPassword: cameraViewUrl === '' ? '' : input.cameraPassword ?? existingOverride?.cameraPassword ?? '', + }; + const existingLearningSteps = this.resolveLearningSteps( existingOverride?.learningSteps, this.stringArray(existingOverride?.topics) ?? targetWeek?.topics.map((topic) => topic.title) ?? ['학습단계 1'], @@ -1764,7 +1776,7 @@ export class InstructorService { selectedTagLabel = tagOption.label; } - if (!targetWeek || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) { + if (!targetWeek || input.cameraViewUrl !== undefined || input.cameraPassword !== undefined || input.tagLabel !== undefined || input.title || input.duration || input.overview || input.deliverable || input.isTeamProject !== undefined || input.learningSteps || input.topics || input.topicDetails || input.topicVideoUrls || input.practiceContents || input.disabledModuleIds) { await this.prisma.instructorCurriculumWeekOverride.upsert({ where: { organizationName_courseId_week: { @@ -1776,6 +1788,7 @@ export class InstructorService { update: { organizationId, tagLabel: selectedTagLabel ?? existingOverride?.tagLabel ?? targetWeek?.tags[0] ?? '커리큘럼', + ...cameraSettings, title: this.optionalTrim(input.title) ?? existingOverride?.title ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`, duration: this.optionalTrim(input.duration) ?? existingOverride?.duration ?? targetWeek?.duration ?? '-', overview: this.optionalTrim(input.overview) ?? existingOverride?.overview ?? targetWeek?.overview ?? '', @@ -1797,6 +1810,7 @@ export class InstructorService { courseId: course.id, week: input.activeWeek, tagLabel: selectedTagLabel ?? targetWeek?.tags[0] ?? '커리큘럼', + ...cameraSettings, title: this.optionalTrim(input.title) ?? targetWeek?.title ?? `${input.activeWeek}주차 커리큘럼`, duration: this.optionalTrim(input.duration) ?? targetWeek?.duration ?? '-', overview: this.optionalTrim(input.overview) ?? targetWeek?.overview ?? '', @@ -2227,12 +2241,6 @@ export class InstructorService { const maxSteeringAngleDegrees = this.toBoundedNumber(input.maxSteeringAngleDegrees, 30, 0, 90); const sensors = this.normalizeNameDescriptionList(input.sensors, { withEnabled: true }); const algorithms = this.normalizeAlgorithms(input.algorithms); - let cameraViewUrl: string | undefined; - try { - cameraViewUrl = input.cameraViewUrl === undefined ? undefined : normalizeCameraUrl(input.cameraViewUrl); - } catch { - throw new BadRequestException('VDO.Ninja 시청 URL에 올바른 room과 view를 지정해주세요.'); - } const platform = await this.prisma.instructorPlatform.findFirst({ where: { @@ -2245,7 +2253,6 @@ export class InstructorService { select: { id: true, displayName: true, - studioSettingsOverride: true, }, }); if (!platform) throw new NotFoundException('Platform not found'); @@ -2264,14 +2271,6 @@ export class InstructorService { algorithms, controlDurationMinutes, controlWarningMinutes, - ...((cameraViewUrl !== undefined || input.cameraPassword !== undefined) ? { - studioSettingsOverride: { - ...(platform.studioSettingsOverride && typeof platform.studioSettingsOverride === 'object' && !Array.isArray(platform.studioSettingsOverride) - ? platform.studioSettingsOverride : {}), - ...(cameraViewUrl !== undefined ? { cameraViewUrl } : {}), - ...(cameraViewUrl === '' ? { cameraPassword: '' } : input.cameraPassword !== undefined ? { cameraPassword: input.cameraPassword } : {}), - }, - } : {}), }, }), this.prisma.studentGroup.updateMany({