Sync LMS backend source

This commit is contained in:
2026-07-07 16:09:12 +09:00
commit 5a2ecd0fea
126 changed files with 22959 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getRoot() {
return this.appService.getRoot();
}
@Get('health')
getHealth() {
return this.appService.getHealth();
}
@Get('health/db')
getDatabaseHealth() {
return this.appService.getDatabaseHealth();
}
}
+30
View File
@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { ChatModule } from './chat/chat.module';
import { CurriculumModule } from './curriculum/curriculum.module';
import { InstructorModule } from './instructor/instructor.module';
import { PlatformControlModule } from './platform-control/platform-control.module';
import { PrismaModule } from './prisma/prisma.module';
import { StudioModule } from './studio/studio.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
AuthModule,
ChatModule,
CurriculumModule,
InstructorModule,
PlatformControlModule,
PrismaModule,
StudioModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
+35
View File
@@ -0,0 +1,35 @@
import { AppService } from './app.service';
import { PrismaService } from './prisma/prisma.service';
describe('AppService', () => {
let service: AppService;
let prisma: Pick<PrismaService, '$queryRaw'>;
beforeEach(() => {
prisma = {
$queryRaw: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
} as Pick<PrismaService, '$queryRaw'>;
service = new AppService(prisma as PrismaService);
});
it('returns the root service status', () => {
expect(service.getRoot()).toEqual({
service: 'lms-be',
status: 'ok',
});
});
it('returns a health payload', () => {
expect(service.getHealth()).toMatchObject({
status: 'ok',
});
});
it('checks database health', async () => {
await expect(service.getDatabaseHealth()).resolves.toMatchObject({
status: 'ok',
provider: 'postgresql',
});
expect(prisma.$queryRaw).toHaveBeenCalled();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from './prisma/prisma.service';
@Injectable()
export class AppService {
constructor(private readonly prisma: PrismaService) {}
getRoot() {
return {
service: 'lms-be',
status: 'ok',
};
}
getHealth() {
return {
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
};
}
async getDatabaseHealth() {
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ok',
provider: 'postgresql',
timestamp: new Date().toISOString(),
};
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Body, Controller, Get, Post, Req } from '@nestjs/common';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { HandoffExchangeDto } from './dto/handoff-exchange.dto';
type RefreshBody = {
refreshToken?: string;
};
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('handoff/exchange')
exchangeHandoff(@Body() handoffExchangeDto: HandoffExchangeDto) {
return this.authService.exchangeHandoff(
handoffExchangeDto.handoffToken,
handoffExchangeDto.audienceApp,
);
}
@Post('refresh')
refresh(@Body() body: RefreshBody) {
return this.authService.refresh(body.refreshToken ?? '');
}
@Get('me')
me(@Req() request: Request) {
return this.authService.me(this.getBearerToken(request));
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
+162
View File
@@ -0,0 +1,162 @@
import {
BadGatewayException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export type AuthResponseUser = {
id: string;
email: string;
name: string | null;
role: string;
roles?: string[];
primaryAffiliation?: {
id: string;
organizationId: string;
organizationName: string;
organizationType: string;
department: string | null;
position: string | null;
} | null;
currentLicense?: {
id: string;
code: string;
name: string;
startsAt: string;
expiresAt: string | null;
} | null;
};
export type CreateManagedAuthUserInput = {
email: string;
password: string;
name: string;
organizationId: string;
department?: string | null;
position?: string | null;
licenseCode?: 'FREE' | 'EDU' | 'PRO';
};
type AuthTokenResponse = {
accessToken: string;
accessTokenExpiresIn?: number;
refreshToken?: string;
refreshTokenExpiresIn?: number;
user: AuthResponseUser;
};
@Injectable()
export class AuthService {
private readonly authServerUrl: string;
constructor(private readonly configService: ConfigService) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
}
async me(accessToken: string | undefined) {
const authResponse = await this.request<{ user: AuthResponseUser }>(
'/auth/me',
{
method: 'GET',
accessToken,
unauthorizedMessage: 'Invalid bearer token',
},
);
return authResponse;
}
async exchangeHandoff(handoffToken: string, audienceApp?: string) {
const authResponse = await this.request<AuthTokenResponse>(
'/auth/handoff/exchange',
{
method: 'POST',
body: { handoffToken, audienceApp },
unauthorizedMessage: 'Invalid handoff token',
},
);
return this.frontendTokenResponse(authResponse);
}
async refresh(refreshToken: string) {
const authResponse = await this.request<AuthTokenResponse>('/auth/refresh', {
method: 'POST',
body: { refreshToken },
unauthorizedMessage: 'Invalid refresh token',
});
return this.frontendTokenResponse(authResponse);
}
async createManagedUser(
accessToken: string,
input: CreateManagedAuthUserInput,
) {
return this.request<{ user: AuthResponseUser }>('/auth/admin/users', {
method: 'POST',
body: input,
accessToken,
unauthorizedMessage: 'Invalid bearer token',
});
}
private async frontendTokenResponse(authResponse: AuthTokenResponse) {
return {
accessToken: authResponse.accessToken,
accessTokenExpiresIn: authResponse.accessTokenExpiresIn,
refreshToken: authResponse.refreshToken,
refreshTokenExpiresIn: authResponse.refreshTokenExpiresIn,
tokenType: 'Bearer',
user: authResponse.user,
};
}
private async request<T>(
path: string,
options: {
method: 'GET' | 'POST';
body?: unknown;
accessToken?: string;
unauthorizedMessage?: string;
},
): Promise<T> {
const headers: Record<string, string> = {};
if (options.body) headers['content-type'] = 'application/json';
if (options.accessToken) {
headers.authorization = `Bearer ${options.accessToken}`;
}
let authResponse: globalThis.Response;
try {
authResponse = await fetch(`${this.authServerUrl}${path}`, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
});
} catch {
throw new BadGatewayException('Auth server is unavailable');
}
const payload: unknown = await authResponse.json().catch((): null => null);
if (authResponse.status === 401) {
throw new UnauthorizedException(
options.unauthorizedMessage ?? 'Unauthorized',
);
}
if (!authResponse.ok) {
throw new BadGatewayException({
message: 'Auth server request failed',
statusCode: authResponse.status,
error: payload,
});
}
return payload as T;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class HandoffExchangeDto {
@IsString()
handoffToken: string;
@IsOptional()
@IsString()
audienceApp?: string;
}
+109
View File
@@ -0,0 +1,109 @@
import { Body, Controller, Delete, Get, Param, Post, Query, Req } from '@nestjs/common';
import { Request } from 'express';
import { ChatService } from './chat.service';
import { CreateAiResponseDto } from './dto/create-ai-response.dto';
import { CreateChatMessageDto } from './dto/create-chat-message.dto';
import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto';
import { ListChatThreadsDto } from './dto/list-chat-threads.dto';
@Controller('chat')
export class ChatController {
constructor(private readonly chatService: ChatService) {}
@Get('threads')
listThreads(@Req() request: Request, @Query() query: ListChatThreadsDto) {
return this.chatService.listThreads(this.getBearerToken(request), query);
}
@Delete('threads')
clearThreads(@Req() request: Request, @Query() query: ListChatThreadsDto) {
return this.chatService.clearStudentThreads(this.getBearerToken(request), query);
}
@Post('messages')
createMessage(
@Req() request: Request,
@Body() createChatMessageDto: CreateChatMessageDto,
) {
return this.chatService.createStudentMessage(
this.getBearerToken(request),
createChatMessageDto,
);
}
@Post('ai/respond')
createAiResponse(
@Req() request: Request,
@Body() createAiResponseDto: CreateAiResponseDto,
) {
return this.chatService.createAiResponse(
this.getBearerToken(request),
createAiResponseDto,
);
}
@Post('ai/messages/:messageId/cancel')
cancelAiMessage(
@Req() request: Request,
@Param('messageId') messageId: string,
@Body() body: { content?: string },
) {
return this.chatService.cancelAiMessage(
this.getBearerToken(request),
messageId,
body.content,
);
}
@Post('ai/cancel-active')
cancelActiveAiResponse(
@Req() request: Request,
@Body() body: { week?: number },
) {
return this.chatService.cancelActiveAiResponse(
this.getBearerToken(request),
body.week,
);
}
@Get('instructor/threads')
listInstructorThreads(
@Req() request: Request,
@Query() query: ListChatThreadsDto,
) {
return this.chatService.listInstructorThreads(
this.getBearerToken(request),
query,
);
}
@Get('instructor/threads/:threadId')
getInstructorThread(
@Req() request: Request,
@Param('threadId') threadId: string,
) {
return this.chatService.getInstructorThread(
this.getBearerToken(request),
threadId,
);
}
@Post('instructor/threads/:threadId/messages')
createInstructorMessage(
@Req() request: Request,
@Param('threadId') threadId: string,
@Body() createInstructorChatMessageDto: CreateInstructorChatMessageDto,
) {
return this.chatService.createInstructorMessage(
this.getBearerToken(request),
threadId,
createInstructorChatMessageDto,
);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+220
View File
@@ -0,0 +1,220 @@
import { Injectable } from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
type ChatSocketUser = {
id: string;
email: string;
role: string;
roles?: string[];
};
export type ChatNotificationPayload = {
type: 'chat.message.created';
threadId: string;
messageId: string;
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM';
preview: string;
createdAt: string;
};
export type UserNotificationPayload =
| ChatNotificationPayload
| {
type: 'platform.control.warning';
sessionId: string;
platformId: string | null;
instructorPlatformId: string | null;
endsAt: string;
warningAt: string;
}
| {
type: 'platform.control.granted';
sessionId: string;
startedAt: string;
endsAt: string;
warningAt: string;
}
| {
type: 'platform.control.terminated';
sessionId: string;
platformId: string | null;
instructorPlatformId: string | null;
reason: string;
message: string;
endedAt: string;
};
@Injectable()
@WebSocketGateway({
cors: {
origin: true,
credentials: true,
},
})
export class ChatGateway implements OnGatewayConnection {
@WebSocketServer()
private server!: Server;
constructor(
private readonly authService: AuthService,
private readonly prisma: PrismaService,
) {}
async handleConnection(client: Socket) {
const accessToken = this.getAccessToken(client);
if (!accessToken) {
client.disconnect(true);
return;
}
try {
const { user } = await this.authService.me(accessToken);
const lmsUser = await this.prisma.user.upsert({
where: { email: user.email },
update: this.toUserSyncData(user),
create: {
email: user.email,
...this.toUserSyncData(user),
},
});
const socketUser: ChatSocketUser = {
id: lmsUser.id,
email: user.email,
role: user.role,
roles: user.roles,
};
client.data.user = socketUser;
await client.join(this.userRoom(lmsUser.id));
const organizationName = user.primaryAffiliation?.organizationName?.trim();
if (organizationName) {
await client.join(this.organizationRoom(organizationName));
}
if (this.isInstructor(socketUser)) {
const activeCourses = await this.prisma.course.findMany({
where: { isActive: true },
select: { id: true },
});
await Promise.all(
activeCourses.map((course) =>
client.join(this.courseInstructorsRoom(course.id)),
),
);
}
} catch {
client.disconnect(true);
}
}
@SubscribeMessage('chat.thread.join')
async joinThread(
@ConnectedSocket() client: Socket,
@MessageBody() payload: { threadId?: string },
) {
const threadId = payload.threadId;
if (!threadId || !client.data.user) return { ok: false };
await client.join(this.threadRoom(threadId));
return { ok: true };
}
@SubscribeMessage('chat.thread.leave')
async leaveThread(
@ConnectedSocket() client: Socket,
@MessageBody() payload: { threadId?: string },
) {
const threadId = payload.threadId;
if (!threadId) return { ok: false };
await client.leave(this.threadRoom(threadId));
return { ok: true };
}
notifyUser(userId: string, payload: UserNotificationPayload) {
this.server.to(this.userRoom(userId)).emit(payload.type, payload);
}
notifyCourseInstructors(courseId: string, payload: ChatNotificationPayload) {
this.server
.to(this.courseInstructorsRoom(courseId))
.emit(payload.type, payload);
}
notifyThread(threadId: string, payload: ChatNotificationPayload) {
this.server.to(this.threadRoom(threadId)).emit(payload.type, payload);
}
notifyOrganization(organizationName: string, eventName: string, payload: unknown) {
this.server.to(this.organizationRoom(organizationName)).emit(eventName, payload);
}
async notifyGroupMembers(groupId: string, eventName: string, payload: unknown) {
const members = await this.prisma.studentGroupMember.findMany({
where: { groupId },
select: { userId: true },
});
members.forEach((member) => {
this.server.to(this.userRoom(member.userId)).emit(eventName, payload);
});
}
private getAccessToken(client: Socket) {
const authToken = client.handshake.auth?.token;
if (typeof authToken === 'string' && authToken) return authToken;
const authorization = client.handshake.headers.authorization;
if (!authorization) return null;
const [type, token] = authorization.split(' ');
return type === 'Bearer' ? token : null;
}
private isInstructor(user: ChatSocketUser) {
const roles = new Set(
[user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()),
);
return ['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role));
}
private toUserSyncData(user: AuthResponseUser) {
return {
authUserId: user.id,
name: user.name,
authRole: user.role,
primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null,
primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null,
primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null,
department: user.primaryAffiliation?.department ?? null,
position: user.primaryAffiliation?.position ?? null,
};
}
private userRoom(userId: string) {
return `user:${userId}`;
}
private courseInstructorsRoom(courseId: string) {
return `course:${courseId}:instructors`;
}
private threadRoom(threadId: string) {
return `thread:${threadId}`;
}
private organizationRoom(organizationName: string) {
return `organization:${organizationName}`;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { ChatController } from './chat.controller';
import { ChatGateway } from './chat.gateway';
import { ChatService } from './chat.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [ChatController],
providers: [ChatGateway, ChatService],
exports: [ChatGateway],
})
export class ChatModule {}
+989
View File
@@ -0,0 +1,989 @@
import {
BadGatewayException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
AiChatRunStatus,
ChatMessageStatus,
ChatSenderType,
ChatThreadStatus,
ChatThreadType,
Prisma,
} from '@prisma/client';
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { ChatGateway, type ChatNotificationPayload } from './chat.gateway';
import { CreateAiResponseDto } from './dto/create-ai-response.dto';
import { CreateChatMessageDto } from './dto/create-chat-message.dto';
import { CreateInstructorChatMessageDto } from './dto/create-instructor-chat-message.dto';
import { ListChatThreadsDto } from './dto/list-chat-threads.dto';
type OpenAiChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
usage?: {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
};
error?: {
message?: string;
};
};
type FutureDriveLlmChatResponse = {
answer?: string;
sources?: Array<{
title?: string;
snippet?: string;
score?: number;
}>;
topic?: string;
follow_ups?: string[];
request_id?: string;
error?: {
message?: string;
};
message?: string;
};
const STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT = 20;
const AI_CHAT_CONTEXT_MESSAGE_LIMIT = 6;
@Injectable()
export class ChatService {
private readonly authServerUrl: string;
constructor(
private readonly authService: AuthService,
private readonly chatGateway: ChatGateway,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.authServerUrl = this.configService
.get<string>('AUTH_SERVER_URL', 'http://localhost:4100')
.replace(/\/$/, '');
}
async listThreads(accessToken: string | undefined, query: ListChatThreadsDto) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = query.week
? await this.getCourseWeek(course.id, query.week)
: null;
const threads = await this.prisma.chatThread.findMany({
where: {
courseId: course.id,
studentId: student.id,
weekId: week?.id,
type: query.mode ? this.toThreadType(query.mode) : undefined,
status: { not: ChatThreadStatus.ARCHIVED },
},
orderBy: { lastMessageAt: 'desc' },
include: {
messages: {
where: { deletedAt: null },
orderBy: { createdAt: 'desc' },
take: STUDENT_CHAT_VISIBLE_MESSAGE_LIMIT,
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
},
week: {
select: { week: true, title: true },
},
},
});
return {
threads: threads.map((thread) => this.serializeThread(thread)),
};
}
async clearStudentThreads(accessToken: string | undefined, query: ListChatThreadsDto) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = query.week
? await this.getCourseWeek(course.id, query.week)
: null;
const result = await this.prisma.chatThread.updateMany({
where: {
courseId: course.id,
studentId: student.id,
weekId: week?.id,
type: query.mode ? this.toThreadType(query.mode) : undefined,
status: { not: ChatThreadStatus.ARCHIVED },
},
data: {
status: ChatThreadStatus.ARCHIVED,
},
});
return { deletedCount: result.count };
}
async createStudentMessage(
accessToken: string | undefined,
dto: CreateChatMessageDto,
) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null;
const thread = await this.getOrCreateThread({
courseId: course.id,
weekId: week?.id ?? null,
studentId: student.id,
type: this.toThreadType(dto.mode),
firstMessage: dto.content,
status:
dto.mode === 'teacher'
? ChatThreadStatus.WAITING_INSTRUCTOR
: ChatThreadStatus.OPEN,
});
const message = await this.prisma.$transaction(async (tx) => {
const createdMessage = await tx.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.STUDENT,
senderUserId: student.id,
content: dto.content,
},
});
await tx.chatThread.update({
where: { id: thread.id },
data: {
status:
dto.mode === 'teacher'
? ChatThreadStatus.WAITING_INSTRUCTOR
: thread.status,
lastMessageAt: createdMessage.createdAt,
},
});
return createdMessage;
});
const payload = this.toNotificationPayload(thread.id, message);
this.chatGateway.notifyThread(thread.id, payload);
this.chatGateway.notifyUser(student.id, payload);
if (dto.mode === 'teacher') {
this.chatGateway.notifyCourseInstructors(course.id, payload);
}
return {
threadId: thread.id,
message: this.serializeMessage(message),
};
}
async createAiResponse(
accessToken: string | undefined,
dto: CreateAiResponseDto,
) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = dto.week ? await this.getCourseWeek(course.id, dto.week) : null;
const thread = await this.getOrCreateThread({
courseId: course.id,
weekId: week?.id ?? null,
studentId: student.id,
type: ChatThreadType.AI,
firstMessage: dto.content,
status: ChatThreadStatus.OPEN,
});
const studentMessage = await this.prisma.$transaction(async (tx) => {
const createdMessage = await tx.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.STUDENT,
senderUserId: student.id,
content: dto.content,
},
});
await tx.chatThread.update({
where: { id: thread.id },
data: { lastMessageAt: createdMessage.createdAt },
});
return createdMessage;
});
const studentPayload = this.toNotificationPayload(thread.id, studentMessage);
this.chatGateway.notifyThread(thread.id, studentPayload);
this.chatGateway.notifyUser(student.id, studentPayload);
const recentMessages = await this.prisma.chatMessage.findMany({
where: {
threadId: thread.id,
deletedAt: null,
senderType: { in: [ChatSenderType.STUDENT, ChatSenderType.AI] },
status: ChatMessageStatus.ACTIVE,
},
orderBy: { createdAt: 'desc' },
take: AI_CHAT_CONTEXT_MESSAGE_LIMIT,
});
const startedAt = Date.now();
const aiProvider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
const model = aiProvider === 'futuredrive'
? this.configService.get<string>('FUTUREDRIVE_LLM_MODEL', 'futuredrive-llm')
: this.configService.get<string>('OPENAI_CHAT_MODEL', 'gpt-4.1-nano');
try {
const aiResponse = await this.requestAiChatCompletion(
model,
recentMessages.reverse().map((message) => ({
role:
message.senderType === ChatSenderType.STUDENT
? ('user' as const)
: ('assistant' as const),
content: message.content,
})),
);
const currentStudentMessage = await this.prisma.chatMessage.findUnique({
where: { id: studentMessage.id },
select: { status: true },
});
if (currentStudentMessage?.status === ChatMessageStatus.CANCELED) {
return {
threadId: thread.id,
message: this.serializeMessage({
...studentMessage,
status: ChatMessageStatus.CANCELED,
canceledAt: new Date(),
aiRun: null,
senderUser: null,
}),
content: '',
canceled: true,
};
}
const assistantMessage = await this.prisma.$transaction(async (tx) => {
const message = await tx.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.AI,
content: aiResponse.content,
aiRun: {
create: {
model,
promptTokens: aiResponse.usage.promptTokens,
completionTokens: aiResponse.usage.completionTokens,
totalTokens: aiResponse.usage.totalTokens,
latencyMs: Date.now() - startedAt,
status: AiChatRunStatus.SUCCEEDED,
},
},
},
include: { aiRun: true },
});
await tx.chatThread.update({
where: { id: thread.id },
data: { lastMessageAt: message.createdAt },
});
return message;
});
const payload = this.toNotificationPayload(thread.id, assistantMessage);
this.chatGateway.notifyThread(thread.id, payload);
this.chatGateway.notifyUser(student.id, payload);
return {
threadId: thread.id,
message: this.serializeMessage(assistantMessage),
content: assistantMessage.content,
};
} catch (error) {
const message = error instanceof Error ? error.message : 'AI request failed';
const failedMessage = await this.prisma.chatMessage.create({
data: {
threadId: thread.id,
senderType: ChatSenderType.SYSTEM,
content: message,
aiRun: {
create: {
model,
latencyMs: Date.now() - startedAt,
status: AiChatRunStatus.FAILED,
error: message,
},
},
},
});
await this.prisma.chatThread.update({
where: { id: thread.id },
data: { lastMessageAt: failedMessage.createdAt },
});
const payload = this.toNotificationPayload(thread.id, failedMessage);
this.chatGateway.notifyThread(thread.id, payload);
this.chatGateway.notifyUser(student.id, payload);
throw new BadGatewayException(message);
}
}
async cancelAiMessage(
accessToken: string | undefined,
messageId: string,
content?: string,
) {
const student = await this.getLmsUser(accessToken);
const message = await this.prisma.chatMessage.findFirst({
where: {
id: messageId,
senderType: ChatSenderType.AI,
thread: {
studentId: student.id,
type: ChatThreadType.AI,
status: { not: ChatThreadStatus.ARCHIVED },
},
},
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
if (!message) {
throw new NotFoundException('AI 메시지를 찾을 수 없습니다.');
}
const nextContent = typeof content === 'string' && content.trim()
? content.trim()
: message.content;
const canceledMessage = await this.prisma.chatMessage.update({
where: { id: message.id },
data: {
content: nextContent,
status: ChatMessageStatus.CANCELED,
canceledAt: new Date(),
},
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
return {
message: this.serializeMessage(canceledMessage),
};
}
async cancelActiveAiResponse(
accessToken: string | undefined,
weekNumber?: number,
) {
const student = await this.getLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = weekNumber ? await this.getCourseWeek(course.id, weekNumber) : null;
const message = await this.prisma.chatMessage.findFirst({
where: {
senderType: ChatSenderType.STUDENT,
status: ChatMessageStatus.ACTIVE,
thread: {
courseId: course.id,
studentId: student.id,
weekId: week?.id ?? null,
type: ChatThreadType.AI,
status: { not: ChatThreadStatus.ARCHIVED },
},
},
orderBy: { createdAt: 'desc' },
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
if (!message) {
return { message: null };
}
const canceledMessage = await this.prisma.chatMessage.update({
where: { id: message.id },
data: {
status: ChatMessageStatus.CANCELED,
canceledAt: new Date(),
},
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
});
return {
message: this.serializeMessage(canceledMessage),
};
}
async listInstructorThreads(
accessToken: string | undefined,
query: ListChatThreadsDto,
) {
const instructor = await this.getInstructorLmsUser(accessToken);
const course = await this.getActiveCourse();
const week = query.week
? await this.getCourseWeek(course.id, query.week)
: null;
const threads = await this.prisma.chatThread.findMany({
where: {
courseId: course.id,
weekId: week?.id,
type: ChatThreadType.INSTRUCTOR,
status: { not: ChatThreadStatus.ARCHIVED },
...this.instructorThreadVisibilityWhere(instructor),
},
orderBy: { lastMessageAt: 'desc' },
include: {
student: {
select: {
id: true,
authUserId: true,
email: true,
name: true,
},
},
assignedInstructor: {
select: {
id: true,
email: true,
name: true,
},
},
messages: {
where: { deletedAt: null },
orderBy: { createdAt: 'asc' },
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
},
week: {
select: { week: true, title: true },
},
},
});
return {
threads: threads.map((thread) => this.serializeInstructorThread(thread)),
};
}
async getInstructorThread(accessToken: string | undefined, threadId: string) {
const instructor = await this.getInstructorLmsUser(accessToken);
const thread = await this.getInstructorThreadById(threadId, instructor);
return {
thread: this.serializeInstructorThread(thread),
};
}
async createInstructorMessage(
accessToken: string | undefined,
threadId: string,
dto: CreateInstructorChatMessageDto,
) {
const instructor = await this.getInstructorLmsUser(accessToken);
const thread = await this.getInstructorThreadById(threadId, instructor);
const message = await this.prisma.$transaction(async (tx) => {
const createdMessage = await tx.chatMessage.create({
data: {
threadId,
senderType: ChatSenderType.INSTRUCTOR,
senderUserId: instructor.id,
content: dto.content,
},
});
await tx.chatThread.update({
where: { id: threadId },
data: {
assignedInstructorId: instructor.id,
status: ChatThreadStatus.OPEN,
lastMessageAt: createdMessage.createdAt,
},
});
return createdMessage;
});
const updatedThread = await this.getInstructorThreadById(threadId, instructor);
const payload = this.toNotificationPayload(threadId, message);
this.chatGateway.notifyThread(threadId, payload);
this.chatGateway.notifyUser(thread.student.id, payload);
this.chatGateway.notifyCourseInstructors(thread.courseId, payload);
return {
thread: this.serializeInstructorThread(updatedThread),
message: this.serializeMessage(message),
};
}
private async getLmsUser(accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const { user } = await this.authService.me(accessToken);
return this.prisma.user.upsert({
where: { email: user.email },
update: this.toUserSyncData(user),
create: {
email: user.email,
...this.toUserSyncData(user),
},
});
}
private async getInstructorLmsUser(accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const { user } = await this.authService.me(accessToken);
const roles = new Set(
[user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase()),
);
if (!['INSTRUCTOR', 'LMS_ADMIN'].some((role) => roles.has(role))) {
throw new ForbiddenException('Instructor role required');
}
return this.prisma.user.upsert({
where: { email: user.email },
update: this.toUserSyncData(user),
create: {
email: user.email,
...this.toUserSyncData(user),
},
});
}
private toUserSyncData(user: AuthResponseUser) {
return {
authUserId: user.id,
name: user.name,
authRole: user.role,
primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null,
primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null,
primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null,
department: user.primaryAffiliation?.department ?? null,
position: user.primaryAffiliation?.position ?? null,
};
}
private instructorThreadVisibilityWhere(
instructor: Prisma.UserGetPayload<Record<string, never>>,
): Prisma.ChatThreadWhereInput {
const studentOrganizationFilters = [
instructor.primaryOrganizationId
? {
primaryOrganizationId: instructor.primaryOrganizationId,
}
: undefined,
instructor.primaryOrganizationName
? {
progress: {
some: {
course: {
organizationName: instructor.primaryOrganizationName,
},
},
},
}
: undefined,
].filter(Boolean) as Prisma.UserWhereInput[];
return {
OR: [
{ assignedInstructorId: null },
{ assignedInstructorId: instructor.id },
],
student: studentOrganizationFilters.length
? {
OR: studentOrganizationFilters,
}
: {
id: '__NO_ORGANIZATION_SCOPE__',
},
};
}
private async getActiveCourse() {
const course = await this.prisma.course.findFirst({
where: { slug: 'futuredrive-autonomy-basic', isActive: true },
});
if (!course) throw new NotFoundException('Curriculum course not found');
return course;
}
private async getCourseWeek(courseId: string, weekNumber: number) {
const week = await this.prisma.courseWeek.findUnique({
where: { courseId_week: { courseId, week: weekNumber } },
});
if (!week) throw new NotFoundException('Course week not found');
return week;
}
private async getOrCreateThread(input: {
courseId: string;
weekId: string | null;
studentId: string;
type: ChatThreadType;
firstMessage: string;
status: ChatThreadStatus;
}) {
const thread = await this.prisma.chatThread.findFirst({
where: {
courseId: input.courseId,
weekId: input.weekId,
studentId: input.studentId,
type: input.type,
status: { not: ChatThreadStatus.ARCHIVED },
},
orderBy: { lastMessageAt: 'desc' },
});
if (thread) return thread;
return this.prisma.chatThread.create({
data: {
courseId: input.courseId,
weekId: input.weekId,
studentId: input.studentId,
type: input.type,
status: input.status,
title: input.firstMessage.slice(0, 80),
},
});
}
private async getInstructorThreadById(
threadId: string,
instructor: Prisma.UserGetPayload<Record<string, never>>,
) {
const thread = await this.prisma.chatThread.findFirst({
where: {
id: threadId,
type: ChatThreadType.INSTRUCTOR,
status: { not: ChatThreadStatus.ARCHIVED },
...this.instructorThreadVisibilityWhere(instructor),
},
include: {
student: {
select: {
id: true,
authUserId: true,
email: true,
name: true,
},
},
assignedInstructor: {
select: {
id: true,
email: true,
name: true,
},
},
messages: {
where: { deletedAt: null },
orderBy: { createdAt: 'asc' },
include: {
aiRun: true,
senderUser: {
select: {
authUserId: true,
email: true,
name: true,
},
},
},
},
week: {
select: { week: true, title: true },
},
},
});
if (!thread) throw new NotFoundException('Instructor chat thread not found');
return thread;
}
private async requestAiChatCompletion(
model: string,
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
const provider = this.configService.get<string>('AI_PROVIDER', 'openai').trim().toLowerCase();
if (provider === 'futuredrive') {
return this.requestFutureDriveLlmChatCompletion(messages);
}
return this.requestOpenAiChatCompletion(model, messages);
}
private async requestOpenAiChatCompletion(
model: string,
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
const apiKey = this.configService.get<string>('OPENAI_API_KEY');
if (!apiKey) {
throw new Error(
'OPENAI_API_KEY가 설정되어 있지 않습니다. lms-be .env에 OPENAI_API_KEY=... 형식으로 추가하세요.',
);
}
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model,
temperature: 0.3,
messages: [
{
role: 'system',
content:
'당신은 자율주행 교육용 ROS2, SLAM 실습을 돕는 한국어 AI 챗봇입니다. 답변은 짧고 실습자가 바로 따라 할 수 있게 명확하게 작성하세요.',
},
...messages,
],
}),
});
const data = (await response.json()) as OpenAiChatCompletionResponse;
if (!response.ok) {
throw new Error(data.error?.message ?? `OpenAI request failed: ${response.status}`);
}
const content = data.choices?.[0]?.message?.content?.trim();
if (!content) throw new Error('OpenAI 응답이 비어 있습니다.');
return {
content,
usage: {
promptTokens: data.usage?.prompt_tokens,
completionTokens: data.usage?.completion_tokens,
totalTokens: data.usage?.total_tokens,
},
};
}
private async requestFutureDriveLlmChatCompletion(
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
) {
const apiKey = this.configService.get<string>('FUTUREDRIVE_LLM_API_KEY');
if (!apiKey) {
throw new Error(
'FUTUREDRIVE_LLM_API_KEY가 설정되어 있지 않습니다. lms-be .env에 FUTUREDRIVE_LLM_API_KEY=... 형식으로 추가하세요.',
);
}
const baseUrl = this.configService
.get<string>('FUTUREDRIVE_LLM_BASE_URL', 'https://famine-tissue-cursor.ngrok-free.dev')
.replace(/\/+$/, '');
const lang = this.configService.get<string>('FUTUREDRIVE_LLM_LANG', 'ko');
const response = await fetch(`${baseUrl}/api/chat/complete`, {
method: 'POST',
headers: {
'X-API-Key': apiKey,
'Content-Type': 'application/json',
'ngrok-skip-browser-warning': '1',
},
body: JSON.stringify({
messages,
lang,
}),
});
const data = (await response.json().catch(() => ({}))) as FutureDriveLlmChatResponse;
if (!response.ok) {
throw new Error(data.error?.message ?? data.message ?? `FutureDrive LLM request failed: ${response.status}`);
}
const content = data.answer?.trim();
if (!content) throw new Error('FutureDrive LLM 응답이 비어 있습니다.');
return {
content,
usage: {
promptTokens: undefined,
completionTokens: undefined,
totalTokens: undefined,
},
};
}
private toThreadType(mode: 'ai' | 'teacher') {
return mode === 'ai' ? ChatThreadType.AI : ChatThreadType.INSTRUCTOR;
}
private serializeThread(
thread: Prisma.ChatThreadGetPayload<{
include: {
messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } };
week: { select: { week: true; title: true } };
};
}>,
) {
return {
id: thread.id,
type: thread.type,
status: thread.status,
title: thread.title,
week: thread.week,
lastMessageAt: thread.lastMessageAt,
messages: [...thread.messages]
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
.map((message) => this.serializeMessage(message)),
};
}
private serializeInstructorThread(
thread: Prisma.ChatThreadGetPayload<{
include: {
student: { select: { id: true; authUserId: true; email: true; name: true } };
assignedInstructor: { select: { id: true; email: true; name: true } };
messages: { include: { aiRun: true; senderUser: { select: { authUserId: true; email: true; name: true } } } };
week: { select: { week: true; title: true } };
};
}>,
) {
return {
id: thread.id,
type: thread.type,
status: thread.status,
title: thread.title,
student: thread.student,
studentName: thread.student.name ?? thread.student.email,
studentAvatarUrl: this.getStudentAvatarUrl(thread.student),
assignedInstructor: thread.assignedInstructor,
week: thread.week,
lastMessageAt: thread.lastMessageAt,
messages: thread.messages.map((message) => this.serializeMessage(message)),
};
}
private serializeMessage(
message: Prisma.ChatMessageGetPayload<{
include: {
aiRun: true;
senderUser: { select: { authUserId: true; email: true; name: true } };
};
}> | {
id: string;
senderType: ChatSenderType;
senderUserId: string | null;
senderUser?: { email: string; name: string | null } | null;
content: string;
contentFormat: string;
status?: ChatMessageStatus;
metadata: Prisma.JsonValue | null;
createdAt: Date;
canceledAt?: Date | null;
deletedAt: Date | null;
},
) {
return {
id: message.id,
senderType: message.senderType,
senderUserId: message.senderUserId,
senderName: message.senderUser?.name ?? message.senderUser?.email ?? null,
senderAvatarUrl: this.getUserAvatarUrl(message.senderUser ?? null),
content: message.content,
contentFormat: message.contentFormat,
status: 'status' in message ? message.status : ChatMessageStatus.ACTIVE,
metadata: message.metadata,
createdAt: message.createdAt,
canceledAt: 'canceledAt' in message ? message.canceledAt : null,
};
}
private toNotificationPayload(
threadId: string,
message: {
id: string;
senderType: ChatSenderType;
content: string;
createdAt: Date;
},
): ChatNotificationPayload {
return {
type: 'chat.message.created',
threadId,
messageId: message.id,
senderType: message.senderType,
preview: message.content.slice(0, 120),
createdAt: message.createdAt.toISOString(),
};
}
private getStudentAvatarUrl(student: {
authUserId: string | null;
email: string;
}) {
return this.getUserAvatarUrl(student);
}
private getUserAvatarUrl(user: {
authUserId?: string | null;
email?: string | null;
} | null) {
if (!user) return null;
if (user.authUserId) {
return `${this.authServerUrl}/auth/users/${user.authUserId}/avatar`;
}
if (user.email) {
return `${this.authServerUrl}/auth/users/avatar?email=${encodeURIComponent(user.email)}`;
}
return null;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateAiResponseDto {
@IsString()
content: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
week?: number;
}
+15
View File
@@ -0,0 +1,15 @@
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateChatMessageDto {
@IsIn(['ai', 'teacher'])
mode: 'ai' | 'teacher';
@IsString()
content: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
week?: number;
}
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class CreateInstructorChatMessageDto {
@IsString()
content: string;
}
+15
View File
@@ -0,0 +1,15 @@
import { Transform } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListChatThreadsDto {
@IsOptional()
@IsIn(['ai', 'teacher'])
mode?: 'ai' | 'teacher';
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(100)
week?: number;
}
+41
View File
@@ -0,0 +1,41 @@
import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { CurriculumService } from './curriculum.service';
@Controller('curriculum')
export class CurriculumController {
constructor(private readonly curriculumService: CurriculumService) {}
@Get()
getActiveCurriculum(@Req() request: Request, @Query('courseId') courseId?: string) {
return this.curriculumService.getActiveCurriculum(
this.getBearerToken(request),
courseId,
);
}
@Get('courses')
listCourses(@Req() request: Request) {
return this.curriculumService.listCourses(this.getBearerToken(request));
}
@Get('references/:referenceId/download')
async downloadReference(
@Req() request: Request,
@Param('referenceId') referenceId: string,
@Res() response: Response,
) {
const download = await this.curriculumService.getReferenceDownload(
this.getBearerToken(request),
referenceId,
);
response.setHeader('content-type', download.contentType);
return response.download(download.path, download.fileName);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PlatformControlModule } from '../platform-control/platform-control.module';
import { PrismaModule } from '../prisma/prisma.module';
import { CurriculumController } from './curriculum.controller';
import { CurriculumService } from './curriculum.service';
@Module({
imports: [AuthModule, PrismaModule, PlatformControlModule],
controllers: [CurriculumController],
providers: [CurriculumService],
})
export class CurriculumModule {}
+929
View File
@@ -0,0 +1,929 @@
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { join } from 'node:path';
import { AuthService } from '../auth/auth.service';
import { PlatformControlService } from '../platform-control/platform-control.service';
import { PrismaService } from '../prisma/prisma.service';
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
@Injectable()
export class CurriculumService {
constructor(
private readonly authService: AuthService,
private readonly platformControlService: PlatformControlService,
private readonly prisma: PrismaService,
) {}
async listCourses(accessToken?: string) {
const authUser = accessToken
? await this.authService.me(accessToken).then((response) => response.user).catch(() => null)
: null;
const lmsUser = authUser?.email
? await this.prisma.user.findUnique({ where: { email: authUser.email } })
: null;
if (!lmsUser) {
const guideCourse = await this.findPublicGuideCourse();
return {
courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [],
};
}
const enrollments = await this.prisma.studentEnrollment.findMany({
where: {
userId: lmsUser.id,
status: 'ACTIVE',
course: { isActive: true },
},
include: {
course: {
include: {
teacher: {
select: {
id: true,
email: true,
name: true,
primaryOrganizationName: true,
},
},
studentProgress: {
where: { userId: lmsUser.id },
take: 1,
},
},
},
},
orderBy: { updatedAt: 'desc' },
});
if (enrollments.length) {
return {
courses: enrollments.map((enrollment) => this.serializeCourseListItem(enrollment.course)),
};
}
const guideCourse = await this.findPublicGuideCourse(lmsUser.id);
return {
courses: guideCourse ? [this.serializeCourseListItem(guideCourse, true)] : [],
};
}
async getActiveCurriculum(accessToken?: string, courseId?: string | null) {
const authUser = accessToken
? await this.authService.me(accessToken).then((response) => response.user).catch(() => null)
: null;
const lmsUser = authUser?.email
? await this.prisma.user.findUnique({ where: { email: authUser.email } })
: null;
const requestedCourseId = courseId?.trim();
const enrolledCourse = !requestedCourseId && authUser?.email
? await this.prisma.course.findFirst({
where: {
isActive: true,
enrollments: {
some: {
user: { email: authUser.email },
status: 'ACTIVE',
},
},
},
select: { id: true },
orderBy: { updatedAt: 'desc' },
})
: null;
const course = await this.prisma.course.findFirst({
where: {
...(requestedCourseId
? {
id: requestedCourseId,
...(authUser?.email
? {
OR: [
{ slug: PUBLIC_GUIDE_COURSE_SLUG },
{
enrollments: {
some: {
user: { email: authUser.email },
status: 'ACTIVE',
},
},
},
],
}
: { slug: PUBLIC_GUIDE_COURSE_SLUG }),
}
: enrolledCourse
? { id: enrolledCourse.id }
: { slug: PUBLIC_GUIDE_COURSE_SLUG }),
isActive: true,
},
include: {
teacher: {
select: {
id: true,
email: true,
name: true,
primaryOrganizationName: true,
},
},
weeks: {
orderBy: { week: 'asc' },
include: {
topics: {
orderBy: { sortOrder: 'asc' },
},
practices: {
orderBy: { sortOrder: 'asc' },
},
references: {
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
},
modules: {
orderBy: { sortOrder: 'asc' },
},
},
},
platforms: {
orderBy: { sortOrder: 'asc' },
include: {
platform: true,
},
},
environmentProfiles: {
where: { enabled: true },
},
studentProgress: {
where: {
user: {
email: authUser?.email ?? '__NO_AUTH_USER__',
},
},
take: 1,
},
},
});
if (!course) {
return emptyCurriculumResponse();
}
const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({
where: {
courseId: course.id,
enabled: true,
},
include: {
platform: true,
},
});
const environmentProfileByWeekType = new Map(course.environmentProfiles.map((profile) => [`${profile.week}:${profile.profileType.toUpperCase()}`, profile]));
const visualizationProfilesByWeek = new Map<number, Array<{
id: string;
week: number;
platformKey: string;
profileType: string;
name: string;
config: Prisma.JsonValue;
}>>();
visualizationSelections.forEach((selection) => {
const profileType = selection.platform.type.toUpperCase();
const environmentProfile = environmentProfileByWeekType.get(`${selection.week}:${profileType}`);
const items = visualizationProfilesByWeek.get(selection.week) ?? [];
items.push({
id: environmentProfile?.id ?? selection.id,
week: selection.week,
platformKey: environmentProfile?.platformKey ?? 'futuredrive-local-pc',
profileType: selection.platform.type,
name: environmentProfile?.name ?? selection.platform.displayName,
config: environmentProfile?.config ?? selection.platform.defaultConfig,
});
visualizationProfilesByWeek.set(selection.week, items);
});
const progress = course.studentProgress[0];
const enrollment = lmsUser
? await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId: course.id,
status: 'ACTIVE',
},
})
: null;
const organizationName = enrollment?.organizationName ?? course.organizationName;
const organizationId = enrollment?.organizationId ?? undefined;
const studentGroup = lmsUser
? await this.prisma.studentGroup.findFirst({
where: {
organizationName,
members: {
some: {
userId: lmsUser.id,
user: {
enrollments: {
some: {
courseId: course.id,
status: 'ACTIVE',
},
},
},
},
},
},
select: {
id: true,
name: true,
},
})
: null;
const assignedInstructorPlatformIds = await this.findStudentAssignedInstructorPlatformIds(
organizationId,
organizationName,
authUser?.email,
authUser?.id,
);
const instructorPlatforms = await this.findStudentPlatformOptions(
organizationId,
organizationName,
assignedInstructorPlatformIds,
);
const environmentProfilesByPlatformKey = new Map<string, Array<{
id: string;
week: number;
platformKey: string;
profileType: string;
name: string;
config: Prisma.JsonValue;
}>>();
Array.from(visualizationProfilesByWeek.values()).flat().forEach((profile) => {
const items = environmentProfilesByPlatformKey.get(profile.platformKey) ?? [];
items.push({
id: profile.id,
week: profile.week,
platformKey: profile.platformKey,
profileType: profile.profileType,
name: profile.name,
config: profile.config,
});
environmentProfilesByPlatformKey.set(profile.platformKey, items);
});
const fixedPlatforms = course.platforms
.filter((coursePlatform) => {
return this.isFixedPlatform(coursePlatform.platform);
})
.map((coursePlatform) => {
const isLocal = this.isFixedPlatform(coursePlatform.platform);
return {
id: coursePlatform.platform.id,
platformKey: coursePlatform.platform.platformKey,
title: coursePlatform.platform.displayName,
description: coursePlatform.platform.description,
organizationName: coursePlatform.platform.organizationName,
iconKey: coursePlatform.platform.iconKey,
sourceSettings: coursePlatform.platform.defaultStudioSettings,
environmentProfiles: environmentProfilesByPlatformKey.get(coursePlatform.platform.platformKey) ?? [],
status: isLocal ? '사용 가능' : coursePlatform.status,
action: isLocal ? '연결' : coursePlatform.action,
state: coursePlatform.state,
local: isLocal,
};
});
const controlStates = await this.platformControlService.getControlStates(
{ organizationId, organizationName },
lmsUser?.id ?? null,
[
...fixedPlatforms.filter((platform) => !platform.local).map((platform) =>
this.platformControlService.makePlatformResource(platform.id, null),
),
...instructorPlatforms.map((platform) =>
this.platformControlService.makePlatformResource(null, platform.id),
),
],
);
const platforms = [
...fixedPlatforms.map((platform) => {
const { local: _local, ...payload } = platform;
return platform.local
? payload
: this.withControlState(
payload,
controlStates.get(this.platformControlService.resourceKeyForIds(platform.id, null)),
);
}),
...instructorPlatforms.map((platform) => this.withControlState(
platform,
controlStates.get(this.platformControlService.resourceKeyForIds(null, platform.id)),
)),
];
const groupSetting = studentGroup
? await this.prisma.instructorCurriculumSetting.findFirst({
where: {
organizationName,
courseId: course.id,
groupId: studentGroup.id,
},
select: {
activeWeek: true,
activeLearningStep: true,
},
})
: null;
const defaultSetting = await this.prisma.instructorCurriculumSetting.findFirst({
where: {
organizationName,
courseId: course.id,
groupId: null,
},
select: {
activeWeek: true,
activeLearningStep: true,
},
});
const setting = groupSetting ?? defaultSetting;
const overrides = await this.prisma.instructorCurriculumWeekOverride.findMany({
where: {
organizationName,
courseId: course.id,
},
});
const overrideByWeek = new Map(overrides.map((override) => [override.week, override]));
const weekProgressItems = lmsUser
? await this.prisma.courseStudentWeekProgress.findMany({
where: {
courseId: course.id,
userId: lmsUser.id,
},
})
: [];
const weekProgressByWeek = new Map(weekProgressItems.map((item) => [item.week, item]));
const baseWeekNumbers = new Set(course.weeks.map((week) => week.week));
const baseWeeks = course.weeks.map((week) => {
const override = overrideByWeek.get(week.week);
const weekProgress = weekProgressByWeek.get(week.week);
const disabledModuleIdSet = new Set(this.stringArray(override?.disabledModuleIds) ?? []);
const learningSteps = this.resolveLearningSteps(
override?.learningSteps,
this.stringArray(override?.topics) ?? week.topics.map((topic) => topic.title),
this.stringArray(override?.topicDetails) ?? week.topics.map((topic) => topic.detail),
this.stringArray(override?.topicVideoUrls) ?? week.topics.map(() => ''),
);
const topics = learningSteps.map((step) => step.title);
const topicDetails = learningSteps.map((step) => step.description);
const topicVideoUrls = learningSteps.map((step) => step.videoUrl);
const practiceContents = this.practiceContentsArray(override?.practiceContents)
?? (week.practices.length
? week.practices.map((practice) => ({
title: practice.title,
description: practice.description,
}))
: undefined);
const progressState = this.resolveWeekProgressState(week.week, week.state, topics, setting);
return {
week: week.week,
title: override?.title ?? week.title,
tags: this.resolveWeekTags(this.resolveBaseWeekTags(week.tags, override?.tagLabel), progressState.state),
progress: progressState.progress,
state: progressState.state,
topics,
duration: override?.duration ?? week.duration,
github: week.github,
detail: {
overview: override?.overview ?? week.overview,
learningSteps,
topicDetails,
topicVideoUrls,
practiceContents,
references: week.references.filter((reference) => reference.kind === 'REFERENCE').map((reference) => this.serializeWeekReference(reference, week.week)),
exampleCodes: week.references.filter((reference) => reference.kind === 'EXAMPLE_CODE').map((reference) => this.serializeWeekReference(reference, week.week)),
deliverable: override?.deliverable ?? week.deliverable,
requiresSourceSubmission: override?.requiresSourceSubmission ?? week.requiresSourceSubmission,
sourceSubmitted: Boolean(weekProgress?.submittedAt),
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
},
modules: week.modules
.filter((module) => !disabledModuleIdSet.has(module.id))
.map((module) => {
const moduleWithArtifacts = module as typeof module & {
sourceType?: string | null;
prebuiltArtifacts?: unknown;
};
return {
id: module.id,
sortOrder: module.sortOrder,
category: module.category,
name: module.name,
folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sourceType: moduleWithArtifacts.sourceType ?? 'GIT',
prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [],
dockerImage: module.dockerImage,
};
}),
environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [],
};
});
const addedWeeks = overrides
.filter((override) => !baseWeekNumbers.has(override.week))
.map((override) => {
const learningSteps = this.resolveLearningSteps(
override.learningSteps,
this.stringArray(override.topics) ?? ['학습단계 1'],
this.stringArray(override.topicDetails) ?? [''],
this.stringArray(override.topicVideoUrls) ?? [''],
);
const topics = learningSteps.map((step) => step.title);
const progressState = this.resolveWeekProgressState(override.week, 'ready', topics, setting);
const weekProgress = weekProgressByWeek.get(override.week);
return {
week: override.week,
title: override.title ?? `${override.week}주차 커리큘럼`,
tags: this.resolveWeekTags([override.tagLabel ?? '추가', '대기'], progressState.state),
progress: progressState.progress,
state: progressState.state,
topics,
duration: override.duration ?? '-',
github: '-',
detail: {
overview: override.overview ?? '',
learningSteps,
topicDetails: learningSteps.map((step) => step.description),
topicVideoUrls: learningSteps.map((step) => step.videoUrl),
practiceContents: this.practiceContentsArray(override.practiceContents) ?? undefined,
references: [],
deliverable: override.deliverable ?? '',
requiresSourceSubmission: override.requiresSourceSubmission ?? true,
sourceSubmitted: Boolean(weekProgress?.submittedAt),
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
},
environmentProfiles: visualizationProfilesByWeek.get(override.week) ?? [],
};
});
return {
course: {
id: course.id,
slug: course.slug,
title: course.title,
subtitle: course.subtitle,
description: course.description,
organizationName: course.organizationName,
teacher: course.teacher,
},
weeks: [...baseWeeks, ...addedWeeks].sort((left, right) => left.week - right.week),
activeWeek: setting?.activeWeek ?? course.weeks.find((week) => week.state === 'current')?.week ?? course.weeks[0]?.week ?? null,
activeLearningStep: setting?.activeLearningStep ?? null,
learningStats: this.serializeLearningStats(progress, course.weeks.length),
platforms,
};
}
async getReferenceDownload(accessToken: string | undefined, referenceId: string) {
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
const reference = await this.prisma.courseWeekReference.findUnique({
where: { id: referenceId },
include: {
week: {
include: {
course: {
include: {
enrollments: {
where: {
status: 'ACTIVE',
user: { email: authUser.email },
},
take: 1,
},
},
},
},
},
},
});
if (!reference || !reference.week.course.enrollments.length) {
throw new NotFoundException('Reference not found');
}
return {
path: join(COURSE_REFERENCE_UPLOAD_ROOT, reference.storedFileName),
fileName: reference.originalFileName,
contentType: reference.contentType ?? 'application/octet-stream',
};
}
private withControlState(
platform: {
id: string;
platformKey: string;
title: string;
description: string;
organizationName: string;
iconKey: string;
status: string;
action: string;
state: string;
},
controlState:
| {
status: string;
action: string;
state: string;
session: unknown;
queue: unknown;
warning: boolean;
}
| undefined,
) {
if (!controlState) return platform;
return {
...platform,
status: controlState.status === 'AVAILABLE' ? platform.status : this.toControlStatusLabel(controlState.status),
action: controlState.status === 'AVAILABLE' ? platform.action : controlState.action,
state: controlState.state,
control: controlState,
};
}
private async findPublicGuideCourse(userId?: string) {
return this.prisma.course.findFirst({
where: {
slug: PUBLIC_GUIDE_COURSE_SLUG,
isActive: true,
},
include: {
teacher: {
select: {
id: true,
email: true,
name: true,
primaryOrganizationName: true,
},
},
studentProgress: {
where: { userId: userId ?? '00000000-0000-0000-0000-000000000000' },
take: 1,
},
},
});
}
private serializeCourseListItem(
course: {
id: string;
slug: string;
title: string;
subtitle: string | null;
description: string | null;
organizationName: string;
teacher: {
id: string;
email: string;
name: string | null;
primaryOrganizationName: string | null;
};
studentProgress: Array<{
completedWeeks: number;
totalWeeks: number;
}>;
},
publicGuide = false,
) {
const progress = course.studentProgress[0];
return {
id: course.id,
slug: course.slug,
title: course.title,
subtitle: course.subtitle,
description: course.description,
organizationName: course.organizationName,
teacher: course.teacher,
progress: progress && progress.totalWeeks > 0
? Math.round((progress.completedWeeks / progress.totalWeeks) * 100)
: 0,
publicGuide,
};
}
private serializeLearningStats(
progress: {
completedWeeks: number;
totalWeeks: number;
learningHours: number;
commitCount: number;
averageScore: number;
} | null | undefined,
fallbackTotalWeeks: number,
) {
const completedWeeks = progress?.completedWeeks ?? 0;
const totalWeeks = progress?.totalWeeks ?? fallbackTotalWeeks;
const learningHours = progress?.learningHours ?? 0;
const commitCount = progress?.commitCount ?? 0;
const averageScore = progress?.averageScore ?? 0;
return [
{
key: 'completedWeeks',
value: `${completedWeeks}/${totalWeeks}`,
label: '완료한 주차',
iconKey: 'check',
},
{
key: 'learningHours',
value: this.formatLearningHours(learningHours),
label: '학습 시간',
iconKey: 'clock',
},
{
key: 'commitCount',
value: String(commitCount),
label: '커밋 수',
iconKey: 'trending',
},
{
key: 'averageScore',
value: String(averageScore),
label: '평균 점수',
iconKey: 'gauge',
},
];
}
private toControlStatusLabel(status: string) {
if (status === 'ACTIVE') return '제어 중';
if (status === 'QUEUED') return '대기 예약됨';
if (status === 'IN_USE') return '다른 수강생 사용 중';
return '사용 가능';
}
private async findStudentAssignedInstructorPlatformIds(
organizationId: string | undefined,
organizationName: string,
email: string | undefined,
authUserId: string | undefined,
) {
const userConditions = [
email ? { email } : undefined,
authUserId ? { authUserId } : undefined,
].filter(Boolean) as Prisma.UserWhereInput[];
if (!userConditions.length) return [];
const groups = await this.prisma.studentGroup.findMany({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
organizationId,
organizationName,
),
instructorPlatformId: { not: null },
members: {
some: {
user: {
OR: userConditions,
},
},
},
},
select: {
instructorPlatformId: true,
},
});
const assignedInstructorPlatformIds = [
...new Set(
groups
.map((group) => group.instructorPlatformId)
.filter((platformId): platformId is string => Boolean(platformId)),
),
];
return assignedInstructorPlatformIds;
}
private async findStudentPlatformOptions(
organizationId: string | undefined,
organizationName: string,
assignedInstructorPlatformIds: string[],
) {
if (!assignedInstructorPlatformIds.length) return [];
const platforms = await this.prisma.instructorPlatform.findMany({
where: {
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
organizationId,
organizationName,
),
id: { in: assignedInstructorPlatformIds },
platform: {
is: {
isActive: true,
NOT: [
{ platformKey: { contains: 'local', mode: 'insensitive' } },
{ displayName: { contains: '로컬', mode: 'insensitive' } },
],
},
},
},
orderBy: [{ displayName: 'asc' }],
include: { platform: true },
});
return platforms.map((platform) => {
const blocked = platform.emergencyStop || platform.errorCount > 0;
return {
id: platform.id,
platformKey: platform.platform.platformKey,
title: platform.displayName,
description: platform.platform.description,
organizationName: platform.organizationName,
iconKey: platform.platform.iconKey,
sourceSettings: platform.studioSettingsOverride ?? platform.platform.defaultStudioSettings,
status: blocked
? platform.emergencyStop
? '비상 정지'
: '오류 발생'
: '상태 확인 중',
action: blocked ? '연결 불가' : '제어권 요청',
state: blocked ? 'locked' : 'ready',
};
});
}
private organizationWhere<T>(
organizationId: string | undefined,
organizationName: string | undefined,
): T {
return {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean),
} as T;
}
private isFixedPlatform(platform: { platformKey: string; displayName: string }) {
const platformKey = platform.platformKey.toLowerCase();
const displayName = platform.displayName.toLowerCase();
return (
platformKey.includes('local') ||
displayName.includes('로컬')
);
}
private stringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
return value.filter((item): item is string => typeof item === 'string');
}
private resolveLearningSteps(
value: unknown,
topics: string[],
topicDetails: string[] = [],
topicVideoUrls: string[] = [],
): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> {
return this.normalizeLearningSteps(value) ?? this.normalizeLearningSteps(undefined, topics, topicDetails, topicVideoUrls) ?? [];
}
private normalizeLearningSteps(
value?: unknown,
topics: string[] = [],
topicDetails: string[] = [],
topicVideoUrls: string[] = [],
): Array<{ title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] }> | null {
if (Array.isArray(value)) {
const steps = value
.map((item) => {
if (!item || typeof item !== 'object') return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === 'string' ? record.title.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
const videoUrl = typeof record.videoUrl === 'string' ? record.videoUrl.trim() : '';
const videoEnabled = typeof record.videoEnabled === 'boolean' ? record.videoEnabled : Boolean(videoUrl);
const concepts = Array.isArray(record.concepts)
? record.concepts.filter((concept): concept is string => typeof concept === 'string').map((concept) => concept.trim()).filter(Boolean)
: [];
return title || description || videoUrl || concepts.length
? { title: title || '학습단계', description, videoEnabled, videoUrl: videoEnabled ? videoUrl : '', concepts }
: null;
})
.filter((item): item is { title: string; description: string; videoEnabled: boolean; videoUrl: string; concepts: string[] } => Boolean(item));
return steps.length ? steps : null;
}
const titles = topics.map((topic) => topic.trim()).filter(Boolean);
if (!titles.length) return null;
return titles.map((title, index) => {
const videoUrl = topicVideoUrls[index]?.trim() ?? '';
return {
title,
description: topicDetails[index]?.trim() ?? '',
videoEnabled: Boolean(videoUrl),
videoUrl,
concepts: [],
};
});
}
private practiceContentsArray(value: unknown): Array<{ title: string; description: string }> | null {
if (!Array.isArray(value)) return null;
return value
.map((item) => {
if (!item || typeof item !== 'object') return null;
const record = item as Record<string, unknown>;
const title = typeof record.title === 'string' ? record.title.trim() : '';
const description = typeof record.description === 'string' ? record.description.trim() : '';
return title || description ? { title, description } : null;
})
.filter((item): item is { title: string; description: string } => Boolean(item));
}
private serializeWeekReference(
reference: {
id: string;
kind: string;
title: string;
originalFileName: string;
contentType: string | null;
fileSizeBytes: number;
createdAt: Date;
},
week: number,
) {
return {
id: reference.id,
kind: reference.kind,
title: reference.title,
description: reference.originalFileName,
fileName: reference.originalFileName,
contentType: reference.contentType,
fileSizeBytes: reference.fileSizeBytes,
week,
weekNumber: week,
downloadUrl: `/curriculum/references/${reference.id}/download`,
createdAt: reference.createdAt.toISOString(),
};
}
private resolveWeekProgressState(
week: number,
baseState: string,
topics: string[],
setting: { activeWeek: number; activeLearningStep?: string | null } | null | undefined,
) {
if (!setting) return { state: baseState, progress: baseState === 'done' ? 100 : 0 };
if (week < setting.activeWeek) return { state: 'done', progress: 100 };
if (week > setting.activeWeek) return { state: 'locked', progress: 0 };
const progress = this.resolveLearningStepProgress(topics, setting.activeLearningStep);
return {
state: progress >= 100 ? 'done' : 'current',
progress,
};
}
private resolveLearningStepProgress(topics: string[], activeLearningStep?: string | null) {
if (!topics.length) return activeLearningStep === null ? 100 : 0;
if (activeLearningStep === null) return 100;
const activeIndex = topics.findIndex((topic) => topic === activeLearningStep);
if (activeIndex < 0) return 0;
return Math.min(99, Math.round((activeIndex / topics.length) * 100));
}
private resolveWeekTags(tags: string[], state: string) {
const nextTags = tags.length ? [...tags] : ['커리큘럼'];
nextTags[1] = state === 'done' ? '완료' : state === 'current' ? '진행중' : state === 'locked' ? '잠김' : '대기';
return nextTags;
}
private resolveBaseWeekTags(tags: string[], tagLabel?: string | null) {
const nextTags = tags.length ? [...tags] : ['커리큘럼'];
const label = tagLabel?.trim();
if (label) nextTags[0] = label;
return nextTags;
}
private formatLearningHours(learningMinutes: number) {
const totalMinutes = Math.max(0, Math.floor(learningMinutes));
if (totalMinutes < 60) return `${totalMinutes}m`;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
}
function emptyCurriculumResponse() {
return {
course: null,
weeks: [],
learningStats: [],
platforms: [],
};
}
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class AssignInstructorPlatformDto {
@IsString()
groupId: string;
}
@@ -0,0 +1,34 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateInstructorPlatformDto {
@IsString()
platformId: string;
@IsOptional()
@IsString()
displayName?: string;
@IsOptional()
@IsString()
agentNodeId?: string;
@IsOptional()
@IsString()
agentHostname?: string;
@IsOptional()
@IsString()
agentOwnerEmail?: string;
@IsOptional()
@IsString()
sshUser?: string;
@IsOptional()
@IsString()
sshPort?: string;
@IsOptional()
@IsString()
workspaceRoot?: string;
}
@@ -0,0 +1,18 @@
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateInstructorStudentDto {
@IsOptional()
@IsString()
courseId?: string | null;
@IsEmail()
email: string;
@IsString()
@MaxLength(80)
name: string;
@IsOptional()
@IsString()
groupId?: string | null;
}
@@ -0,0 +1,24 @@
import { IsArray, IsOptional, IsString, MaxLength } from 'class-validator';
export class SaveInstructorGroupDto {
@IsOptional()
@IsString()
courseId?: string | null;
@IsString()
@MaxLength(80)
name: string;
@IsOptional()
@IsString()
platformName?: string | null;
@IsOptional()
@IsString()
instructorPlatformId?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
memberIds?: string[];
}
@@ -0,0 +1,14 @@
import { IsInt, IsString, Max, Min } from 'class-validator';
export class SaveSubmissionScoreDto {
@IsString()
studentId!: string;
@IsString()
weekId!: string;
@IsInt()
@Min(1)
@Max(100)
score!: number;
}
@@ -0,0 +1,94 @@
import { IsArray, IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpdateInstructorCurriculumDto {
@IsOptional()
@IsString()
courseId?: string | null;
@IsOptional()
@IsString()
groupId?: string | null;
@IsInt()
@Min(1)
@Max(52)
activeWeek: number;
@IsOptional()
@IsString()
activeLearningStep?: string | null;
@IsOptional()
@IsBoolean()
applyToStudents?: boolean;
@IsOptional()
@IsBoolean()
completed?: boolean;
@IsOptional()
@IsString()
tagLabel?: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
duration?: string;
@IsOptional()
@IsString()
overview?: string;
@IsOptional()
@IsString()
deliverable?: string;
@IsOptional()
@IsBoolean()
requiresSourceSubmission?: boolean;
@IsOptional()
@IsArray()
learningSteps?: Array<{
title?: string;
description?: string;
videoEnabled?: boolean;
videoUrl?: string;
concepts?: string[];
}>;
@IsOptional()
@IsArray()
@IsString({ each: true })
topics?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
topicDetails?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
topicVideoUrls?: string[];
@IsOptional()
@IsArray()
practiceContents?: Array<{
title?: string;
description?: string;
}>;
@IsOptional()
@IsArray()
@IsString({ each: true })
disabledModuleIds?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
visualizationPlatformKeys?: string[];
}
@@ -0,0 +1,56 @@
import { IsArray, IsInt, IsNumber, IsObject, IsOptional, IsString, Max, Min } from 'class-validator';
export class UpdateInstructorPlatformDto {
@IsString()
displayName: string;
@IsOptional()
@IsString()
vehicleType?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(10)
wheelbaseMeters?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
maxSpeedMetersPerSecond?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(90)
maxSteeringAngleDegrees?: number;
@IsOptional()
@IsArray()
sensors?: Array<{ title?: string; description?: string; enabled?: boolean }>;
@IsOptional()
@IsArray()
drivers?: Array<{ title?: string; description?: string }>;
@IsOptional()
@IsObject()
algorithms?: {
slam?: string;
planning?: string;
control?: string;
};
@IsOptional()
@IsInt()
@Min(1)
@Max(240)
controlDurationMinutes?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(60)
controlWarningMinutes?: number;
}
+215
View File
@@ -0,0 +1,215 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Put, Query, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Request } from 'express';
import { InstructorService } from './instructor.service';
import { AssignInstructorPlatformDto } from './dto/assign-instructor-platform.dto';
import { CreateInstructorPlatformDto } from './dto/create-instructor-platform.dto';
import { CreateInstructorStudentDto } from './dto/create-instructor-student.dto';
import { SaveInstructorGroupDto } from './dto/save-instructor-group.dto';
import { SaveSubmissionScoreDto } from './dto/save-submission-score.dto';
import { UpdateInstructorCurriculumDto } from './dto/update-instructor-curriculum.dto';
import { UpdateInstructorPlatformDto } from './dto/update-instructor-platform.dto';
@Controller('instructor')
export class InstructorController {
constructor(private readonly instructorService: InstructorService) {}
@Get('students')
listStudents(@Req() request: Request, @Query('courseId') courseId?: string) {
return this.instructorService.listStudents(this.getBearerToken(request), courseId);
}
@Get('courses')
listCourses(@Req() request: Request) {
return this.instructorService.listCourses(this.getBearerToken(request));
}
@Get('submissions')
listSubmissions(@Req() request: Request) {
return this.instructorService.listSubmissions(this.getBearerToken(request));
}
@Put('submissions/score')
saveSubmissionScore(@Req() request: Request, @Body() body: SaveSubmissionScoreDto) {
return this.instructorService.saveSubmissionScore(this.getBearerToken(request), body);
}
@Post('students')
createStudent(
@Req() request: Request,
@Body() body: CreateInstructorStudentDto,
) {
return this.instructorService.createStudent(
this.getBearerToken(request),
body,
);
}
@Delete('students/:studentId')
removeStudent(@Req() request: Request, @Param('studentId') studentId: string) {
return this.instructorService.removeStudent(
this.getBearerToken(request),
studentId,
);
}
@Get('groups')
listGroups(@Req() request: Request, @Query('courseId') courseId?: string) {
return this.instructorService.listGroups(this.getBearerToken(request), courseId);
}
@Get('platforms')
listPlatforms(@Req() request: Request) {
return this.instructorService.listPlatforms(this.getBearerToken(request));
}
@Get('platforms/agents')
listPlatformAgents(@Req() request: Request) {
return this.instructorService.listPlatformAgents(this.getBearerToken(request));
}
@Get('curriculum')
getCurriculum(@Req() request: Request, @Query('groupId') groupId?: string, @Query('courseId') courseId?: string) {
return this.instructorService.getCurriculum(this.getBearerToken(request), groupId, courseId);
}
@Put('curriculum')
updateCurriculum(
@Req() request: Request,
@Body() body: UpdateInstructorCurriculumDto,
) {
return this.instructorService.updateCurriculum(
this.getBearerToken(request),
body,
);
}
@Delete('curriculum/weeks/:week')
deleteCurriculumWeek(
@Req() request: Request,
@Param('week', ParseIntPipe) week: number,
) {
return this.instructorService.deleteCurriculumWeek(
this.getBearerToken(request),
week,
);
}
@Post('curriculum/weeks/:week/references')
@UseInterceptors(FileInterceptor('file'))
uploadCurriculumReference(
@Req() request: Request,
@Param('week', ParseIntPipe) week: number,
@UploadedFile() file: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer },
@Body('title') title?: string,
@Body('courseId') courseId?: string,
) {
return this.instructorService.uploadCurriculumReference(
this.getBearerToken(request),
{ courseId, week, title, kind: 'REFERENCE' },
file,
);
}
@Post('curriculum/weeks/:week/example-codes')
@UseInterceptors(FileInterceptor('file'))
uploadCurriculumExampleCode(
@Req() request: Request,
@Param('week', ParseIntPipe) week: number,
@UploadedFile() file: { originalname: string; mimetype?: string; size?: number; buffer?: Buffer },
@Body('title') title?: string,
@Body('courseId') courseId?: string,
) {
return this.instructorService.uploadCurriculumReference(
this.getBearerToken(request),
{ courseId, week, title, kind: 'EXAMPLE_CODE' },
file,
);
}
@Delete('curriculum/references/:referenceId')
deleteCurriculumReference(
@Req() request: Request,
@Param('referenceId') referenceId: string,
@Query('courseId') courseId?: string,
) {
return this.instructorService.deleteCurriculumReference(
this.getBearerToken(request),
referenceId,
courseId,
);
}
@Post('platforms')
createPlatform(
@Req() request: Request,
@Body() body: CreateInstructorPlatformDto,
) {
return this.instructorService.createPlatform(
this.getBearerToken(request),
body,
);
}
@Put('platforms/:platformId')
updatePlatform(
@Req() request: Request,
@Param('platformId') platformId: string,
@Body() body: UpdateInstructorPlatformDto,
) {
return this.instructorService.updatePlatform(this.getBearerToken(request), platformId, body);
}
@Post('platforms/:platformId/assignment')
assignPlatform(
@Req() request: Request,
@Param('platformId') platformId: string,
@Body() body: AssignInstructorPlatformDto,
@Query('force') force?: string,
) {
return this.instructorService.assignPlatformToGroup(
this.getBearerToken(request),
platformId,
body.groupId,
force === 'true',
);
}
@Delete('platforms/:platformId')
deletePlatform(
@Req() request: Request,
@Param('platformId') platformId: string,
@Query('force') force?: string,
) {
return this.instructorService.deletePlatform(this.getBearerToken(request), platformId, force === 'true');
}
@Post('groups')
createGroup(@Req() request: Request, @Body() body: SaveInstructorGroupDto) {
return this.instructorService.createGroup(this.getBearerToken(request), body);
}
@Put('groups/:groupId')
updateGroup(
@Req() request: Request,
@Param('groupId') groupId: string,
@Body() body: SaveInstructorGroupDto,
) {
return this.instructorService.updateGroup(
this.getBearerToken(request),
groupId,
body,
);
}
@Delete('groups/:groupId')
deleteGroup(@Req() request: Request, @Param('groupId') groupId: string, @Query('courseId') courseId?: string) {
return this.instructorService.deleteGroup(this.getBearerToken(request), groupId, courseId);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ChatModule } from '../chat/chat.module';
import { PrismaModule } from '../prisma/prisma.module';
import { PlatformGatewayClient } from '../platform-control/platform-gateway.client';
import { InstructorController } from './instructor.controller';
import { InstructorService } from './instructor.service';
@Module({
imports: [AuthModule, ChatModule, PrismaModule],
controllers: [InstructorController],
providers: [InstructorService, PlatformGatewayClient],
})
export class InstructorModule {}
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 4400);
const corsOrigin = configService.get<string>(
'CORS_ORIGIN',
'http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176',
);
app.setGlobalPrefix('api', {
exclude: [
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/db', method: RequestMethod.GET },
],
});
app.enableCors({
origin:
corsOrigin === '*'
? true
: corsOrigin.split(',').map((origin) => origin.trim()),
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(port, '0.0.0.0');
}
void bootstrap();
@@ -0,0 +1,91 @@
import { Body, Controller, Delete, Param, Post, Req } from '@nestjs/common';
import { Request } from 'express';
import { PlatformControlService } from './platform-control.service';
@Controller('platform-control')
export class PlatformControlController {
constructor(private readonly platformControlService: PlatformControlService) {}
@Post('platforms/statuses')
getPlatformStatuses(
@Req() request: Request,
@Body() body: { platformIds?: string[] },
) {
return this.platformControlService.getPlatformStatuses(
this.getBearerToken(request),
body.platformIds ?? [],
);
}
@Post('platforms/:platformId/request')
requestControl(@Req() request: Request, @Param('platformId') platformId: string) {
return this.platformControlService.requestControl(
this.getBearerToken(request),
platformId,
);
}
@Delete('platforms/:platformId/queue')
cancelQueue(@Req() request: Request, @Param('platformId') platformId: string) {
return this.platformControlService.cancelQueue(
this.getBearerToken(request),
platformId,
);
}
@Post('platforms/:platformId/release')
releaseControl(@Req() request: Request, @Param('platformId') platformId: string) {
return this.platformControlService.releaseControl(
this.getBearerToken(request),
platformId,
);
}
@Post('platforms/:platformId/connect')
connectPlatform(@Req() request: Request, @Param('platformId') platformId: string) {
return this.platformControlService.connectPlatform(
this.getBearerToken(request),
platformId,
);
}
@Post('platforms/:platformId/modules/list')
listRemoteModules(@Req() request: Request, @Param('platformId') platformId: string) {
return this.platformControlService.listRemoteModules(
this.getBearerToken(request),
platformId,
);
}
@Post('platforms/:platformId/modules/start')
startRemoteModule(
@Req() request: Request,
@Param('platformId') platformId: string,
@Body() body: { folderName?: string; rosDomainId?: string },
) {
return this.platformControlService.startRemoteModule(
this.getBearerToken(request),
platformId,
body,
);
}
@Post('platforms/:platformId/modules/stop')
stopRemoteModule(
@Req() request: Request,
@Param('platformId') platformId: string,
@Body() body: { containerKey?: string },
) {
return this.platformControlService.stopRemoteModule(
this.getBearerToken(request),
platformId,
body,
);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ChatModule } from '../chat/chat.module';
import { PrismaModule } from '../prisma/prisma.module';
import { PlatformControlController } from './platform-control.controller';
import { PlatformGatewayClient } from './platform-gateway.client';
import { PlatformControlService } from './platform-control.service';
@Module({
imports: [AuthModule, PrismaModule, ChatModule],
controllers: [PlatformControlController],
providers: [PlatformControlService, PlatformGatewayClient],
exports: [PlatformControlService, PlatformGatewayClient],
})
export class PlatformControlModule {}
@@ -0,0 +1,921 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthService, type AuthResponseUser } from '../auth/auth.service';
import { ChatGateway } from '../chat/chat.gateway';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformGatewayClient } from './platform-gateway.client';
type PlatformResource = {
platformId: string | null;
instructorPlatformId: string | null;
title: string;
durationMinutes: number;
warningMinutes: number;
};
type PlatformControlState = {
status: 'AVAILABLE' | 'ACTIVE' | 'QUEUED' | 'IN_USE';
action: string;
state: 'ready' | 'reserved' | 'locked';
session: ReturnType<PlatformControlService['serializeSession']> | null;
queue: {
id: string;
position?: number;
requestedAt: string;
} | null;
warning: boolean;
};
@Injectable()
export class PlatformControlService implements OnModuleInit, OnModuleDestroy {
private timer: NodeJS.Timeout | null = null;
constructor(
private readonly authService: AuthService,
private readonly chatGateway: ChatGateway,
private readonly platformGatewayClient: PlatformGatewayClient,
private readonly prisma: PrismaService,
) {}
onModuleInit() {
this.timer = setInterval(() => {
void this.processDueControlEvents();
}, 30_000);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async requestControl(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);
return this.prisma.$transaction(async (tx) => {
const active = await tx.platformControlSession.findFirst({
where: {
...this.resourceWhere(resource),
status: 'ACTIVE',
},
orderBy: { startedAt: 'asc' },
});
if (!active) {
const session = await this.createSession(tx, resource, context, user.id, group?.id ?? null);
return {
result: 'GRANTED',
platformId: platformOptionId,
session: this.serializeSession(session),
queue: null,
};
}
if (active.holderUserId === user.id) {
return {
result: 'ALREADY_GRANTED',
platformId: platformOptionId,
session: this.serializeSession(active),
queue: null,
};
}
const existingQueue = await tx.platformControlQueueEntry.findFirst({
where: {
...this.resourceWhere(resource),
userId: user.id,
status: 'WAITING',
},
orderBy: { requestedAt: 'asc' },
});
const queue = existingQueue ?? await tx.platformControlQueueEntry.create({
data: {
organizationId: context.organizationId ?? null,
organizationName: context.organizationName,
platformId: resource.platformId,
instructorPlatformId: resource.instructorPlatformId,
userId: user.id,
groupId: group?.id ?? null,
status: 'WAITING',
},
});
const position = await tx.platformControlQueueEntry.count({
where: {
...this.resourceWhere(resource),
status: 'WAITING',
requestedAt: { lte: queue.requestedAt },
},
});
return {
result: 'QUEUED',
platformId: platformOptionId,
session: this.serializeSession(active),
queue: {
id: queue.id,
position,
requestedAt: queue.requestedAt.toISOString(),
},
};
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
async cancelQueue(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.prisma.platformControlQueueEntry.updateMany({
where: {
...this.resourceWhere(resource),
userId: user.id,
status: 'WAITING',
},
data: {
status: 'CANCELED',
canceledAt: new Date(),
},
});
return { result: 'CANCELED', platformId: platformOptionId };
}
async getPlatformStatuses(accessToken: string | undefined, platformOptionIds: string[]) {
const { authUser, user } = await this.getStudent(accessToken);
const context = this.organizationContext(authUser);
const group = await this.findStudentGroup(user.id, context);
const uniqueIds = Array.from(new Set(platformOptionIds.map((id) => id.trim()).filter(Boolean))).slice(0, 30);
const resources = await Promise.all(uniqueIds.map(async (platformOptionId) => {
const resource = await this.resolveResource(platformOptionId, context, group?.instructorPlatformId ?? null).catch(() => null);
return resource ? { platformOptionId, resource } : null;
}));
const validResources = resources.filter((entry): entry is { platformOptionId: string; resource: PlatformResource } => Boolean(entry));
const controlStates = await this.getControlStates(
context,
user.id,
validResources.map((entry) => entry.resource),
);
const statuses = await Promise.all(validResources.map(async ({ platformOptionId, resource }) => {
if (resource.platformId && !resource.instructorPlatformId) {
const platform = await this.prisma.platform.findUnique({
where: { id: resource.platformId },
select: { platformKey: true, displayName: true },
});
if (platform && this.isLocalPlatform(platform)) {
return {
id: platformOptionId,
status: '사용 가능',
action: '연결',
state: 'ready',
control: null,
connection: null,
};
}
}
const control = controlStates.get(this.resourceKey(resource)) ?? this.emptyState('AVAILABLE');
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource);
const connection = gatewayPlatformId
? await this.platformGatewayClient.getPlatformStatus(gatewayPlatformId, agentOwnerEmail ?? authUser.email).catch(() => null)
: null;
const remoteUnavailable = Boolean(gatewayPlatformId && (!connection?.headscaleOnline || !connection?.agentHealthy));
return {
id: platformOptionId,
status: remoteUnavailable
? '연결 끊김'
: control.status === 'AVAILABLE'
? '사용 가능'
: this.toControlStatusLabel(control.status),
action: control.status === 'AVAILABLE'
? '제어권 요청'
: control.action,
state: control.state,
control,
connection: gatewayPlatformId
? {
registered: Boolean(connection),
headscaleOnline: Boolean(connection?.headscaleOnline),
agentHealthy: Boolean(connection?.agentHealthy),
agentUrl: connection?.agentUrl ?? null,
sshUser: connection?.sshUser ?? null,
workspaceRoot: connection?.workspaceRoot ?? null,
lastSeenAt: connection?.lastSeenAt ?? null,
message: connection?.message ?? null,
}
: null,
};
}));
return { platforms: statuses };
}
async releaseControl(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);
return 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');
}
await tx.platformControlSession.update({
where: { id: active.id },
data: {
status: 'RELEASED',
releasedAt: new Date(),
endReason: 'USER_RELEASED',
},
});
const next = await this.grantNextQueued(tx, resource, context);
if (next) this.notifyGranted(next);
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
if (gatewayPlatformId) {
await this.platformGatewayClient.release({
platformId: gatewayPlatformId,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.authRole,
},
});
}
return {
result: 'RELEASED',
platformId: platformOptionId,
nextSession: next ? this.serializeSession(next) : null,
};
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
async connectPlatform(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);
const active = await this.prisma.platformControlSession.findFirst({
where: {
...this.resourceWhere(resource),
holderUserId: user.id,
status: 'ACTIVE',
},
});
if (!active) throw new ForbiddenException('Active control session required');
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
if (!gatewayPlatformId) {
return {
mode: 'local',
gateway: null,
environmentConfigOverride: null,
};
}
const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource);
await this.platformGatewayClient.syncAccess([{
platformId: gatewayPlatformId,
platformName: resource.title,
agentOwnerEmail,
studentEmails: authUser.email ? [authUser.email] : [],
}]);
const ttlSeconds = Math.max(60, Math.floor((active.endsAt.getTime() - Date.now()) / 1000));
const gateway = await this.platformGatewayClient.startSession({
platformId: gatewayPlatformId,
agentOwnerEmail,
ttlSeconds,
user: {
id: user.id,
email: user.email || undefined,
name: user.name || undefined,
role: user.authRole || undefined,
},
})
.catch(() => null);
return {
mode: gateway?.connection ? 'remote' : 'gateway-unavailable',
gateway,
environmentConfigOverride: gateway?.connection
? {
target: 'remote-jetson',
agent: {
url: gateway.connection.agentUrl,
sshUser: gateway.connection.sshUser ?? null,
workspaceRoot: gateway.connection.workspaceRoot ?? null,
},
bridge: {
env: {
BRIDGE_HTTP_URL: gateway.connection.bridgeHttpUrl,
BRIDGE_WS_URL: gateway.connection.bridgeWsUrl,
},
},
}
: null,
};
}
async listRemoteModules(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);
const gatewayPlatformId = await this.resolveGatewayPlatformId(resource);
if (!gatewayPlatformId) throw new BadRequestException('Remote platform is required');
const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource);
return this.platformGatewayClient.getModuleStatuses(gatewayPlatformId, agentOwnerEmail ?? authUser.email);
}
async startRemoteModule(
accessToken: string | undefined,
platformOptionId: string,
input: { folderName?: string | null; rosDomainId?: string | null },
) {
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('Remote platform is required');
const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource);
return this.platformGatewayClient.startModule(gatewayPlatformId, {
accountEmail: agentOwnerEmail ?? authUser.email,
folderName: input.folderName,
rosDomainId: input.rosDomainId,
});
}
async stopRemoteModule(
accessToken: string | undefined,
platformOptionId: string,
input: { containerKey?: string | null },
) {
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('Remote platform is required');
const agentOwnerEmail = await this.resolveAgentOwnerEmail(resource);
return this.platformGatewayClient.stopModule(gatewayPlatformId, {
accountEmail: agentOwnerEmail ?? authUser.email,
containerKey: input.containerKey,
});
}
async getControlStates(
context: { organizationId?: string; organizationName: string },
currentUserId: string | null,
resources: PlatformResource[],
) {
await this.applyExpiredSessions(context);
const states = new Map<string, PlatformControlState>();
await Promise.all(resources.map(async (resource) => {
const active = await this.prisma.platformControlSession.findFirst({
where: {
...this.resourceWhere(resource),
status: 'ACTIVE',
},
include: {
holderUser: { select: { id: true, name: true, email: true } },
},
});
const queue = currentUserId ? await this.prisma.platformControlQueueEntry.findFirst({
where: {
...this.resourceWhere(resource),
userId: currentUserId,
status: 'WAITING',
},
}) : null;
const key = this.resourceKey(resource);
if (!active) {
states.set(key, this.emptyState('AVAILABLE'));
return;
}
if (active.holderUserId === currentUserId) {
states.set(key, {
status: 'ACTIVE',
action: '반납',
state: 'ready',
session: this.serializeSession(active),
queue: null,
warning: new Date() >= active.warningAt,
});
return;
}
states.set(key, {
status: queue ? 'QUEUED' : 'IN_USE',
action: queue ? '대기 취소' : '대기 예약',
state: 'reserved',
session: this.serializeSession(active),
queue: queue ? {
id: queue.id,
requestedAt: queue.requestedAt.toISOString(),
} : null,
warning: false,
});
}));
return states;
}
private async applyExpiredSessions(context: { organizationId?: string; organizationName: string }) {
const now = new Date();
const expired = await this.prisma.platformControlSession.findMany({
where: {
...this.organizationWhere<Prisma.PlatformControlSessionWhereInput>(
context.organizationId,
context.organizationName,
),
status: 'ACTIVE',
endsAt: { lte: now },
},
});
for (const session of expired) {
await this.prisma.$transaction(async (tx) => {
const current = await tx.platformControlSession.findFirst({
where: {
id: session.id,
status: 'ACTIVE',
},
});
if (!current) return;
await tx.platformControlSession.update({
where: { id: current.id },
data: {
status: 'EXPIRED',
releasedAt: now,
endReason: 'EXPIRED',
},
});
await this.grantNextQueued(tx, {
platformId: current.platformId,
instructorPlatformId: current.instructorPlatformId,
title: '',
durationMinutes: 30,
warningMinutes: 5,
}, context);
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
}
private async assertActiveControl(resource: PlatformResource, userId: string) {
const active = await this.prisma.platformControlSession.findFirst({
where: {
...this.resourceWhere(resource),
holderUserId: userId,
status: 'ACTIVE',
},
});
if (!active) throw new ForbiddenException('Active control session required');
}
private async processDueControlEvents() {
const now = new Date();
const warningSessions = await this.prisma.platformControlSession.findMany({
where: {
status: 'ACTIVE',
warningSentAt: null,
warningAt: { lte: now },
endsAt: { gt: now },
},
});
for (const session of warningSessions) {
await this.prisma.platformControlSession.updateMany({
where: {
id: session.id,
status: 'ACTIVE',
warningSentAt: null,
},
data: {
warningSentAt: now,
},
});
this.chatGateway.notifyUser(session.holderUserId, {
type: 'platform.control.warning',
sessionId: session.id,
platformId: session.platformId,
instructorPlatformId: session.instructorPlatformId,
endsAt: session.endsAt.toISOString(),
warningAt: session.warningAt.toISOString(),
});
}
const expiredSessions = await this.prisma.platformControlSession.findMany({
where: {
status: 'ACTIVE',
endsAt: { lte: now },
},
});
for (const session of expiredSessions) {
await this.prisma.$transaction(async (tx) => {
const current = await tx.platformControlSession.findFirst({
where: {
id: session.id,
status: 'ACTIVE',
},
});
if (!current) return;
await tx.platformControlSession.update({
where: { id: current.id },
data: {
status: 'EXPIRED',
releasedAt: now,
endReason: 'EXPIRED',
},
});
const next = await this.grantNextQueued(tx, {
platformId: current.platformId,
instructorPlatformId: current.instructorPlatformId,
title: '',
durationMinutes: 30,
warningMinutes: 5,
}, {
organizationId: current.organizationId ?? undefined,
organizationName: current.organizationName,
});
if (next) this.notifyGranted(next);
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}
}
private async grantNextQueued(
tx: Prisma.TransactionClient,
resource: PlatformResource,
context: { organizationId?: string; organizationName: string },
) {
const next = await tx.platformControlQueueEntry.findFirst({
where: {
...this.resourceWhere(resource),
status: 'WAITING',
},
orderBy: { requestedAt: 'asc' },
});
if (!next) return null;
await tx.platformControlQueueEntry.update({
where: { id: next.id },
data: {
status: 'GRANTED',
grantedAt: new Date(),
},
});
const fullResource = await this.resolveResourceForGrant(tx, resource);
return this.createSession(
tx,
fullResource,
context,
next.userId,
next.groupId,
);
}
private async resolveResourceForGrant(tx: Prisma.TransactionClient, resource: PlatformResource) {
if (resource.instructorPlatformId) {
const platform = await tx.instructorPlatform.findUnique({
where: { id: resource.instructorPlatformId },
});
if (platform) {
return {
...resource,
durationMinutes: platform.controlDurationMinutes,
warningMinutes: platform.controlWarningMinutes,
};
}
}
return resource;
}
private async resolveGatewayPlatformId(resource: Pick<PlatformResource, 'platformId' | 'instructorPlatformId'>) {
if (resource.instructorPlatformId) {
const instructorPlatform = await this.prisma.instructorPlatform.findUnique({
where: { id: resource.instructorPlatformId },
include: { platform: true },
});
if (!instructorPlatform) return null;
return this.readGatewayPlatformId(instructorPlatform.studioSettingsOverride)
?? this.readGatewayPlatformId(instructorPlatform.platform.defaultStudioSettings)
?? instructorPlatform.platform.platformKey;
}
if (!resource.platformId) return null;
const platform = await this.prisma.platform.findUnique({
where: { id: resource.platformId },
});
if (!platform || this.isLocalPlatform(platform)) return null;
return this.readGatewayPlatformId(platform.defaultStudioSettings) ?? platform.platformKey;
}
private async resolveAgentOwnerEmail(resource: Pick<PlatformResource, 'platformId' | 'instructorPlatformId'>) {
if (resource.instructorPlatformId) {
const instructorPlatform = await this.prisma.instructorPlatform.findUnique({
where: { id: resource.instructorPlatformId },
include: { platform: true },
});
if (!instructorPlatform) return null;
return this.readAgentOwnerEmail(instructorPlatform.studioSettingsOverride)
?? this.readAgentOwnerEmail(instructorPlatform.platform.defaultStudioSettings);
}
if (!resource.platformId) return null;
const platform = await this.prisma.platform.findUnique({
where: { id: resource.platformId },
});
return this.readAgentOwnerEmail(platform?.defaultStudioSettings);
}
private readAgentOwnerEmail(settings: unknown): string | null {
if (!settings || typeof settings !== 'object') return null;
const value = (settings as Record<string, unknown>).agentOwnerEmail;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private readGatewayPlatformId(settings: unknown): string | null {
if (!settings || typeof settings !== 'object') return null;
const value = (settings as Record<string, unknown>).gatewayPlatformId;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
private createSession(
tx: Prisma.TransactionClient,
resource: PlatformResource,
context: { organizationId?: string; organizationName: string },
userId: string,
groupId: string | null,
) {
const startedAt = new Date();
const endsAt = new Date(startedAt.getTime() + resource.durationMinutes * 60_000);
const warningAt = new Date(endsAt.getTime() - resource.warningMinutes * 60_000);
return tx.platformControlSession.create({
data: {
organizationId: context.organizationId ?? null,
organizationName: context.organizationName,
platformId: resource.platformId,
instructorPlatformId: resource.instructorPlatformId,
holderUserId: userId,
groupId,
status: 'ACTIVE',
startedAt,
endsAt,
warningAt,
},
});
}
private async resolveResource(
platformOptionId: string,
context: { organizationId?: string; organizationName: string },
assignedInstructorPlatformId: string | null,
): Promise<PlatformResource> {
const instructorPlatform = await this.prisma.instructorPlatform.findFirst({
where: {
id: platformOptionId,
...this.organizationWhere<Prisma.InstructorPlatformWhereInput>(
context.organizationId,
context.organizationName,
),
},
include: { platform: true },
});
if (instructorPlatform) {
if (assignedInstructorPlatformId !== instructorPlatform.id) {
throw new ForbiddenException('Platform is not assigned to your group');
}
return {
platformId: null,
instructorPlatformId: instructorPlatform.id,
title: instructorPlatform.displayName,
durationMinutes: instructorPlatform.controlDurationMinutes,
warningMinutes: instructorPlatform.controlWarningMinutes,
};
}
const coursePlatform = await this.prisma.coursePlatform.findFirst({
where: {
platformId: platformOptionId,
course: {
slug: 'futuredrive-autonomy-basic',
isActive: true,
},
},
include: { platform: true },
});
if (!coursePlatform) {
throw new NotFoundException('Platform not found');
}
const isLocal = this.isLocalPlatform(coursePlatform.platform);
if (!isLocal) {
throw new ForbiddenException('Platform is not assigned to your group');
}
return {
platformId: coursePlatform.platformId,
instructorPlatformId: null,
title: coursePlatform.platform.displayName,
durationMinutes: 30,
warningMinutes: 5,
};
}
private async findStudentGroup(
userId: string,
context: { organizationId?: string; organizationName: string },
) {
return this.prisma.studentGroup.findFirst({
where: {
...this.organizationWhere<Prisma.StudentGroupWhereInput>(
context.organizationId,
context.organizationName,
),
members: {
some: { userId },
},
},
orderBy: { updatedAt: 'desc' },
});
}
private async getStudent(accessToken: string | undefined) {
if (!accessToken) throw new UnauthorizedException('Bearer token required');
const { user } = await this.authService.me(accessToken);
const roles = [user.role, ...(user.roles ?? [])].map((role) => role.toUpperCase());
if (roles.some((role) => role === 'INSTRUCTOR' || role === 'LMS_ADMIN')) {
throw new ForbiddenException('Student role required');
}
const lmsUser = await this.prisma.user.upsert({
where: { email: user.email },
update: this.toUserSyncData(user),
create: {
email: user.email,
...this.toUserSyncData(user),
},
});
return { authUser: user, user: lmsUser };
}
private organizationContext(user: AuthResponseUser) {
const organizationName = user.primaryAffiliation?.organizationName?.trim();
if (!organizationName) {
throw new BadRequestException('Organization is required');
}
return {
organizationId: user.primaryAffiliation?.organizationId,
organizationName,
};
}
private resourceWhere(resource: Pick<PlatformResource, 'platformId' | 'instructorPlatformId'>) {
return resource.instructorPlatformId
? { instructorPlatformId: resource.instructorPlatformId }
: { platformId: resource.platformId };
}
private resourceKey(resource: Pick<PlatformResource, 'platformId' | 'instructorPlatformId'>) {
return resource.instructorPlatformId
? `instructor:${resource.instructorPlatformId}`
: `platform:${resource.platformId}`;
}
private emptyState(status: 'AVAILABLE'): PlatformControlState {
return {
status,
action: '제어권 요청',
state: 'ready',
session: null,
queue: null,
warning: false,
};
}
private toControlStatusLabel(status: string) {
if (status === 'ACTIVE') return '제어 중';
if (status === 'QUEUED') return '대기 예약됨';
if (status === 'IN_USE') return '다른 수강생 사용 중';
return '사용 가능';
}
serializeSession(session: {
id: string;
holderUserId: string;
startedAt: Date;
endsAt: Date;
warningAt: Date;
holderUser?: { name: string | null; email: string } | null;
}) {
return {
id: session.id,
holderUserId: session.holderUserId,
holderName: session.holderUser?.name ?? session.holderUser?.email ?? null,
startedAt: session.startedAt.toISOString(),
endsAt: session.endsAt.toISOString(),
warningAt: session.warningAt.toISOString(),
};
}
private notifyGranted(session: { id: string; holderUserId: string; startedAt: Date; endsAt: Date; warningAt: Date }) {
this.chatGateway.notifyUser(session.holderUserId, {
type: 'platform.control.granted',
sessionId: session.id,
startedAt: session.startedAt.toISOString(),
endsAt: session.endsAt.toISOString(),
warningAt: session.warningAt.toISOString(),
});
}
makePlatformResource(platformId: string | null, instructorPlatformId: string | null): PlatformResource {
return {
platformId,
instructorPlatformId,
title: '',
durationMinutes: 30,
warningMinutes: 5,
};
}
resourceKeyForIds(platformId: string | null, instructorPlatformId: string | null) {
return this.resourceKey({ platformId, instructorPlatformId });
}
private organizationWhere<T>(
organizationId: string | undefined,
organizationName: string | undefined,
): T {
return {
OR: [
organizationId ? { organizationId } : undefined,
organizationName ? { organizationName } : undefined,
].filter(Boolean),
} as T;
}
private isLocalPlatform(platform: { platformKey: string; displayName: string }) {
return platform.platformKey.toLowerCase().includes('local') ||
platform.displayName.toLowerCase().includes('로컬');
}
private toUserSyncData(user: AuthResponseUser) {
return {
authUserId: user.id,
name: user.name,
authRole: user.role,
primaryOrganizationId: user.primaryAffiliation?.organizationId ?? null,
primaryOrganizationName: user.primaryAffiliation?.organizationName ?? null,
primaryOrganizationType: user.primaryAffiliation?.organizationType ?? null,
department: user.primaryAffiliation?.department ?? null,
position: user.primaryAffiliation?.position ?? null,
};
}
}
@@ -0,0 +1,165 @@
import { Injectable, Logger } from '@nestjs/common';
type GatewayConnection = {
agentUrl: string;
sshUser?: string | null;
workspaceRoot?: string | null;
bridgeHttpUrl: string;
bridgeWsUrl: string;
};
type GatewaySessionResponse = {
session?: unknown;
connection?: GatewayConnection;
error?: string;
};
export type GatewayAgent = {
id: string;
hostname: string;
name: string;
user: string;
tags: string[];
ip: string;
sshUser?: string | null;
workspaceRoot?: string | null;
connected: boolean;
expired: boolean;
lastSeenAt: string | null;
};
export type GatewayPlatformConnectionStatus = {
platformId: string;
registered: boolean;
headscaleOnline: boolean;
agentHealthy: boolean;
agentUrl: string | null;
sshUser?: string | null;
workspaceRoot?: string | null;
lastSeenAt: string | null;
state: string;
message: string | null;
};
export type GatewayAccessAssignment = {
platformId: string;
platformName?: string | null;
agentHostname?: string | null;
agentOwnerEmail?: string | null;
studentEmails: string[];
};
@Injectable()
export class PlatformGatewayClient {
private readonly logger = new Logger(PlatformGatewayClient.name);
private readonly baseUrl = (process.env.PLATFORM_GATEWAY_URL ?? '').replace(/\/+$/, '');
private readonly token = process.env.PLATFORM_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? '';
get enabled() {
return Boolean(this.baseUrl && this.token);
}
async startSession(input: {
platformId: string;
agentOwnerEmail?: string | null;
user: { id: string; email?: string; name?: string | null; role?: string | null };
ttlSeconds: number;
}): Promise<GatewaySessionResponse | null> {
if (!this.enabled) return null;
return this.request<GatewaySessionResponse>('/api/lms/platform-sessions', {
method: 'POST',
body: JSON.stringify(input),
});
}
async release(input: {
platformId: string;
user: { id: string; email?: string; name?: string | null; role?: string | null };
}): Promise<void> {
if (!this.enabled) return;
await this.request(`/api/lms/platforms/${encodeURIComponent(input.platformId)}/release`, {
method: 'POST',
body: JSON.stringify({ user: input.user }),
}).catch(() => undefined);
}
async listAgents(accountEmail?: string | null): Promise<GatewayAgent[]> {
if (!this.enabled) {
this.logger.warn(`Platform gateway is disabled. baseUrl=${this.baseUrl || '(empty)'} tokenConfigured=${Boolean(this.token)}`);
return [];
}
const query = accountEmail ? `?accountEmail=${encodeURIComponent(accountEmail)}` : '';
const payload = await this.request<{ agents?: GatewayAgent[] }>(`/api/lms/agents${query}`, {
method: 'GET',
});
return payload.agents ?? [];
}
async getPlatformStatus(platformId: string, accountEmail?: string | null): Promise<GatewayPlatformConnectionStatus | null> {
if (!this.enabled) return null;
const query = accountEmail ? `?accountEmail=${encodeURIComponent(accountEmail)}` : '';
return this.request<GatewayPlatformConnectionStatus>(
`/api/lms/platforms/${encodeURIComponent(platformId)}/status${query}`,
{ method: 'GET' },
);
}
async getModuleStatuses(platformId: string, accountEmail?: string | null): Promise<unknown> {
if (!this.enabled) return null;
const query = accountEmail ? `?accountEmail=${encodeURIComponent(accountEmail)}` : '';
return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/modules/statuses${query}`, {
method: 'GET',
});
}
async startModule(platformId: string, input: { accountEmail?: string | null; folderName?: string | null; rosDomainId?: string | null }): Promise<unknown> {
if (!this.enabled) return null;
const query = input.accountEmail ? `?accountEmail=${encodeURIComponent(input.accountEmail)}` : '';
return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/modules/start${query}`, {
method: 'POST',
body: JSON.stringify({
folderName: input.folderName,
rosDomainId: input.rosDomainId,
}),
});
}
async stopModule(platformId: string, input: { accountEmail?: string | null; containerKey?: string | null }): Promise<unknown> {
if (!this.enabled) return null;
const query = input.accountEmail ? `?accountEmail=${encodeURIComponent(input.accountEmail)}` : '';
return this.request(`/api/lms/platforms/${encodeURIComponent(platformId)}/modules/stop${query}`, {
method: 'POST',
body: JSON.stringify({
containerKey: input.containerKey,
}),
});
}
async syncAccess(assignments: GatewayAccessAssignment[]): Promise<void> {
if (!this.enabled) return;
await this.request('/api/lms/access-sync', {
method: 'POST',
body: JSON.stringify({ assignments }),
}).catch((error) => {
this.logger.warn(`Platform access sync failed. message=${error instanceof Error ? error.message : String(error)}`);
});
}
private async request<T = unknown>(path: string, init: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...init,
headers: {
'content-type': 'application/json',
'x-lms-gateway-token': this.token,
...(init.headers ?? {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
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}`);
throw new Error(message);
}
return payload as T;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+58
View File
@@ -0,0 +1,58 @@
import { Body, Controller, Post, Req } from '@nestjs/common';
import { Request } from 'express';
import { StudioService } from './studio.service';
@Controller('studio')
export class StudioController {
constructor(private readonly studioService: StudioService) {}
@Post('repositories/prepare')
prepareRepository(
@Req() request: Request,
@Body()
body: {
courseId?: string;
week?: number;
moduleName?: string;
folderName?: string;
templateRepositoryUrl?: string;
},
) {
return this.studioService.prepareRepository(this.getBearerToken(request), body);
}
@Post('submissions/record')
recordSubmission(
@Req() request: Request,
@Body()
body: {
courseId?: string;
week?: number;
completedWeeks?: number;
totalWeeks?: number;
commitCount?: number;
},
) {
return this.studioService.recordSubmission(this.getBearerToken(request), body);
}
@Post('learning-time')
recordLearningTime(
@Req() request: Request,
@Body()
body: {
courseId?: string;
week?: number;
learningMinutes?: number;
learningMinutesDelta?: number;
},
) {
return this.studioService.recordLearningTime(this.getBearerToken(request), body);
}
private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { StudioController } from './studio.controller';
import { StudioService } from './studio.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [StudioController],
providers: [StudioService],
})
export class StudioModule {}
+388
View File
@@ -0,0 +1,388 @@
import {
BadGatewayException,
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class StudioService {
private readonly giteaBaseUrl: string;
private readonly giteaToken: string;
private readonly studentOwner: string;
constructor(
private readonly authService: AuthService,
private readonly configService: ConfigService,
private readonly prisma: PrismaService,
) {
this.giteaBaseUrl = this.configService
.get<string>('GITEA_BASE_URL', 'http://localhost:3000')
.replace(/\/+$/, '');
this.giteaToken = this.configService.get<string>('GITEA_ADMIN_TOKEN', '');
this.studentOwner = this.configService.get<string>('GITEA_STUDENT_OWNER', 'student');
}
async prepareRepository(
accessToken: string | undefined,
input: {
courseId?: string;
week?: number;
moduleName?: string;
folderName?: string;
templateRepositoryUrl?: string;
},
) {
if (!this.giteaToken) {
throw new BadGatewayException('GITEA_ADMIN_TOKEN is not configured');
}
const { user: authUser } = await this.authService.me(accessToken);
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
if (!input.week || input.week < 1) throw new BadRequestException('week is required');
const moduleName = input.moduleName?.trim();
const folderName = input.folderName?.trim();
if (!moduleName && !folderName) throw new BadRequestException('moduleName or folderName is required');
const lmsUser = await this.prisma.user.findUnique({
where: { email: authUser.email },
});
if (!lmsUser) throw new NotFoundException('LMS user not found');
const courseId = input.courseId?.trim();
if (courseId) {
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
}
const courseWeek = await this.prisma.courseWeek.findFirst({
where: courseId
? {
courseId,
week: input.week,
course: { isActive: true },
}
: {
week: input.week,
course: {
slug: 'futuredrive-autonomy-basic',
isActive: true,
},
},
});
if (!courseWeek) throw new NotFoundException('Course week not found');
const moduleConditions = [
moduleName ? { name: moduleName } : undefined,
folderName ? { folderName } : undefined,
].filter((condition): condition is { name: string } | { folderName: string } => Boolean(condition));
const module = await this.prisma.courseWeekModule.findFirst({
where: {
weekId: courseWeek.id,
OR: moduleConditions,
},
});
if (!module) throw new NotFoundException('Course week module not found');
const templateRepositoryUrl =
module.templateRepositoryUrl?.trim() || input.templateRepositoryUrl?.trim();
if (!templateRepositoryUrl) {
throw new BadRequestException('templateRepositoryUrl is required');
}
const repoName = `${this.slug(authUser.email.split('@')[0] || authUser.email)}-week-${courseWeek.week}-${this.slug(module.name)}`;
const repositoryUrl = `${this.giteaBaseUrl}/${this.studentOwner}/${repoName}.git`;
await this.ensureGiteaRepository(repoName, `Studio workspace for ${authUser.email} ${courseWeek.week}W ${module.name}`);
const repository = await this.prisma.studioRepository.upsert({
where: {
userId_moduleId: {
userId: lmsUser.id,
moduleId: module.id,
},
},
update: {
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
templateRepositoryUrl,
},
create: {
userId: lmsUser.id,
weekId: courseWeek.id,
moduleId: module.id,
repoOwner: this.studentOwner,
repoName,
repositoryUrl,
templateRepositoryUrl,
},
});
const moduleWithArtifacts = module as typeof module & {
sourceType?: string | null;
prebuiltArtifacts?: unknown;
};
return {
id: repository.id,
owner: this.studentOwner,
repoName,
repositoryUrl,
repositoryUrlWithToken: this.withToken(repositoryUrl),
templateRepositoryUrl,
templateRepositoryUrlWithToken: this.withToken(templateRepositoryUrl),
sourceType: moduleWithArtifacts.sourceType ?? 'GIT',
prebuiltArtifacts: moduleWithArtifacts.prebuiltArtifacts ?? [],
};
}
async recordSubmission(
accessToken: string | undefined,
input: {
courseId?: string;
week?: number;
completedWeeks?: number;
totalWeeks?: number;
commitCount?: number;
},
) {
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 courseId = input.courseId?.trim();
if (!courseId) throw new BadRequestException('courseId is required');
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
include: {
course: {
include: {
weeks: true,
},
},
},
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
const totalWeeks = Math.max(0, input.totalWeeks ?? enrollment.course.weeks.length);
const completedWeeks = Math.max(0, Math.min(totalWeeks, input.completedWeeks ?? Math.max(0, (input.week ?? 1) - 1)));
const commitCount = Math.max(0, input.commitCount ?? 0);
const progress = await this.prisma.courseStudentProgress.upsert({
where: {
courseId_userId: {
courseId,
userId: lmsUser.id,
},
},
update: {
completedWeeks,
totalWeeks,
commitCount: {
increment: commitCount,
},
},
create: {
courseId,
userId: lmsUser.id,
completedWeeks,
totalWeeks,
commitCount,
},
});
const week = Math.max(0, Math.floor(input.week ?? 0));
if (week > 0 && commitCount > 0) {
await this.prisma.courseStudentWeekProgress.upsert({
where: {
courseId_userId_week: {
courseId,
userId: lmsUser.id,
week,
},
},
update: {
submittedAt: new Date(),
commitCount: {
increment: commitCount,
},
},
create: {
courseId,
userId: lmsUser.id,
week,
submittedAt: new Date(),
commitCount,
},
});
}
return {
ok: true,
progress: {
completedWeeks: progress.completedWeeks,
totalWeeks: progress.totalWeeks,
commitCount: progress.commitCount,
},
};
}
async recordLearningTime(
accessToken: string | undefined,
input: {
courseId?: string;
week?: number;
learningMinutes?: number;
learningMinutesDelta?: number;
},
) {
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 courseId = input.courseId?.trim();
if (!courseId) throw new BadRequestException('courseId is required');
const enrollment = await this.prisma.studentEnrollment.findFirst({
where: {
userId: lmsUser.id,
courseId,
status: 'ACTIVE',
},
include: {
course: {
include: {
weeks: true,
},
},
},
});
if (!enrollment) throw new NotFoundException('Student enrollment not found');
const learningMinutes = Math.max(0, Math.floor(input.learningMinutes ?? 0));
const learningMinutesDelta = Math.max(0, Math.min(10, Math.floor(input.learningMinutesDelta ?? 0)));
const totalWeeks = enrollment.course.weeks.length;
const existingProgress = await this.prisma.courseStudentProgress.findUnique({
where: {
courseId_userId: {
courseId,
userId: lmsUser.id,
},
},
});
const previousLearningMinutes = existingProgress?.learningHours ?? 0;
const nextLearningMinutes = learningMinutesDelta > 0
? previousLearningMinutes + learningMinutesDelta
: Math.max(previousLearningMinutes, learningMinutes);
const progress = await this.prisma.courseStudentProgress.upsert({
where: {
courseId_userId: {
courseId,
userId: lmsUser.id,
},
},
update: {
totalWeeks,
learningHours: nextLearningMinutes,
},
create: {
courseId,
userId: lmsUser.id,
completedWeeks: 0,
totalWeeks,
learningHours: nextLearningMinutes,
commitCount: 0,
},
});
return {
ok: true,
progress: {
learningMinutes: progress.learningHours,
totalWeeks: progress.totalWeeks,
},
};
}
private async ensureGiteaRepository(repoName: string, description: string) {
const existingResponse = await fetch(`${this.giteaBaseUrl}/api/v1/repos/${encodeURIComponent(this.studentOwner)}/${encodeURIComponent(repoName)}`, {
headers: this.giteaHeaders(),
});
if (existingResponse.ok) return;
if (existingResponse.status !== 404) {
throw new BadGatewayException(await existingResponse.text());
}
const createResponse = await fetch(`${this.giteaBaseUrl}/api/v1/orgs/${encodeURIComponent(this.studentOwner)}/repos`, {
method: 'POST',
headers: {
...this.giteaHeaders(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: repoName,
description,
private: true,
auto_init: false,
}),
});
if (!createResponse.ok && createResponse.status !== 409) {
throw new BadGatewayException(await createResponse.text());
}
}
private giteaHeaders() {
return {
Authorization: `token ${this.giteaToken}`,
};
}
private withToken(repositoryUrl: string) {
try {
const url = new URL(repositoryUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return repositoryUrl;
url.username = 'oauth2';
url.password = this.giteaToken;
return url.toString();
} catch {
return repositoryUrl;
}
}
private slug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'workspace';
}
}