File size: 14,960 Bytes
5be4180
 
 
 
c7ff5ed
c206b01
5be4180
 
 
 
 
 
 
c206b01
4767783
5be4180
 
 
 
 
8596926
5be4180
 
 
8da07c9
8596926
83f8a81
5be4180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83f8a81
5be4180
 
 
 
 
 
83f8a81
d4a87c0
5be4180
 
d4a87c0
5be4180
83f8a81
 
 
 
 
 
 
 
 
 
 
 
 
d4a87c0
 
 
 
5be4180
 
d4a87c0
 
5be4180
d4a87c0
5be4180
 
 
 
d4a87c0
8da07c9
d4a87c0
8da07c9
 
 
 
5be4180
 
d4a87c0
5be4180
 
 
 
 
8596926
 
 
 
 
 
 
 
 
d4a87c0
5be4180
d4a87c0
5be4180
722bd5c
5be4180
 
 
 
 
d4a87c0
5be4180
 
 
 
 
 
 
d4a87c0
5be4180
83f8a81
5be4180
 
 
8da07c9
 
d4a87c0
8da07c9
8924be7
8da07c9
 
 
d4a87c0
8da07c9
d4a87c0
8da07c9
 
 
 
 
d4a87c0
c7ff5ed
83f8a81
c7ff5ed
 
 
 
 
d4a87c0
c7ff5ed
d4a87c0
c7ff5ed
d4a87c0
c7ff5ed
 
5be4180
 
c206b01
 
5be4180
 
 
 
 
 
d4a87c0
5be4180
 
 
 
 
d4a87c0
5be4180
d4a87c0
5be4180
 
 
 
 
d4a87c0
5be4180
 
d4a87c0
5be4180
 
 
 
c7ff5ed
db4ecbb
 
 
 
 
 
 
 
 
 
 
 
 
d4a87c0
c7ff5ed
d4a87c0
c7ff5ed
 
 
 
d4a87c0
cedf0da
 
cdd8339
c7ff5ed
 
5901859
 
 
 
cdd8339
 
5901859
c7ff5ed
cdd8339
c7ff5ed
d4a87c0
c7ff5ed
 
d4a87c0
9705fa5
c7ff5ed
83f8a81
41c548e
 
 
 
 
 
 
 
 
 
 
 
 
 
