@hed-hog/lms 0.0.319 → 0.0.321

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/class-group/class-group.controller.d.ts +64 -1
  2. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  3. package/dist/class-group/class-group.controller.js +35 -0
  4. package/dist/class-group/class-group.controller.js.map +1 -1
  5. package/dist/class-group/class-group.service.d.ts +66 -1
  6. package/dist/class-group/class-group.service.d.ts.map +1 -1
  7. package/dist/class-group/class-group.service.js +164 -13
  8. package/dist/class-group/class-group.service.js.map +1 -1
  9. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -1
  10. package/dist/class-group/dto/create-class-group.dto.js +2 -1
  11. package/dist/class-group/dto/create-class-group.dto.js.map +1 -1
  12. package/dist/class-group/dto/material.dto.d.ts +18 -0
  13. package/dist/class-group/dto/material.dto.d.ts.map +1 -0
  14. package/dist/class-group/dto/material.dto.js +86 -0
  15. package/dist/class-group/dto/material.dto.js.map +1 -0
  16. package/dist/course/course.service.d.ts +2 -0
  17. package/dist/course/course.service.d.ts.map +1 -1
  18. package/dist/course/course.service.js +27 -2
  19. package/dist/course/course.service.js.map +1 -1
  20. package/dist/course/dto/create-course.dto.d.ts +2 -2
  21. package/dist/course/dto/create-course.dto.d.ts.map +1 -1
  22. package/dist/course/dto/create-course.dto.js.map +1 -1
  23. package/dist/enterprise/enterprise.controller.d.ts +7 -1
  24. package/dist/enterprise/enterprise.controller.d.ts.map +1 -1
  25. package/dist/enterprise/enterprise.controller.js +72 -2
  26. package/dist/enterprise/enterprise.controller.js.map +1 -1
  27. package/dist/enterprise/enterprise.module.d.ts.map +1 -1
  28. package/dist/enterprise/enterprise.module.js +2 -1
  29. package/dist/enterprise/enterprise.module.js.map +1 -1
  30. package/dist/enterprise/enterprise.service.d.ts +3 -0
  31. package/dist/enterprise/enterprise.service.d.ts.map +1 -1
  32. package/dist/enterprise/enterprise.service.js +84 -1
  33. package/dist/enterprise/enterprise.service.js.map +1 -1
  34. package/dist/enterprise/training/enterprise-training.module.d.ts +3 -0
  35. package/dist/enterprise/training/enterprise-training.module.d.ts.map +1 -0
  36. package/dist/enterprise/training/enterprise-training.module.js +40 -0
  37. package/dist/enterprise/training/enterprise-training.module.js.map +1 -0
  38. package/dist/enterprise/training/training-admin.controller.d.ts +525 -0
  39. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -0
  40. package/dist/enterprise/training/training-admin.controller.js +385 -0
  41. package/dist/enterprise/training/training-admin.controller.js.map +1 -0
  42. package/dist/enterprise/training/training-admin.service.d.ts +582 -0
  43. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -0
  44. package/dist/enterprise/training/training-admin.service.js +2283 -0
  45. package/dist/enterprise/training/training-admin.service.js.map +1 -0
  46. package/dist/enterprise/training/training-instructor.controller.d.ts +260 -0
  47. package/dist/enterprise/training/training-instructor.controller.d.ts.map +1 -0
  48. package/dist/enterprise/training/training-instructor.controller.js +199 -0
  49. package/dist/enterprise/training/training-instructor.controller.js.map +1 -0
  50. package/dist/enterprise/training/training-instructor.service.d.ts +280 -0
  51. package/dist/enterprise/training/training-instructor.service.d.ts.map +1 -0
  52. package/dist/enterprise/training/training-instructor.service.js +1218 -0
  53. package/dist/enterprise/training/training-instructor.service.js.map +1 -0
  54. package/dist/enterprise/training/training-student.controller.d.ts +168 -0
  55. package/dist/enterprise/training/training-student.controller.d.ts.map +1 -0
  56. package/dist/enterprise/training/training-student.controller.js +104 -0
  57. package/dist/enterprise/training/training-student.controller.js.map +1 -0
  58. package/dist/enterprise/training/training-student.service.d.ts +185 -0
  59. package/dist/enterprise/training/training-student.service.d.ts.map +1 -0
  60. package/dist/enterprise/training/training-student.service.js +674 -0
  61. package/dist/enterprise/training/training-student.service.js.map +1 -0
  62. package/dist/enterprise/training/training-viewer.controller.d.ts +298 -0
  63. package/dist/enterprise/training/training-viewer.controller.d.ts.map +1 -0
  64. package/dist/enterprise/training/training-viewer.controller.js +178 -0
  65. package/dist/enterprise/training/training-viewer.controller.js.map +1 -0
  66. package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts +18 -0
  67. package/dist/evaluation/dto/create-evaluation-topic.dto.d.ts.map +1 -0
  68. package/dist/evaluation/dto/create-evaluation-topic.dto.js +59 -0
  69. package/dist/evaluation/dto/create-evaluation-topic.dto.js.map +1 -0
  70. package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts +6 -0
  71. package/dist/evaluation/dto/update-evaluation-topic.dto.d.ts.map +1 -0
  72. package/dist/evaluation/dto/update-evaluation-topic.dto.js +9 -0
  73. package/dist/evaluation/dto/update-evaluation-topic.dto.js.map +1 -0
  74. package/dist/evaluation/evaluation.controller.d.ts +66 -0
  75. package/dist/evaluation/evaluation.controller.d.ts.map +1 -1
  76. package/dist/evaluation/evaluation.controller.js +73 -0
  77. package/dist/evaluation/evaluation.controller.js.map +1 -1
  78. package/dist/evaluation/evaluation.service.d.ts +71 -0
  79. package/dist/evaluation/evaluation.service.d.ts.map +1 -1
  80. package/dist/evaluation/evaluation.service.js +121 -0
  81. package/dist/evaluation/evaluation.service.js.map +1 -1
  82. package/dist/instructor/instructor.service.js +6 -6
  83. package/dist/instructor/instructor.service.js.map +1 -1
  84. package/dist/lms.module.d.ts.map +1 -1
  85. package/dist/lms.module.js +3 -0
  86. package/dist/lms.module.js.map +1 -1
  87. package/hedhog/data/menu.yaml +19 -2
  88. package/hedhog/data/route.yaml +730 -0
  89. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +74 -8
  90. package/hedhog/frontend/app/_components/course-avatar.tsx.ejs +10 -30
  91. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +3 -3
  92. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1 -1
  93. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +2141 -308
  94. package/hedhog/frontend/app/classes/page.tsx.ejs +8 -7
  95. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +14 -1
  96. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +6 -2
  97. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-form-sheet.tsx.ejs +201 -0
  98. package/hedhog/frontend/app/evaluations/_components/evaluation-topic-types.ts.ejs +49 -0
  99. package/hedhog/frontend/app/evaluations/page.tsx.ejs +483 -1112
  100. package/hedhog/frontend/app/instructors/page.tsx.ejs +22 -20
  101. package/hedhog/frontend/app/reports/evaluations/page.tsx.ejs +1278 -0
  102. package/hedhog/frontend/messages/en.json +98 -7
  103. package/hedhog/frontend/messages/pt.json +98 -7
  104. package/hedhog/table/course_class_group_material.yaml +45 -0
  105. package/package.json +8 -8
  106. package/src/class-group/class-group.controller.ts +30 -0
  107. package/src/class-group/class-group.service.ts +176 -5
  108. package/src/class-group/dto/create-class-group.dto.ts +2 -2
  109. package/src/class-group/dto/material.dto.ts +69 -0
  110. package/src/course/course.service.ts +41 -8
  111. package/src/course/dto/create-course.dto.ts +2 -2
  112. package/src/enterprise/enterprise.controller.ts +62 -1
  113. package/src/enterprise/enterprise.module.ts +2 -1
  114. package/src/enterprise/enterprise.service.ts +84 -1
  115. package/src/enterprise/training/enterprise-training.module.ts +27 -0
  116. package/src/enterprise/training/training-admin.controller.ts +278 -0
  117. package/src/enterprise/training/training-admin.service.ts +2523 -0
  118. package/src/enterprise/training/training-instructor.controller.ts +141 -0
  119. package/src/enterprise/training/training-instructor.service.ts +1303 -0
  120. package/src/enterprise/training/training-student.controller.ts +65 -0
  121. package/src/enterprise/training/training-student.service.ts +762 -0
  122. package/src/enterprise/training/training-viewer.controller.ts +115 -0
  123. package/src/evaluation/dto/create-evaluation-topic.dto.ts +48 -0
  124. package/src/evaluation/dto/update-evaluation-topic.dto.ts +6 -0
  125. package/src/evaluation/evaluation.controller.ts +63 -1
  126. package/src/evaluation/evaluation.service.ts +150 -1
  127. package/src/instructor/instructor.service.ts +4 -4
  128. package/src/lms.module.ts +3 -0
