@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,2523 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import {
3
+ BadRequestException,
4
+ ConflictException,
5
+ ForbiddenException,
6
+ Injectable,
7
+ NotFoundException,
8
+ } from '@nestjs/common';
9
+
10
+ @Injectable()
11
+ export class TrainingAdminService {
12
+ constructor(private readonly prisma: PrismaService) {}
13
+
14
+ async getDashboard(userId: number, requestedEnterpriseId?: number) {
15
+ if (requestedEnterpriseId !== undefined) {
16
+ const allowed = await this.prisma.enterprise_user.findFirst({
17
+ where: { user_id: userId, enterprise_id: requestedEnterpriseId, status: 'active' },
18
+ select: { enterprise_id: true },
19
+ });
20
+ if (!allowed) {
21
+ throw new ForbiddenException('Access denied to the requested enterprise');
22
+ }
23
+ }
24
+
25
+ const enterpriseUser = requestedEnterpriseId !== undefined
26
+ ? { enterprise_id: requestedEnterpriseId }
27
+ : await this.prisma.enterprise_user.findFirst({
28
+ where: { user_id: userId, status: 'active' },
29
+ select: { enterprise_id: true },
30
+ });
31
+
32
+ if (!enterpriseUser) {
33
+ throw new NotFoundException('No active enterprise found for this user');
34
+ }
35
+
36
+ const enterpriseId = enterpriseUser.enterprise_id;
37
+ const now = new Date();
38
+ const currentMonthStart = this.startOfMonth(now);
39
+ const previousMonthStart = this.startOfMonth(this.addMonths(now, -1));
40
+
41
+ const [kpis, upcomingClasses, alerts, licenseInfo] = await Promise.all([
42
+ this.getKpis(enterpriseId, now, currentMonthStart, previousMonthStart),
43
+ this.getUpcomingClasses(enterpriseId, now),
44
+ this.getAlerts(enterpriseId),
45
+ this.getLicenseInfo(enterpriseId),
46
+ ]);
47
+
48
+ return {
49
+ kpis,
50
+ upcomingClasses,
51
+ alerts,
52
+ licenseInfo,
53
+ };
54
+ }
55
+
56
+ // ── Private helpers ───────────────────────────────────────────────────────────
57
+
58
+ private async getKpis(
59
+ enterpriseId: number,
60
+ now: Date,
61
+ currentMonthStart: Date,
62
+ previousMonthStart: Date,
63
+ ) {
64
+ const previousMonthEnd = this.endOfMonth(this.addMonths(now, -1));
65
+
66
+ const [
67
+ totalStudents,
68
+ previousTotalStudents,
69
+ activeClasses,
70
+ previousActiveClasses,
71
+ slotsInUse,
72
+ previousSlotsInUse,
73
+ avgProgress,
74
+ ] = await Promise.all([
75
+ this.prisma.enterprise_student.count({
76
+ where: { enterprise_id: enterpriseId },
77
+ }),
78
+ this.prisma.enterprise_student.count({
79
+ where: { enterprise_id: enterpriseId, created_at: { lt: currentMonthStart } },
80
+ }),
81
+ this.prisma.enterprise_class_group.count({
82
+ where: {
83
+ enterprise_id: enterpriseId,
84
+ course_class_group: { status: 'ongoing' },
85
+ },
86
+ }),
87
+ this.prisma.enterprise_class_group.count({
88
+ where: {
89
+ enterprise_id: enterpriseId,
90
+ course_class_group: { status: 'ongoing', created_at: { lt: currentMonthStart } },
91
+ },
92
+ }),
93
+ this.prisma.course_enrollment.count({
94
+ where: {
95
+ status: { not: 'cancelled' },
96
+ course_class_group: {
97
+ enterprise_class_group: { some: { enterprise_id: enterpriseId } },
98
+ },
99
+ },
100
+ }),
101
+ this.prisma.course_enrollment.count({
102
+ where: {
103
+ status: { not: 'cancelled' },
104
+ enrolled_at: { lt: currentMonthStart },
105
+ course_class_group: {
106
+ enterprise_class_group: { some: { enterprise_id: enterpriseId } },
107
+ },
108
+ },
109
+ }),
110
+ this.prisma.course_enrollment.aggregate({
111
+ where: {
112
+ status: { in: ['active', 'completed'] },
113
+ course_class_group: {
114
+ enterprise_class_group: { some: { enterprise_id: enterpriseId } },
115
+ },
116
+ },
117
+ _avg: { progress_percent: true },
118
+ }),
119
+ ]);
120
+
121
+ return {
122
+ totalStudents: {
123
+ value: totalStudents,
124
+ change: this.percentChange(totalStudents, previousTotalStudents),
125
+ changeType: 'percent' as const,
126
+ },
127
+ activeClasses: {
128
+ value: activeClasses,
129
+ change: activeClasses - previousActiveClasses,
130
+ changeType: 'absolute' as const,
131
+ },
132
+ slotsInUse: {
133
+ value: slotsInUse,
134
+ change: slotsInUse - previousSlotsInUse,
135
+ changeType: 'absolute' as const,
136
+ },
137
+ averageProgress: {
138
+ value: this.round(avgProgress._avg.progress_percent ?? 0, 1),
139
+ change: null,
140
+ changeType: 'percent' as const,
141
+ },
142
+ };
143
+ }
144
+
145
+ private async getUpcomingClasses(enterpriseId: number, now: Date) {
146
+ const rows = await this.prisma.enterprise_class_group.findMany({
147
+ where: {
148
+ enterprise_id: enterpriseId,
149
+ course_class_group: { status: { in: ['open', 'ongoing'] } },
150
+ },
151
+ take: 10,
152
+ include: {
153
+ course_class_group: {
154
+ include: {
155
+ course: {
156
+ select: {
157
+ id: true,
158
+ title: true,
159
+ course_image: {
160
+ where: { image_type: { slug: 'course-logo' } },
161
+ orderBy: { is_primary: 'desc' as const },
162
+ take: 1,
163
+ select: { file: { select: { location: true } } },
164
+ },
165
+ },
166
+ },
167
+ _count: {
168
+ select: {
169
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
170
+ },
171
+ },
172
+ course_class_session: {
173
+ where: { session_date: { gte: now } },
174
+ orderBy: { session_date: 'asc' },
175
+ take: 1,
176
+ include: {
177
+ course_class_session_instructor: {
178
+ where: { role: 'lead' },
179
+ take: 1,
180
+ include: {
181
+ instructor: {
182
+ include: { person: { select: { name: true } } },
183
+ },
184
+ },
185
+ },
186
+ },
187
+ },
188
+ },
189
+ },
190
+ },
191
+ });
192
+
193
+ return rows.map((row) => {
194
+ const cg = row.course_class_group;
195
+ const nextSession = cg.course_class_session[0] ?? null;
196
+ const leadInstructor =
197
+ nextSession?.course_class_session_instructor[0]?.instructor?.person?.name ?? null;
198
+
199
+ return {
200
+ id: cg.id,
201
+ name: cg.title,
202
+ courseId: cg.course?.id ?? null,
203
+ courseName: cg.course?.title ?? null,
204
+ courseLogoUrl: cg.course?.course_image?.[0]?.file?.location ?? null,
205
+ instructor: leadInstructor,
206
+ startDate: cg.start_date,
207
+ nextSession: nextSession
208
+ ? {
209
+ date: nextSession.session_date,
210
+ time: nextSession.start_time,
211
+ location: nextSession.location ?? null,
212
+ }
213
+ : null,
214
+ slots: cg._count.course_enrollment,
215
+ totalSlots: cg.capacity ?? null,
216
+ status: cg.status,
217
+ deliveryMode: cg.delivery_mode ?? null,
218
+ };
219
+ });
220
+ }
221
+
222
+ private async getLicenseInfo(enterpriseId: number) {
223
+ const [enterprise, totalStudents, activePersonRows] = await Promise.all([
224
+ this.prisma.enterprise.findUnique({
225
+ where: { id: enterpriseId },
226
+ select: { license_limit: true },
227
+ }),
228
+ this.prisma.enterprise_student.count({
229
+ where: { enterprise_id: enterpriseId },
230
+ }),
231
+ this.prisma.course_enrollment.findMany({
232
+ where: {
233
+ status: 'active',
234
+ course_class_group: {
235
+ enterprise_class_group: { some: { enterprise_id: enterpriseId } },
236
+ },
237
+ },
238
+ select: { person_id: true },
239
+ distinct: ['person_id'],
240
+ }),
241
+ ]);
242
+
243
+ return {
244
+ limit: (enterprise?.license_limit as number | null) ?? null,
245
+ totalStudents,
246
+ activeStudents: activePersonRows.length,
247
+ };
248
+ }
249
+
250
+ private async getAlerts(enterpriseId: number) {
251
+ const alerts: Array<{
252
+ type: string;
253
+ message: string;
254
+ severity: 'info' | 'warning' | 'danger';
255
+ }> = [];
256
+
257
+ // Classes near capacity (>= 90%)
258
+ const openRows = await this.prisma.enterprise_class_group.findMany({
259
+ where: {
260
+ enterprise_id: enterpriseId,
261
+ course_class_group: { status: 'open' },
262
+ },
263
+ include: {
264
+ course_class_group: {
265
+ include: {
266
+ _count: {
267
+ select: {
268
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
269
+ },
270
+ },
271
+ },
272
+ },
273
+ },
274
+ });
275
+
276
+ for (const row of openRows) {
277
+ const cg = row.course_class_group;
278
+ const max = cg.capacity ?? 0;
279
+ if (max > 0 && cg._count.course_enrollment / max >= 0.9) {
280
+ alerts.push({
281
+ type: 'capacity',
282
+ message: `Turma "${cg.title}" está quase lotada (${cg._count.course_enrollment}/${max} vagas)`,
283
+ severity: 'warning',
284
+ });
285
+ }
286
+ }
287
+
288
+ // Open classes without any sessions scheduled
289
+ const withoutSessions = await this.prisma.enterprise_class_group.count({
290
+ where: {
291
+ enterprise_id: enterpriseId,
292
+ course_class_group: {
293
+ status: 'open',
294
+ course_class_session: { none: {} },
295
+ },
296
+ },
297
+ });
298
+
299
+ if (withoutSessions > 0) {
300
+ alerts.push({
301
+ type: 'no_sessions',
302
+ message: `${withoutSessions} turma(s) aberta(s) sem sessões agendadas`,
303
+ severity: 'info',
304
+ });
305
+ }
306
+
307
+ return alerts;
308
+ }
309
+
310
+ async getClassGroups(
311
+ userId: number,
312
+ options: {
313
+ enterpriseId?: number;
314
+ search?: string;
315
+ status?: string;
316
+ deliveryMode?: string;
317
+ instructorId?: number;
318
+ } = {},
319
+ ) {
320
+ if (options.enterpriseId !== undefined) {
321
+ const allowed = await this.prisma.enterprise_user.findFirst({
322
+ where: { user_id: userId, enterprise_id: options.enterpriseId, status: 'active' },
323
+ select: { enterprise_id: true },
324
+ });
325
+ if (!allowed) {
326
+ throw new ForbiddenException('Access denied to the requested enterprise');
327
+ }
328
+ }
329
+
330
+ const enterpriseUser =
331
+ options.enterpriseId !== undefined
332
+ ? { enterprise_id: options.enterpriseId }
333
+ : await this.prisma.enterprise_user.findFirst({
334
+ where: { user_id: userId, status: 'active' },
335
+ select: { enterprise_id: true },
336
+ });
337
+
338
+ if (!enterpriseUser) {
339
+ throw new NotFoundException('No active enterprise found for this user');
340
+ }
341
+
342
+ const enterpriseId = enterpriseUser.enterprise_id;
343
+ const { search, status, deliveryMode, instructorId } = options;
344
+ const now = new Date();
345
+
346
+ const classGroups = await this.prisma.course_class_group.findMany({
347
+ where: {
348
+ AND: [
349
+ { enterprise_class_group: { some: { enterprise_id: enterpriseId } } },
350
+ ...(status ? [{ status: status as any }] : []),
351
+ ...(deliveryMode ? [{ delivery_mode: deliveryMode as any }] : []),
352
+ ...(instructorId !== undefined ? [{ instructor_id: instructorId }] : []),
353
+ ...(search
354
+ ? [
355
+ {
356
+ OR: [
357
+ { title: { contains: search, mode: 'insensitive' as const } },
358
+ { code: { contains: search, mode: 'insensitive' as const } },
359
+ { course: { title: { contains: search, mode: 'insensitive' as const } } },
360
+ ],
361
+ },
362
+ ]
363
+ : []),
364
+ ],
365
+ },
366
+ orderBy: { start_date: 'desc' },
367
+ include: {
368
+ course: {
369
+ select: {
370
+ id: true,
371
+ title: true,
372
+ course_image: {
373
+ where: { image_type: { slug: { in: ['course-logo', 'course-banner'] } } },
374
+ orderBy: { is_primary: 'desc' as const },
375
+ select: {
376
+ file: { select: { location: true } },
377
+ image_type: { select: { slug: true } },
378
+ },
379
+ },
380
+ },
381
+ },
382
+ instructor: {
383
+ include: { person: { select: { name: true } } },
384
+ },
385
+ _count: {
386
+ select: {
387
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
388
+ },
389
+ },
390
+ course_class_session: {
391
+ where: { session_date: { gte: now } },
392
+ orderBy: { session_date: 'asc' },
393
+ take: 1,
394
+ select: { session_date: true, start_time: true, location: true },
395
+ },
396
+ },
397
+ });
398
+
399
+ const cgIds = classGroups.map((cg) => cg.id);
400
+
401
+ const completedCounts =
402
+ cgIds.length > 0
403
+ ? await this.prisma.course_enrollment.groupBy({
404
+ by: ['course_class_group_id'],
405
+ where: {
406
+ course_class_group_id: { in: cgIds },
407
+ status: { not: 'cancelled' },
408
+ completed_at: { not: null },
409
+ },
410
+ _count: true,
411
+ })
412
+ : [];
413
+
414
+ const completedCountMap = new Map(
415
+ completedCounts.map((r) => [r.course_class_group_id, r._count]),
416
+ );
417
+
418
+ const data = classGroups.map((cg) => {
419
+ const nextSession = cg.course_class_session[0] ?? null;
420
+ const totalEnrolled = cg._count.course_enrollment;
421
+ const completedCount = completedCountMap.get(cg.id) ?? 0;
422
+ const completionRate =
423
+ totalEnrolled > 0 ? this.round((completedCount / totalEnrolled) * 100, 0) : 0;
424
+
425
+ return {
426
+ id: cg.id,
427
+ name: cg.title,
428
+ code: cg.code ?? '',
429
+ courseId: cg.course?.id ?? null,
430
+ courseName: cg.course?.title ?? null,
431
+ courseLogoUrl:
432
+ cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-logo')?.file
433
+ ?.location ?? null,
434
+ courseBannerUrl:
435
+ cg.course?.course_image.find((ci) => ci.image_type?.slug === 'course-banner')?.file
436
+ ?.location ?? null,
437
+ instructorName: (cg as any).instructor?.person?.name ?? null,
438
+ instructorAvatarUrl: null as string | null,
439
+ startDate: cg.start_date,
440
+ endDate: cg.end_date ?? null,
441
+ nextSession: nextSession
442
+ ? {
443
+ date: nextSession.session_date,
444
+ time: nextSession.start_time,
445
+ location: nextSession.location ?? null,
446
+ }
447
+ : null,
448
+ slots: totalEnrolled,
449
+ totalSlots: cg.capacity ?? null,
450
+ status: cg.status,
451
+ deliveryMode: cg.delivery_mode ?? null,
452
+ completionRate,
453
+ };
454
+ });
455
+
456
+ return { data, total: data.length };
457
+ }
458
+
459
+ // ── Class-group detail endpoints ─────────────────────────────────────────────
460
+
461
+ private async assertAdminHasAccessToClassGroup(userId: number, classGroupId: number) {
462
+ const enterpriseUser = await this.prisma.enterprise_user.findFirst({
463
+ where: { user_id: userId, status: 'active' },
464
+ select: { enterprise_id: true },
465
+ });
466
+ if (!enterpriseUser) throw new NotFoundException('No active enterprise found for this user');
467
+
468
+ const cg = await this.prisma.course_class_group.findFirst({
469
+ where: {
470
+ id: classGroupId,
471
+ enterprise_class_group: { some: { enterprise_id: enterpriseUser.enterprise_id } },
472
+ },
473
+ select: { id: true },
474
+ });
475
+ if (!cg) throw new NotFoundException('Class group not found or not accessible');
476
+ return enterpriseUser.enterprise_id;
477
+ }
478
+
479
+ async getClassGroupDetail(userId: number, classGroupId: number) {
480
+ await this.assertAdminHasAccessToClassGroup(userId, classGroupId);
481
+ const now = new Date();
482
+
483
+ const cg = await this.prisma.course_class_group.findUnique({
484
+ where: { id: classGroupId },
485
+ include: {
486
+ course: { select: { id: true, title: true } },
487
+ instructor: { select: { person: { select: { name: true } } } },
488
+ _count: {
489
+ select: {
490
+ course_enrollment: { where: { status: { not: 'cancelled' } } },
491
+ course_class_session: true,
492
+ },
493
+ },
494
+ course_class_session: {
495
+ where: { session_date: { gte: now } },
496
+ orderBy: { session_date: 'asc' },
497
+ take: 1,
498
+ select: { session_date: true, start_time: true, location: true, meeting_url: true },
499
+ },
500
+ },
501
+ });
502
+ if (!cg) throw new NotFoundException(`Class group #${classGroupId} not found`);
503
+
504
+ const completedSessions = await this.prisma.course_class_session.count({
505
+ where: { course_class_group_id: classGroupId, session_date: { lt: now } },
506
+ });
507
+
508
+ const pastSessions = await this.prisma.course_class_session.findMany({
509
+ where: { course_class_group_id: classGroupId, session_date: { lt: now } },
510
+ select: {
511
+ id: true,
512
+ course_class_attendance: { where: { present: true }, select: { student_id: true } },
513
+ },
514
+ });
515
+
516
+ const totalEnrolled = cg._count.course_enrollment;
517
+ let avgAttendance = 0;
518
+ if (pastSessions.length > 0 && totalEnrolled > 0) {
519
+ const pcts = pastSessions.map((s) => (s.course_class_attendance.length / totalEnrolled) * 100);
520
+ avgAttendance = this.round(pcts.reduce((a, b) => a + b, 0) / pcts.length, 0);
521
+ }
522
+
523
+ const completedEnrollments = await this.prisma.course_enrollment.count({
524
+ where: { course_class_group_id: classGroupId, status: { not: 'cancelled' }, completed_at: { not: null } },
525
+ });
526
+ const completionRate = totalEnrolled > 0 ? this.round((completedEnrollments / totalEnrolled) * 100, 0) : 0;
527
+
528
+ const nextSession = cg.course_class_session[0] ?? null;
529
+ return {
530
+ id: cg.id,
531
+ courseName: cg.course?.title ?? null,
532
+ className: cg.title,
533
+ code: cg.code ?? null,
534
+ instructorName: cg.instructor?.person?.name ?? null,
535
+ instructorAvatarUrl: null as string | null,
536
+ instructorBio: null as string | null,
537
+ startDate: cg.start_date,
538
+ endDate: cg.end_date ?? null,
539
+ status: cg.status,
540
+ deliveryMode: cg.delivery_mode,
541
+ totalSlots: cg.capacity ?? null,
542
+ enrolledCount: totalEnrolled,
543
+ totalSessions: cg._count.course_class_session,
544
+ completedSessions,
545
+ avgAttendance,
546
+ completionRate,
547
+ nextSession: nextSession
548
+ ? {
549
+ date: nextSession.session_date,
550
+ time: nextSession.start_time,
551
+ location: nextSession.location ?? null,
552
+ meetingUrl: nextSession.meeting_url ?? null,
553
+ }
554
+ : null,
555
+ };
556
+ }
557
+
558
+ async getClassGroupStudents(
559
+ userId: number,
560
+ classGroupId: number,
561
+ params: { page?: number; pageSize?: number; search?: string; status?: string } = {},
562
+ ) {
563
+ await this.assertAdminHasAccessToClassGroup(userId, classGroupId);
564
+ const page = Math.max(Number(params.page) || 1, 1);
565
+ const pageSize = Math.max(Number(params.pageSize) || 20, 1);
566
+ const skip = (page - 1) * pageSize;
567
+
568
+ const where: any = {
569
+ course_class_group_id: classGroupId,
570
+ status: { not: 'cancelled' },
571
+ };
572
+ if (params.search) {
573
+ where.person = { name: { contains: params.search, mode: 'insensitive' } };
574
+ }
575
+
576
+ const [enrollments, total, totalSessions] = await Promise.all([
577
+ this.prisma.course_enrollment.findMany({
578
+ where,
579
+ skip,
580
+ take: pageSize,
581
+ orderBy: { person: { name: 'asc' } },
582
+ include: { person: { select: { id: true, name: true } } },
583
+ }),
584
+ this.prisma.course_enrollment.count({ where }),
585
+ this.prisma.course_class_session.count({ where: { course_class_group_id: classGroupId } }),
586
+ ]);
587
+
588
+ if (enrollments.length === 0) {
589
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data: [] };
590
+ }
591
+
592
+ const personIds = enrollments.map((e) => e.person_id).filter(Boolean) as number[];
593
+ const attendanceRows = await this.prisma.course_class_attendance.groupBy({
594
+ by: ['student_id'],
595
+ where: { student_id: { in: personIds }, present: true },
596
+ _count: { id: true },
597
+ });
598
+ const attendanceMap = new Map(attendanceRows.map((r) => [r.student_id, r._count.id]));
599
+
600
+ const allData = enrollments.map((e) => {
601
+ const presentCount = attendanceMap.get(e.person_id ?? 0) ?? 0;
602
+ const attendance = totalSessions > 0 ? this.round((presentCount / totalSessions) * 100, 0) : 0;
603
+ const progress = e.progress_percent ?? 0;
604
+ const grade = e.final_score != null ? Number(e.final_score) : null;
605
+ let status: 'ok' | 'risk' | 'inactive';
606
+ if (e.status === 'paused') status = 'inactive';
607
+ else if (attendance < 60 || progress < 50) status = 'risk';
608
+ else status = 'ok';
609
+ return { id: e.person_id, name: e.person?.name ?? null, avatarUrl: null as string | null, progress, attendance, grade, status };
610
+ }).filter((s) => !params.status || s.status === params.status);
611
+
612
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data: allData };
613
+ }
614
+
615
+ async getClassGroupSessions(userId: number, classGroupId: number) {
616
+ await this.assertAdminHasAccessToClassGroup(userId, classGroupId);
617
+ const now = new Date();
618
+
619
+ const sessions = await this.prisma.course_class_session.findMany({
620
+ where: { course_class_group_id: classGroupId },
621
+ orderBy: { session_date: 'asc' },
622
+ select: {
623
+ id: true,
624
+ title: true,
625
+ session_date: true,
626
+ start_time: true,
627
+ end_time: true,
628
+ location: true,
629
+ meeting_url: true,
630
+ course_class_attendance: { where: { present: true }, select: { student_id: true } },
631
+ },
632
+ });
633
+
634
+ const totalStudents = await this.prisma.course_enrollment.count({
635
+ where: { course_class_group_id: classGroupId, status: { not: 'cancelled' } },
636
+ });
637
+
638
+ let hasFoundNext = false;
639
+ return sessions.map((s) => {
640
+ const isPast = s.session_date < now;
641
+ let status: 'completed' | 'next' | 'upcoming';
642
+ if (isPast) {
643
+ status = 'completed';
644
+ } else if (!hasFoundNext) {
645
+ status = 'next';
646
+ hasFoundNext = true;
647
+ } else {
648
+ status = 'upcoming';
649
+ }
650
+ return {
651
+ id: s.id,
652
+ title: s.title,
653
+ date: s.session_date,
654
+ time: s.start_time,
655
+ endTime: s.end_time ?? null,
656
+ location: s.location ?? null,
657
+ meetingUrl: s.meeting_url ?? null,
658
+ status,
659
+ attendanceCount: isPast ? s.course_class_attendance.length : null,
660
+ totalStudents,
661
+ };
662
+ });
663
+ }
664
+
665
+ async getClassGroupMaterials(userId: number, classGroupId: number) {
666
+ await this.assertAdminHasAccessToClassGroup(userId, classGroupId);
667
+
668
+ const materials = await this.prisma.course_class_group_material.findMany({
669
+ where: { course_class_group_id: classGroupId },
670
+ orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
671
+ select: {
672
+ id: true,
673
+ title: true,
674
+ description: true,
675
+ material_type: true,
676
+ url: true,
677
+ sort_order: true,
678
+ created_at: true,
679
+ },
680
+ });
681
+
682
+ return materials.map((m) => ({
683
+ id: m.id,
684
+ title: m.title,
685
+ description: m.description ?? null,
686
+ materialType: m.material_type,
687
+ url: m.url ?? null,
688
+ }));
689
+ }
690
+
691
+ // ── License management methods ────────────────────────────────────────────────
692
+
693
+ async listLicenses(
694
+ userId: number,
695
+ params: { page?: number; pageSize?: number; search?: string; status?: string; department?: string },
696
+ ) {
697
+ const enterpriseUser = await this.prisma.enterprise_user.findFirst({
698
+ where: { user_id: userId, status: 'active' },
699
+ select: { enterprise_id: true },
700
+ });
701
+ if (!enterpriseUser) throw new NotFoundException('No active enterprise found for this user');
702
+
703
+ const enterpriseId = enterpriseUser.enterprise_id;
704
+ const page = Math.max(Number(params.page) || 1, 1);
705
+ const pageSize = Math.max(Number(params.pageSize) || 10, 1);
706
+ const skip = (page - 1) * pageSize;
707
+
708
+ const where: any = { enterprise_id: enterpriseId };
709
+ if (params.search?.trim()) {
710
+ where.person = { name: { contains: params.search.trim(), mode: 'insensitive' } };
711
+ }
712
+ if (params.status && params.status !== 'all') {
713
+ where.status = params.status;
714
+ }
715
+
716
+ const [rows, total, enterprise] = await Promise.all([
717
+ this.prisma.enterprise_student.findMany({
718
+ where,
719
+ skip,
720
+ take: pageSize,
721
+ orderBy: { created_at: 'desc' },
722
+ include: {
723
+ person: {
724
+ select: {
725
+ id: true,
726
+ name: true,
727
+ contact: {
728
+ where: { is_primary: true, contact_type: { code: { in: ['email', 'EMAIL'] } } },
729
+ select: { value: true },
730
+ take: 1,
731
+ },
732
+ },
733
+ },
734
+ },
735
+ }),
736
+ this.prisma.enterprise_student.count({ where }),
737
+ this.prisma.enterprise.findUnique({
738
+ where: { id: enterpriseId },
739
+ select: { license_limit: true },
740
+ }),
741
+ ]);
742
+
743
+ return {
744
+ total,
745
+ page,
746
+ pageSize,
747
+ lastPage: Math.max(1, Math.ceil(total / pageSize)),
748
+ licenseLimit: (enterprise?.license_limit as number | null) ?? null,
749
+ data: rows.map((r) => ({
750
+ id: r.id,
751
+ personId: r.person_id,
752
+ name: r.person?.name ?? null,
753
+ email: r.person?.contact?.[0]?.value ?? null,
754
+ status: r.status,
755
+ createdAt: r.created_at,
756
+ })),
757
+ };
758
+ }
759
+
760
+ async assignLicense(
761
+ userId: number,
762
+ dto: { name: string; email?: string; department?: string },
763
+ ) {
764
+ const enterpriseUser = await this.prisma.enterprise_user.findFirst({
765
+ where: { user_id: userId, status: 'active' },
766
+ select: { enterprise_id: true },
767
+ });
768
+ if (!enterpriseUser) throw new NotFoundException('No active enterprise found for this user');
769
+
770
+ const enterpriseId = enterpriseUser.enterprise_id;
771
+
772
+ const licenseInfo = await this.getLicenseInfo(enterpriseId);
773
+ if (licenseInfo.limit !== null && licenseInfo.totalStudents >= licenseInfo.limit) {
774
+ throw new ConflictException('Enterprise license limit reached');
775
+ }
776
+
777
+ const name = dto.name.trim();
778
+ if (!name) throw new BadRequestException('Name is required');
779
+ const email = dto.email?.trim();
780
+
781
+ let personId: number | undefined;
782
+
783
+ if (email) {
784
+ const existing = await this.prisma.person.findFirst({
785
+ where: {
786
+ contact: {
787
+ some: {
788
+ value: { equals: email, mode: 'insensitive' },
789
+ contact_type: { code: { in: ['email', 'EMAIL'] } },
790
+ },
791
+ },
792
+ },
793
+ select: { id: true },
794
+ });
795
+ if (existing) personId = existing.id;
796
+ }
797
+
798
+ if (!personId) {
799
+ const emailType = email
800
+ ? await this.prisma.contact_type.findFirst({
801
+ where: { code: { in: ['email', 'EMAIL'] } },
802
+ select: { id: true },
803
+ })
804
+ : null;
805
+
806
+ const created = await this.prisma.person.create({
807
+ data: { name, type: 'individual', status: 'active' },
808
+ select: { id: true },
809
+ });
810
+ personId = created.id;
811
+
812
+ if (email && emailType) {
813
+ await this.prisma.contact.create({
814
+ data: {
815
+ person_id: personId,
816
+ contact_type_id: emailType.id,
817
+ value: email,
818
+ is_primary: true,
819
+ },
820
+ });
821
+ }
822
+ }
823
+
824
+ const existingStudent = await this.prisma.enterprise_student.findFirst({
825
+ where: { enterprise_id: enterpriseId, person_id: personId },
826
+ select: { id: true, status: true },
827
+ });
828
+
829
+ if (existingStudent) {
830
+ if (existingStudent.status !== 'inactive') {
831
+ throw new ConflictException('This person already has an active license');
832
+ }
833
+ return this.prisma.enterprise_student.update({
834
+ where: { id: existingStudent.id },
835
+ data: { status: 'active' },
836
+ select: { id: true, person_id: true, status: true, created_at: true },
837
+ });
838
+ }
839
+
840
+ return this.prisma.enterprise_student.create({
841
+ data: { enterprise_id: enterpriseId, person_id: personId, status: 'pending' },
842
+ select: { id: true, person_id: true, status: true, created_at: true },
843
+ });
844
+ }
845
+
846
+ async revokeLicense(userId: number, personId: number) {
847
+ const enterpriseUser = await this.prisma.enterprise_user.findFirst({
848
+ where: { user_id: userId, status: 'active' },
849
+ select: { enterprise_id: true },
850
+ });
851
+ if (!enterpriseUser) throw new NotFoundException('No active enterprise found for this user');
852
+
853
+ const existing = await this.prisma.enterprise_student.findFirst({
854
+ where: { enterprise_id: enterpriseUser.enterprise_id, person_id: personId },
855
+ select: { id: true },
856
+ });
857
+ if (!existing) throw new NotFoundException('Student not found in your enterprise');
858
+
859
+ return this.prisma.enterprise_student.update({
860
+ where: { id: existing.id },
861
+ data: { status: 'inactive' },
862
+ select: { id: true },
863
+ });
864
+ }
865
+
866
+ // ── Enrollment methods ────────────────────────────────────────────────────────
867
+
868
+ async getEnrollableStudents(
869
+ userId: number,
870
+ classGroupId: number,
871
+ search?: string,
872
+ ) {
873
+ const enterpriseId = await this.assertAdminHasAccessToClassGroup(userId, classGroupId);
874
+
875
+ const enrolled = await this.prisma.course_enrollment.findMany({
876
+ where: {
877
+ course_class_group_id: classGroupId,
878
+ status: { not: 'cancelled' },
879
+ },
880
+ select: { person_id: true },
881
+ });
882
+ const enrolledIds = enrolled.map((e) => e.person_id);
883
+
884
+ const where: Record<string, unknown> = { enterprise_id: enterpriseId };
885
+ if (enrolledIds.length > 0) {
886
+ where.person_id = { notIn: enrolledIds };
887
+ }
888
+ if (search?.trim()) {
889
+ where.person = { name: { contains: search.trim(), mode: 'insensitive' } };
890
+ }
891
+
892
+ const rows = await this.prisma.enterprise_student.findMany({
893
+ where: where as any,
894
+ take: 50,
895
+ orderBy: { person: { name: 'asc' } },
896
+ include: {
897
+ person: {
898
+ select: {
899
+ id: true,
900
+ name: true,
901
+ contact: {
902
+ where: { is_primary: true, contact_type: { code: { in: ['email', 'EMAIL'] } } },
903
+ select: { value: true },
904
+ take: 1,
905
+ },
906
+ },
907
+ },
908
+ },
909
+ });
910
+
911
+ return {
912
+ data: rows.map((r) => ({
913
+ id: r.id,
914
+ personId: r.person_id,
915
+ name: r.person?.name ?? null,
916
+ email: r.person?.contact?.[0]?.value ?? null,
917
+ status: r.status,
918
+ })),
919
+ };
920
+ }
921
+
922
+ async getLicenseAvailability(userId: number) {
923
+ const enterpriseUser = await this.prisma.enterprise_user.findFirst({
924
+ where: { user_id: userId, status: 'active' },
925
+ select: { enterprise_id: true },
926
+ });
927
+ if (!enterpriseUser) throw new NotFoundException('No active enterprise found for this user');
928
+
929
+ const info = await this.getLicenseInfo(enterpriseUser.enterprise_id);
930
+ return {
931
+ limit: info.limit,
932
+ totalStudents: info.totalStudents,
933
+ hasAvailableSlot: info.limit === null || info.totalStudents < info.limit,
934
+ };
935
+ }
936
+
937
+ async enrollExistingStudent(
938
+ userId: number,
939
+ classGroupId: number,
940
+ personId: number,
941
+ ) {
942
+ const enterpriseId = await this.assertAdminHasAccessToClassGroup(userId, classGroupId);
943
+
944
+ const esRecord = await this.prisma.enterprise_student.findFirst({
945
+ where: { enterprise_id: enterpriseId, person_id: personId },
946
+ select: { id: true },
947
+ });
948
+ if (!esRecord) throw new NotFoundException('Student not found in your enterprise');
949
+
950
+ const classGroup = await this.prisma.course_class_group.findUnique({
951
+ where: { id: classGroupId },
952
+ select: {
953
+ capacity: true,
954
+ course_id: true,
955
+ _count: { select: { course_enrollment: { where: { status: { not: 'cancelled' } } } } },
956
+ },
957
+ });
958
+ if (
959
+ classGroup?.capacity &&
960
+ classGroup._count.course_enrollment >= classGroup.capacity
961
+ ) {
962
+ throw new ConflictException('Class group is at full capacity');
963
+ }
964
+
965
+ const licenseInfo = await this.getLicenseInfo(enterpriseId);
966
+ if (licenseInfo.limit !== null && licenseInfo.totalStudents >= licenseInfo.limit) {
967
+ throw new ConflictException('Enterprise license limit reached');
968
+ }
969
+
970
+ const existing = await this.prisma.course_enrollment.findFirst({
971
+ where: { course_class_group_id: classGroupId, person_id: personId },
972
+ });
973
+ if (existing) {
974
+ if (existing.status === 'cancelled') {
975
+ return this.prisma.course_enrollment.update({
976
+ where: { id: existing.id },
977
+ data: { status: 'active', enrolled_at: new Date() },
978
+ });
979
+ }
980
+ throw new ConflictException('Student already enrolled');
981
+ }
982
+
983
+ return this.prisma.course_enrollment.create({
984
+ data: {
985
+ person_id: personId,
986
+ course_class_group_id: classGroupId,
987
+ course_id: classGroup!.course_id,
988
+ status: 'active',
989
+ enrolled_at: new Date(),
990
+ },
991
+ });
992
+ }
993
+
994
+ async createAndEnrollNewStudent(
995
+ userId: number,
996
+ classGroupId: number,
997
+ dto: { name: string; email?: string; department?: string },
998
+ ) {
999
+ const enterpriseId = await this.assertAdminHasAccessToClassGroup(userId, classGroupId);
1000
+
1001
+ const classGroup = await this.prisma.course_class_group.findUnique({
1002
+ where: { id: classGroupId },
1003
+ select: {
1004
+ capacity: true,
1005
+ course_id: true,
1006
+ _count: { select: { course_enrollment: { where: { status: { not: 'cancelled' } } } } },
1007
+ },
1008
+ });
1009
+ if (
1010
+ classGroup?.capacity &&
1011
+ classGroup._count.course_enrollment >= classGroup.capacity
1012
+ ) {
1013
+ throw new ConflictException('Class group is at full capacity');
1014
+ }
1015
+
1016
+ const licenseInfo = await this.getLicenseInfo(enterpriseId);
1017
+ if (licenseInfo.limit !== null && licenseInfo.totalStudents >= licenseInfo.limit) {
1018
+ throw new ConflictException('Enterprise license limit reached');
1019
+ }
1020
+
1021
+ const name = dto.name.trim();
1022
+ if (!name) throw new BadRequestException('Name is required');
1023
+ const email = dto.email?.trim();
1024
+
1025
+ let personId: number | undefined;
1026
+
1027
+ if (email) {
1028
+ const existing = await this.prisma.person.findFirst({
1029
+ where: {
1030
+ contact: {
1031
+ some: {
1032
+ value: { equals: email, mode: 'insensitive' },
1033
+ contact_type: { code: { in: ['email', 'EMAIL'] } },
1034
+ },
1035
+ },
1036
+ },
1037
+ select: { id: true },
1038
+ });
1039
+ if (existing) personId = existing.id;
1040
+ }
1041
+
1042
+ if (!personId) {
1043
+ const emailType = email
1044
+ ? await this.prisma.contact_type.findFirst({
1045
+ where: { code: { in: ['email', 'EMAIL'] } },
1046
+ select: { id: true },
1047
+ })
1048
+ : null;
1049
+
1050
+ const created = await this.prisma.person.create({
1051
+ data: { name, type: 'individual', status: 'active' },
1052
+ select: { id: true },
1053
+ });
1054
+ personId = created.id;
1055
+
1056
+ if (email && emailType) {
1057
+ await this.prisma.contact.create({
1058
+ data: {
1059
+ person_id: personId,
1060
+ contact_type_id: emailType.id,
1061
+ value: email,
1062
+ is_primary: true,
1063
+ },
1064
+ });
1065
+ }
1066
+ }
1067
+
1068
+ let esRecord = await this.prisma.enterprise_student.findFirst({
1069
+ where: { enterprise_id: enterpriseId, person_id: personId },
1070
+ select: { id: true },
1071
+ });
1072
+ if (!esRecord) {
1073
+ esRecord = await this.prisma.enterprise_student.create({
1074
+ data: {
1075
+ enterprise_id: enterpriseId,
1076
+ person_id: personId,
1077
+ status: 'active',
1078
+ },
1079
+ select: { id: true },
1080
+ });
1081
+ }
1082
+
1083
+ const existingEnrollment = await this.prisma.course_enrollment.findFirst({
1084
+ where: { course_class_group_id: classGroupId, person_id: personId },
1085
+ });
1086
+ if (existingEnrollment) {
1087
+ if (existingEnrollment.status === 'cancelled') {
1088
+ return this.prisma.course_enrollment.update({
1089
+ where: { id: existingEnrollment.id },
1090
+ data: { status: 'active', enrolled_at: new Date() },
1091
+ });
1092
+ }
1093
+ throw new ConflictException('Student already enrolled');
1094
+ }
1095
+
1096
+ return this.prisma.course_enrollment.create({
1097
+ data: {
1098
+ person_id: personId,
1099
+ course_class_group_id: classGroupId,
1100
+ course_id: classGroup!.course_id,
1101
+ status: 'active',
1102
+ enrolled_at: new Date(),
1103
+ },
1104
+ });
1105
+ }
1106
+
1107
+ // ── Enterprise ID resolution ─────────────────────────────────────────────────
1108
+
1109
+ private async resolveEnterpriseId(userId: number, requestedEnterpriseId?: number): Promise<number> {
1110
+ if (requestedEnterpriseId !== undefined) {
1111
+ const allowed = await this.prisma.enterprise_user.findFirst({
1112
+ where: { user_id: userId, enterprise_id: requestedEnterpriseId, status: 'active' },
1113
+ select: { enterprise_id: true },
1114
+ });
1115
+ if (!allowed) throw new ForbiddenException('Access denied to the requested enterprise');
1116
+ return requestedEnterpriseId;
1117
+ }
1118
+ const enterpriseUser = await this.prisma.enterprise_user.findFirst({
1119
+ where: { user_id: userId, status: 'active' },
1120
+ select: { enterprise_id: true },
1121
+ });
1122
+ if (!enterpriseUser) throw new NotFoundException('No active enterprise found for this user');
1123
+ return enterpriseUser.enterprise_id;
1124
+ }
1125
+
1126
+ // ── Course listing endpoints ──────────────────────────────────────────────────
1127
+
1128
+ async getCourses(
1129
+ userId: number,
1130
+ options: {
1131
+ enterpriseId?: number;
1132
+ page?: number;
1133
+ pageSize?: number;
1134
+ search?: string;
1135
+ level?: string;
1136
+ category?: string;
1137
+ } = {},
1138
+ ) {
1139
+ const enterpriseId = await this.resolveEnterpriseId(userId, options.enterpriseId);
1140
+
1141
+ const page = Math.max(Number(options.page) || 1, 1);
1142
+ const pageSize = Math.max(Number(options.pageSize) || 20, 1);
1143
+ const skip = (page - 1) * pageSize;
1144
+
1145
+ const courseWhere: any = {};
1146
+ if (options.search?.trim()) {
1147
+ courseWhere.title = { contains: options.search.trim(), mode: 'insensitive' };
1148
+ }
1149
+ if (options.level) {
1150
+ courseWhere.level = options.level;
1151
+ }
1152
+ if (options.category?.trim()) {
1153
+ courseWhere.course_category = {
1154
+ some: { category: { slug: { equals: options.category.trim(), mode: 'insensitive' } } },
1155
+ };
1156
+ }
1157
+
1158
+ const [courses, total] = await Promise.all([
1159
+ this.prisma.course.findMany({
1160
+ where: courseWhere,
1161
+ skip,
1162
+ take: pageSize,
1163
+ orderBy: { title: 'asc' },
1164
+ include: {
1165
+ course_module: {
1166
+ select: {
1167
+ id: true,
1168
+ _count: { select: { course_lesson: true } },
1169
+ },
1170
+ },
1171
+ course_instructor: {
1172
+ include: {
1173
+ instructor: {
1174
+ include: { person: { select: { id: true, name: true } } },
1175
+ },
1176
+ },
1177
+ },
1178
+ course_category: {
1179
+ include: { category: { select: { slug: true } } },
1180
+ },
1181
+ },
1182
+ }),
1183
+ this.prisma.course.count({ where: courseWhere }),
1184
+ ]);
1185
+
1186
+ if (courses.length === 0) {
1187
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data: [] };
1188
+ }
1189
+
1190
+ const courseIds = courses.map((c) => c.id);
1191
+
1192
+ const enterpriseStudents = await this.prisma.enterprise_student.findMany({
1193
+ where: { enterprise_id: enterpriseId },
1194
+ select: { person_id: true },
1195
+ });
1196
+ const personIds = enterpriseStudents.map((s) => s.person_id);
1197
+
1198
+ const enrollmentRows =
1199
+ personIds.length > 0
1200
+ ? await this.prisma.course_enrollment.findMany({
1201
+ where: { course_id: { in: courseIds }, person_id: { in: personIds } },
1202
+ select: { course_id: true, status: true, progress_percent: true },
1203
+ })
1204
+ : [];
1205
+
1206
+ const statsMap = new Map<
1207
+ number,
1208
+ { enrolledCount: number; completedCount: number; inProgressCount: number; totalProgress: number }
1209
+ >();
1210
+ for (const row of enrollmentRows) {
1211
+ if (!statsMap.has(row.course_id)) {
1212
+ statsMap.set(row.course_id, {
1213
+ enrolledCount: 0,
1214
+ completedCount: 0,
1215
+ inProgressCount: 0,
1216
+ totalProgress: 0,
1217
+ });
1218
+ }
1219
+ const s = statsMap.get(row.course_id)!;
1220
+ s.enrolledCount++;
1221
+ s.totalProgress += row.progress_percent ?? 0;
1222
+ if (row.status === 'completed') s.completedCount++;
1223
+ if (row.status === 'active') s.inProgressCount++;
1224
+ }
1225
+
1226
+ const data = courses.map((c) => {
1227
+ const stats = statsMap.get(c.id) ?? {
1228
+ enrolledCount: 0,
1229
+ completedCount: 0,
1230
+ inProgressCount: 0,
1231
+ totalProgress: 0,
1232
+ };
1233
+ const moduleCount = c.course_module.length;
1234
+ const lessonCount = c.course_module.reduce((sum, m) => sum + m._count.course_lesson, 0);
1235
+ return {
1236
+ id: c.id,
1237
+ title: c.title,
1238
+ description: c.description ?? null,
1239
+ level: c.level,
1240
+ categories: c.course_category.map((cc) => cc.category?.slug ?? '').filter(Boolean),
1241
+ durationHours: c.duration_hours,
1242
+ moduleCount,
1243
+ lessonCount,
1244
+ instructors: c.course_instructor.map((ci) => ({
1245
+ id: ci.instructor_id,
1246
+ name: ci.instructor?.person?.name ?? '',
1247
+ avatarId: null as number | null,
1248
+ })),
1249
+ createdAt: c.created_at,
1250
+ enrolledCount: stats.enrolledCount,
1251
+ completedCount: stats.completedCount,
1252
+ inProgressCount: stats.inProgressCount,
1253
+ avgProgress:
1254
+ stats.enrolledCount > 0
1255
+ ? this.round(stats.totalProgress / stats.enrolledCount, 1)
1256
+ : 0,
1257
+ };
1258
+ });
1259
+
1260
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data };
1261
+ }
1262
+
1263
+ async getCourseStats(userId: number, enterpriseId?: number) {
1264
+ const resolvedEnterpriseId = await this.resolveEnterpriseId(userId, enterpriseId);
1265
+
1266
+ const enterpriseStudents = await this.prisma.enterprise_student.findMany({
1267
+ where: { enterprise_id: resolvedEnterpriseId },
1268
+ select: { person_id: true },
1269
+ });
1270
+ const personIds = enterpriseStudents.map((s) => s.person_id);
1271
+
1272
+ if (personIds.length === 0) {
1273
+ return { totalCourses: 0, totalEnrolled: 0, totalCompleted: 0, avgCompletion: 0 };
1274
+ }
1275
+
1276
+ const enrollmentRows = await this.prisma.course_enrollment.findMany({
1277
+ where: { person_id: { in: personIds } },
1278
+ select: { course_id: true, status: true, progress_percent: true },
1279
+ });
1280
+
1281
+ const courseSet = new Set(enrollmentRows.map((r) => r.course_id));
1282
+ const totalEnrolled = enrollmentRows.length;
1283
+ const totalCompleted = enrollmentRows.filter((r) => r.status === 'completed').length;
1284
+ const totalProgress = enrollmentRows.reduce((sum, r) => sum + (r.progress_percent ?? 0), 0);
1285
+
1286
+ return {
1287
+ totalCourses: courseSet.size,
1288
+ totalEnrolled,
1289
+ totalCompleted,
1290
+ avgCompletion: totalEnrolled > 0 ? this.round(totalProgress / totalEnrolled, 1) : 0,
1291
+ };
1292
+ }
1293
+
1294
+ async getCourseDetail(userId: number, courseId: number, enterpriseId?: number) {
1295
+ const resolvedEnterpriseId = await this.resolveEnterpriseId(userId, enterpriseId);
1296
+
1297
+ const course = await this.prisma.course.findUnique({
1298
+ where: { id: courseId },
1299
+ include: {
1300
+ course_module: {
1301
+ orderBy: { order: 'asc' },
1302
+ include: {
1303
+ course_lesson: {
1304
+ orderBy: { order: 'asc' },
1305
+ select: {
1306
+ id: true,
1307
+ order: true,
1308
+ title: true,
1309
+ type: true,
1310
+ duration_seconds: true,
1311
+ },
1312
+ },
1313
+ },
1314
+ },
1315
+ course_instructor: {
1316
+ include: {
1317
+ instructor: {
1318
+ include: { person: { select: { id: true, name: true } } },
1319
+ },
1320
+ },
1321
+ },
1322
+ course_category: {
1323
+ include: { category: { select: { slug: true } } },
1324
+ },
1325
+ },
1326
+ });
1327
+
1328
+ if (!course) throw new NotFoundException(`Course #${courseId} not found`);
1329
+
1330
+ const enterpriseStudents = await this.prisma.enterprise_student.findMany({
1331
+ where: { enterprise_id: resolvedEnterpriseId },
1332
+ select: { person_id: true },
1333
+ });
1334
+ const personIds = enterpriseStudents.map((s) => s.person_id);
1335
+
1336
+ const enrollments =
1337
+ personIds.length > 0
1338
+ ? await this.prisma.course_enrollment.findMany({
1339
+ where: { course_id: courseId, person_id: { in: personIds } },
1340
+ include: {
1341
+ person: {
1342
+ select: {
1343
+ id: true,
1344
+ name: true,
1345
+ contact: {
1346
+ where: {
1347
+ is_primary: true,
1348
+ contact_type: { code: { in: ['email', 'EMAIL'] } },
1349
+ },
1350
+ select: { value: true },
1351
+ take: 1,
1352
+ },
1353
+ },
1354
+ },
1355
+ },
1356
+ })
1357
+ : [];
1358
+
1359
+ const enrolledCount = enrollments.length;
1360
+ const completedCount = enrollments.filter((e) => e.status === 'completed').length;
1361
+ const inProgressCount = enrollments.filter((e) => e.status === 'active').length;
1362
+ const totalProgress = enrollments.reduce((sum, e) => sum + (e.progress_percent ?? 0), 0);
1363
+ const moduleCount = course.course_module.length;
1364
+ const lessonCount = course.course_module.reduce((sum, m) => sum + m.course_lesson.length, 0);
1365
+
1366
+ return {
1367
+ id: course.id,
1368
+ title: course.title,
1369
+ description: course.description ?? null,
1370
+ level: course.level,
1371
+ categories: course.course_category.map((cc) => cc.category?.slug ?? '').filter(Boolean),
1372
+ durationHours: course.duration_hours,
1373
+ moduleCount,
1374
+ lessonCount,
1375
+ instructors: course.course_instructor.map((ci) => ({
1376
+ id: ci.instructor_id,
1377
+ name: ci.instructor?.person?.name ?? '',
1378
+ avatarId: null as number | null,
1379
+ })),
1380
+ createdAt: course.created_at,
1381
+ objectives: course.objectives ?? null,
1382
+ requirements: course.requirements ?? null,
1383
+ enrolledCount,
1384
+ completedCount,
1385
+ inProgressCount,
1386
+ avgProgress: enrolledCount > 0 ? this.round(totalProgress / enrolledCount, 1) : 0,
1387
+ structure: course.course_module.map((m) => ({
1388
+ id: m.id,
1389
+ order: m.order,
1390
+ title: m.title,
1391
+ lessons: m.course_lesson.map((l) => ({
1392
+ id: l.id,
1393
+ order: l.order,
1394
+ title: l.title,
1395
+ type: l.type,
1396
+ durationMinutes: Math.round((l.duration_seconds ?? 0) / 60),
1397
+ })),
1398
+ })),
1399
+ students: enrollments.map((e) => ({
1400
+ id: e.person?.id ?? e.person_id,
1401
+ name: e.person?.name ?? null,
1402
+ email: e.person?.contact?.[0]?.value ?? null,
1403
+ department: null as string | null,
1404
+ status: e.status,
1405
+ progress: e.progress_percent ?? 0,
1406
+ enrolledAt: e.enrolled_at,
1407
+ completedAt: e.completed_at ?? null,
1408
+ lastAccess: null as Date | null,
1409
+ })),
1410
+ };
1411
+ }
1412
+
1413
+ // ── Student listing endpoints ─────────────────────────────────────────────────
1414
+
1415
+ async getStudents(
1416
+ userId: number,
1417
+ options: {
1418
+ enterpriseId?: number;
1419
+ page?: number;
1420
+ pageSize?: number;
1421
+ search?: string;
1422
+ status?: string;
1423
+ } = {},
1424
+ ) {
1425
+ const enterpriseId = await this.resolveEnterpriseId(userId, options.enterpriseId);
1426
+
1427
+ const page = Math.max(Number(options.page) || 1, 1);
1428
+ const pageSize = Math.max(Number(options.pageSize) || 20, 1);
1429
+ const skip = (page - 1) * pageSize;
1430
+
1431
+ const where: any = { enterprise_id: enterpriseId };
1432
+ if (options.status && options.status !== 'all') {
1433
+ where.status = options.status;
1434
+ }
1435
+ if (options.search?.trim()) {
1436
+ where.OR = [
1437
+ { person: { name: { contains: options.search.trim(), mode: 'insensitive' } } },
1438
+ {
1439
+ person: {
1440
+ contact: {
1441
+ some: { value: { contains: options.search.trim(), mode: 'insensitive' } },
1442
+ },
1443
+ },
1444
+ },
1445
+ ];
1446
+ }
1447
+
1448
+ const [rows, total] = await Promise.all([
1449
+ this.prisma.enterprise_student.findMany({
1450
+ where,
1451
+ skip,
1452
+ take: pageSize,
1453
+ orderBy: { person: { name: 'asc' } },
1454
+ include: {
1455
+ person: {
1456
+ select: {
1457
+ id: true,
1458
+ name: true,
1459
+ contact: {
1460
+ where: { is_primary: true, contact_type: { code: { in: ['email', 'EMAIL'] } } },
1461
+ select: { value: true },
1462
+ take: 1,
1463
+ },
1464
+ person_metadata: {
1465
+ where: { key: 'department' },
1466
+ select: { value: true },
1467
+ take: 1,
1468
+ },
1469
+ },
1470
+ },
1471
+ },
1472
+ }),
1473
+ this.prisma.enterprise_student.count({ where }),
1474
+ ]);
1475
+
1476
+ if (rows.length === 0) {
1477
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data: [] };
1478
+ }
1479
+
1480
+ const personIds = rows.map((r) => r.person_id);
1481
+
1482
+ const [enrollmentRows, certificateRows] = await Promise.all([
1483
+ this.prisma.course_enrollment.findMany({
1484
+ where: { person_id: { in: personIds } },
1485
+ select: { person_id: true, course_class_group_id: true, status: true, progress_percent: true },
1486
+ }),
1487
+ this.prisma.certificate.findMany({
1488
+ where: { student_id: { in: personIds } },
1489
+ select: { student_id: true },
1490
+ }),
1491
+ ]);
1492
+
1493
+ const enrollMap = new Map<number, Array<{ course_class_group_id: number | null; status: string; progress_percent: number }>>();
1494
+ for (const e of enrollmentRows) {
1495
+ if (!enrollMap.has(e.person_id)) enrollMap.set(e.person_id, []);
1496
+ enrollMap.get(e.person_id)!.push({
1497
+ course_class_group_id: e.course_class_group_id,
1498
+ status: e.status,
1499
+ progress_percent: e.progress_percent ?? 0,
1500
+ });
1501
+ }
1502
+
1503
+ const certMap = new Map<number, number>();
1504
+ for (const c of certificateRows) {
1505
+ certMap.set(c.student_id, (certMap.get(c.student_id) ?? 0) + 1);
1506
+ }
1507
+
1508
+ const data = rows.map((r) => {
1509
+ const enrollments = enrollMap.get(r.person_id) ?? [];
1510
+ const enrolledClasses = enrollments.filter(
1511
+ (e) => e.course_class_group_id !== null && e.status === 'active',
1512
+ ).length;
1513
+ const completedCourses = enrollments.filter((e) => e.status === 'completed').length;
1514
+ const certificates = certMap.get(r.person_id) ?? 0;
1515
+ const avgProgress =
1516
+ enrollments.length > 0
1517
+ ? this.round(
1518
+ enrollments.reduce((sum, e) => sum + e.progress_percent, 0) / enrollments.length,
1519
+ 1,
1520
+ )
1521
+ : 0;
1522
+
1523
+ const rawDept = r.person?.person_metadata?.[0]?.value;
1524
+ const department = typeof rawDept === 'string' ? rawDept : (rawDept as any)?.toString?.() ?? '';
1525
+
1526
+ return {
1527
+ id: r.person_id,
1528
+ personId: r.person_id,
1529
+ name: r.person?.name ?? null,
1530
+ email: r.person?.contact?.[0]?.value ?? null,
1531
+ department,
1532
+ status: r.status,
1533
+ enrolledClasses,
1534
+ completedCourses,
1535
+ certificates,
1536
+ avgProgress,
1537
+ joinedAt: r.created_at,
1538
+ };
1539
+ });
1540
+
1541
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data };
1542
+ }
1543
+
1544
+ async getStudentStats(userId: number, enterpriseId?: number) {
1545
+ const resolvedEnterpriseId = await this.resolveEnterpriseId(userId, enterpriseId);
1546
+
1547
+ const [totalStudents, activeStudents, inactiveStudents, pendingStudents, personRows] =
1548
+ await Promise.all([
1549
+ this.prisma.enterprise_student.count({ where: { enterprise_id: resolvedEnterpriseId } }),
1550
+ this.prisma.enterprise_student.count({ where: { enterprise_id: resolvedEnterpriseId, status: 'active' } }),
1551
+ this.prisma.enterprise_student.count({ where: { enterprise_id: resolvedEnterpriseId, status: 'inactive' } }),
1552
+ this.prisma.enterprise_student.count({ where: { enterprise_id: resolvedEnterpriseId, status: 'pending' } }),
1553
+ this.prisma.enterprise_student.findMany({
1554
+ where: { enterprise_id: resolvedEnterpriseId },
1555
+ select: { person_id: true },
1556
+ }),
1557
+ ]);
1558
+
1559
+ const personIds = personRows.map((r) => r.person_id);
1560
+ let avgProgress = 0;
1561
+ if (personIds.length > 0) {
1562
+ const agg = await this.prisma.course_enrollment.aggregate({
1563
+ where: { person_id: { in: personIds } },
1564
+ _avg: { progress_percent: true },
1565
+ });
1566
+ avgProgress = this.round(agg._avg.progress_percent ?? 0, 1);
1567
+ }
1568
+
1569
+ return { totalStudents, activeStudents, inactiveStudents, pendingStudents, avgProgress };
1570
+ }
1571
+
1572
+ async getStudentDetail(userId: number, personId: number, enterpriseId?: number) {
1573
+ const resolvedEnterpriseId = await this.resolveEnterpriseId(userId, enterpriseId);
1574
+
1575
+ const esRecord = await this.prisma.enterprise_student.findFirst({
1576
+ where: { enterprise_id: resolvedEnterpriseId, person_id: personId },
1577
+ select: { id: true, status: true, created_at: true },
1578
+ });
1579
+ if (!esRecord) throw new NotFoundException('Student not found in your enterprise');
1580
+
1581
+ const [person, enrollments, certificates, examAttempts, personUsers] = await Promise.all([
1582
+ this.prisma.person.findUnique({
1583
+ where: { id: personId },
1584
+ select: {
1585
+ id: true,
1586
+ name: true,
1587
+ contact: {
1588
+ where: { is_primary: true, contact_type: { code: { in: ['email', 'EMAIL'] } } },
1589
+ select: { value: true },
1590
+ take: 1,
1591
+ },
1592
+ person_metadata: {
1593
+ where: { key: 'department' },
1594
+ select: { value: true },
1595
+ take: 1,
1596
+ },
1597
+ },
1598
+ }),
1599
+ this.prisma.course_enrollment.findMany({
1600
+ where: { person_id: personId },
1601
+ include: {
1602
+ course: {
1603
+ select: {
1604
+ id: true,
1605
+ title: true,
1606
+ duration_hours: true,
1607
+ course_category: {
1608
+ include: { category: { select: { slug: true } } },
1609
+ },
1610
+ },
1611
+ },
1612
+ course_class_group: {
1613
+ select: {
1614
+ id: true,
1615
+ title: true,
1616
+ code: true,
1617
+ status: true,
1618
+ start_date: true,
1619
+ end_date: true,
1620
+ instructor: {
1621
+ select: { person: { select: { name: true } } },
1622
+ },
1623
+ },
1624
+ },
1625
+ },
1626
+ orderBy: { enrolled_at: 'desc' },
1627
+ }),
1628
+ this.prisma.certificate.findMany({
1629
+ where: { student_id: personId },
1630
+ select: {
1631
+ id: true,
1632
+ verification_code: true,
1633
+ course_name: true,
1634
+ issued_at: true,
1635
+ certificate_type: true,
1636
+ },
1637
+ orderBy: { issued_at: 'desc' },
1638
+ }),
1639
+ this.prisma.exam_attempt.findMany({
1640
+ where: { student_id: personId },
1641
+ include: { exam: { select: { id: true, title: true } } },
1642
+ orderBy: { started_at: 'desc' },
1643
+ }),
1644
+ this.prisma.person_user.findMany({
1645
+ where: { person_id: personId },
1646
+ select: { user_id: true },
1647
+ }),
1648
+ ]);
1649
+
1650
+ if (!person) throw new NotFoundException(`Person #${personId} not found`);
1651
+
1652
+ const rawDept = person.person_metadata?.[0]?.value;
1653
+ const department = typeof rawDept === 'string' ? rawDept : (rawDept as any)?.toString?.() ?? '';
1654
+
1655
+ const userIds = personUsers.map((pu) => pu.user_id);
1656
+ const acessos =
1657
+ userIds.length > 0
1658
+ ? await this.prisma.user_session.findMany({
1659
+ where: { user_id: { in: userIds } },
1660
+ select: { id: true, ip_address: true, user_agent: true, created_at: true },
1661
+ orderBy: { created_at: 'desc' },
1662
+ take: 20,
1663
+ })
1664
+ : [];
1665
+
1666
+ const turmas = enrollments
1667
+ .filter((e) => e.course_class_group_id !== null)
1668
+ .map((e) => {
1669
+ const cg = e.course_class_group!;
1670
+ return {
1671
+ id: cg.id,
1672
+ code: cg.code,
1673
+ title: cg.title,
1674
+ status: cg.status,
1675
+ progress: e.progress_percent ?? 0,
1676
+ instructorName: (cg as any).instructor?.person?.name ?? null,
1677
+ startDate: cg.start_date,
1678
+ endDate: cg.end_date ?? null,
1679
+ };
1680
+ });
1681
+
1682
+ const cursos = enrollments.map((e) => ({
1683
+ id: e.course?.id ?? e.course_id,
1684
+ title: e.course?.title ?? null,
1685
+ categories:
1686
+ e.course?.course_category?.map((cc) => cc.category?.slug ?? '').filter(Boolean) ?? [],
1687
+ durationHours: e.course?.duration_hours ?? null,
1688
+ status: e.status,
1689
+ progress: e.progress_percent ?? 0,
1690
+ enrolledAt: e.enrolled_at,
1691
+ completedAt: e.completed_at ?? null,
1692
+ }));
1693
+
1694
+ const certificadosList = certificates.map((c) => ({
1695
+ id: c.id,
1696
+ verificationCode: c.verification_code,
1697
+ courseName: c.course_name,
1698
+ issuedAt: c.issued_at,
1699
+ type: c.certificate_type,
1700
+ }));
1701
+
1702
+ const avaliacoes = examAttempts.map((a) => ({
1703
+ id: a.id,
1704
+ title: a.exam?.title ?? null,
1705
+ score: a.score ?? null,
1706
+ status: a.finished_at ? 'graded' : 'pending',
1707
+ submittedAt: a.finished_at ?? a.started_at,
1708
+ }));
1709
+
1710
+ const acessosList = acessos.map((s) => ({
1711
+ id: s.id,
1712
+ ipAddress: s.ip_address,
1713
+ userAgent: s.user_agent,
1714
+ createdAt: s.created_at,
1715
+ }));
1716
+
1717
+ return {
1718
+ id: person.id,
1719
+ name: person.name,
1720
+ email: person.contact?.[0]?.value ?? null,
1721
+ department,
1722
+ status: esRecord.status,
1723
+ joinedAt: esRecord.created_at,
1724
+ enrolledClasses: turmas.length,
1725
+ completedCourses: cursos.filter((c) => c.status === 'completed').length,
1726
+ certificates: certificadosList.length,
1727
+ avgProgress:
1728
+ cursos.length > 0
1729
+ ? this.round(cursos.reduce((sum, c) => sum + c.progress, 0) / cursos.length, 1)
1730
+ : 0,
1731
+ turmas,
1732
+ cursos,
1733
+ certificados: certificadosList,
1734
+ avaliacoes,
1735
+ acessos: acessosList,
1736
+ };
1737
+ }
1738
+
1739
+ // ── Admin management ─────────────────────────────────────────────────────────
1740
+
1741
+ private async resolveEnterpriseIdAsAdmin(userId: number, requestedEnterpriseId?: number): Promise<number> {
1742
+ if (requestedEnterpriseId !== undefined) {
1743
+ const allowed = await this.prisma.enterprise_user.findFirst({
1744
+ where: { user_id: userId, enterprise_id: requestedEnterpriseId, status: 'active', role: 'enterprise_admin' },
1745
+ select: { enterprise_id: true },
1746
+ });
1747
+ if (!allowed) throw new ForbiddenException('Access denied: enterprise admin role required');
1748
+ return requestedEnterpriseId;
1749
+ }
1750
+ const enterpriseUser = await this.prisma.enterprise_user.findFirst({
1751
+ where: { user_id: userId, status: 'active', role: 'enterprise_admin' },
1752
+ select: { enterprise_id: true },
1753
+ });
1754
+ if (!enterpriseUser) throw new ForbiddenException('Enterprise admin role required');
1755
+ return enterpriseUser.enterprise_id;
1756
+ }
1757
+
1758
+ private mapRoleToPermission(role: string): 'admin' | 'hr-manager' | 'viewer' {
1759
+ if (role === 'enterprise_admin') return 'admin';
1760
+ if (role === 'hr_manager') return 'hr-manager';
1761
+ return 'viewer';
1762
+ }
1763
+
1764
+ private mapPermissionToRole(permission: string): 'enterprise_admin' | 'hr_manager' | 'viewer' {
1765
+ if (permission === 'admin') return 'enterprise_admin';
1766
+ if (permission === 'hr-manager') return 'hr_manager';
1767
+ return 'viewer';
1768
+ }
1769
+
1770
+ async getAdminStats(userId: number, enterpriseId?: number) {
1771
+ const resolvedId = await this.resolveEnterpriseIdAsAdmin(userId, enterpriseId);
1772
+ const rows = await this.prisma.enterprise_user.groupBy({
1773
+ by: ['role'],
1774
+ where: { enterprise_id: resolvedId, role: { not: 'instructor' } },
1775
+ _count: { id: true },
1776
+ });
1777
+
1778
+ let adminCount = 0;
1779
+ let hrManagerCount = 0;
1780
+ let viewerCount = 0;
1781
+ let total = 0;
1782
+
1783
+ for (const r of rows) {
1784
+ const count = r._count.id;
1785
+ total += count;
1786
+ if (r.role === 'enterprise_admin') adminCount = count;
1787
+ else if (r.role === 'hr_manager') hrManagerCount = count;
1788
+ else if (r.role === 'viewer') viewerCount = count;
1789
+ }
1790
+
1791
+ return { total, adminCount, hrManagerCount, viewerCount };
1792
+ }
1793
+
1794
+ async getAdmins(
1795
+ userId: number,
1796
+ options: { enterpriseId?: number; page?: number; pageSize?: number; search?: string; role?: string } = {},
1797
+ ) {
1798
+ const resolvedId = await this.resolveEnterpriseIdAsAdmin(userId, options.enterpriseId);
1799
+ const page = Math.max(Number(options.page) || 1, 1);
1800
+ const pageSize = Math.max(Number(options.pageSize) || 20, 1);
1801
+ const skip = (page - 1) * pageSize;
1802
+
1803
+ const where: any = {
1804
+ enterprise_id: resolvedId,
1805
+ role: options.role ? this.mapPermissionToRole(options.role) : { not: 'instructor' },
1806
+ };
1807
+
1808
+ if (options.search?.trim()) {
1809
+ where.OR = [
1810
+ { user: { name: { contains: options.search.trim(), mode: 'insensitive' } } },
1811
+ { person: { name: { contains: options.search.trim(), mode: 'insensitive' } } },
1812
+ ];
1813
+ }
1814
+
1815
+ const [rows, total] = await Promise.all([
1816
+ this.prisma.enterprise_user.findMany({
1817
+ where,
1818
+ skip,
1819
+ take: pageSize,
1820
+ orderBy: { created_at: 'desc' },
1821
+ include: {
1822
+ user: { select: { id: true, name: true, last_login_at: true } },
1823
+ person: {
1824
+ select: {
1825
+ id: true,
1826
+ name: true,
1827
+ person_metadata: { where: { key: 'department' }, select: { value: true }, take: 1 },
1828
+ contact: {
1829
+ where: { contact_type: { code: { in: ['email', 'EMAIL'] } } },
1830
+ select: { value: true },
1831
+ take: 1,
1832
+ },
1833
+ },
1834
+ },
1835
+ },
1836
+ }),
1837
+ this.prisma.enterprise_user.count({ where }),
1838
+ ]);
1839
+
1840
+ const data = rows.map((eu) => {
1841
+ const name = eu.person?.name ?? eu.user.name;
1842
+ const email = eu.person?.contact?.[0]?.value ?? null;
1843
+ const rawDept = eu.person?.person_metadata?.[0]?.value;
1844
+ const department = typeof rawDept === 'string' ? rawDept : (rawDept as any)?.toString?.() ?? '';
1845
+ return {
1846
+ id: eu.id,
1847
+ personId: eu.person_id,
1848
+ name,
1849
+ email,
1850
+ department,
1851
+ permission: this.mapRoleToPermission(eu.role),
1852
+ status: eu.status,
1853
+ addedAt: eu.created_at.toISOString(),
1854
+ lastAccess: eu.user.last_login_at?.toISOString() ?? null,
1855
+ };
1856
+ });
1857
+
1858
+ return { total, page, pageSize, lastPage: Math.max(1, Math.ceil(total / pageSize)), data };
1859
+ }
1860
+
1861
+ async addAdmin(
1862
+ userId: number,
1863
+ dto: { name: string; email?: string; department?: string; role?: string },
1864
+ ) {
1865
+ const enterpriseId = await this.resolveEnterpriseIdAsAdmin(userId);
1866
+ const name = dto.name?.trim();
1867
+ if (!name) throw new BadRequestException('Name is required');
1868
+ const email = dto.email?.trim();
1869
+ const dbRole = this.mapPermissionToRole(dto.role ?? 'viewer');
1870
+
1871
+ let personId: number | undefined;
1872
+ let linkedUserId: number | undefined;
1873
+
1874
+ if (email) {
1875
+ const existingPerson = await this.prisma.person.findFirst({
1876
+ where: {
1877
+ contact: {
1878
+ some: {
1879
+ value: { equals: email, mode: 'insensitive' },
1880
+ contact_type: { code: { in: ['email', 'EMAIL'] } },
1881
+ },
1882
+ },
1883
+ },
1884
+ select: { id: true, person_user: { select: { user_id: true }, take: 1 } },
1885
+ });
1886
+ if (existingPerson) {
1887
+ personId = existingPerson.id;
1888
+ linkedUserId = existingPerson.person_user?.[0]?.user_id;
1889
+ }
1890
+ }
1891
+
1892
+ if (!personId) {
1893
+ const emailType = email
1894
+ ? await this.prisma.contact_type.findFirst({
1895
+ where: { code: { in: ['email', 'EMAIL'] } },
1896
+ select: { id: true },
1897
+ })
1898
+ : null;
1899
+ const created = await this.prisma.person.create({
1900
+ data: { name, type: 'individual', status: 'active' },
1901
+ select: { id: true },
1902
+ });
1903
+ personId = created.id;
1904
+ if (email && emailType) {
1905
+ await this.prisma.contact.create({
1906
+ data: { person_id: personId, contact_type_id: emailType.id, value: email, is_primary: true },
1907
+ });
1908
+ }
1909
+ }
1910
+
1911
+ if (!linkedUserId) {
1912
+ const newUser = await this.prisma.user.create({
1913
+ data: { name },
1914
+ select: { id: true },
1915
+ });
1916
+ linkedUserId = newUser.id;
1917
+ await this.prisma.person_user.create({
1918
+ data: { person_id: personId, user_id: linkedUserId },
1919
+ });
1920
+ }
1921
+
1922
+ const existing = await this.prisma.enterprise_user.findFirst({
1923
+ where: { enterprise_id: enterpriseId, user_id: linkedUserId },
1924
+ select: { id: true },
1925
+ });
1926
+ if (existing) throw new ConflictException('This user already has access to this enterprise');
1927
+
1928
+ return this.prisma.enterprise_user.create({
1929
+ data: {
1930
+ enterprise_id: enterpriseId,
1931
+ user_id: linkedUserId,
1932
+ person_id: personId,
1933
+ role: dbRole,
1934
+ status: 'active',
1935
+ },
1936
+ select: { id: true, role: true, status: true, created_at: true },
1937
+ });
1938
+ }
1939
+
1940
+ async updateAdminRole(userId: number, enterpriseUserId: number, permission: string) {
1941
+ const enterpriseId = await this.resolveEnterpriseIdAsAdmin(userId);
1942
+ const target = await this.prisma.enterprise_user.findFirst({
1943
+ where: { id: enterpriseUserId, enterprise_id: enterpriseId },
1944
+ select: { id: true, user_id: true },
1945
+ });
1946
+ if (!target) throw new NotFoundException('Admin not found in your enterprise');
1947
+ if (target.user_id === userId) throw new ForbiddenException('Cannot change your own role');
1948
+ const dbRole = this.mapPermissionToRole(permission);
1949
+ return this.prisma.enterprise_user.update({
1950
+ where: { id: enterpriseUserId },
1951
+ data: { role: dbRole },
1952
+ select: { id: true, role: true },
1953
+ });
1954
+ }
1955
+
1956
+ async removeAdmin(userId: number, enterpriseUserId: number) {
1957
+ const enterpriseId = await this.resolveEnterpriseIdAsAdmin(userId);
1958
+ const target = await this.prisma.enterprise_user.findFirst({
1959
+ where: { id: enterpriseUserId, enterprise_id: enterpriseId },
1960
+ select: { id: true, user_id: true },
1961
+ });
1962
+ if (!target) throw new NotFoundException('Admin not found in your enterprise');
1963
+ if (target.user_id === userId) throw new ForbiddenException('Cannot remove yourself');
1964
+ await this.prisma.enterprise_user.delete({ where: { id: enterpriseUserId } });
1965
+ return { success: true };
1966
+ }
1967
+
1968
+ // ── Evaluation endpoints ───────────────────────────────────────────────────────
1969
+
1970
+ async getEvaluationStats(userId: number, enterpriseId?: number) {
1971
+ const entId = await this.resolveEnterpriseId(userId, enterpriseId);
1972
+
1973
+ const cgRows = await this.prisma.enterprise_class_group.findMany({
1974
+ where: { enterprise_id: entId },
1975
+ select: { course_class_group_id: true },
1976
+ });
1977
+ const cgIds = cgRows.map((r) => r.course_class_group_id);
1978
+
1979
+ if (cgIds.length === 0) {
1980
+ return { activeClassGroups: 0, totalEvaluations: 0, avgScore: 0, evaluatedStudentCount: 0 };
1981
+ }
1982
+
1983
+ const ratings = await this.prisma.evaluation_rating.findMany({
1984
+ where: {
1985
+ evaluation_topic: {
1986
+ target_type: 'course_class_session',
1987
+ course_class_session: { course_class_group_id: { in: cgIds } },
1988
+ },
1989
+ },
1990
+ select: {
1991
+ score: true,
1992
+ evaluator_id: true,
1993
+ evaluation_topic: {
1994
+ select: {
1995
+ course_class_session_id: true,
1996
+ course_class_session: { select: { course_class_group_id: true } },
1997
+ },
1998
+ },
1999
+ },
2000
+ });
2001
+
2002
+ const evaluatorSet = new Set(ratings.map((r) => r.evaluator_id).filter(Boolean));
2003
+ const ratedCgIds = new Set(
2004
+ ratings
2005
+ .map((r) => r.evaluation_topic.course_class_session?.course_class_group_id)
2006
+ .filter(Boolean),
2007
+ );
2008
+ const evalPairs = new Set(
2009
+ ratings.map((r) => `${r.evaluator_id}-${r.evaluation_topic.course_class_session_id}`),
2010
+ );
2011
+ const totalScore = ratings.reduce((s, r) => s + Number(r.score), 0);
2012
+ const avgScore = ratings.length > 0 ? this.round(totalScore / ratings.length, 2) : 0;
2013
+
2014
+ return {
2015
+ activeClassGroups: ratedCgIds.size,
2016
+ totalEvaluations: evalPairs.size,
2017
+ avgScore,
2018
+ evaluatedStudentCount: evaluatorSet.size,
2019
+ };
2020
+ }
2021
+
2022
+ async getEvaluationClassGroups(
2023
+ userId: number,
2024
+ options: { enterpriseId?: number; page?: number; pageSize?: number; search?: string } = {},
2025
+ ) {
2026
+ const entId = await this.resolveEnterpriseId(userId, options.enterpriseId);
2027
+ const page = Math.max(Number(options.page) || 1, 1);
2028
+ const pageSize = Math.max(Number(options.pageSize) || 20, 1);
2029
+ const skip = (page - 1) * pageSize;
2030
+
2031
+ const where: any = { enterprise_class_group: { some: { enterprise_id: entId } } };
2032
+ if (options.search?.trim()) {
2033
+ where.OR = [
2034
+ { title: { contains: options.search.trim(), mode: 'insensitive' } },
2035
+ { course: { title: { contains: options.search.trim(), mode: 'insensitive' } } },
2036
+ ];
2037
+ }
2038
+
2039
+ const [total, classGroups] = await Promise.all([
2040
+ this.prisma.course_class_group.count({ where }),
2041
+ this.prisma.course_class_group.findMany({
2042
+ where,
2043
+ skip,
2044
+ take: pageSize,
2045
+ orderBy: { start_date: 'desc' },
2046
+ include: {
2047
+ course: { select: { id: true, title: true } },
2048
+ _count: {
2049
+ select: { course_enrollment: { where: { status: { not: 'cancelled' } } } },
2050
+ },
2051
+ course_class_session: {
2052
+ select: {
2053
+ id: true,
2054
+ evaluation_topic: {
2055
+ where: { target_type: 'course_class_session' },
2056
+ select: {
2057
+ id: true,
2058
+ evaluation_rating: { select: { evaluator_id: true, score: true } },
2059
+ },
2060
+ },
2061
+ },
2062
+ },
2063
+ },
2064
+ }),
2065
+ ]);
2066
+
2067
+ const data = classGroups.map((cg) => {
2068
+ const allRatings = cg.course_class_session.flatMap((s) =>
2069
+ s.evaluation_topic.flatMap((t) => t.evaluation_rating),
2070
+ );
2071
+ const evaluatorSet = new Set(allRatings.map((r) => r.evaluator_id).filter(Boolean));
2072
+ const evaluatedSessionCount = cg.course_class_session.filter((s) =>
2073
+ s.evaluation_topic.some((t) => t.evaluation_rating.length > 0),
2074
+ ).length;
2075
+ const totalScore = allRatings.reduce((sum, r) => sum + Number(r.score), 0);
2076
+ const avgScore = allRatings.length > 0 ? this.round(totalScore / allRatings.length, 2) : 0;
2077
+
2078
+ return {
2079
+ id: cg.id,
2080
+ title: cg.title,
2081
+ courseId: cg.course?.id ?? null,
2082
+ courseTitle: cg.course?.title ?? null,
2083
+ startDate: cg.start_date,
2084
+ endDate: cg.end_date ?? null,
2085
+ totalStudentCount: cg._count.course_enrollment,
2086
+ sessionCount: cg.course_class_session.length,
2087
+ evaluatedSessionCount,
2088
+ avgScore,
2089
+ evaluatedStudentCount: evaluatorSet.size,
2090
+ };
2091
+ });
2092
+
2093
+ return { total, page, pageSize, lastPage: Math.max(Math.ceil(total / pageSize), 1), data };
2094
+ }
2095
+
2096
+ async getEvaluationClassGroupDetail(
2097
+ userId: number,
2098
+ classGroupId: number,
2099
+ enterpriseId?: number,
2100
+ ) {
2101
+ const entId = await this.resolveEnterpriseId(userId, enterpriseId);
2102
+
2103
+ const cgCheck = await this.prisma.enterprise_class_group.findFirst({
2104
+ where: { enterprise_id: entId, course_class_group_id: classGroupId },
2105
+ });
2106
+ if (!cgCheck) throw new NotFoundException('Class group not found in your enterprise');
2107
+
2108
+ const classGroup = await this.prisma.course_class_group.findUnique({
2109
+ where: { id: classGroupId },
2110
+ include: {
2111
+ course: { select: { id: true, title: true } },
2112
+ course_class_session: {
2113
+ orderBy: { session_date: 'asc' },
2114
+ include: {
2115
+ evaluation_topic: {
2116
+ where: { target_type: 'course_class_session' },
2117
+ orderBy: { order: 'asc' },
2118
+ include: {
2119
+ evaluation_rating: {
2120
+ include: { person: { select: { id: true, name: true } } },
2121
+ },
2122
+ },
2123
+ },
2124
+ },
2125
+ },
2126
+ },
2127
+ });
2128
+ if (!classGroup) throw new NotFoundException('Class group not found');
2129
+
2130
+ // Course-level topics + rater IDs for evaluatedCourse flag
2131
+ let courseTopics: { id: number; name: string }[] = [];
2132
+ const courseRaterIds = new Set<number>();
2133
+ if (classGroup.course_id) {
2134
+ const cTopics = await this.prisma.evaluation_topic.findMany({
2135
+ where: { course_id: classGroup.course_id, target_type: 'course' },
2136
+ orderBy: { order: 'asc' },
2137
+ include: { evaluation_rating: { select: { evaluator_id: true } } },
2138
+ });
2139
+ courseTopics = cTopics.map((t) => ({ id: t.id, name: t.name }));
2140
+ cTopics.forEach((t) =>
2141
+ t.evaluation_rating.forEach((r) => {
2142
+ if (r.evaluator_id) courseRaterIds.add(r.evaluator_id);
2143
+ }),
2144
+ );
2145
+ }
2146
+
2147
+ // Deduplicate topics across all sessions
2148
+ const topicMap = new Map<number, string>();
2149
+ classGroup.course_class_session.forEach((s) =>
2150
+ s.evaluation_topic.forEach((t) => topicMap.set(t.id, t.name)),
2151
+ );
2152
+
2153
+ const sessions = classGroup.course_class_session.map((session) => {
2154
+ const raterMap = new Map<
2155
+ number,
2156
+ { name: string; topicScores: Record<number, number>; comment: string | null }
2157
+ >();
2158
+ session.evaluation_topic.forEach((topic) => {
2159
+ topic.evaluation_rating.forEach((rating) => {
2160
+ if (!rating.evaluator_id || !rating.person) return;
2161
+ if (!raterMap.has(rating.evaluator_id)) {
2162
+ raterMap.set(rating.evaluator_id, {
2163
+ name: rating.person.name,
2164
+ topicScores: {},
2165
+ comment: null,
2166
+ });
2167
+ }
2168
+ const entry = raterMap.get(rating.evaluator_id)!;
2169
+ entry.topicScores[topic.id] = Number(rating.score);
2170
+ if (rating.comment && !entry.comment) entry.comment = rating.comment;
2171
+ });
2172
+ });
2173
+
2174
+ const allRatings = session.evaluation_topic.flatMap((t) => t.evaluation_rating);
2175
+ const totalScore = allRatings.reduce((s, r) => s + Number(r.score), 0);
2176
+ const avg = allRatings.length > 0 ? this.round(totalScore / allRatings.length, 2) : 0;
2177
+
2178
+ return {
2179
+ id: session.id,
2180
+ title: session.title,
2181
+ date: session.session_date,
2182
+ avg,
2183
+ ratings: Array.from(raterMap.entries()).map(([personId, d]) => ({
2184
+ personId,
2185
+ name: d.name,
2186
+ topicScores: d.topicScores,
2187
+ comment: d.comment,
2188
+ evaluatedCourse: courseRaterIds.has(personId),
2189
+ })),
2190
+ };
2191
+ });
2192
+
2193
+ return {
2194
+ classGroup: {
2195
+ id: classGroup.id,
2196
+ title: classGroup.title,
2197
+ courseId: classGroup.course?.id ?? null,
2198
+ courseTitle: classGroup.course?.title ?? null,
2199
+ startDate: classGroup.start_date,
2200
+ endDate: classGroup.end_date ?? null,
2201
+ },
2202
+ topics: Array.from(topicMap.entries()).map(([id, name]) => ({ id, name })),
2203
+ sessions,
2204
+ courseTopics,
2205
+ };
2206
+ }
2207
+
2208
+ // ── Admin reports endpoint ─────────────────────────────────────────────────────
2209
+
2210
+ async getAdminReports(
2211
+ userId: number,
2212
+ options: { enterpriseId?: number; dateFrom?: string; dateTo?: string } = {},
2213
+ ) {
2214
+ const entId = await this.resolveEnterpriseId(userId, options.enterpriseId);
2215
+
2216
+ const now = new Date();
2217
+ const dateFrom = options.dateFrom
2218
+ ? new Date(`${options.dateFrom}T00:00:00.000Z`)
2219
+ : new Date(Date.UTC(now.getFullYear(), 0, 1));
2220
+ const dateTo = options.dateTo
2221
+ ? new Date(`${options.dateTo}T23:59:59.999Z`)
2222
+ : new Date(Date.UTC(now.getFullYear(), 11, 31, 23, 59, 59));
2223
+
2224
+ const [esRows, ecgRows] = await Promise.all([
2225
+ this.prisma.enterprise_student.findMany({
2226
+ where: { enterprise_id: entId },
2227
+ select: { person_id: true, status: true },
2228
+ }),
2229
+ this.prisma.enterprise_class_group.findMany({
2230
+ where: { enterprise_id: entId },
2231
+ select: { course_class_group_id: true },
2232
+ }),
2233
+ ]);
2234
+
2235
+ const personIds = esRows.map((r) => r.person_id);
2236
+ const cgIds = ecgRows.map((r) => r.course_class_group_id);
2237
+
2238
+ if (personIds.length === 0) {
2239
+ return {
2240
+ kpis: { totalStudents: 0, enrollmentsInPeriod: 0, completedInPeriod: 0, activeClassGroups: 0, avgSatisfaction: 0, certificatesIssued: 0, atRiskStudentsCount: 0, activeCoursesCount: 0 },
2241
+ enrollmentEvents: [] as { enrolledDate: string | null; completedDate: string | null }[],
2242
+ sessionAttendance: [] as { date: string; title: string; totalStudents: number; presentCount: number; pct: number }[],
2243
+ courseStats: [] as { courseId: number; title: string; enrolledCount: number; completedCount: number; completionPct: number; avgSatisfaction: number }[],
2244
+ studentStatusCounts: { active: 0, atRisk: 0, inactive: 0 },
2245
+ evalSessions: [] as { date: string; sessionTitle: string; scores: { topicName: string; avg: number }[]; overallAvg: number }[],
2246
+ evalTopicAverages: [] as { topicName: string; avgScore: number }[],
2247
+ atRiskStudents: [] as { personId: number; name: string; attendancePct: number; lastPresentDate: string | null; classGroupTitle: string | null }[],
2248
+ };
2249
+ }
2250
+
2251
+ const [
2252
+ enrollmentsInPeriodCount,
2253
+ completedInPeriodCount,
2254
+ activeClassGroupsCount,
2255
+ certificatesIssuedCount,
2256
+ allEnrollments,
2257
+ sessionAttendanceRaw,
2258
+ evalRatings,
2259
+ attendanceForRisk,
2260
+ ] = await Promise.all([
2261
+ this.prisma.course_enrollment.count({
2262
+ where: { person_id: { in: personIds }, enrolled_at: { gte: dateFrom, lte: dateTo } },
2263
+ }),
2264
+ this.prisma.course_enrollment.count({
2265
+ where: { person_id: { in: personIds }, completed_at: { gte: dateFrom, lte: dateTo } },
2266
+ }),
2267
+ this.prisma.course_class_group.count({
2268
+ where: { enterprise_class_group: { some: { enterprise_id: entId } }, status: 'ongoing' },
2269
+ }),
2270
+ this.prisma.certificate.count({
2271
+ where: { student_id: { in: personIds }, issued_at: { gte: dateFrom, lte: dateTo } },
2272
+ }),
2273
+ this.prisma.course_enrollment.findMany({
2274
+ where: { person_id: { in: personIds } },
2275
+ select: {
2276
+ course_id: true,
2277
+ status: true,
2278
+ enrolled_at: true,
2279
+ completed_at: true,
2280
+ course: { select: { id: true, title: true } },
2281
+ },
2282
+ }),
2283
+ cgIds.length > 0
2284
+ ? this.prisma.course_class_attendance.findMany({
2285
+ where: {
2286
+ course_class_session: {
2287
+ course_class_group_id: { in: cgIds },
2288
+ session_date: { gte: dateFrom, lte: dateTo },
2289
+ },
2290
+ student_id: { in: personIds },
2291
+ },
2292
+ select: {
2293
+ student_id: true,
2294
+ present: true,
2295
+ course_class_session: { select: { id: true, session_date: true, title: true } },
2296
+ },
2297
+ })
2298
+ : Promise.resolve([] as { student_id: number; present: boolean; course_class_session: { id: number; session_date: Date; title: string } | null }[]),
2299
+ cgIds.length > 0
2300
+ ? this.prisma.evaluation_rating.findMany({
2301
+ where: {
2302
+ evaluation_topic: {
2303
+ course_class_session: {
2304
+ course_class_group_id: { in: cgIds },
2305
+ session_date: { gte: dateFrom, lte: dateTo },
2306
+ },
2307
+ },
2308
+ },
2309
+ select: {
2310
+ score: true,
2311
+ evaluation_topic: {
2312
+ select: {
2313
+ name: true,
2314
+ course_class_session: {
2315
+ select: {
2316
+ session_date: true,
2317
+ title: true,
2318
+ course_class_group: { select: { course_id: true } },
2319
+ },
2320
+ },
2321
+ },
2322
+ },
2323
+ },
2324
+ })
2325
+ : Promise.resolve([] as { score: any; evaluation_topic: { name: string; course_class_session: { session_date: Date; title: string; course_class_group: { course_id: number } | null } | null } }[]),
2326
+ cgIds.length > 0
2327
+ ? this.prisma.course_class_attendance.findMany({
2328
+ where: {
2329
+ course_class_session: { course_class_group_id: { in: cgIds } },
2330
+ student_id: { in: personIds },
2331
+ },
2332
+ select: {
2333
+ student_id: true,
2334
+ present: true,
2335
+ course_class_session: { select: { session_date: true, course_class_group_id: true } },
2336
+ },
2337
+ })
2338
+ : Promise.resolve([] as { student_id: number; present: boolean; course_class_session: { session_date: Date; course_class_group_id: number } | null }[]),
2339
+ ]);
2340
+
2341
+ // ── Course stats ─────────────────────────────────────────────────────────────
2342
+ const courseMap = new Map<number, { title: string; enrolledCount: number; completedCount: number }>();
2343
+ for (const e of allEnrollments) {
2344
+ if (!courseMap.has(e.course_id)) {
2345
+ courseMap.set(e.course_id, { title: e.course?.title ?? `Curso #${e.course_id}`, enrolledCount: 0, completedCount: 0 });
2346
+ }
2347
+ const cs = courseMap.get(e.course_id)!;
2348
+ cs.enrolledCount++;
2349
+ if (e.status === 'completed') cs.completedCount++;
2350
+ }
2351
+
2352
+ const courseSatisfactionMap = new Map<number, { total: number; count: number }>();
2353
+ for (const r of evalRatings) {
2354
+ const courseId = r.evaluation_topic?.course_class_session?.course_class_group?.course_id;
2355
+ if (!courseId) continue;
2356
+ if (!courseSatisfactionMap.has(courseId)) courseSatisfactionMap.set(courseId, { total: 0, count: 0 });
2357
+ const cs = courseSatisfactionMap.get(courseId)!;
2358
+ cs.total += Number(r.score);
2359
+ cs.count++;
2360
+ }
2361
+
2362
+ const courseStats = Array.from(courseMap.entries())
2363
+ .sort(([, a], [, b]) => b.enrolledCount - a.enrolledCount)
2364
+ .slice(0, 10)
2365
+ .map(([courseId, cs]) => {
2366
+ const sat = courseSatisfactionMap.get(courseId);
2367
+ return {
2368
+ courseId,
2369
+ title: cs.title,
2370
+ enrolledCount: cs.enrolledCount,
2371
+ completedCount: cs.completedCount,
2372
+ completionPct: cs.enrolledCount > 0 ? this.round((cs.completedCount / cs.enrolledCount) * 100, 0) : 0,
2373
+ avgSatisfaction: sat ? this.round(sat.total / sat.count, 2) : 0,
2374
+ };
2375
+ });
2376
+
2377
+ const totalEvalScore = evalRatings.reduce((s, r) => s + Number(r.score), 0);
2378
+ const avgSatisfaction = evalRatings.length > 0 ? this.round(totalEvalScore / evalRatings.length, 2) : 0;
2379
+
2380
+ // ── Enrollment events for time-series chart ───────────────────────────────────
2381
+ const enrollmentEvents = allEnrollments
2382
+ .filter((e) => {
2383
+ const inPeriodEnrolled = e.enrolled_at >= dateFrom && e.enrolled_at <= dateTo;
2384
+ const inPeriodCompleted = e.completed_at != null && e.completed_at >= dateFrom && e.completed_at <= dateTo;
2385
+ return inPeriodEnrolled || inPeriodCompleted;
2386
+ })
2387
+ .map((e) => ({
2388
+ enrolledDate: e.enrolled_at >= dateFrom && e.enrolled_at <= dateTo ? e.enrolled_at.toISOString().slice(0, 10) : null,
2389
+ completedDate: e.completed_at != null && e.completed_at >= dateFrom && e.completed_at <= dateTo ? e.completed_at.toISOString().slice(0, 10) : null,
2390
+ }));
2391
+
2392
+ // ── Session attendance for frequency chart ────────────────────────────────────
2393
+ const sessionAttMap = new Map<number, { title: string; date: Date; total: number; present: number }>();
2394
+ for (const att of sessionAttendanceRaw) {
2395
+ const session = att.course_class_session;
2396
+ if (!session) continue;
2397
+ if (!sessionAttMap.has(session.id)) sessionAttMap.set(session.id, { title: session.title ?? '', date: session.session_date, total: 0, present: 0 });
2398
+ const sm = sessionAttMap.get(session.id)!;
2399
+ sm.total++;
2400
+ if (att.present) sm.present++;
2401
+ }
2402
+ const sessionAttendance = Array.from(sessionAttMap.values())
2403
+ .map((sm) => ({ date: sm.date.toISOString().slice(0, 10), title: sm.title, totalStudents: sm.total, presentCount: sm.present, pct: sm.total > 0 ? this.round((sm.present / sm.total) * 100, 0) : 0 }))
2404
+ .sort((a, b) => a.date.localeCompare(b.date));
2405
+
2406
+ // ── Eval sessions for trend chart ─────────────────────────────────────────────
2407
+ const evalSessionMap = new Map<string, { title: string; topicScores: Map<string, number[]>; allScores: number[] }>();
2408
+ for (const r of evalRatings) {
2409
+ const session = r.evaluation_topic?.course_class_session;
2410
+ if (!session) continue;
2411
+ const key = `${session.session_date.toISOString().slice(0, 10)}|${session.title ?? ''}`;
2412
+ if (!evalSessionMap.has(key)) evalSessionMap.set(key, { title: session.title ?? '', topicScores: new Map(), allScores: [] });
2413
+ const esm = evalSessionMap.get(key)!;
2414
+ if (!esm.topicScores.has(r.evaluation_topic.name)) esm.topicScores.set(r.evaluation_topic.name, []);
2415
+ esm.topicScores.get(r.evaluation_topic.name)!.push(Number(r.score));
2416
+ esm.allScores.push(Number(r.score));
2417
+ }
2418
+ const evalSessions = Array.from(evalSessionMap.entries())
2419
+ .sort(([a], [b]) => a.localeCompare(b))
2420
+ .map(([key, esm]) => ({
2421
+ date: key.split('|')[0],
2422
+ sessionTitle: esm.title,
2423
+ scores: Array.from(esm.topicScores.entries()).map(([topicName, scores]) => ({ topicName, avg: this.round(scores.reduce((s, v) => s + v, 0) / scores.length, 2) })),
2424
+ overallAvg: esm.allScores.length > 0 ? this.round(esm.allScores.reduce((s, v) => s + v, 0) / esm.allScores.length, 2) : 0,
2425
+ }));
2426
+
2427
+ // ── Eval topic radar ──────────────────────────────────────────────────────────
2428
+ const topicTotalMap = new Map<string, { total: number; count: number }>();
2429
+ for (const r of evalRatings) {
2430
+ if (!topicTotalMap.has(r.evaluation_topic.name)) topicTotalMap.set(r.evaluation_topic.name, { total: 0, count: 0 });
2431
+ const tt = topicTotalMap.get(r.evaluation_topic.name)!;
2432
+ tt.total += Number(r.score);
2433
+ tt.count++;
2434
+ }
2435
+ const evalTopicAverages = Array.from(topicTotalMap.entries()).map(([topicName, tt]) => ({ topicName, avgScore: this.round(tt.total / tt.count, 2) }));
2436
+
2437
+ // ── At-risk students ──────────────────────────────────────────────────────────
2438
+ const studentRiskMap = new Map<number, { total: number; present: number; lastPresentDate: string | null; cgId: number | null }>();
2439
+ for (const att of attendanceForRisk) {
2440
+ if (!studentRiskMap.has(att.student_id)) studentRiskMap.set(att.student_id, { total: 0, present: 0, lastPresentDate: null, cgId: null });
2441
+ const sa = studentRiskMap.get(att.student_id)!;
2442
+ sa.total++;
2443
+ if (att.present) {
2444
+ sa.present++;
2445
+ const ds = att.course_class_session?.session_date?.toISOString()?.slice(0, 10) ?? null;
2446
+ if (ds && (!sa.lastPresentDate || ds > sa.lastPresentDate)) sa.lastPresentDate = ds;
2447
+ }
2448
+ if (!sa.cgId && att.course_class_session?.course_class_group_id) sa.cgId = att.course_class_session.course_class_group_id;
2449
+ }
2450
+ const atRiskPersonIds: number[] = [];
2451
+ const atRiskAttMap = new Map<number, { pct: number; lastPresentDate: string | null; cgId: number | null }>();
2452
+ for (const [personId, sa] of studentRiskMap.entries()) {
2453
+ if (sa.total === 0) continue;
2454
+ const pct = this.round((sa.present / sa.total) * 100, 0);
2455
+ if (pct < 75) {
2456
+ atRiskPersonIds.push(personId);
2457
+ atRiskAttMap.set(personId, { pct, lastPresentDate: sa.lastPresentDate, cgId: sa.cgId });
2458
+ }
2459
+ }
2460
+
2461
+ let atRiskStudents: { personId: number; name: string; attendancePct: number; lastPresentDate: string | null; classGroupTitle: string | null }[] = [];
2462
+ if (atRiskPersonIds.length > 0) {
2463
+ const cgIdsForRisk = [...new Set(Array.from(atRiskAttMap.values()).map((v) => v.cgId).filter((id): id is number => id !== null))];
2464
+ const [atRiskPersons, cgListForRisk] = await Promise.all([
2465
+ this.prisma.person.findMany({ where: { id: { in: atRiskPersonIds } }, select: { id: true, name: true } }),
2466
+ cgIdsForRisk.length > 0
2467
+ ? this.prisma.course_class_group.findMany({ where: { id: { in: cgIdsForRisk } }, select: { id: true, title: true } })
2468
+ : Promise.resolve([] as { id: number; title: string }[]),
2469
+ ]);
2470
+ const personNameMap = new Map(atRiskPersons.map((p) => [p.id, p.name]));
2471
+ const cgTitleMap = new Map(cgListForRisk.map((c) => [c.id, c.title]));
2472
+ atRiskStudents = atRiskPersonIds
2473
+ .map((pid) => {
2474
+ const att = atRiskAttMap.get(pid)!;
2475
+ return { personId: pid, name: personNameMap.get(pid) ?? `Pessoa #${pid}`, attendancePct: att.pct, lastPresentDate: att.lastPresentDate, classGroupTitle: att.cgId ? (cgTitleMap.get(att.cgId) ?? null) : null };
2476
+ })
2477
+ .sort((a, b) => a.attendancePct - b.attendancePct)
2478
+ .slice(0, 10);
2479
+ }
2480
+
2481
+ const atRiskSet = new Set(atRiskPersonIds);
2482
+ const activeStudents = esRows.filter((r) => r.status === 'active' && !atRiskSet.has(r.person_id)).length;
2483
+ const inactiveStudents = esRows.filter((r) => r.status !== 'active').length;
2484
+ const activeCoursesCount = new Set(allEnrollments.filter((e) => e.status !== 'cancelled').map((e) => e.course_id)).size;
2485
+
2486
+ return {
2487
+ kpis: { totalStudents: esRows.length, enrollmentsInPeriod: enrollmentsInPeriodCount, completedInPeriod: completedInPeriodCount, activeClassGroups: activeClassGroupsCount, avgSatisfaction, certificatesIssued: certificatesIssuedCount, atRiskStudentsCount: atRiskPersonIds.length, activeCoursesCount },
2488
+ enrollmentEvents,
2489
+ sessionAttendance,
2490
+ courseStats,
2491
+ studentStatusCounts: { active: activeStudents, atRisk: atRiskPersonIds.length, inactive: inactiveStudents },
2492
+ evalSessions,
2493
+ evalTopicAverages,
2494
+ atRiskStudents,
2495
+ };
2496
+ }
2497
+
2498
+ // ── Date helpers ──────────────────────────────────────────────────────────────
2499
+
2500
+ private percentChange(current: number, previous: number) {
2501
+ if (previous <= 0) return current > 0 ? 100 : 0;
2502
+ return this.round(((current - previous) / previous) * 100, 1);
2503
+ }
2504
+
2505
+ private round(value: number, digits = 0) {
2506
+ const factor = 10 ** digits;
2507
+ return Math.round(value * factor) / factor;
2508
+ }
2509
+
2510
+ private addMonths(date: Date, months: number) {
2511
+ const result = new Date(date);
2512
+ result.setMonth(result.getMonth() + months);
2513
+ return result;
2514
+ }
2515
+
2516
+ private startOfMonth(date: Date) {
2517
+ return new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0);
2518
+ }
2519
+
2520
+ private endOfMonth(date: Date) {
2521
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59, 999);
2522
+ }
2523
+ }