83f8a81
 
 
 
 
 
 
 
 
 
 
2a3577c
 
 
 
 
 
 
83f8a81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5be4180
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
import {
  Controller,
  Get,
  Post,
  Patch,
  Put,
  Param,
  Query,
  Body,
  Req,
  UseGuards,
  ParseUUIDPipe,
  ForbiddenException,
  NotFoundException,
  BadRequestException,
  Logger,
  UseInterceptors,
  UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { TaService } from './ta.service';
import { SupabaseService } from '../../database/supabase.service';
import { IngestSignalDto } from './dto/ingest-signal.dto';
import { AnalyzeAtRiskQueryDto } from './dto/analyze-at-risk.dto';
import { GenerateQuizDto, UpdateQuizDto, SendQuizDto, SubmitAttemptDto } from './dto/quiz.dto';

interface RequestWithUser extends Request {
  user: { id: string };
}

@ApiTags('TA Dashboard')
@Controller('ta')
export class TaController {
  private readonly logger = new Logger(TaController.name);

  constructor(
    private readonly taService: TaService,
    private readonly supabaseService: SupabaseService,
  ) {}

  private async checkTaPermission(spaceId: string, userId: string) {
    const { data: member, error } = await this.supabaseService
      .from('space_members')
      .select('role')
      .eq('space_id', spaceId)
      .eq('user_id', userId)
      .single();

    if (error || !member || (member.role !== 'owner' && member.role !== 'admin')) {
      const { data: space } = await this.supabaseService
        .from('spaces')
        .select('owner_id')
        .eq('id', spaceId)
        .single();

      if (space?.owner_id === userId) return;
      throw new ForbiddenException('Chỉ Owner hoặc Admin mới có quyền truy cập Dashboard');
    }
  }

  private async checkSpaceMembership(spaceId: string, userId: string) {
    const { data: member } = await this.supabaseService
      .from('space_members')
      .select('role')
      .eq('space_id', spaceId)
      .eq('user_id', userId)
      .single();

    if (!member) {
      throw new ForbiddenException('Bạn không phải là thành viên của không gian này');
    }
  }

  @ApiOperation({ summary: '[TEST] Tạo dữ liệu Stress Test' })
  @Post('test/generate-stress-data')
  async generateStressData(@Query('spaceId', ParseUUIDPipe) spaceId: string) {
    return this.taService.generateStressTestData(spaceId);
  }

  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth()
  @Post('scan-at-risk')
  async scanAtRisk(@Req() req: RequestWithUser, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    return await this.taService.scanAtRiskStudents(spaceId);
  }

  @ApiOperation({ summary: 'Thu nạp tín hiệu rủi ro' })
  @Post('ingest-signal')
  async ingestSignal(@Req() req: RequestWithUser, @Body() dto: IngestSignalDto) {
    await this.checkTaPermission(dto.space_id, req.user.id);
    return await this.taService.ingestSignal(dto);
  }

  @ApiOperation({ summary: 'Lấy danh sách học viên có nguy cơ' })
  @Get('at-risk')
  async getAtRisk(@Req() req: RequestWithUser, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.getAtRiskList(spaceId);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Phân tích real-time signals từ chat messages' })
  @Post('at-risk/analyze')
  async analyzeAtRisk(@Req() req: RequestWithUser, @Query() dto: AnalyzeAtRiskQueryDto) {
    await this.checkTaPermission(dto.spaceId, req.user.id);
    const hours = dto.hours ?? 168;
    const data = await this.taService.analyzeAtRiskSignals(dto.spaceId, hours);
    return data;
  }

  @ApiOperation({ summary: 'Đánh dấu đã giải quyết cảnh báo' })
  @Post('at-risk/:id/resolve')
  async resolveAlert(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) snapshotId: string, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.resolveAlert(snapshotId, req.user.id, spaceId);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Lấy hàng chờ duyệt tóm tắt AI' })
  @Get('summary-queue')
  async getSummaryQueue(@Req() req: RequestWithUser, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.getSummaryQueue(spaceId);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Duyệt bản tóm tắt AI' })
  @Post('summary-queue/:id/approve')
  async approveSummary(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) draftId: string, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.approveSummary(draftId, req.user.id, spaceId);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Đặt lịch gửi bản tóm tắt AI' })
  @Post('summary-queue/:id/schedule')
  async scheduleSummary(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) draftId: string, @Query('spaceId', ParseUUIDPipe) spaceId: string, @Body('scheduled_at') scheduledAt: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.scheduleSummary(draftId, scheduledAt, req.user.id);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Hủy đặt lịch' })
  @Post('summary-queue/:id/cancel-schedule')
  async cancelSchedule(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) draftId: string, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.cancelSchedule(draftId);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Cập nhật bản thảo' })
  @Patch('summary-queue/:id')
  async updateSummaryDraft(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) draftId: string, @Query('spaceId', ParseUUIDPipe) spaceId: string, @Body() updates: Record<string, unknown>) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.updateSummaryDraft(draftId, updates);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Gửi tin nhắn riêng thông minh' })
  @Post('send-smart-message')
  async sendSmartMessage(@Req() req: RequestWithUser, @Query('spaceId', ParseUUIDPipe) spaceId: string, @Body() dto: { studentId: string, content: string, snapshotId?: string }) {
    await this.checkTaPermission(spaceId, req.user.id);
    return this.taService.sendSmartMessage({ taId: req.user.id, spaceId, ...dto });
  }

  @ApiOperation({ summary: 'AI: Tạo bản thảo tóm tắt' })
  @Post('summary-queue/draft')
  async createSummaryDraft(@Req() req: RequestWithUser, @Body() dto: { spaceId: string; content: string; draft_type?: string; metadata?: Record<string, unknown> }) {
    if (dto.spaceId) await this.checkTaPermission(dto.spaceId, req.user.id);
    const data = await this.taService.createSummaryDraft(dto);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Lấy nhật ký hành động' })
  @Get('action-logs')
  async getActionLogs(@Req() req: RequestWithUser, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.getActionLogs(spaceId);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Lấy bộ Context cho Agent' })
  @Get('at-risk-context/:snapshotId')
  async getAtRiskContext(@Req() req: RequestWithUser, @Param('snapshotId', ParseUUIDPipe) snapshotId: string, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.getAtRiskContext(snapshotId);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'Tải lên slide' })
  @Post('upload-slide')
  @UseInterceptors(FileInterceptor('file'))
  async uploadSlide(@Req() req: RequestWithUser, @UploadedFile() file: Express.Multer.File, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    const data = await this.taService.saveLectureSlide(spaceId, file, req.user.id);
    return { success: true, data };
  }

  @ApiOperation({ summary: 'AI: Index PDF vào Qdrant' })
  @Post('agent/index-pdf')
  @UseInterceptors(FileInterceptor('file'))
  async indexPdf(@Req() req: RequestWithUser, @UploadedFile() file: Express.Multer.File, @Body() body: { spaceId: string; conversationId: string }) {
    await this.checkTaPermission(body.spaceId, req.user.id);

    if (!file.mimetype?.includes('pdf')) {
      throw new BadRequestException('Chỉ chấp nhận file PDF.');
    }

    return await this.taService.indexPdfToQdrant(body.spaceId, file, body.conversationId);
  }

  @ApiOperation({ summary: 'AI: Gọi Agent Chat' })
  @Post('agent/chat')
  async callAgentChat(@Req() req: RequestWithUser, @Body() body: { spaceId: string; query: string; senderId: string }) {
    await this.checkTaPermission(body.spaceId, req.user.id);
    return await this.taService.callAgentChat(body.spaceId, body.query, body.senderId);
  }

  @ApiOperation({ summary: 'AI: Gọi Agent với File' })
  @ApiQuery({ name: 'spaceId', type: String, description: 'ID của không gian' })
  @ApiQuery({ name: 'query', type: String, required: false, description: 'Câu hỏi về file' })
  @ApiQuery({ name: 'conversationId', type: String, required: false, description: 'ID conversation FastAPI' })
  @Post('agent/chat-with-file')
  @UseInterceptors(FileInterceptor('file'))
  async callAgentWithFile(
    @Req() req: RequestWithUser,
    @UploadedFile() file: Express.Multer.File,
    @Query('spaceId', ParseUUIDPipe) spaceId: string,
    @Query('query') query: string,
    @Query('conversationId') conversationId?: string
  ) {
    await this.checkTaPermission(spaceId, req.user.id);
    return await this.taService.callAgentWithFile(spaceId, query, req.user.id, file, conversationId);
  }

  @ApiOperation({ summary: 'AI: Tạo bản thảo tin nhắn thông minh' })
  @Get('agent/smart-message/:snapshotId')
  async generateSmartMessage(@Req() req: RequestWithUser, @Param('snapshotId', ParseUUIDPipe) snapshotId: string, @Query('tone') tone: string, @Query('instruction') instruction?: string, @Query('pronouns') pronouns?: string, @Query('taName') taName?: string) {
    return this.taService.generateSmartMessage(snapshotId, tone, req.user.id, { instruction, pronouns, taName });
  }

  @ApiOperation({ summary: 'At-Risk: Lấy settings (weights, thresholds)' })
  @Get('at-risk/settings')
  async getAtRiskSettings(@Req() req: RequestWithUser, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    return this.taService.getAtRiskSettingsForUi(spaceId);
  }

  @ApiOperation({ summary: 'At-Risk: Cập nhật settings (weights, thresholds)' })
  @Post('at-risk/settings')
  async updateAtRiskSettings(@Req() req: RequestWithUser, @Body() body: { spaceId: string; weights?: { last_seen: number; fastapi_signals: number; quiz_avg_score: number }; thresholds?: { warning: number; critical: number } }) {
    await this.checkTaPermission(body.spaceId, req.user.id);
    return this.taService.updateAtRiskSettings(body.spaceId, body.weights, body.thresholds, req.user.id);
  }

  // ==========================================
  // QUIZ ENDPOINTS
  // ==========================================

  @ApiOperation({ summary: 'AI: Tạo quiz từ bài giảng' })
  @Post('quiz/generate')
  async generateQuiz(@Req() req: RequestWithUser, @Body() dto: GenerateQuizDto) {
    if (dto.space_id) await this.checkTaPermission(dto.space_id, req.user.id);
    return await this.taService.generateQuiz(dto, req.user.id);
  }

  @ApiOperation({ summary: 'List quizzes for TA by space' })
  @Get('quizzes')
  async listQuizzes(@Req() req: RequestWithUser, @Query('spaceId', ParseUUIDPipe) spaceId: string) {
    await this.checkTaPermission(spaceId, req.user.id);
    return await this.taService.listQuizzes(spaceId);
  }

  @ApiOperation({ summary: 'Get quiz chi tiết (edit mode)' })
  @Get('quiz/:id')
  async getQuiz(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) quizId: string) {
    const { data: quiz } = await this.supabaseService.from('quizzes').select('space_id').eq('id', quizId).single();
    if (quiz) await this.checkTaPermission(quiz.space_id, req.user.id);
    return await this.taService.getQuiz(quizId, req.user.id);
  }

  @ApiOperation({ summary: 'Cập nhật quiz' })
  @Put('quiz/:id')
  async updateQuiz(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) quizId: string, @Body() dto: UpdateQuizDto) {
    const { data: quiz } = await this.supabaseService.from('quizzes').select('space_id').eq('id', quizId).single();
    if (quiz) await this.checkTaPermission(quiz.space_id, req.user.id);
    return await this.taService.updateQuiz(quizId, dto, req.user.id);
  }

  @ApiOperation({ summary: 'Gửi quiz vào chat' })
  @Post('quiz/:id/send')
  async sendQuiz(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) quizId: string, @Body() dto: SendQuizDto) {
    if (dto.space_id) await this.checkTaPermission(dto.space_id, req.user.id);
    return await this.taService.sendQuizToChat(quizId, dto, req.user.id);
  }

  @ApiOperation({ summary: 'Get quiz cho học viên làm' })
  @Get('quiz/:id/student')
  async getQuizForStudent(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) quizId: string) {
    const { data: quiz } = await this.supabaseService.from('quizzes').select('space_id').eq('id', quizId).single();
    if (!quiz) throw new NotFoundException('Quiz not found');
    await this.checkSpaceMembership(quiz.space_id, req.user.id);
    return await this.taService.getQuizForStudent(quizId, req.user.id);
  }

  @ApiOperation({ summary: 'Submit quiz attempt' })
  @Post('quiz/:id/submit')
  async submitQuizAttempt(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) quizId: string, @Body() dto: SubmitAttemptDto) {
    const { data: quiz } = await this.supabaseService.from('quizzes').select('space_id').eq('id', quizId).single();
    if (!quiz) throw new NotFoundException('Quiz not found');
    await this.checkSpaceMembership(quiz.space_id, req.user.id);
    return await this.taService.submitQuizAttempt(quizId, req.user.id, dto);
  }

  @ApiOperation({ summary: 'Get quiz summary (TA)' })
  @Get('quiz/:id/summary')
  async getQuizSummary(@Req() req: RequestWithUser, @Param('id', ParseUUIDPipe) quizId: string) {
    const { data: quiz } = await this.supabaseService.from('quizzes').select('space_id').eq('id', quizId).single();
    if (quiz) await this.checkTaPermission(quiz.space_id, req.user.id);
    return await this.taService.getQuizSummary(quizId, req.user.id);
  }
}