@@ -0,0 +1,1303 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { FileService } from '@hed-hog/core';
3
+ import { Injectable, NotFoundException } from '@nestjs/common';
4
+
5
+ @Injectable()
6
+ export class TrainingInstructorService {
7
+ constructor(
8
+ private readonly prisma: PrismaService,
9
+ private readonly fileService: FileService,
10
+ ) {}
11
+
12
+ async getDashboard(userId: number, enterpriseId?: number) {
13
+ const instructor = await this.prisma.instructor.findFirst({
14
+ where: { person: { person_user: { some: { user_id: userId } } } },
15
+ select: { id: true, person: { select: { name: true } } },
16
+ });
17
+
18
+ if (!instructor) {
19
+ throw new NotFoundException('No instructor profile found for this user');
20
+ }
21
+
22
+ const now = new Date();
23
+ const weekStart = this.startOfWeek(now);
24
+ const weekEnd = this.endOfDay(this.addDays(weekStart, 6));
25
+
26
+ const enterpriseFilter = enterpriseId !== undefined
27
+ ? { enterprise_class_group: { some: { enterprise_id: enterpriseId } } }
28
+ : {};
29
+
30
+ // Collect class group IDs where this instructor has sessions
31
+ const sessionRows = await this.prisma.course_class_session.findMany({
32
+ where: {
33
+ course_class_session_instructor: { some: { instructor_id: instructor.id } },
34
+ course_class_group: { status: { in: ['open', 'ongoing'] }, ...enterpriseFilter },
35
+ },
36
+ select: { course_class_group_id: true },
37
+ distinct: ['course_class_group_id'],
38
+ });
39
+
40
+ const cgIds = sessionRows
41
+ .map((r) => r.course_class_group_id)
42
+ .filter((id): id is number => id != null);
43
+
44
+ const [kpis, weeklyAgenda, myClasses, receivedFeedback] = await Promise.all([
45
+ this.getKpis(instructor.id, cgIds, weekStart, weekEnd),
46
+ this.getWeeklyAgenda(instructor.id, weekStart, weekEnd),
47
+ this.getMyClasses(cgIds, now),
48
+ this.getReceivedFeedbackSummary(instructor.id),
49
+ ]);
50
+
51
+ return {
52
+ kpis,
53
+ weeklyAgenda,
54
+ myClasses,
55
+ receivedFeedback,
56
+ };
57
+ }
58
+
59
+ // ── Private helpers ───────────────────────────────────────────────────────────
60
+
61
+ private async getKpis(
62
+ instructorId: number,
63
+ cgIds: number[],
64
+ weekStart: Date,
65
+ weekEnd: Date,
66
+ ) {
67
+ const [totalStudents, sessionsThisWeek, avgScore] = await Promise.all([
68
+ cgIds.length > 0
69
+ ? this.prisma.course_enrollment.count({
70
+ where: {
71
+ course_class_group_id: { in: cgIds },
72
+ status: { not: 'cancelled' },
73
+ },
74
+ })
75
+ : Promise.resolve(0),
76
+ this.prisma.course_class_session.count({
77
+ where: {
78
+ course_class_session_instructor: { some: { instructor_id: instructorId } },
79
+ session_date: { gte: weekStart, lte: weekEnd },
80
+ },
81
+ }),
82
+ this.prisma.exam_attempt.aggregate({
83
+ where: {
84
+ status: 'completed',
85
+ score: { not: null },
86
+ course_enrollment: {
87
+ course_class_group_id: cgIds.length > 0 ? { in: cgIds } : undefined,
88
+ },
89
+ },
90
+ _avg: { score: true },
91
+ }),
92
+ ]);
93
+
94
+ return {
95
+ activeClasses: {
96
+ value: cgIds.length,
97
+ change: null,
98
+ changeType: 'absolute' as const,
99
+ },
100
+ totalStudents: {
101
+ value: totalStudents,
102
+ change: null,
103
+ changeType: 'absolute' as const,
104
+ },
105
+ sessionsThisWeek: {
106
+ value: sessionsThisWeek,
107
+ change: null,
108
+ changeType: 'absolute' as const,
109
+ },
110
+ averageScore: {
111
+ value: this.round(avgScore._avg.score ?? 0, 1),
112
+ change: null,
113
+ changeType: 'absolute' as const,
114
+ },
115
+ };
116
+ }
117
+
118
+ private async getWeeklyAgenda(instructorId: number, weekStart: Date, weekEnd: Date) {
119
+ const sessions = await this.prisma.course_class_session.findMany({
120
+ where: {
121
+ course_class_session_instructor: { some: { instructor_id: instructorId } },
122
+ session_date: { gte: weekStart, lte: weekEnd },
123
+ },
124
+ orderBy: [{ session_date: 'asc' }, { start_time: 'asc' }],
125
+ include: {
126
+ course_class_group: {
127
+ include: {
128
+ course: { select: { title: true } },
129
+ _count: {
130
+ select: {
131
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
132
+ },
133
+ },
134
+ },
135
+ },
136
+ },
137
+ });
138
+
139
+ return sessions.map((s) => ({
140
+ sessionId: s.id,
141
+ classGroupId: s.course_class_group?.id ?? null,
142
+ date: s.session_date,
143
+ time: s.start_time,
144
+ endTime: s.end_time ?? null,
145
+ className: s.course_class_group?.title ?? null,
146
+ courseName: s.course_class_group?.course?.title ?? null,
147
+ topic: (s as any).topic ?? null,
148
+ location: s.location ?? null,
149
+ meetingUrl: s.meeting_url ?? null,
150
+ deliveryMode: s.course_class_group?.delivery_mode ?? null,
151
+ studentCount: s.course_class_group?._count.course_enrollment ?? 0,
152
+ }));
153
+ }
154
+
155
+ private async getMyClasses(cgIds: number[], now: Date) {
156
+ if (cgIds.length === 0) return [];
157
+
158
+ const classGroups = await this.prisma.course_class_group.findMany({
159
+ where: { id: { in: cgIds } },
160
+ orderBy: { start_date: 'desc' },
161
+ include: {
162
+ course: {
163
+ select: {
164
+ id: true,
165
+ title: true,
166
+ course_image: {
167
+ where: { image_type: { slug: 'course-logo' } },
168
+ orderBy: { is_primary: 'desc' as const },
169
+ take: 1,
170
+ select: { file: { select: { location: true } } },
171
+ },
172
+ },
173
+ },
174
+ _count: {
175
+ select: {
176
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
177
+ },
178
+ },
179
+ course_class_session: {
180
+ where: { session_date: { gte: now } },
181
+ orderBy: { session_date: 'asc' },
182
+ take: 1,
183
+ select: { session_date: true, start_time: true, location: true },
184
+ },
185
+ },
186
+ });
187
+
188
+ return classGroups.map((cg) => {
189
+ const nextSession = cg.course_class_session[0] ?? null;
190
+ const start = cg.start_date?.getTime() ?? 0;
191
+ const end = cg.end_date?.getTime() ?? 0;
192
+ const elapsed = end > start ? ((now.getTime() - start) / (end - start)) * 100 : 0;
193
+
194
+ return {
195
+ id: cg.id,
196
+ name: cg.title,
197
+ code: cg.code ?? null,
198
+ courseId: cg.course?.id ?? null,
199
+ courseName: cg.course?.title ?? null,
200
+ courseLogoUrl: cg.course?.course_image?.[0]?.file?.location ?? null,
201
+ studentCount: cg._count.course_enrollment,
202
+ nextSession: nextSession
203
+ ? {
204
+ date: nextSession.session_date,
205
+ time: nextSession.start_time,
206
+ location: nextSession.location ?? null,
207
+ }
208
+ : null,
209
+ progress: this.round(Math.min(Math.max(elapsed, 0), 100), 0),
210
+ status: cg.status,
211
+ deliveryMode: cg.delivery_mode ?? null,
212
+ pendingEvaluations: 0, // populated separately if needed
213
+ };
214
+ });
215
+ }
216
+
217
+ private async getReceivedFeedbackSummary(instructorId: number) {
218
+ // Find session IDs taught by this instructor
219
+ const sessionRows = await this.prisma.course_class_session.findMany({
220
+ where: {
221
+ course_class_session_instructor: { some: { instructor_id: instructorId } },
222
+ },
223
+ select: {
224
+ id: true,
225
+ title: true,
226
+ session_date: true,
227
+ course_class_group: { select: { title: true } },
228
+ },
229
+ orderBy: { session_date: 'desc' },
230
+ });
231
+
232
+ const sessionIds = sessionRows.map((s) => s.id);
233
+ if (sessionIds.length === 0) {
234
+ return { totalRatings: 0, averageScore: 0, recentItems: [] };
235
+ }
236
+
237
+ // Fetch evaluation topics for these sessions
238
+ const topics = await this.prisma.evaluation_topic.findMany({
239
+ where: {
240
+ course_class_session_id: { in: sessionIds },
241
+ target_type: 'course_class_session',
242
+ is_active: true,
243
+ },
244
+ include: {
245
+ evaluation_rating: { select: { score: true } },
246
+ course_class_session: {
247
+ select: {
248
+ id: true,
249
+ title: true,
250
+ session_date: true,
251
+ course_class_group: { select: { title: true } },
252
+ },
253
+ },
254
+ },
255
+ });
256
+
257
+ // Aggregate per session
258
+ const sessionMap = new Map<number, {
259
+ sessionTitle: string;
260
+ className: string | null;
261
+ sessionDate: Date;
262
+ scores: number[];
263
+ }>();
264
+
265
+ for (const topic of topics) {
266
+ const sess = topic.course_class_session;
267
+ if (!sess) continue;
268
+ if (!sessionMap.has(sess.id)) {
269
+ sessionMap.set(sess.id, {
270
+ sessionTitle: sess.title,
271
+ className: sess.course_class_group?.title ?? null,
272
+ sessionDate: sess.session_date,
273
+ scores: [],
274
+ });
275
+ }
276
+ for (const r of topic.evaluation_rating) {
277
+ sessionMap.get(sess.id)!.scores.push(Number(r.score));
278
+ }
279
+ }
280
+
281
+ const allScores = [...sessionMap.values()].flatMap((v) => v.scores);
282
+ const totalRatings = allScores.length;
283
+ const averageScore =
284
+ totalRatings > 0 ? this.round(allScores.reduce((a, b) => a + b, 0) / totalRatings, 1) : 0;
285
+
286
+ // Recent items — last 5 sessions with at least 1 rating, sorted desc
287
+ const recentItems = [...sessionMap.entries()]
288
+ .filter(([, v]) => v.scores.length > 0)
289
+ .sort(([, a], [, b]) => b.sessionDate.getTime() - a.sessionDate.getTime())
290
+ .slice(0, 5)
291
+ .map(([, v]) => ({
292
+ sessionTitle: v.sessionTitle,
293
+ className: v.className,
294
+ sessionDate: v.sessionDate,
295
+ avgScore: this.round(v.scores.reduce((a, b) => a + b, 0) / v.scores.length, 1),
296
+ ratingCount: v.scores.length,
297
+ }));
298
+
299
+ return { totalRatings, averageScore, recentItems };
300
+ }
301
+
302
+ private async getPendingEvals(cgIds: number[]) {
303
+ if (cgIds.length === 0) return [];
304
+
305
+ const attempts = await this.prisma.exam_attempt.findMany({
306
+ where: {
307
+ status: 'completed',
308
+ score: null,
309
+ course_enrollment: { course_class_group_id: { in: cgIds } },
310
+ },
311
+ take: 20,
312
+ orderBy: { finished_at: 'asc' },
313
+ include: {
314
+ course_enrollment: {
315
+ include: {
316
+ person: { select: { name: true } },
317
+ course_class_group: { select: { title: true } },
318
+ },
319
+ },
320
+ exam: { select: { id: true, title: true } },
321
+ },
322
+ });
323
+
324
+ return attempts.map((a) => ({
325
+ attemptId: a.id,
326
+ studentName: a.course_enrollment?.person?.name ?? null,
327
+ className: a.course_enrollment?.course_class_group?.title ?? null,
328
+ examTitle: a.exam?.title ?? null,
329
+ submittedAt: a.finished_at,
330
+ }));
331
+ }
332
+
333
+ // ── Date helpers ──────────────────────────────────────────────────────────────
334
+
335
+ async getClassGroups(
336
+ userId: number,
337
+ options: {
338
+ enterpriseId?: number;
339
+ search?: string;
340
+ status?: string;
341
+ deliveryMode?: string;
342
+ } = {},
343
+ ) {
344
+ const instructor = await this.prisma.instructor.findFirst({
345
+ where: { person: { person_user: { some: { user_id: userId } } } },
346
+ select: { id: true },
347
+ });
348
+
349
+ if (!instructor) {
350
+ throw new NotFoundException('No instructor profile found for this user');
351
+ }
352
+
353
+ const { enterpriseId, search, status, deliveryMode } = options;
354
+ const now = new Date();
355
+
356
+ // IDs where instructor leads at least one session
357
+ const sessionRows = await this.prisma.course_class_session.findMany({
358
+ where: {
359
+ course_class_session_instructor: { some: { instructor_id: instructor.id } },
360
+ },
361
+ select: { course_class_group_id: true },
362
+ distinct: ['course_class_group_id'],
363
+ });
364
+ const cgIdsFromSessions = sessionRows
365
+ .map((r) => r.course_class_group_id)
366
+ .filter((id): id is number => id != null);
367
+
368
+ const classGroups = await this.prisma.course_class_group.findMany({
369
+ where: {
370
+ AND: [
371
+ // instructor of the group OR instructor of at least one session
372
+ {
373
+ OR: [
374
+ { instructor_id: instructor.id },
375
+ { id: { in: cgIdsFromSessions } },
376
+ ],
377
+ },
378
+ // enterprise filter
379
+ ...(enterpriseId !== undefined
380
+ ? [{ enterprise_class_group: { some: { enterprise_id: enterpriseId } } }]
381
+ : []),
382
+ // status filter
383
+ ...(status ? [{ status: status as any }] : []),
384
+ // delivery mode filter
385
+ ...(deliveryMode ? [{ delivery_mode: deliveryMode as any }] : []),
386
+ // search filter
387
+ ...(search
388
+ ? [
389
+ {
390
+ OR: [
391
+ { title: { contains: search, mode: 'insensitive' as const } },
392
+ { code: { contains: search, mode: 'insensitive' as const } },
393
+ { course: { title: { contains: search, mode: 'insensitive' as const } } },
394
+ ],
395
+ },
396
+ ]
397
+ : []),
398
+ ],
399
+ },
400
+ orderBy: { start_date: 'desc' },
401
+ include: {
402
+ course: {
403
+ select: {
404
+ id: true,
405
+ title: true,
406
+ course_image: {
407
+ where: { image_type: { slug: { in: ['course-logo', 'course-banner'] } } },
408
+ orderBy: { is_primary: 'desc' as const },
409
+ select: {
410
+ file: { select: { location: true } },
411
+ image_type: { select: { slug: true } },
412
+ },
413
+ },
414
+ },
415
+ },
416
+ _count: {
417
+ select: {
418
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
419
+ },
420
+ },
421
+ course_class_session: {
422
+ where: { session_date: { gte: now } },
423
+ orderBy: { session_date: 'asc' },
424
+ take: 1,
425
+ select: {
426
+ session_date: true,
427
+ start_time: true,
428
+ location: true,
429
+ meeting_url: true,
430
+ },
431
+ },
432
+ exam: {
433
+ select: {
434
+ id: true,
435
+ exam_attempt: {
436
+ where: { status: 'completed', score: null },
437
+ select: { id: true },
438
+ },
439
+ },
440
+ },
441
+ },
442
+ });
443
+
444
+ const data = classGroups.map((cg) => {
445
+ const start = cg.start_date?.getTime() ?? 0;
446
+ const end = cg.end_date?.getTime() ?? 0;
447
+ const elapsed = end > start ? ((now.getTime() - start) / (end - start)) * 100 : 0;
448
+
449
+ const nextSession = cg.course_class_session[0] ?? null;
450
+
451
+ const pendingEvaluations = cg.exam.reduce(
452
+ (sum, ex) => sum + ex.exam_attempt.length,
453
+ 0,
454
+ );
455
+
456
+ return {
457
+ id: cg.id,
458
+ name: cg.title,
459
+ code: cg.code ?? '',
460
+ courseId: cg.course?.id ?? null,
461
+ courseName: cg.course?.title ?? null,
462
+ courseLogoUrl: cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-logo')?.file?.location ?? null,
463
+ courseBannerUrl: cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-banner')?.file?.location ?? null,
464
+ studentCount: cg._count.course_enrollment,
465
+ maxStudents: cg.capacity,
466
+ nextSession: nextSession
467
+ ? {
468
+ date: nextSession.session_date,
469
+ time: nextSession.start_time,
470
+ location: nextSession.location ?? null,
471
+ meetingUrl: nextSession.meeting_url ?? null,
472
+ }
473
+ : null,
474
+ progress: this.round(Math.min(Math.max(elapsed, 0), 100), 0),
475
+ status: cg.status,
476
+ deliveryMode: cg.delivery_mode,
477
+ startDate: cg.start_date,
478
+ endDate: cg.end_date ?? null,
479
+ pendingEvaluations,
480
+ };
481
+ });
482
+
483
+ return { data, total: data.length };
484
+ }
485
+
486
+ // ── Class-group detail endpoints ─────────────────────────────────────────────
487
+
488
+ private async assertInstructorOwnsClass(userId: number, classGroupId: number) {
489
+ const instructor = await this.prisma.instructor.findFirst({
490
+ where: { person: { person_user: { some: { user_id: userId } } } },
491
+ select: { id: true },
492
+ });
493
+ if (!instructor) throw new NotFoundException('No instructor profile found for this user');
494
+
495
+ const cg = await this.prisma.course_class_group.findFirst({
496
+ where: {
497
+ id: classGroupId,
498
+ OR: [
499
+ { instructor_id: instructor.id },
500
+ {
501
+ course_class_session: {
502
+ some: {
503
+ course_class_session_instructor: { some: { instructor_id: instructor.id } },
504
+ },
505
+ },
506
+ },
507
+ ],
508
+ },
509
+ select: { id: true },
510
+ });
511
+ if (!cg) throw new NotFoundException('Class group not found or not accessible to this instructor');
512
+ return { instructorId: instructor.id };
513
+ }
514
+
515
+ async getClassGroupDetail(userId: number, classGroupId: number) {
516
+ await this.assertInstructorOwnsClass(userId, classGroupId);
517
+ const now = new Date();
518
+
519
+ const cg = await this.prisma.course_class_group.findUnique({
520
+ where: { id: classGroupId },
521
+ include: {
522
+ course: { select: { id: true, title: true } },
523
+ instructor: { select: { person: { select: { name: true } } } },
524
+ _count: {
525
+ select: {
526
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
527
+ course_class_session: true,
528
+ },
529
+ },
530
+ course_class_session: {
531
+ where: { session_date: { gte: now } },
532
+ orderBy: { session_date: 'asc' },
533
+ take: 1,
534
+ select: { session_date: true, start_time: true, location: true, meeting_url: true },
535
+ },
536
+ },
537
+ });
538
+ if (!cg) throw new NotFoundException(`Class group #${classGroupId} not found`);
539
+
540
+ // completedSessions = sessions with session_date < now
541
+ const completedSessions = await this.prisma.course_class_session.count({
542
+ where: { course_class_group_id: classGroupId, session_date: { lt: now } },
543
+ });
544
+
545
+ // avgAttendance: mean of (present/total) per past session
546
+ const pastSessions = await this.prisma.course_class_session.findMany({
547
+ where: { course_class_group_id: classGroupId, session_date: { lt: now } },
548
+ select: {
549
+ id: true,
550
+ _count: { select: { course_class_attendance: true } },
551
+ course_class_attendance: {
552
+ where: { present: true },
553
+ select: { student_id: true },
554
+ },
555
+ },
556
+ });
557
+
558
+ const totalEnrolled = cg._count.course_enrollment;
559
+ let avgAttendance = 0;
560
+ if (pastSessions.length > 0 && totalEnrolled > 0) {
561
+ const pcts = pastSessions.map((s) =>
562
+ (s.course_class_attendance.length / totalEnrolled) * 100,
563
+ );
564
+ avgAttendance = this.round(pcts.reduce((a, b) => a + b, 0) / pcts.length, 0);
565
+ }
566
+
567
+ const nextSession = cg.course_class_session[0] ?? null;
568
+
569
+ return {
570
+ id: cg.id,
571
+ courseName: cg.course?.title ?? null,
572
+ className: cg.title,
573
+ code: cg.code,
574
+ instructorName: cg.instructor?.person?.name ?? null,
575
+ startDate: cg.start_date,
576
+ endDate: cg.end_date ?? null,
577
+ status: cg.status,
578
+ deliveryMode: cg.delivery_mode,
579
+ totalSlots: cg.capacity,
580
+ enrolledCount: totalEnrolled,
581
+ totalSessions: cg._count.course_class_session,
582
+ completedSessions,
583
+ avgAttendance,
584
+ nextSession: nextSession
585
+ ? {
586
+ date: nextSession.session_date,
587
+ time: nextSession.start_time,
588
+ location: nextSession.location ?? null,
589
+ meetingUrl: nextSession.meeting_url ?? null,
590
+ }
591
+ : null,
592
+ };
593
+ }
594
+
595
+ async getClassGroupStudents(
596
+ userId: number,
597
+ classGroupId: number,
598
+ params: { page?: number; pageSize?: number; search?: string; status?: string },
599
+ ) {
600
+ await this.assertInstructorOwnsClass(userId, classGroupId);
601
+ const page = Math.max(Number(params.page) || 1, 1);
602
+ const pageSize = Math.max(Number(params.pageSize) || 20, 1);
603
+ const skip = (page - 1) * pageSize;
604
+
605
+ const where: any = {
606
+ course_class_group_id: classGroupId,
607
+ status: { not: 'cancelled' },
608
+ };
609
+ if (params.search) {
610
+ where.person = { name: { contains: params.search, mode: 'insensitive' } };
611
+ }
612
+
613
+ const [enrollments, total, totalSessions] = await Promise.all([
614
+ this.prisma.course_enrollment.findMany({
615
+ where,
616
+ skip,
617
+ take: pageSize,
618
+ orderBy: { person: { name: 'asc' } },
619
+ include: {
620
+ person: { select: { id: true, name: true } },
621
+ },
622
+ }),
623
+ this.prisma.course_enrollment.count({ where }),
624
+ this.prisma.course_class_session.count({ where: { course_class_group_id: classGroupId } }),
625
+ ]);
626
+
627
+ if (enrollments.length === 0) {
628
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data: [] };
629
+ }
630
+
631
+ // Fetch attendance counts per student in one query
632
+ const personIds = enrollments.map((e) => e.person_id).filter(Boolean) as number[];
633
+ const attendanceRows = await this.prisma.course_class_attendance.groupBy({
634
+ by: ['student_id'],
635
+ where: { student_id: { in: personIds }, present: true },
636
+ _count: { id: true },
637
+ });
638
+ const attendanceMap = new Map(attendanceRows.map((r) => [r.student_id, r._count.id]));
639
+
640
+ const data = enrollments.map((e) => {
641
+ const presentCount = attendanceMap.get(e.person_id ?? 0) ?? 0;
642
+ const attendance = totalSessions > 0 ? this.round((presentCount / totalSessions) * 100, 0) : 0;
643
+ const progress = e.progress_percent ?? 0;
644
+ const grade = e.final_score != null ? Number(e.final_score) : null;
645
+ let status: 'ok' | 'risk' | 'inactive';
646
+ if (e.status === 'paused') status = 'inactive';
647
+ else if (attendance < 60 || progress < 50) status = 'risk';
648
+ else status = 'ok';
649
+
650
+ // Filter by status if requested
651
+ return { _status: status, id: e.person_id, name: e.person?.name ?? null, avatarUrl: null, progress, attendance, grade, status };
652
+ }).filter((s) => !params.status || s.status === params.status);
653
+
654
+ return {
655
+ total,
656
+ page,
657
+ pageSize,
658
+ lastPage: Math.max(1, Math.ceil(total / pageSize)),
659
+ data: data.map(({ _status: _, ...rest }) => rest),
660
+ };
661
+ }
662
+
663
+ async getClassGroupSessions(userId: number, classGroupId: number) {
664
+ await this.assertInstructorOwnsClass(userId, classGroupId);
665
+ const now = new Date();
666
+
667
+ const sessions = await this.prisma.course_class_session.findMany({
668
+ where: { course_class_group_id: classGroupId },
669
+ orderBy: { session_date: 'asc' },
670
+ select: {
671
+ id: true,
672
+ title: true,
673
+ session_date: true,
674
+ start_time: true,
675
+ end_time: true,
676
+ location: true,
677
+ meeting_url: true,
678
+ _count: { select: { course_class_attendance: true } },
679
+ course_class_attendance: {
680
+ where: { present: true },
681
+ select: { student_id: true },
682
+ },
683
+ },
684
+ });
685
+
686
+ const totalStudents = await this.prisma.course_enrollment.count({
687
+ where: { course_class_group_id: classGroupId, status: { not: 'cancelled' } },
688
+ });
689
+
690
+ let hasFoundNext = false;
691
+ return sessions.map((s) => {
692
+ const isPast = s.session_date < now;
693
+ let status: 'completed' | 'next' | 'upcoming';
694
+ if (isPast) {
695
+ status = 'completed';
696
+ } else if (!hasFoundNext) {
697
+ status = 'next';
698
+ hasFoundNext = true;
699
+ } else {
700
+ status = 'upcoming';
701
+ }
702
+
703
+ return {
704
+ id: s.id,
705
+ title: s.title,
706
+ date: s.session_date,
707
+ time: s.start_time,
708
+ endTime: s.end_time ?? null,
709
+ location: s.location ?? null,
710
+ meetingUrl: s.meeting_url ?? null,
711
+ status,
712
+ attendanceCount: isPast ? s.course_class_attendance.length : null,
713
+ totalStudents,
714
+ };
715
+ });
716
+ }
717
+
718
+ async getClassGroupAttendance(userId: number, classGroupId: number) {
719
+ await this.assertInstructorOwnsClass(userId, classGroupId);
720
+ const now = new Date();
721
+
722
+ const [sessions, enrollments] = await Promise.all([
723
+ this.prisma.course_class_session.findMany({
724
+ where: { course_class_group_id: classGroupId, session_date: { lt: now } },
725
+ orderBy: { session_date: 'asc' },
726
+ select: {
727
+ id: true,
728
+ title: true,
729
+ session_date: true,
730
+ course_class_attendance: {
731
+ select: { student_id: true, present: true },
732
+ },
733
+ },
734
+ }),
735
+ this.prisma.course_enrollment.findMany({
736
+ where: { course_class_group_id: classGroupId, status: { not: 'cancelled' } },
737
+ select: { person_id: true, person: { select: { name: true } } },
738
+ orderBy: { person: { name: 'asc' } },
739
+ }),
740
+ ]);
741
+
742
+ return {
743
+ students: enrollments.map((e) => ({
744
+ studentId: e.person_id,
745
+ studentName: e.person?.name ?? null,
746
+ })),
747
+ sessions: sessions.map((s) => {
748
+ const attendanceMap = new Map(s.course_class_attendance.map((a) => [a.student_id, a.present]));
749
+ return {
750
+ sessionId: s.id,
751
+ title: s.title,
752
+ date: s.session_date,
753
+ records: enrollments.map((e) => ({
754
+ studentId: e.person_id,
755
+ present: attendanceMap.get(e.person_id ?? 0) ?? null,
756
+ })),
757
+ };
758
+ }),
759
+ };
760
+ }
761
+
762
+ async getClassGroupMaterials(userId: number, classGroupId: number) {
763
+ await this.assertInstructorOwnsClass(userId, classGroupId);
764
+
765
+ const materials = await this.prisma.course_class_group_material.findMany({
766
+ where: { course_class_group_id: classGroupId },
767
+ orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
768
+ select: {
769
+ id: true,
770
+ title: true,
771
+ description: true,
772
+ material_type: true,
773
+ file_id: true,
774
+ url: true,
775
+ course_class_session_id: true,
776
+ sort_order: true,
777
+ created_at: true,
778
+ },
779
+ });
780
+
781
+ return materials.map((m) => ({
782
+ id: m.id,
783
+ title: m.title,
784
+ description: m.description ?? null,
785
+ materialType: m.material_type,
786
+ fileId: m.file_id ?? null,
787
+ url: m.url ?? null,
788
+ sessionId: m.course_class_session_id ?? null,
789
+ sortOrder: m.sort_order,
790
+ createdAt: m.created_at?.toISOString() ?? null,
791
+ }));
792
+ }
793
+
794
+ async getMaterialDownloadUrl(userId: number, classGroupId: number, materialId: number) {
795
+ await this.assertInstructorOwnsClass(userId, classGroupId);
796
+
797
+ const material = await this.prisma.course_class_group_material.findFirst({
798
+ where: { id: materialId, course_class_group_id: classGroupId },
799
+ select: { file_id: true },
800
+ });
801
+
802
+ if (!material) {
803
+ throw new NotFoundException('Material not found');
804
+ }
805
+
806
+ if (!material.file_id) {
807
+ throw new NotFoundException('This material has no associated file');
808
+ }
809
+
810
+ const file = await this.fileService.get(material.file_id);
811
+ return { url: await this.fileService.tempURL(file.path) };
812
+ }
813
+
814
+ async uploadMaterial(userId: number, classGroupId: number, file: MulterFile) {
815
+ await this.assertInstructorOwnsClass(userId, classGroupId);
816
+
817
+ const uploaded = await this.fileService.upload('class-materials', file);
818
+
819
+ const material = await this.prisma.course_class_group_material.create({
820
+ data: {
821
+ course_class_group_id: classGroupId,
822
+ title: uploaded.filename,
823
+ material_type: 'file' as any,
824
+ file_id: uploaded.id,
825
+ sort_order: 0,
826
+ },
827
+ select: {
828
+ id: true,
829
+ title: true,
830
+ description: true,
831
+ material_type: true,
832
+ file_id: true,
833
+ url: true,
834
+ course_class_session_id: true,
835
+ sort_order: true,
836
+ created_at: true,
837
+ },
838
+ });
839
+
840
+ return {
841
+ id: material.id,
842
+ title: material.title,
843
+ description: material.description ?? null,
844
+ materialType: material.material_type,
845
+ fileId: material.file_id ?? null,
846
+ url: material.url ?? null,
847
+ sessionId: material.course_class_session_id ?? null,
848
+ sortOrder: material.sort_order,
849
+ createdAt: material.created_at?.toISOString() ?? null,
850
+ };
851
+ }
852
+
853
+ async deleteMaterial(userId: number, classGroupId: number, materialId: number) {
854
+ await this.assertInstructorOwnsClass(userId, classGroupId);
855
+
856
+ const material = await this.prisma.course_class_group_material.findFirst({
857
+ where: { id: materialId, course_class_group_id: classGroupId },
858
+ select: { id: true },
859
+ });
860
+
861
+ if (!material) {
862
+ throw new NotFoundException('Material not found');
863
+ }
864
+
865
+ await this.prisma.course_class_group_material.delete({
866
+ where: { id: materialId },
867
+ });
868
+
869
+ return { success: true };
870
+ }
871
+
872
+ async getClassEvaluations(userId: number, classGroupId: number) {
873
+ await this.assertInstructorOwnsClass(userId, classGroupId);
874
+
875
+ // Fetch sessions of this class group
876
+ const sessions = await this.prisma.course_class_session.findMany({
877
+ where: { course_class_group_id: classGroupId },
878
+ select: { id: true, title: true, session_date: true },
879
+ orderBy: { session_date: 'asc' },
880
+ });
881
+
882
+ const sessionIds = sessions.map((s) => s.id);
883
+ if (sessionIds.length === 0) {
884
+ return {
885
+ totalRatings: 0,
886
+ averageScore: 0,
887
+ distribution: [
888
+ { range: '9–10', count: 0 },
889
+ { range: '7–8', count: 0 },
890
+ { range: '5–6', count: 0 },
891
+ { range: '0–4', count: 0 },
892
+ ],
893
+ sessions: [],
894
+ };
895
+ }
896
+
897
+ // Fetch active evaluation topics for these sessions (anonymous — scores + comments, per-topic names)
898
+ const topics = await this.prisma.evaluation_topic.findMany({
899
+ where: {
900
+ course_class_session_id: { in: sessionIds },
901
+ target_type: 'course_class_session',
902
+ is_active: true,
903
+ },
904
+ select: {
905
+ id: true,
906
+ name: true,
907
+ course_class_session_id: true,
908
+ evaluation_rating: { select: { score: true, comment: true } },
909
+ },
910
+ });
911
+
912
+ // Build per-session aggregations (scores, anonymous comments, per-topic breakdown)
913
+ type SessionAgg = {
914
+ scores: number[];
915
+ comments: string[];
916
+ topics: Map<number, { name: string; scores: number[] }>;
917
+ };
918
+ const sessionMap = new Map<number, SessionAgg>();
919
+ for (const sessId of sessionIds) {
920
+ sessionMap.set(sessId, { scores: [], comments: [], topics: new Map() });
921
+ }
922
+ for (const topic of topics) {
923
+ const sessId = topic.course_class_session_id;
924
+ if (sessId == null) continue;
925
+ const agg = sessionMap.get(sessId);
926
+ if (!agg) continue;
927
+ if (!agg.topics.has(topic.id)) {
928
+ agg.topics.set(topic.id, { name: topic.name, scores: [] });
929
+ }
930
+ const topicAgg = agg.topics.get(topic.id)!;
931
+ for (const r of topic.evaluation_rating) {
932
+ const score = Number(r.score);
933
+ agg.scores.push(score);
934
+ topicAgg.scores.push(score);
935
+ if (r.comment) agg.comments.push(r.comment);
936
+ }
937
+ }
938
+
939
+ const allScores = [...sessionMap.values()].flatMap((a) => a.scores);
940
+ const totalRatings = allScores.length;
941
+ const averageScore =
942
+ totalRatings > 0 ? this.round(allScores.reduce((a, b) => a + b, 0) / totalRatings, 1) : 0;
943
+
944
+ const distribution = [
945
+ { range: '9–10', count: allScores.filter((s) => s >= 9).length },
946
+ { range: '7–8', count: allScores.filter((s) => s >= 7 && s < 9).length },
947
+ { range: '5–6', count: allScores.filter((s) => s >= 5 && s < 7).length },
948
+ { range: '0–4', count: allScores.filter((s) => s < 5).length },
949
+ ];
950
+
951
+ const sessionData = sessions.map((s) => {
952
+ const agg = sessionMap.get(s.id) ?? {
953
+ scores: [], comments: [], topics: new Map<number, { name: string; scores: number[] }>(),
954
+ };
955
+ const avg =
956
+ agg.scores.length > 0
957
+ ? this.round(agg.scores.reduce((a, b) => a + b, 0) / agg.scores.length, 1)
958
+ : null;
959
+ const topicBreakdown = Array.from(agg.topics.entries()).map(([topicId, { name, scores }]) => ({
960
+ topicId,
961
+ name,
962
+ avgScore:
963
+ scores.length > 0
964
+ ? this.round(scores.reduce((a, b) => a + b, 0) / scores.length, 1)
965
+ : null,
966
+ }));
967
+ return {
968
+ sessionId: s.id,
969
+ title: s.title,
970
+ date: s.session_date,
971
+ avgScore: avg,
972
+ ratingCount: agg.scores.length,
973
+ topics: topicBreakdown,
974
+ comments: agg.comments,
975
+ };
976
+ });
977
+
978
+ return { totalRatings, averageScore, distribution, sessions: sessionData };
979
+ }
980
+
981
+ async getReports(userId: number, enterpriseId?: number, dateFrom?: string, dateTo?: string) {
982
+ const instructor = await this.prisma.instructor.findFirst({
983
+ where: { person: { person_user: { some: { user_id: userId } } } },
984
+ select: { id: true },
985
+ });
986
+ if (!instructor) throw new NotFoundException('No instructor profile found for this user');
987
+
988
+ const now = new Date();
989
+
990
+ // Date range for period filtering
991
+ const startDate = dateFrom ? new Date(dateFrom) : new Date(now.getFullYear(), 0, 1);
992
+ const endDate = dateTo ? new Date(dateTo + 'T23:59:59.999Z') : new Date(now.getFullYear(), 11, 31, 23, 59, 59);
993
+ const sessionDateFilter = { gte: startDate, lte: endDate };
994
+
995
+ // Treat enterpriseId=0 as "no filter" (frontend may send 0 when no enterprise is selected)
996
+ const effectiveEnterpriseId = enterpriseId && enterpriseId > 0 ? enterpriseId : undefined;
997
+
998
+ const enterpriseFilter = effectiveEnterpriseId !== undefined
999
+ ? { enterprise_class_group: { some: { enterprise_id: effectiveEnterpriseId } } }
1000
+ : {};
1001
+
1002
+ // ── 1. Collect all class groups taught by this instructor ──────────────────
1003
+ const sessionRows = await this.prisma.course_class_session.findMany({
1004
+ where: {
1005
+ course_class_session_instructor: { some: { instructor_id: instructor.id } },
1006
+ session_date: sessionDateFilter,
1007
+ ...(effectiveEnterpriseId !== undefined ? { course_class_group: enterpriseFilter } : {}),
1008
+ },
1009
+ select: { course_class_group_id: true },
1010
+ distinct: ['course_class_group_id'],
1011
+ });
1012
+ const cgIds = sessionRows
1013
+ .map((r) => r.course_class_group_id)
1014
+ .filter((id): id is number => id != null);
1015
+
1016
+ const CLASS_COLORS = ['#F97316', '#6366F1', '#10B981', '#0EA5E9', '#8B5CF6', '#F43F5E'];
1017
+
1018
+ if (cgIds.length === 0) {
1019
+ return {
1020
+ kpis: { totalStudents: 0, averageEvaluationScore: 0, averageAttendance: 0, sessionsCompleted: 0 },
1021
+ classes: [],
1022
+ topicNames: [],
1023
+ evaluationsBySession: [],
1024
+ attendanceBySession: [],
1025
+ studentGrades: [],
1026
+ };
1027
+ }
1028
+
1029
+ const classGroupRows = await this.prisma.course_class_group.findMany({
1030
+ where: { id: { in: cgIds } },
1031
+ orderBy: { start_date: 'asc' },
1032
+ select: { id: true, title: true },
1033
+ take: 6,
1034
+ });
1035
+ const cgIdsCapped = classGroupRows.map((cg) => cg.id);
1036
+ const classes = classGroupRows.map((cg, i) => ({
1037
+ key: `t${i + 1}` as string,
1038
+ name: cg.title,
1039
+ color: CLASS_COLORS[i] ?? '#94A3B8',
1040
+ }));
1041
+ const classKeyById = new Map(classGroupRows.map((cg, i) => [cg.id, `t${i + 1}`]));
1042
+ const classNameById = new Map(classGroupRows.map((cg) => [cg.id, cg.title]));
1043
+
1044
+ // ── 2. Parallel data fetches ───────────────────────────────────────────────
1045
+ const [allSessions, evalTopics, attendanceRows, enrollmentRows, completedSessionCount] =
1046
+ await Promise.all([
1047
+ // All sessions for these class groups within the date range
1048
+ this.prisma.course_class_session.findMany({
1049
+ where: { course_class_group_id: { in: cgIdsCapped }, session_date: sessionDateFilter },
1050
+ select: {
1051
+ id: true,
1052
+ title: true,
1053
+ session_date: true,
1054
+ course_class_group_id: true,
1055
+ },
1056
+ orderBy: { session_date: 'asc' },
1057
+ }),
1058
+ // Evaluation topics+ratings for sessions taught by this instructor within the date range
1059
+ this.prisma.evaluation_topic.findMany({
1060
+ where: {
1061
+ target_type: 'course_class_session',
1062
+ is_active: true,
1063
+ course_class_session: {
1064
+ course_class_group_id: { in: cgIdsCapped },
1065
+ session_date: sessionDateFilter,
1066
+ },
1067
+ },
1068
+ select: {
1069
+ id: true,
1070
+ name: true,
1071
+ course_class_session_id: true,
1072
+ evaluation_rating: { select: { score: true } },
1073
+ },
1074
+ }),
1075
+ // Attendance records for sessions in the date range
1076
+ this.prisma.course_class_attendance.findMany({
1077
+ where: {
1078
+ course_class_session: {
1079
+ course_class_group_id: { in: cgIdsCapped },
1080
+ session_date: { gte: startDate, lt: now < endDate ? now : endDate },
1081
+ },
1082
+ },
1083
+ select: {
1084
+ course_class_session_id: true,
1085
+ student_id: true,
1086
+ present: true,
1087
+ },
1088
+ }),
1089
+ // Enrollment final scores (notas de prova)
1090
+ this.prisma.course_enrollment.findMany({
1091
+ where: {
1092
+ course_class_group_id: { in: cgIdsCapped },
1093
+ final_score: { not: null },
1094
+ status: { not: 'cancelled' },
1095
+ },
1096
+ select: {
1097
+ course_class_group_id: true,
1098
+ final_score: true,
1099
+ person: { select: { name: true } },
1100
+ },
1101
+ orderBy: { final_score: 'desc' },
1102
+ }),
1103
+ // Completed sessions count within the date range
1104
+ this.prisma.course_class_session.count({
1105
+ where: {
1106
+ course_class_group_id: { in: cgIdsCapped },
1107
+ session_date: { gte: startDate, lt: now < endDate ? now : endDate },
1108
+ },
1109
+ }),
1110
+ ]);
1111
+
1112
+ // ── 3. KPIs ────────────────────────────────────────────────────────────────
1113
+ const totalStudents = await this.prisma.course_enrollment.count({
1114
+ where: {
1115
+ course_class_group_id: { in: cgIdsCapped },
1116
+ status: { not: 'cancelled' },
1117
+ },
1118
+ });
1119
+
1120
+ // Average evaluation score: compute from already-filtered evalTopics
1121
+ const allEvalScores: number[] = [];
1122
+ for (const topic of evalTopics) {
1123
+ for (const r of topic.evaluation_rating) allEvalScores.push(Number(r.score));
1124
+ }
1125
+ const averageEvaluationScore =
1126
+ allEvalScores.length > 0
1127
+ ? this.round(allEvalScores.reduce((a, b) => a + b, 0) / allEvalScores.length, 2)
1128
+ : 0;
1129
+
1130
+ // Average attendance: per session, count present / total records
1131
+ const pastSessions = allSessions.filter((s) => s.session_date < now);
1132
+ let attendanceSum = 0;
1133
+ let attendanceDenom = 0;
1134
+ for (const sess of pastSessions) {
1135
+ const records = attendanceRows.filter((r) => r.course_class_session_id === sess.id);
1136
+ if (records.length > 0) {
1137
+ attendanceSum += records.filter((r) => r.present).length;
1138
+ attendanceDenom += records.length;
1139
+ }
1140
+ }
1141
+ const averageAttendance =
1142
+ attendanceDenom > 0 ? this.round((attendanceSum / attendanceDenom) * 100, 0) : 0;
1143
+
1144
+ // ── 4. Evaluations by session ──────────────────────────────────────────────
1145
+ // Build session → { topics: Map<name, scores[]> }
1146
+ type SessEvalAgg = { topics: Map<string, number[]> };
1147
+ const sessEvalMap = new Map<number, SessEvalAgg>();
1148
+ for (const s of allSessions) sessEvalMap.set(s.id, { topics: new Map() });
1149
+
1150
+ // Collect all unique topic names
1151
+ const allTopicNames = new Set<string>();
1152
+ for (const topic of evalTopics) {
1153
+ if (topic.course_class_session_id == null) continue;
1154
+ allTopicNames.add(topic.name);
1155
+ const agg = sessEvalMap.get(topic.course_class_session_id);
1156
+ if (!agg) continue;
1157
+ if (!agg.topics.has(topic.name)) agg.topics.set(topic.name, []);
1158
+ for (const r of topic.evaluation_rating) {
1159
+ agg.topics.get(topic.name)!.push(Number(r.score));
1160
+ }
1161
+ }
1162
+ const topicNames = Array.from(allTopicNames).sort();
1163
+
1164
+ const evaluationsBySession = allSessions
1165
+ .filter((s) => {
1166
+ const agg = sessEvalMap.get(s.id);
1167
+ return agg && [...agg.topics.values()].some((scores) => scores.length > 0);
1168
+ })
1169
+ .map((s) => {
1170
+ const agg = sessEvalMap.get(s.id)!;
1171
+ const topicsObj: Record<string, number | null> = {};
1172
+ let totalScore = 0;
1173
+ let totalCount = 0;
1174
+ for (const tName of topicNames) {
1175
+ const scores = agg.topics.get(tName) ?? [];
1176
+ if (scores.length > 0) {
1177
+ const avg = this.round(scores.reduce((a, b) => a + b, 0) / scores.length, 2);
1178
+ topicsObj[tName] = avg;
1179
+ totalScore += avg;
1180
+ totalCount++;
1181
+ } else {
1182
+ topicsObj[tName] = null;
1183
+ }
1184
+ }
1185
+ const media = totalCount > 0 ? this.round(totalScore / totalCount, 2) : 0;
1186
+ const classKey = classKeyById.get(s.course_class_group_id ?? -1) ?? null;
1187
+ const className = classNameById.get(s.course_class_group_id ?? -1) ?? null;
1188
+ // Short label: first 20 chars of title
1189
+ const label = s.title.length > 20 ? `${s.title.slice(0, 18)}…` : s.title;
1190
+ return {
1191
+ sessionId: s.id,
1192
+ label,
1193
+ date: s.session_date,
1194
+ classKey,
1195
+ className,
1196
+ topics: topicsObj,
1197
+ media,
1198
+ hi: Math.min(5, this.round(media + 0.5, 2)),
1199
+ lo: Math.max(0, this.round(media - 0.5, 2)),
1200
+ };
1201
+ });
1202
+
1203
+ // ── 5. Attendance by session ───────────────────────────────────────────────
1204
+ const attendanceBySession = pastSessions
1205
+ .filter((s) => attendanceRows.some((r) => r.course_class_session_id === s.id))
1206
+ .map((s) => {
1207
+ const records = attendanceRows.filter((r) => r.course_class_session_id === s.id);
1208
+ const presentCount = records.filter((r) => r.present).length;
1209
+ const classKey = classKeyById.get(s.course_class_group_id ?? -1) ?? null;
1210
+ const className = classNameById.get(s.course_class_group_id ?? -1) ?? null;
1211
+ const label = s.title.length > 12 ? `${s.title.slice(0, 10)}…` : s.title;
1212
+ return {
1213
+ sessionId: s.id,
1214
+ label,
1215
+ date: s.session_date,
1216
+ classKey,
1217
+ className,
1218
+ presentCount,
1219
+ };
1220
+ });
1221
+
1222
+ // ── 6. Student grades ──────────────────────────────────────────────────────
1223
+ const studentGrades = enrollmentRows
1224
+ .filter((e) => e.final_score != null)
1225
+ .map((e) => ({
1226
+ name: e.person?.name ?? 'Aluno',
1227
+ grade: Number(e.final_score),
1228
+ classKey: classKeyById.get(e.course_class_group_id ?? -1) ?? null,
1229
+ className: classNameById.get(e.course_class_group_id ?? -1) ?? null,
1230
+ }))
1231
+ .sort((a, b) => b.grade - a.grade);
1232
+
1233
+ return {
1234
+ kpis: {
1235
+ totalStudents,
1236
+ averageEvaluationScore,
1237
+ averageAttendance,
1238
+ sessionsCompleted: completedSessionCount,
1239
+ },
1240
+ classes,
1241
+ topicNames,
1242
+ evaluationsBySession,
1243
+ attendanceBySession,
1244
+ studentGrades,
1245
+ };
1246
+ }
1247
+
1248
+ private round(value: number, digits = 0) {
1249
+ const factor = 10 ** digits;
1250
+ return Math.round(value * factor) / factor;
1251
+ }
1252
+
1253
+ private addDays(date: Date, days: number) {
1254
+ const result = new Date(date);
1255
+ result.setDate(result.getDate() + days);
1256
+ return result;
1257
+ }
1258
+
1259
+ async submitSessionAttendance(
1260
+ userId: number,
1261
+ classGroupId: number,
1262
+ sessionId: number,
1263
+ records: { studentId: number; present: boolean }[],
1264
+ ) {
1265
+ await this.assertInstructorOwnsClass(userId, classGroupId);
1266
+
1267
+ const session = await this.prisma.course_class_session.findFirst({
1268
+ where: { id: sessionId, course_class_group_id: classGroupId },
1269
+ select: { id: true },
1270
+ });
1271
+ if (!session) throw new NotFoundException('Session not found in this class group');
1272
+
1273
+ await this.prisma.$transaction([
1274
+ this.prisma.course_class_attendance.deleteMany({
1275
+ where: { course_class_session_id: sessionId },
1276
+ }),
1277
+ this.prisma.course_class_attendance.createMany({
1278
+ data: records.map((r) => ({
1279
+ course_class_session_id: sessionId,
1280
+ student_id: r.studentId,
1281
+ present: r.present,
1282
+ })),
1283
+ }),
1284
+ ]);
1285
+
1286
+ return { success: true, count: records.length };
1287
+ }
1288
+
1289
+ private endOfDay(date: Date) {
1290
+ const result = new Date(date);
1291
+ result.setHours(23, 59, 59, 999);
1292
+ return result;
1293
+ }
1294
+
1295
+ private startOfWeek(date: Date) {
1296
+ const result = new Date(date);
1297
+ result.setHours(0, 0, 0, 0);
1298
+ const day = result.getDay();
1299
+ const diff = day === 0 ? -6 : 1 - day;
1300
+ result.setDate(result.getDate() + diff);
1301
+ return result;
1302
+ }
1303
+ }