@hed-hog/lms 0.0.319 → 0.0.320

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