Sync LMS backend source
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class HandoffExchangeDto {
|
||||
@IsString()
|
||||
handoffToken: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
audienceApp?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user