feat: configure overview camera per curriculum week

This commit is contained in:
2026-09-22 17:49:24 +09:00
parent a5cd3528b7
commit 420a7ecf2b
8 changed files with 39 additions and 34 deletions
@@ -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;
@@ -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;
+17 -18
View File
@@ -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({
-23
View File
@@ -1,23 +0,0 @@
import { normalizeCameraUrl, readPlatformCamera } from './platform-camera';
describe('platform 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');
expect(url.searchParams.get('view')).toBe('camera');
expect(url.searchParams.has('scene')).toBe(true);
expect(url.searchParams.has('push')).toBe(false);
expect(url.searchParams.has('director')).toBe(false);
expect(url.searchParams.has('password')).toBe(false);
});
it.each(['http://vdo.ninja/?room=x&view=y', 'https://example.com/?room=x&view=y', 'https://vdo.ninja/?room=x', 'javascript:alert(1)', 'https://vdo.ninja:8443/?room=x&view=y'])('rejects unsafe or incomplete URL %s', (url) => {
expect(() => normalizeCameraUrl(url)).toThrow();
});
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 ' });
});
});
-24
View File
@@ -1,24 +0,0 @@
export function readPlatformCamera(settings: unknown): { url: string; password: string } {
const value = settings && typeof settings === 'object' ? settings as Record<string, unknown> : {};
return {
url: typeof value.cameraViewUrl === 'string' ? value.cameraViewUrl : '',
password: typeof value.cameraPassword === 'string' ? value.cameraPassword : '',
};
}
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을 입력해주세요.');
}
if (!url.searchParams.get('room')?.trim() || !url.searchParams.get('view')?.trim()) {
throw new Error('시청 URL에 room과 view가 필요합니다.');
}
// Only viewer parameters are accepted; never retain publishing/director options.
const view = new URL('https://vdo.ninja/');
view.searchParams.set('scene', '');
view.searchParams.set('room', url.searchParams.get('room')!);
view.searchParams.set('view', url.searchParams.get('view')!);
return view.toString();
}