@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,762 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { Injectable, NotFoundException } from '@nestjs/common';
3
+
4
+ @Injectable()
5
+ export class TrainingStudentService {
6
+ constructor(private readonly prisma: PrismaService) {}
7
+
8
+ async getDashboard(userId: number, enterpriseId?: number) {
9
+ const personUser = await this.prisma.person_user.findFirst({
10
+ where: { user_id: userId },
11
+ select: { person_id: true },
12
+ });
13
+
14
+ if (!personUser) {
15
+ throw new NotFoundException('No person profile found for this user');
16
+ }
17
+
18
+ const personId = personUser.person_id;
19
+ const now = new Date();
20
+ const weekStart = this.startOfWeek(now);
21
+ const weekEnd = this.endOfDay(this.addDays(weekStart, 6));
22
+
23
+ // Fetch all active enrollments for this student
24
+ const enrollments = await this.prisma.course_enrollment.findMany({
25
+ where: { person_id: personId, status: { not: 'cancelled' } },
26
+ select: {
27
+ id: true,
28
+ course_id: true,
29
+ course_class_group_id: true,
30
+ status: true,
31
+ progress_percent: true,
32
+ final_score: true,
33
+ enrolled_at: true,
34
+ completed_at: true,
35
+ },
36
+ });
37
+
38
+ let classEnrollments = enrollments.filter((e) => e.course_class_group_id != null);
39
+
40
+ // If enterpriseId provided, restrict to class groups linked to that enterprise
41
+ if (enterpriseId !== undefined) {
42
+ const enterpriseCgRows = await this.prisma.enterprise_class_group.findMany({
43
+ where: { enterprise_id: enterpriseId },
44
+ select: { course_class_group_id: true },
45
+ });
46
+ const enterpriseCgIds = new Set(enterpriseCgRows.map((r) => r.course_class_group_id));
47
+ classEnrollments = classEnrollments.filter((e) => enterpriseCgIds.has(e.course_class_group_id!));
48
+ }
49
+
50
+ const onDemandEnrollments = enrollments.filter((e) => e.course_class_group_id == null);
51
+ const cgIds = classEnrollments
52
+ .map((e) => e.course_class_group_id)
53
+ .filter((id): id is number => id != null);
54
+ const enrollmentIds = enrollments.map((e) => e.id);
55
+
56
+ const [kpis, myClasses, onDemandCourses, weeklySchedule, ranking, gamification] = await Promise.all([
57
+ this.getKpis(personId, enrollments, enrollmentIds),
58
+ this.getMyClasses(classEnrollments, now),
59
+ this.getOnDemandCourses(onDemandEnrollments),
60
+ this.getWeeklySchedule(cgIds, weekStart, weekEnd),
61
+ this.getRanking(personId, cgIds),
62
+ this.getGamification(personId, enrollmentIds, weekStart, weekEnd),
63
+ ]);
64
+
65
+ const myRanking = ranking.find((r) => r.isMe);
66
+
67
+ return {
68
+ kpis,
69
+ myClasses,
70
+ onDemandCourses,
71
+ weeklySchedule,
72
+ ranking,
73
+ gamification: {
74
+ ...gamification,
75
+ rankingPosition: myRanking?.position ?? null,
76
+ },
77
+ };
78
+ }
79
+
80
+ async getClassGroups(
81
+ userId: number,
82
+ options: {
83
+ enterpriseId?: number;
84
+ search?: string;
85
+ status?: string;
86
+ deliveryMode?: string;
87
+ } = {},
88
+ ) {
89
+ const personUser = await this.prisma.person_user.findFirst({
90
+ where: { user_id: userId },
91
+ select: { person_id: true },
92
+ });
93
+
94
+ if (!personUser) {
95
+ throw new NotFoundException('No person profile found for this user');
96
+ }
97
+
98
+ const personId = personUser.person_id;
99
+ const { enterpriseId, search, status, deliveryMode } = options;
100
+ const now = new Date();
101
+
102
+ let cgFilter: any = {};
103
+ if (enterpriseId !== undefined) {
104
+ cgFilter = { enterprise_class_group: { some: { enterprise_id: enterpriseId } } };
105
+ }
106
+ if (status) {
107
+ cgFilter = { ...cgFilter, status: status as any };
108
+ }
109
+ if (deliveryMode) {
110
+ cgFilter = { ...cgFilter, delivery_mode: deliveryMode as any };
111
+ }
112
+ if (search) {
113
+ cgFilter = {
114
+ ...cgFilter,
115
+ OR: [
116
+ { title: { contains: search, mode: 'insensitive' as const } },
117
+ { code: { contains: search, mode: 'insensitive' as const } },
118
+ { course: { title: { contains: search, mode: 'insensitive' as const } } },
119
+ ],
120
+ };
121
+ }
122
+
123
+ const enrollments = await this.prisma.course_enrollment.findMany({
124
+ where: {
125
+ person_id: personId,
126
+ status: { not: 'cancelled' },
127
+ course_class_group_id: { not: null },
128
+ course_class_group: Object.keys(cgFilter).length > 0 ? cgFilter : undefined,
129
+ },
130
+ include: {
131
+ course_class_group: {
132
+ include: {
133
+ course: {
134
+ select: {
135
+ id: true,
136
+ title: true,
137
+ course_image: {
138
+ where: { image_type: { slug: { in: ['course-logo', 'course-banner'] } } },
139
+ orderBy: { is_primary: 'desc' as const },
140
+ select: {
141
+ file: { select: { location: true } },
142
+ image_type: { select: { slug: true } },
143
+ },
144
+ },
145
+ },
146
+ },
147
+ instructor: {
148
+ include: { person: { select: { name: true } } },
149
+ },
150
+ course_class_session: {
151
+ where: { session_date: { gte: now } },
152
+ orderBy: { session_date: 'asc' },
153
+ take: 1,
154
+ select: { session_date: true, start_time: true, location: true },
155
+ },
156
+ },
157
+ },
158
+ },
159
+ orderBy: { enrolled_at: 'desc' },
160
+ });
161
+
162
+ // Compute session totals per class group for hour estimation
163
+ const cgIds = enrollments
164
+ .map((e) => e.course_class_group_id)
165
+ .filter((id): id is number => id != null);
166
+
167
+ const [totalSessionCounts, pastSessionCounts] =
168
+ cgIds.length > 0
169
+ ? await Promise.all([
170
+ this.prisma.course_class_session.groupBy({
171
+ by: ['course_class_group_id'],
172
+ where: { course_class_group_id: { in: cgIds } },
173
+ _count: { id: true },
174
+ }),
175
+ this.prisma.course_class_session.groupBy({
176
+ by: ['course_class_group_id'],
177
+ where: { course_class_group_id: { in: cgIds }, session_date: { lt: now } },
178
+ _count: { id: true },
179
+ }),
180
+ ])
181
+ : [[], []];
182
+
183
+ const totalSessionMap = new Map(totalSessionCounts.map((r) => [r.course_class_group_id, r._count.id]));
184
+ const pastSessionMap = new Map(pastSessionCounts.map((r) => [r.course_class_group_id, r._count.id]));
185
+
186
+ const HOURS_PER_SESSION = 2;
187
+
188
+ const data = enrollments.map((enrollment) => {
189
+ const cg = enrollment.course_class_group;
190
+ if (!cg) return null;
191
+ const nextSession = cg.course_class_session[0] ?? null;
192
+ const totalSessions = totalSessionMap.get(cg.id) ?? 0;
193
+ const pastSessions = pastSessionMap.get(cg.id) ?? 0;
194
+
195
+ return {
196
+ id: enrollment.id,
197
+ classGroupId: cg.id,
198
+ courseId: cg.course?.id ?? null,
199
+ courseName: cg.course?.title ?? null,
200
+ courseLogoUrl:
201
+ cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-logo')?.file
202
+ ?.location ?? null,
203
+ courseBannerUrl:
204
+ cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-banner')?.file
205
+ ?.location ?? null,
206
+ className: cg.title,
207
+ code: cg.code ?? '',
208
+ instructorName: (cg as any).instructor?.person?.name ?? null,
209
+ instructorAvatarUrl: null as string | null,
210
+ progress: enrollment.progress_percent ?? 0,
211
+ nextSession: nextSession
212
+ ? {
213
+ date: nextSession.session_date,
214
+ time: nextSession.start_time,
215
+ location: nextSession.location ?? null,
216
+ }
217
+ : null,
218
+ status: cg.status,
219
+ deliveryMode: cg.delivery_mode ?? null,
220
+ totalHours: totalSessions * HOURS_PER_SESSION,
221
+ completedHours: pastSessions * HOURS_PER_SESSION,
222
+ };
223
+ }).filter((item): item is NonNullable<typeof item> => item !== null);
224
+
225
+ return { data, total: data.length };
226
+ }
227
+
228
+ // ── Private helpers ───────────────────────────────────────────────────────────
229
+
230
+ private async getKpis(
231
+ personId: number,
232
+ enrollments: Array<{
233
+ id: number;
234
+ status: string;
235
+ progress_percent: number | null;
236
+ }>,
237
+ enrollmentIds: number[],
238
+ ) {
239
+ const completedCourses = enrollments.filter((e) => e.status === 'completed').length;
240
+ const activeEnrollments = enrollments.filter((e) =>
241
+ ['active', 'completed'].includes(e.status),
242
+ );
243
+
244
+ const overallProgress =
245
+ activeEnrollments.length > 0
246
+ ? this.round(
247
+ activeEnrollments.reduce((sum, e) => sum + (e.progress_percent ?? 0), 0) /
248
+ activeEnrollments.length,
249
+ 0,
250
+ )
251
+ : 0;
252
+
253
+ const [pendingEvaluations, attendanceData] = await Promise.all([
254
+ enrollmentIds.length > 0
255
+ ? this.prisma.exam_attempt.count({
256
+ where: {
257
+ course_enrollment_id: { in: enrollmentIds },
258
+ status: { not: 'completed' },
259
+ },
260
+ })
261
+ : Promise.resolve(0),
262
+ this.prisma.course_class_attendance.findMany({
263
+ where: { student_id: personId },
264
+ select: { present: true },
265
+ }),
266
+ ]);
267
+
268
+ const totalAttendance = (attendanceData as Array<{ present: boolean }>).length;
269
+ const presentAttendance = (attendanceData as Array<{ present: boolean }>).filter(
270
+ (a) => a.present,
271
+ ).length;
272
+ const attendancePct =
273
+ totalAttendance > 0 ? this.round((presentAttendance / totalAttendance) * 100, 0) : 0;
274
+
275
+ return {
276
+ attendancePct: {
277
+ value: attendancePct,
278
+ change: null,
279
+ changeType: 'percent' as const,
280
+ },
281
+ overallProgress: {
282
+ value: overallProgress,
283
+ change: null,
284
+ changeType: 'percent' as const,
285
+ },
286
+ completedCourses: {
287
+ value: completedCourses,
288
+ change: null,
289
+ changeType: 'absolute' as const,
290
+ },
291
+ pendingEvaluations: {
292
+ value: pendingEvaluations,
293
+ change: null,
294
+ changeType: 'absolute' as const,
295
+ },
296
+ };
297
+ }
298
+
299
+ private async getMyClasses(
300
+ classEnrollments: Array<{
301
+ id: number;
302
+ course_id: number;
303
+ course_class_group_id: number | null;
304
+ progress_percent: number | null;
305
+ }>,
306
+ now: Date,
307
+ ) {
308
+ if (classEnrollments.length === 0) return [];
309
+
310
+ const cgIds = classEnrollments
311
+ .map((e) => e.course_class_group_id)
312
+ .filter((id): id is number => id != null);
313
+
314
+ const classGroups = await this.prisma.course_class_group.findMany({
315
+ where: { id: { in: cgIds } },
316
+ include: {
317
+ course: {
318
+ select: {
319
+ id: true,
320
+ title: true,
321
+ course_image: {
322
+ where: { image_type: { slug: 'course-logo' } },
323
+ orderBy: { is_primary: 'desc' as const },
324
+ take: 1,
325
+ select: { file: { select: { id: true } } },
326
+ },
327
+ },
328
+ },
329
+ course_class_session: {
330
+ where: { session_date: { gte: now } },
331
+ orderBy: { session_date: 'asc' },
332
+ take: 1,
333
+ include: {
334
+ course_class_session_instructor: {
335
+ where: { role: 'lead' },
336
+ take: 1,
337
+ include: {
338
+ instructor: { include: { person: { select: { name: true } } } },
339
+ },
340
+ },
341
+ },
342
+ },
343
+ },
344
+ });
345
+
346
+ const cgById = new Map(classGroups.map((cg) => [cg.id, cg]));
347
+
348
+ return classEnrollments.map((enrollment) => {
349
+ const cg = cgById.get(enrollment.course_class_group_id!);
350
+ const nextSession = cg?.course_class_session[0] ?? null;
351
+ const leadInstructor =
352
+ nextSession?.course_class_session_instructor[0]?.instructor?.person?.name ?? null;
353
+
354
+ return {
355
+ enrollmentId: enrollment.id,
356
+ classGroupId: enrollment.course_class_group_id,
357
+ courseId: cg?.course?.id ?? null,
358
+ courseName: cg?.course?.title ?? null,
359
+ hasCourseLogo: !!(cg?.course as any)?.course_image?.[0],
360
+ className: cg?.title ?? null,
361
+ instructorName: leadInstructor,
362
+ progress: enrollment.progress_percent ?? 0,
363
+ nextSession: nextSession
364
+ ? {
365
+ date: nextSession.session_date,
366
+ time: nextSession.start_time,
367
+ location: nextSession.location ?? null,
368
+ }
369
+ : null,
370
+ status: cg?.status ?? null,
371
+ deliveryMode: cg?.delivery_mode ?? null,
372
+ };
373
+ });
374
+ }
375
+
376
+ private async getOnDemandCourses(
377
+ onDemandEnrollments: Array<{
378
+ id: number;
379
+ course_id: number;
380
+ progress_percent: number | null;
381
+ }>,
382
+ ) {
383
+ if (onDemandEnrollments.length === 0) return [];
384
+
385
+ const courseIds = onDemandEnrollments.map((e) => e.course_id);
386
+ const courses = await this.prisma.course.findMany({
387
+ where: { id: { in: courseIds } },
388
+ select: {
389
+ id: true,
390
+ title: true,
391
+ duration_hours: true,
392
+ course_image: {
393
+ where: { image_type: { slug: 'course-logo' } },
394
+ orderBy: { is_primary: 'desc' as const },
395
+ take: 1,
396
+ select: { file: { select: { id: true } } },
397
+ },
398
+ },
399
+ });
400
+
401
+ const courseById = new Map(courses.map((c) => [c.id, c]));
402
+
403
+ return onDemandEnrollments.map((enrollment) => {
404
+ const course = courseById.get(enrollment.course_id);
405
+ return {
406
+ enrollmentId: enrollment.id,
407
+ courseId: enrollment.course_id,
408
+ courseName: course?.title ?? null,
409
+ hasCourseLogo: !!(course as any)?.course_image?.[0],
410
+ durationHours: (course as any)?.duration_hours ?? null,
411
+ progress: enrollment.progress_percent ?? 0,
412
+ };
413
+ });
414
+ }
415
+
416
+ private async getWeeklySchedule(cgIds: number[], weekStart: Date, weekEnd: Date) {
417
+ if (cgIds.length === 0) return [];
418
+
419
+ const sessions = await this.prisma.course_class_session.findMany({
420
+ where: {
421
+ course_class_group_id: { in: cgIds },
422
+ session_date: { gte: weekStart, lte: weekEnd },
423
+ },
424
+ orderBy: [{ session_date: 'asc' }, { start_time: 'asc' }],
425
+ select: {
426
+ id: true,
427
+ session_date: true,
428
+ start_time: true,
429
+ end_time: true,
430
+ location: true,
431
+ course_class_group_id: true,
432
+ course_class_group: { select: { title: true } },
433
+ },
434
+ });
435
+
436
+ // Group by day of week
437
+ const byDay = new Map<string, typeof sessions>();
438
+ for (const s of sessions) {
439
+ const key = s.session_date.toISOString().slice(0, 10);
440
+ if (!byDay.has(key)) byDay.set(key, []);
441
+ byDay.get(key)!.push(s);
442
+ }
443
+
444
+ return Array.from(byDay.entries()).map(([date, daySessions]) => ({
445
+ date,
446
+ sessions: daySessions.map((s) => ({
447
+ sessionId: s.id,
448
+ time: s.start_time,
449
+ endTime: s.end_time ?? null,
450
+ className: s.course_class_group?.title ?? null,
451
+ location: s.location ?? null,
452
+ })),
453
+ }));
454
+ }
455
+
456
+ private async getRanking(personId: number, cgIds: number[]) {
457
+ if (cgIds.length === 0) return [];
458
+
459
+ const enrollments = await this.prisma.course_enrollment.findMany({
460
+ where: {
461
+ course_class_group_id: { in: cgIds },
462
+ status: { not: 'cancelled' },
463
+ },
464
+ orderBy: { progress_percent: 'desc' },
465
+ take: 10,
466
+ include: {
467
+ person: { select: { id: true, name: true } },
468
+ },
469
+ });
470
+
471
+ return enrollments.map((e, index) => ({
472
+ position: index + 1,
473
+ personId: e.person_id,
474
+ name: e.person?.name ?? null,
475
+ progress: e.progress_percent ?? 0,
476
+ isMe: e.person_id === personId,
477
+ }));
478
+ }
479
+
480
+ // ── Date helpers ──────────────────────────────────────────────────────────────
481
+
482
+ private async getGamification(
483
+ personId: number,
484
+ enrollmentIds: number[],
485
+ weekStart: Date,
486
+ weekEnd: Date,
487
+ ) {
488
+ const attendanceThisWeek = await this.prisma.course_class_attendance.count({
489
+ where: {
490
+ student_id: personId,
491
+ present: true,
492
+ course_class_session: { session_date: { gte: weekStart, lte: weekEnd } },
493
+ },
494
+ });
495
+
496
+ const studyHoursThisWeek = Math.round(attendanceThisWeek * 1.5 * 10) / 10;
497
+
498
+ // Streak: distinct dates with attendance, ordered desc
499
+ const attendanceDates = await this.prisma.course_class_attendance.findMany({
500
+ where: { student_id: personId, present: true },
501
+ select: { course_class_session: { select: { session_date: true } } },
502
+ orderBy: { course_class_session: { session_date: 'desc' } },
503
+ });
504
+
505
+ const uniqueDates = Array.from(
506
+ new Set(
507
+ attendanceDates
508
+ .map((a) => a.course_class_session?.session_date?.toISOString().slice(0, 10))
509
+ .filter((d): d is string => !!d),
510
+ ),
511
+ ).sort((a, b) => b.localeCompare(a));
512
+
513
+ let streak = 0;
514
+ if (uniqueDates.length > 0) {
515
+ const today = new Date();
516
+ today.setHours(0, 0, 0, 0);
517
+ let cursor = new Date(today);
518
+ for (const dateStr of uniqueDates) {
519
+ const d = new Date(dateStr + 'T00:00:00');
520
+ const diff = Math.round((cursor.getTime() - d.getTime()) / 86400000);
521
+ if (diff > 1) break;
522
+ streak++;
523
+ cursor = d;
524
+ }
525
+ }
526
+
527
+ return { streak, studyHoursThisWeek };
528
+ }
529
+
530
+ // ── Class-group detail endpoints ─────────────────────────────────────────────
531
+
532
+ private async resolvePersonIdFromUser(userId: number) {
533
+ const personUser = await this.prisma.person_user.findFirst({
534
+ where: { user_id: userId },
535
+ select: { person_id: true },
536
+ });
537
+ if (!personUser) throw new NotFoundException('No person profile found for this user');
538
+ return personUser.person_id;
539
+ }
540
+
541
+ private async assertStudentEnrolled(userId: number, classGroupId: number) {
542
+ const personId = await this.resolvePersonIdFromUser(userId);
543
+ const enrollment = await this.prisma.course_enrollment.findFirst({
544
+ where: { person_id: personId, course_class_group_id: classGroupId, status: { not: 'cancelled' } },
545
+ select: { id: true },
546
+ });
547
+ if (!enrollment) throw new NotFoundException('Enrollment not found for this class group');
548
+ return { personId };
549
+ }
550
+
551
+ async getClassGroupDetail(userId: number, classGroupId: number) {
552
+ const { personId } = await this.assertStudentEnrolled(userId, classGroupId);
553
+ const now = new Date();
554
+
555
+ const [cg, enrollment] = await Promise.all([
556
+ this.prisma.course_class_group.findUnique({
557
+ where: { id: classGroupId },
558
+ include: {
559
+ course: { select: { id: true, title: true } },
560
+ instructor: { select: { person: { select: { name: true } } } },
561
+ _count: { select: { course_enrollment: { where: { status: { not: 'cancelled' } } } } },
562
+ course_class_session: {
563
+ where: { session_date: { gte: now } },
564
+ orderBy: { session_date: 'asc' },
565
+ take: 1,
566
+ select: { id: true, title: true, session_date: true, start_time: true, location: true, meeting_url: true },
567
+ },
568
+ },
569
+ }),
570
+ this.prisma.course_enrollment.findFirst({
571
+ where: { person_id: personId, course_class_group_id: classGroupId },
572
+ select: { id: true, progress_percent: true, final_score: true, status: true },
573
+ }),
574
+ ]);
575
+
576
+ if (!cg || !enrollment) throw new NotFoundException(`Class group #${classGroupId} not found`);
577
+
578
+ const [totalSessions, pastSessions] = await Promise.all([
579
+ this.prisma.course_class_session.count({ where: { course_class_group_id: classGroupId } }),
580
+ this.prisma.course_class_session.count({ where: { course_class_group_id: classGroupId, session_date: { lt: now } } }),
581
+ ]);
582
+
583
+ const myAttendanceCount = await this.prisma.course_class_attendance.count({
584
+ where: { student_id: personId, present: true, course_class_session: { course_class_group_id: classGroupId } },
585
+ });
586
+ const myAttendance = totalSessions > 0 ? this.round((myAttendanceCount / totalSessions) * 100, 0) : 0;
587
+
588
+ const allEnrollments = await this.prisma.course_enrollment.findMany({
589
+ where: { course_class_group_id: classGroupId, status: { not: 'cancelled' } },
590
+ orderBy: { progress_percent: 'desc' },
591
+ select: { person_id: true, progress_percent: true },
592
+ });
593
+
594
+ const rankingPosition = allEnrollments.findIndex((e) => e.person_id === personId) + 1;
595
+ const totalStudents = allEnrollments.length;
596
+
597
+ const HOURS_PER_SESSION = 2;
598
+ const totalHours = totalSessions * HOURS_PER_SESSION;
599
+ const completedHours = pastSessions * HOURS_PER_SESSION;
600
+ const nextSession = cg.course_class_session[0] ?? null;
601
+
602
+ const rankingData = allEnrollments.slice(0, 5).map((e, idx) => ({
603
+ position: idx + 1,
604
+ personId: e.person_id,
605
+ progress: e.progress_percent ?? 0,
606
+ isMe: e.person_id === personId,
607
+ }));
608
+ if (rankingPosition > 5) {
609
+ rankingData.push({ position: rankingPosition, personId, progress: enrollment.progress_percent ?? 0, isMe: true });
610
+ }
611
+
612
+ return {
613
+ id: cg.id,
614
+ courseName: cg.course?.title ?? null,
615
+ className: cg.title,
616
+ code: cg.code ?? null,
617
+ instructorName: cg.instructor?.person?.name ?? null,
618
+ instructorAvatarUrl: null as string | null,
619
+ instructorBio: null as string | null,
620
+ startDate: cg.start_date,
621
+ endDate: cg.end_date ?? null,
622
+ status: cg.status,
623
+ deliveryMode: cg.delivery_mode,
624
+ totalHours,
625
+ completedHours,
626
+ lessonsCompleted: pastSessions,
627
+ totalLessons: totalSessions,
628
+ progress: enrollment.progress_percent ?? 0,
629
+ myGrade: enrollment.final_score != null ? Number(enrollment.final_score) : null,
630
+ myAttendance,
631
+ rankingPosition: rankingPosition > 0 ? rankingPosition : totalStudents,
632
+ totalStudents,
633
+ rankingData,
634
+ nextSession: nextSession
635
+ ? {
636
+ date: nextSession.session_date,
637
+ time: nextSession.start_time,
638
+ title: nextSession.title,
639
+ location: nextSession.location ?? null,
640
+ meetingUrl: nextSession.meeting_url ?? null,
641
+ }
642
+ : null,
643
+ };
644
+ }
645
+
646
+ async getClassGroupSessions(userId: number, classGroupId: number) {
647
+ await this.assertStudentEnrolled(userId, classGroupId);
648
+ const now = new Date();
649
+
650
+ const sessions = await this.prisma.course_class_session.findMany({
651
+ where: { course_class_group_id: classGroupId },
652
+ orderBy: { session_date: 'asc' },
653
+ select: {
654
+ id: true,
655
+ title: true,
656
+ session_date: true,
657
+ start_time: true,
658
+ end_time: true,
659
+ location: true,
660
+ meeting_url: true,
661
+ },
662
+ });
663
+
664
+ let hasFoundNext = false;
665
+ return sessions.map((s) => {
666
+ const isPast = s.session_date < now;
667
+ let status: 'completed' | 'next' | 'upcoming';
668
+ if (isPast) {
669
+ status = 'completed';
670
+ } else if (!hasFoundNext) {
671
+ status = 'next';
672
+ hasFoundNext = true;
673
+ } else {
674
+ status = 'upcoming';
675
+ }
676
+ return {
677
+ id: s.id,
678
+ title: s.title,
679
+ date: s.session_date,
680
+ time: s.start_time,
681
+ endTime: s.end_time ?? null,
682
+ location: s.location ?? null,
683
+ meetingUrl: s.meeting_url ?? null,
684
+ status,
685
+ recordingUrl: null as string | null,
686
+ };
687
+ });
688
+ }
689
+
690
+ async getClassGroupMaterials(userId: number, classGroupId: number) {
691
+ await this.assertStudentEnrolled(userId, classGroupId);
692
+
693
+ const materials = await this.prisma.course_class_group_material.findMany({
694
+ where: { course_class_group_id: classGroupId },
695
+ orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
696
+ select: { id: true, title: true, description: true, material_type: true, url: true },
697
+ });
698
+
699
+ return materials.map((m) => ({
700
+ id: m.id,
701
+ title: m.title,
702
+ description: m.description ?? null,
703
+ materialType: m.material_type,
704
+ url: m.url ?? null,
705
+ }));
706
+ }
707
+
708
+ async getMyEvaluations(userId: number, classGroupId: number) {
709
+ const { personId } = await this.assertStudentEnrolled(userId, classGroupId);
710
+
711
+ const enrollment = await this.prisma.course_enrollment.findFirst({
712
+ where: { person_id: personId, course_class_group_id: classGroupId, status: { not: 'cancelled' } },
713
+ select: { id: true },
714
+ });
715
+ if (!enrollment) throw new NotFoundException('Enrollment not found');
716
+
717
+ const attempts = await this.prisma.exam_attempt.findMany({
718
+ where: { course_enrollment_id: enrollment.id },
719
+ orderBy: { started_at: 'desc' },
720
+ include: { exam: { select: { id: true, title: true } } },
721
+ });
722
+
723
+ const data = attempts.map((a) => ({
724
+ id: a.id,
725
+ titulo: a.exam?.title ?? 'Avaliação',
726
+ data: a.started_at?.toISOString().slice(0, 10) ?? null,
727
+ nota: a.score != null ? Number(a.score) : null,
728
+ status: a.score != null ? 'graded' : a.status === 'completed' ? 'pending' : 'in_progress',
729
+ comentario: (a as any).feedback ?? null,
730
+ }));
731
+
732
+ const graded = data.filter((e) => e.nota != null);
733
+ const avg = graded.length > 0 ? this.round(graded.reduce((s, e) => s + (e.nota ?? 0), 0) / graded.length, 1) : 0;
734
+ return { avg, total: data.length, graded: graded.length, data };
735
+ }
736
+
737
+ private round(value: number, digits = 0) {
738
+ const factor = 10 ** digits;
739
+ return Math.round(value * factor) / factor;
740
+ }
741
+
742
+ private addDays(date: Date, days: number) {
743
+ const result = new Date(date);
744
+ result.setDate(result.getDate() + days);
745
+ return result;
746
+ }
747
+
748
+ private endOfDay(date: Date) {
749
+ const result = new Date(date);
750
+ result.setHours(23, 59, 59, 999);
751
+ return result;
752
+ }
753
+
754
+ private startOfWeek(date: Date) {
755
+ const result = new Date(date);
756
+ result.setHours(0, 0, 0, 0);
757
+ const day = result.getDay();
758
+ const diff = day === 0 ? -6 : 1 - day;
759
+ result.setDate(result.getDate() + diff);
760
+ return result;
761
+ }
762
+ }