|
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
|
|
ConflictException,
|
|
|
|
|
ForbiddenException,
|
|
|
|
|
Injectable,
|
|
|
|
|
Logger,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
UnauthorizedException,
|
|
|
|
|
} from '@nestjs/common';
|
|
|
|
@@ -24,6 +25,8 @@ import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
|
|
|
|
|
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
|
|
|
|
|
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.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 DEFAULT_SUBMISSION_BOT_EMAIL = 'lms-bot@futuredrive.net';
|
|
|
|
@@ -59,6 +62,8 @@ type StudentIssueItem = {
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class InstructorService {
|
|
|
|
|
private readonly logger = new Logger(InstructorService.name);
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly authService: AuthService,
|
|
|
|
|
private readonly chatGateway: ChatGateway,
|
|
|
|
@@ -734,7 +739,7 @@ export class InstructorService {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (group && organizationName) {
|
|
|
|
|
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
}
|
|
|
|
|
return this.listStudents(accessToken, course.id);
|
|
|
|
|
}
|
|
|
|
@@ -789,7 +794,7 @@ export class InstructorService {
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if (organizationName) {
|
|
|
|
|
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
}
|
|
|
|
|
return this.listStudents(accessToken);
|
|
|
|
|
}
|
|
|
|
@@ -1123,6 +1128,27 @@ export class InstructorService {
|
|
|
|
|
items.push(selection);
|
|
|
|
|
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 baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
|
|
|
|
|
const baseWeeks = course.weeks.map((week) => {
|
|
|
|
@@ -1172,8 +1198,19 @@ export class InstructorService {
|
|
|
|
|
folderName: module.folderName,
|
|
|
|
|
templateRepositoryUrl: module.templateRepositoryUrl,
|
|
|
|
|
dockerImage: module.dockerImage,
|
|
|
|
|
defaultParameters: this.stringArray(module.defaultParameters) ?? [],
|
|
|
|
|
enabled: !disabledModuleIdSet.has(module.id),
|
|
|
|
|
})),
|
|
|
|
|
environmentProfiles: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => {
|
|
|
|
|
const profile = environmentProfileByWeekPlatformKey.get(`${week.week}:${selection.platformKey}`);
|
|
|
|
|
return {
|
|
|
|
|
id: profile?.id ?? `${week.week}:${selection.platformKey}`,
|
|
|
|
|
platformKey: selection.platformKey,
|
|
|
|
|
profileType: profile?.profileType ?? selection.platform.type,
|
|
|
|
|
name: profile?.name ?? selection.platform.displayName,
|
|
|
|
|
config: profile?.config ?? selection.platform.defaultConfig,
|
|
|
|
|
};
|
|
|
|
|
}),
|
|
|
|
|
visualizationPlatforms: (visualizationSelectionsByWeek.get(week.week) ?? []).map((selection) => ({
|
|
|
|
|
key: selection.platform.key,
|
|
|
|
|
type: selection.platform.type,
|
|
|
|
@@ -1181,6 +1218,7 @@ export class InstructorService {
|
|
|
|
|
description: selection.platform.description,
|
|
|
|
|
config: selection.platform.defaultConfig,
|
|
|
|
|
})),
|
|
|
|
|
mapKeys: mapKeysByWeek.get(week.week) ?? [],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
const addedWeeks = overrides
|
|
|
|
@@ -1221,6 +1259,7 @@ export class InstructorService {
|
|
|
|
|
description: selection.platform.description,
|
|
|
|
|
config: selection.platform.defaultConfig,
|
|
|
|
|
})),
|
|
|
|
|
mapKeys: mapKeysByWeek.get(override.week) ?? [],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -1241,6 +1280,19 @@ export class InstructorService {
|
|
|
|
|
description: platform.description,
|
|
|
|
|
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: {
|
|
|
|
|
groupId: setting?.groupId ?? null,
|
|
|
|
|
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 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;
|
|
|
|
|
if (input.tagLabel !== undefined) {
|
|
|
|
|
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 validPlatforms = selectedKeys.length
|
|
|
|
|
? 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) {
|
|
|
|
|
const targetGroup = selectedGroup;
|
|
|
|
|
if (!targetGroup) throw new BadRequestException('강의 진행을 적용하려면 그룹을 선택해 주세요.');
|
|
|
|
@@ -1449,6 +1658,28 @@ export class InstructorService {
|
|
|
|
|
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) {
|
|
|
|
|
const { authUser, instructor } = await this.getInstructor(accessToken);
|
|
|
|
|
const organizationId = authUser.primaryAffiliation?.organizationId;
|
|
|
|
@@ -1708,6 +1939,40 @@ export class InstructorService {
|
|
|
|
|
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(
|
|
|
|
|
accessToken: string | undefined,
|
|
|
|
|
platformId: string,
|
|
|
|
@@ -1812,7 +2077,8 @@ export class InstructorService {
|
|
|
|
|
});
|
|
|
|
|
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: {
|
|
|
|
|
id: groupId,
|
|
|
|
|
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
|
|
|
|
@@ -1826,8 +2092,8 @@ export class InstructorService {
|
|
|
|
|
instructorPlatformId: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!group) throw new NotFoundException('Group not found');
|
|
|
|
|
if (group.instructorPlatformId && group.instructorPlatformId !== platform.id) {
|
|
|
|
|
if (!unassign && !group) throw new NotFoundException('Group not found');
|
|
|
|
|
if (group?.instructorPlatformId && group.instructorPlatformId !== platform.id) {
|
|
|
|
|
throw new BadRequestException('이미 플랫폼이 배정된 그룹입니다.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1871,20 +2137,22 @@ export class InstructorService {
|
|
|
|
|
organizationName,
|
|
|
|
|
),
|
|
|
|
|
instructorPlatformId: platform.id,
|
|
|
|
|
NOT: { id: group.id },
|
|
|
|
|
...(group ? { NOT: { id: group.id } } : {}),
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
instructorPlatformId: null,
|
|
|
|
|
platformName: null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
await tx.studentGroup.update({
|
|
|
|
|
where: { id: group.id },
|
|
|
|
|
data: {
|
|
|
|
|
instructorPlatformId: platform.id,
|
|
|
|
|
platformName: platform.displayName,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (group) {
|
|
|
|
|
await tx.studentGroup.update({
|
|
|
|
|
where: { id: group.id },
|
|
|
|
|
data: {
|
|
|
|
|
instructorPlatformId: platform.id,
|
|
|
|
|
platformName: platform.displayName,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if (activeSession && force) {
|
|
|
|
|
await tx.platformControlSession.updateMany({
|
|
|
|
|
where: {
|
|
|
|
@@ -1916,12 +2184,14 @@ export class InstructorService {
|
|
|
|
|
sessionId: activeSession.id,
|
|
|
|
|
platformId: null,
|
|
|
|
|
instructorPlatformId: platform.id,
|
|
|
|
|
reason: 'GROUP_ASSIGNMENT_CHANGED',
|
|
|
|
|
message: `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`,
|
|
|
|
|
reason: group ? 'GROUP_ASSIGNMENT_CHANGED' : 'GROUP_ASSIGNMENT_REMOVED',
|
|
|
|
|
message: group
|
|
|
|
|
? `${platform.displayName} 플랫폼 배정이 변경되어 사용이 종료되었습니다.`
|
|
|
|
|
: `${platform.displayName} 플랫폼 배정이 해제되어 사용이 종료되었습니다.`,
|
|
|
|
|
endedAt: now.toISOString(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
return this.listPlatforms(accessToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2040,7 +2310,7 @@ export class InstructorService {
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
if (terminatedSessionNotice) {
|
|
|
|
|
this.chatGateway.notifyUser(terminatedSessionNotice.userId, {
|
|
|
|
|
type: 'platform.control.terminated',
|
|
|
|
@@ -2116,7 +2386,7 @@ export class InstructorService {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
return this.listGroups(accessToken, input.courseId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2221,7 +2491,7 @@ export class InstructorService {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.syncPlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
this.queuePlatformAccessPolicy(organizationId, organizationName);
|
|
|
|
|
this.notifyPlatformTerminatedSessions(termination.notices, termination.endedAt, termination.reason);
|
|
|
|
|
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);
|
|
|
|
|
return this.listGroups(accessToken, courseId);
|
|
|
|
|
}
|
|
|
|
@@ -2408,57 +2678,75 @@ 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(
|
|
|
|
|
organizationId: string | undefined,
|
|
|
|
|
organizationName: string,
|
|
|
|
|
) {
|
|
|
|
|
const groups = await this.prisma.studentGroup.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
|
|
|
|
|
const [platforms, groups] = await Promise.all([
|
|
|
|
|
this.prisma.instructorPlatform.findMany({
|
|
|
|
|
where: this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
|
|
|
|
|
organizationId,
|
|
|
|
|
organizationName,
|
|
|
|
|
),
|
|
|
|
|
instructorPlatformId: { not: null },
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
instructorPlatform: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
displayName: true,
|
|
|
|
|
studioSettingsOverride: true,
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
displayName: true,
|
|
|
|
|
studioSettingsOverride: true,
|
|
|
|
|
},
|
|
|
|
|
members: {
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
email: true,
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.studentGroup.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
|
|
|
|
|
organizationId,
|
|
|
|
|
organizationName,
|
|
|
|
|
),
|
|
|
|
|
instructorPlatformId: { not: null },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
instructorPlatformId: true,
|
|
|
|
|
members: {
|
|
|
|
|
select: {
|
|
|
|
|
user: {
|
|
|
|
|
select: {
|
|
|
|
|
email: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const assignments = groups
|
|
|
|
|
.map((group): GatewayAccessAssignment | null => {
|
|
|
|
|
const platform = group.instructorPlatform;
|
|
|
|
|
if (!platform) return null;
|
|
|
|
|
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 {
|
|
|
|
|
platformId: gatewayPlatformId,
|
|
|
|
|
platformName: platform.displayName,
|
|
|
|
|
agentHostname,
|
|
|
|
|
agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'),
|
|
|
|
|
studentEmails,
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
.filter((assignment): assignment is GatewayAccessAssignment => Boolean(assignment));
|
|
|
|
|
const studentEmailsByPlatform = new Map<string, string[]>();
|
|
|
|
|
for (const group of groups) {
|
|
|
|
|
if (!group.instructorPlatformId) continue;
|
|
|
|
|
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');
|
|
|
|
|
return {
|
|
|
|
|
platformId: this.readStringSetting(platform.studioSettingsOverride, 'gatewayPlatformId') ?? agentHostname ?? platform.id,
|
|
|
|
|
platformName: platform.displayName,
|
|
|
|
|
agentHostname,
|
|
|
|
|
agentOwnerEmail: this.readStringSetting(platform.studioSettingsOverride, 'agentOwnerEmail'),
|
|
|
|
|
studentEmails: studentEmailsByPlatform.get(platform.id) ?? [],
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await this.platformGatewayClient.syncAccess(assignments);
|
|
|
|
|
}
|
|
|
|
@@ -2654,20 +2942,18 @@ export class InstructorService {
|
|
|
|
|
agents: GatewayAgent[],
|
|
|
|
|
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))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
if (!bindingTokens.length) return null;
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
return bindingTokens.some((bindingToken) => agentTokens.some((agentToken) => (
|
|
|
|
|
agentToken === bindingToken ||
|
|
|
|
|
agentToken.includes(bindingToken) ||
|
|
|
|
|
bindingToken.includes(agentToken)
|
|
|
|
|
)));
|
|
|
|
|
return bindingTokens.some((bindingToken) => agentTokens.includes(bindingToken));
|
|
|
|
|
}) ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2714,6 +3000,10 @@ export class InstructorService {
|
|
|
|
|
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) {
|
|
|
|
|
const launcherApiBaseUrl = this.configService
|
|
|
|
|
.get<string>('LAUNCHER_API_BASE_URL', 'http://localhost:4200/api')
|
|
|
|
|
.replace(/\/$/, '');
|
|
|
|
|
const launcherApiBaseUrl = this.launcherApiBaseUrl();
|
|
|
|
|
const response = await fetch(
|
|
|
|
|
`${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 }>) {
|
|
|
|
|
const total = students.length;
|
|
|
|
|
const averageProgress = total
|
|
|
|
|