@hed-hog/lms 0.0.365 → 0.0.370

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 (243) hide show
  1. package/dist/certificate/certificate.controller.d.ts +1 -1
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +4 -2
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +50 -0
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +73 -0
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/class-group/class-group.controller.d.ts +1 -0
  10. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  11. package/dist/class-group/class-group.service.d.ts +1 -0
  12. package/dist/class-group/class-group.service.d.ts.map +1 -1
  13. package/dist/course/course-ai-usage.service.d.ts +58 -0
  14. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  15. package/dist/course/course-ai-usage.service.js +176 -0
  16. package/dist/course/course-ai-usage.service.js.map +1 -0
  17. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  18. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  19. package/dist/course/course-audio-transcription.service.js +381 -29
  20. package/dist/course/course-audio-transcription.service.js.map +1 -1
  21. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  22. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  23. package/dist/course/course-export-scorm12.service.js +141 -6
  24. package/dist/course/course-export-scorm12.service.js.map +1 -1
  25. package/dist/course/course-export.service.d.ts.map +1 -1
  26. package/dist/course/course-export.service.js +2 -1
  27. package/dist/course/course-export.service.js.map +1 -1
  28. package/dist/course/course-lesson.controller.d.ts +25 -3
  29. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  30. package/dist/course/course-lesson.controller.js +71 -8
  31. package/dist/course/course-lesson.controller.js.map +1 -1
  32. package/dist/course/course-structure.controller.d.ts +30 -7
  33. package/dist/course/course-structure.controller.d.ts.map +1 -1
  34. package/dist/course/course-structure.controller.js +37 -4
  35. package/dist/course/course-structure.controller.js.map +1 -1
  36. package/dist/course/course-structure.service.d.ts +37 -5
  37. package/dist/course/course-structure.service.d.ts.map +1 -1
  38. package/dist/course/course-structure.service.js +165 -20
  39. package/dist/course/course-structure.service.js.map +1 -1
  40. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  41. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  42. package/dist/course/course-transcription-translation.service.js +227 -0
  43. package/dist/course/course-transcription-translation.service.js.map +1 -0
  44. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  45. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  46. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  47. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  48. package/dist/course/course-video-hls.service.d.ts +14 -0
  49. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  50. package/dist/course/course-video-hls.service.js +25 -8
  51. package/dist/course/course-video-hls.service.js.map +1 -1
  52. package/dist/course/course.controller.d.ts +2 -0
  53. package/dist/course/course.controller.d.ts.map +1 -1
  54. package/dist/course/course.module.d.ts.map +1 -1
  55. package/dist/course/course.module.js +9 -0
  56. package/dist/course/course.module.js.map +1 -1
  57. package/dist/course/course.service.d.ts +2 -0
  58. package/dist/course/course.service.d.ts.map +1 -1
  59. package/dist/course/course.service.js +36 -2
  60. package/dist/course/course.service.js.map +1 -1
  61. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  62. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  63. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  64. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  65. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  66. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  67. package/dist/course/dto/create-course-export.dto.js +6 -0
  68. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  69. package/dist/course/ffmpeg.util.d.ts +10 -0
  70. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  71. package/dist/course/ffmpeg.util.js +79 -0
  72. package/dist/course/ffmpeg.util.js.map +1 -0
  73. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  74. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  75. package/dist/course/lms-bulk-upload-automation.service.js +33 -16
  76. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  77. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  78. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  79. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  80. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  81. package/dist/course/lms-bulk-upload.service.js +48 -29
  82. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  83. package/dist/course/subtitle.util.d.ts +46 -0
  84. package/dist/course/subtitle.util.d.ts.map +1 -0
  85. package/dist/course/subtitle.util.js +206 -0
  86. package/dist/course/subtitle.util.js.map +1 -0
  87. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  88. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  89. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  90. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  91. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  92. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  93. package/dist/enterprise/training/training-student.service.js +197 -10
  94. package/dist/enterprise/training/training-student.service.js.map +1 -1
  95. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  96. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  97. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  98. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  99. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  100. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  101. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  102. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  103. package/dist/lms.module.d.ts.map +1 -1
  104. package/dist/lms.module.js +14 -0
  105. package/dist/lms.module.js.map +1 -1
  106. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  107. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  108. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  109. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  110. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  111. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  112. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  113. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  114. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  115. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  116. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  117. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  118. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  119. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  120. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  121. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  122. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  123. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  124. package/dist/platforma/platforma-performance.service.js +500 -0
  125. package/dist/platforma/platforma-performance.service.js.map +1 -0
  126. package/dist/platforma/platforma-search.service.d.ts +21 -0
  127. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  128. package/dist/platforma/platforma-search.service.js +64 -0
  129. package/dist/platforma/platforma-search.service.js.map +1 -0
  130. package/dist/platforma/platforma-video.service.d.ts +8 -0
  131. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  132. package/dist/platforma/platforma-video.service.js +45 -2
  133. package/dist/platforma/platforma-video.service.js.map +1 -1
  134. package/dist/platforma/platforma.controller.d.ts +213 -1
  135. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  136. package/dist/platforma/platforma.controller.js +159 -2
  137. package/dist/platforma/platforma.controller.js.map +1 -1
  138. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  139. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  140. package/dist/realtime/lms-realtime.controller.js +31 -0
  141. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  142. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  143. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  144. package/dist/realtime/lms-realtime.service.js.map +1 -1
  145. package/dist/training/dto/create-training.dto.d.ts +9 -0
  146. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  147. package/dist/training/dto/create-training.dto.js +45 -1
  148. package/dist/training/dto/create-training.dto.js.map +1 -1
  149. package/dist/training/training.controller.d.ts +144 -0
  150. package/dist/training/training.controller.d.ts.map +1 -1
  151. package/dist/training/training.service.d.ts +149 -0
  152. package/dist/training/training.service.d.ts.map +1 -1
  153. package/dist/training/training.service.js +332 -167
  154. package/dist/training/training.service.js.map +1 -1
  155. package/hedhog/data/image_type.yaml +10 -0
  156. package/hedhog/data/route.yaml +251 -0
  157. package/hedhog/data/setting_group.yaml +97 -0
  158. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  159. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  160. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  161. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  162. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  163. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  164. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  165. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  166. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  167. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  168. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  169. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  170. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  171. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  172. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  173. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  174. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  175. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  176. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  177. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  178. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  179. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  180. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  181. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  182. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  183. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  184. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  185. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  186. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  187. package/hedhog/frontend/app/courses/page.tsx.ejs +66 -13
  188. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  189. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  190. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  191. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  192. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  193. package/hedhog/frontend/app/paths/page.tsx.ejs +650 -168
  194. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  195. package/hedhog/frontend/messages/en.json +41 -12
  196. package/hedhog/frontend/messages/pt.json +44 -13
  197. package/hedhog/query/triggers.sql +33 -0
  198. package/hedhog/table/course_ai_usage.yaml +46 -0
  199. package/hedhog/table/course_enrollment.yaml +3 -0
  200. package/hedhog/table/course_lesson.yaml +3 -0
  201. package/hedhog/table/course_lesson_answer.yaml +37 -0
  202. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  203. package/hedhog/table/learning_path.yaml +6 -0
  204. package/hedhog/table/learning_path_module.yaml +22 -0
  205. package/hedhog/table/learning_path_step.yaml +9 -6
  206. package/hedhog/table/lesson_view_event.yaml +66 -0
  207. package/package.json +8 -7
  208. package/src/certificate/certificate.controller.ts +2 -0
  209. package/src/certificate/certificate.service.ts +99 -0
  210. package/src/course/course-ai-usage.service.ts +221 -0
  211. package/src/course/course-audio-transcription.service.ts +471 -43
  212. package/src/course/course-export-scorm12.service.ts +149 -5
  213. package/src/course/course-export.service.ts +1 -0
  214. package/src/course/course-lesson.controller.ts +59 -6
  215. package/src/course/course-structure.controller.ts +19 -1
  216. package/src/course/course-structure.service.ts +184 -10
  217. package/src/course/course-transcription-translation.service.ts +293 -0
  218. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  219. package/src/course/course-video-hls.service.ts +30 -10
  220. package/src/course/course.module.ts +9 -0
  221. package/src/course/course.service.ts +46 -1
  222. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  223. package/src/course/dto/create-course-export.dto.ts +6 -0
  224. package/src/course/ffmpeg.util.ts +65 -0
  225. package/src/course/lms-bulk-upload-automation.service.ts +33 -8
  226. package/src/course/lms-bulk-upload.service.ts +20 -1
  227. package/src/course/subtitle.util.ts +220 -0
  228. package/src/enterprise/training/training-student.service.ts +224 -4
  229. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  230. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  231. package/src/lms.module.ts +14 -0
  232. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  233. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  234. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  235. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  236. package/src/platforma/platforma-performance.service.ts +606 -0
  237. package/src/platforma/platforma-search.service.ts +48 -0
  238. package/src/platforma/platforma-video.service.ts +59 -3
  239. package/src/platforma/platforma.controller.ts +130 -0
  240. package/src/realtime/lms-realtime.controller.ts +27 -1
  241. package/src/realtime/lms-realtime.service.ts +2 -1
  242. package/src/training/dto/create-training.dto.ts +36 -0
  243. package/src/training/training.service.ts +360 -163
@@ -302,6 +302,8 @@ export class TrainingStudentService {
302
302
 
303
303
  let completedLessonIds: number[] = [];
304
304
  let progressPercent = 0;
305
+ let lastLessonId: number | null = null;
306
+ let lastPositionSeconds = 0;
305
307
 
306
308
  if (userId) {
307
309
  const personUser = await this.prisma.person_user.findFirst({
@@ -315,11 +317,30 @@ export class TrainingStudentService {
315
317
  });
316
318
  if (enrollment) {
317
319
  progressPercent = enrollment.progress_percent ?? 0;
318
- const completedRows = await this.prisma.course_lesson_progress.findMany({
319
- where: { course_enrollment_id: enrollment.id, status: 'completed' },
320
- select: { course_lesson_id: true },
321
- });
320
+ const [completedRows, lastProgressRow] = await Promise.all([
321
+ this.prisma.course_lesson_progress.findMany({
322
+ where: { course_enrollment_id: enrollment.id, status: 'completed' },
323
+ select: { course_lesson_id: true },
324
+ }),
325
+ this.prisma.course_lesson_progress.findFirst({
326
+ where: {
327
+ course_enrollment_id: enrollment.id,
328
+ status: { not: 'not_started' },
329
+ },
330
+ orderBy: { updated_at: 'desc' },
331
+ select: {
332
+ course_lesson_id: true,
333
+ video_progress_seconds: true,
334
+ status: true,
335
+ },
336
+ }),
337
+ ]);
322
338
  completedLessonIds = completedRows.map((r) => r.course_lesson_id);
339
+ lastLessonId = lastProgressRow?.course_lesson_id ?? null;
340
+ lastPositionSeconds =
341
+ lastProgressRow?.status === 'in_progress'
342
+ ? (lastProgressRow.video_progress_seconds ?? 0)
343
+ : 0;
323
344
  }
324
345
  }
325
346
  }
@@ -338,6 +359,8 @@ export class TrainingStudentService {
338
359
  categoryLabel: this.formatCategoryLabel(categorySlug),
339
360
  completedLessonIds,
340
361
  progressPercent,
362
+ lastLessonId,
363
+ lastPositionSeconds,
341
364
  modules: row.course_module.map((m) => ({
342
365
  id: m.id,
343
366
  title: m.title,
@@ -355,6 +378,172 @@ export class TrainingStudentService {
355
378
  };
356
379
  }
357
380
 
381
+ async submitLessonAnswer(
382
+ userId: number,
383
+ lessonId: number,
384
+ questionId: number,
385
+ optionId: number | null,
386
+ answerText: string | null,
387
+ ) {
388
+ // Determine correctness from the selected option
389
+ let isCorrect: boolean | null = null;
390
+ if (optionId != null) {
391
+ const option = await (this.prisma as any).exam_option.findUnique({
392
+ where: { id: optionId },
393
+ select: { is_correct: true },
394
+ });
395
+ isCorrect = option?.is_correct ?? null;
396
+ }
397
+
398
+ // Upsert the answer
399
+ await (this.prisma as any).course_lesson_answer.upsert({
400
+ where: {
401
+ user_id_course_lesson_id_question_id: {
402
+ user_id: userId,
403
+ course_lesson_id: lessonId,
404
+ question_id: questionId,
405
+ },
406
+ },
407
+ update: {
408
+ exam_option_id: optionId,
409
+ answer_text: answerText,
410
+ is_correct: isCorrect,
411
+ updated_at: new Date(),
412
+ },
413
+ create: {
414
+ user_id: userId,
415
+ course_lesson_id: lessonId,
416
+ question_id: questionId,
417
+ exam_option_id: optionId,
418
+ answer_text: answerText,
419
+ is_correct: isCorrect,
420
+ },
421
+ });
422
+
423
+ // Check if all questions in this lesson are now answered
424
+ const [totalQuestions, answeredQuestions] = await Promise.all([
425
+ (this.prisma as any).course_lesson_question.count({
426
+ where: { course_lesson_id: lessonId },
427
+ }),
428
+ (this.prisma as any).course_lesson_answer.count({
429
+ where: { user_id: userId, course_lesson_id: lessonId },
430
+ }),
431
+ ]);
432
+
433
+ const allAnswered = totalQuestions > 0 && answeredQuestions >= totalQuestions;
434
+
435
+ if (allAnswered) {
436
+ await this.markLessonComplete(userId, lessonId);
437
+ }
438
+
439
+ return { isCorrect, allAnswered };
440
+ }
441
+
442
+ private async markLessonComplete(userId: number, lessonId: number) {
443
+ const lesson = await (this.prisma as any).course_lesson.findUnique({
444
+ where: { id: lessonId },
445
+ select: { course_module: { select: { course_id: true } } },
446
+ });
447
+
448
+ const personUser = await this.prisma.person_user.findFirst({
449
+ where: { user_id: userId },
450
+ select: { person_id: true },
451
+ });
452
+
453
+ if (!personUser || !lesson) return;
454
+
455
+ const personId = personUser.person_id;
456
+ const courseId: number = lesson.course_module?.course_id;
457
+
458
+ const enrollment = await (this.prisma as any).course_enrollment.findFirst({
459
+ where: { person_id: personId, course_id: courseId, status: { in: ['active', 'completed'] } },
460
+ select: { id: true },
461
+ });
462
+
463
+ if (!enrollment) return;
464
+
465
+ const enrollmentId: number = enrollment.id;
466
+
467
+ const existing = await (this.prisma as any).course_lesson_progress.findUnique({
468
+ where: {
469
+ course_enrollment_id_course_lesson_id: {
470
+ course_enrollment_id: enrollmentId,
471
+ course_lesson_id: lessonId,
472
+ },
473
+ },
474
+ select: { status: true },
475
+ });
476
+
477
+ if (existing?.status === 'completed') return;
478
+
479
+ await (this.prisma as any).course_lesson_progress.upsert({
480
+ where: {
481
+ course_enrollment_id_course_lesson_id: {
482
+ course_enrollment_id: enrollmentId,
483
+ course_lesson_id: lessonId,
484
+ },
485
+ },
486
+ create: {
487
+ course_enrollment_id: enrollmentId,
488
+ course_lesson_id: lessonId,
489
+ status: 'completed',
490
+ progress_percent: 100,
491
+ started_at: new Date(),
492
+ completed_at: new Date(),
493
+ time_spent_seconds: 0,
494
+ },
495
+ update: {
496
+ status: 'completed',
497
+ progress_percent: 100,
498
+ completed_at: new Date(),
499
+ },
500
+ });
501
+
502
+ // Sync course-level progress
503
+ const [totalLessons, completedLessons] = await Promise.all([
504
+ (this.prisma as any).course_lesson.count({
505
+ where: { published: true, course_module: { course_id: courseId } },
506
+ }),
507
+ (this.prisma as any).course_lesson_progress.count({
508
+ where: {
509
+ course_enrollment_id: enrollmentId,
510
+ status: 'completed',
511
+ course_lesson: { published: true, course_module: { course_id: courseId } },
512
+ },
513
+ }),
514
+ ]);
515
+
516
+ const progressPercent =
517
+ totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
518
+
519
+ await (this.prisma as any).course_enrollment.update({
520
+ where: { id: enrollmentId },
521
+ data: {
522
+ progress_percent: progressPercent,
523
+ ...(progressPercent === 100 ? { status: 'completed', completed_at: new Date() } : {}),
524
+ },
525
+ });
526
+ }
527
+
528
+ async getLessonStudentAnswers(userId: number, lessonId: number) {
529
+ const rows = await (this.prisma as any).course_lesson_answer.findMany({
530
+ where: { user_id: userId, course_lesson_id: lessonId },
531
+ select: {
532
+ question_id: true,
533
+ exam_option_id: true,
534
+ answer_text: true,
535
+ is_correct: true,
536
+ },
537
+ });
538
+
539
+ return rows as Array<{
540
+ question_id: number;
541
+ exam_option_id: number | null;
542
+ answer_text: string | null;
543
+ is_correct: boolean | null;
544
+ }>;
545
+ }
546
+
358
547
  async getLessonDetail(lessonId: number) {
359
548
  const lesson = await this.prisma.course_lesson.findFirst({
360
549
  where: { id: lessonId, published: true },
@@ -383,6 +572,35 @@ export class TrainingStudentService {
383
572
  (f) => f.file?.id && f.is_public && !f.type?.startsWith('video'),
384
573
  );
385
574
 
575
+ const lessonQuestions = lesson.type === 'quiz'
576
+ ? await this.prisma.course_lesson_question.findMany({
577
+ where: { course_lesson_id: lessonId },
578
+ orderBy: { order: 'asc' },
579
+ include: {
580
+ question: {
581
+ include: {
582
+ exam_option: { orderBy: { position: 'asc' } },
583
+ },
584
+ },
585
+ },
586
+ })
587
+ : [];
588
+
589
+ const questions = lessonQuestions.map((lq) => ({
590
+ id: lq.question.id,
591
+ questionType: lq.question.question_type,
592
+ statement: lq.question.statement,
593
+ explanation: lq.question.explanation,
594
+ points: lq.question.points,
595
+ options: lq.question.exam_option.map((o) => ({
596
+ id: o.id,
597
+ optionText: o.option_text,
598
+ isCorrect: o.is_correct,
599
+ feedback: o.feedback,
600
+ position: o.position,
601
+ })),
602
+ }));
603
+
386
604
  // HLS takes priority: if a video_hls file exists, use it exclusively
387
605
  const hlsFile = lesson.course_lesson_file.find((f) => f.file?.id && f.type === 'video_hls');
388
606
  if (hlsFile) {
@@ -399,6 +617,7 @@ export class TrainingStudentService {
399
617
  title: r.title,
400
618
  url: r.file?.id ? `/file/open/${r.file.id}` : null,
401
619
  })),
620
+ questions,
402
621
  };
403
622
  }
404
623
 
@@ -430,6 +649,7 @@ export class TrainingStudentService {
430
649
  title: r.title,
431
650
  url: r.file?.id ? `/file/open/${r.file.id}` : null,
432
651
  })),
652
+ questions,
433
653
  };
434
654
  }
435
655
 
@@ -10,6 +10,7 @@ import {
10
10
  OnModuleInit,
11
11
  } from '@nestjs/common';
12
12
  import axios from 'axios';
13
+ import { CourseAiUsageService } from '../course/course-ai-usage.service';
13
14
 
14
15
  type XpSegmentAiResult = {
15
16
  startSeconds: number;
@@ -44,6 +45,8 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
44
45
  private readonly registry: QueueHandlerRegistry,
45
46
  @Inject(forwardRef(() => DatabaseQueueProvider))
46
47
  private readonly dbQueue: DatabaseQueueProvider,
48
+ @Inject(forwardRef(() => CourseAiUsageService))
49
+ private readonly aiUsageService: CourseAiUsageService,
47
50
  ) {}
48
51
 
49
52
  onModuleInit() {
@@ -295,6 +298,17 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
295
298
  return;
296
299
  }
297
300
 
301
+ // Record AI cost (gpt-4o, billed per token).
302
+ const xpUsage = response.data?.usage ?? {};
303
+ await this.aiUsageService.recordChatUsage({
304
+ lessonId,
305
+ jobType: 'xp_calculation',
306
+ provider: 'openai',
307
+ model: 'gpt-4o',
308
+ inputTokens: Number(xpUsage.prompt_tokens ?? 0),
309
+ outputTokens: Number(xpUsage.completion_tokens ?? 0),
310
+ });
311
+
298
312
  const rawSegments: any[] = Array.isArray(parsed.segments) ? parsed.segments : [];
299
313
  const areaIds = new Set(areas.map((a) => a.id));
300
314
  const skillIds = new Set(skills.map((s) => s.id));
@@ -2,6 +2,7 @@ import { PrismaModule } from '@hed-hog/api-prisma';
2
2
  import { CoreModule } from '@hed-hog/core';
3
3
  import { QueueModule } from '@hed-hog/queue';
4
4
  import { forwardRef, Module } from '@nestjs/common';
5
+ import { CourseAiUsageService } from '../course/course-ai-usage.service';
5
6
  import { LessonXpAiCalculationService } from './lesson-xp-ai-calculation.service';
6
7
  import { LessonXpMapController } from './lesson-xp-map.controller';
7
8
  import { LessonXpMapService } from './lesson-xp-map.service';
@@ -15,7 +16,7 @@ import { LessonXpSegmentService } from './lesson-xp-segment.service';
15
16
  forwardRef(() => QueueModule),
16
17
  ],
17
18
  controllers: [LessonXpMapController, LessonXpSegmentController],
18
- providers: [LessonXpMapService, LessonXpSegmentService, LessonXpAiCalculationService],
19
+ providers: [LessonXpMapService, LessonXpSegmentService, LessonXpAiCalculationService, CourseAiUsageService],
19
20
  exports: [LessonXpMapService, LessonXpSegmentService, LessonXpAiCalculationService],
20
21
  })
21
22
  export class LessonXpMapModule {}
package/src/lms.module.ts CHANGED
@@ -13,7 +13,9 @@ import { ClassGroupModule } from './class-group/class-group.module';
13
13
  import { CourseLessonDiscussionModule } from './course-lesson-discussion/course-lesson-discussion.module';
14
14
  import { CourseLessonNoteModule } from './course-lesson-note/course-lesson-note.module';
15
15
  import { LmsCommerceAccessSubscriber } from './lms-commerce-access.subscriber';
16
+ import { CourseAiUsageService } from './course/course-ai-usage.service';
16
17
  import { CourseAudioTranscriptionService } from './course/course-audio-transcription.service';
18
+ import { CourseTranscriptionTranslationService } from './course/course-transcription-translation.service';
17
19
  import { CourseModule } from './course/course.module';
18
20
  import { LmsDashboardModule } from './dashboard/dashboard.module';
19
21
  import { EnterpriseModule } from './enterprise/enterprise.module';
@@ -22,7 +24,12 @@ import { EvaluationModule } from './evaluation/evaluation.module';
22
24
  import { ExamModule } from './exam/exam.module';
23
25
  import { InstructorModule } from './instructor/instructor.module';
24
26
  import { LessonXpMapModule } from './lesson-xp-map/lesson-xp-map.module';
27
+ import { EmitCertificateHandler } from './platforma/handlers/emit-certificate.handler';
28
+ import { LessonHeartbeatHandler } from './platforma/handlers/lesson-heartbeat.handler';
25
29
  import { PlataformaController } from './platforma/platforma.controller';
30
+ import { PlatformaHeartbeatService } from './platforma/platforma-heartbeat.service';
31
+ import { PlatformaPerformanceService } from './platforma/platforma-performance.service';
32
+ import { PlatformaSearchService } from './platforma/platforma-search.service';
26
33
  import { PlatformaVideoService } from './platforma/platforma-video.service';
27
34
  import { StudentXpModule } from './student-xp/student-xp.module';
28
35
  import { XpCatalogModule } from './xp-catalog/xp-catalog.module';
@@ -62,9 +69,16 @@ import { TrainingModule } from './training/training.module';
62
69
  ],
63
70
  controllers: [PlataformaController],
64
71
  providers: [
72
+ CourseAiUsageService,
65
73
  CourseAudioTranscriptionService,
74
+ CourseTranscriptionTranslationService,
66
75
  PlatformaService,
67
76
  PlatformaVideoService,
77
+ PlatformaHeartbeatService,
78
+ PlatformaPerformanceService,
79
+ PlatformaSearchService,
80
+ LessonHeartbeatHandler,
81
+ EmitCertificateHandler,
68
82
  LmsCommerceAccessSubscriber,
69
83
  ],
70
84
  exports: [
@@ -0,0 +1,30 @@
1
+ import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
2
+
3
+ export class HeartbeatDto {
4
+ @IsInt()
5
+ lessonId: number;
6
+
7
+ @IsInt()
8
+ @Min(0)
9
+ positionSeconds: number;
10
+
11
+ @IsOptional()
12
+ @IsString()
13
+ sessionId?: string;
14
+
15
+ @IsOptional()
16
+ @IsInt()
17
+ @Min(1)
18
+ @Max(10000)
19
+ screenWidth?: number;
20
+
21
+ @IsOptional()
22
+ @IsInt()
23
+ @Min(1)
24
+ @Max(10000)
25
+ screenHeight?: number;
26
+
27
+ @IsOptional()
28
+ @IsBoolean()
29
+ isTouch?: boolean;
30
+ }
@@ -0,0 +1,117 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { IJobHandler, QueueHandlerRegistry } from '@hed-hog/queue';
3
+ import { Inject, Injectable, Logger, OnModuleInit, forwardRef } from '@nestjs/common';
4
+ import { randomBytes } from 'crypto';
5
+
6
+ export const EMIT_CERTIFICATE_JOB = 'lms.emit_certificate';
7
+
8
+ @Injectable()
9
+ export class EmitCertificateHandler implements OnModuleInit, IJobHandler {
10
+ private readonly logger = new Logger(EmitCertificateHandler.name);
11
+
12
+ constructor(
13
+ @Inject(forwardRef(() => PrismaService))
14
+ private readonly prisma: PrismaService,
15
+ @Inject(forwardRef(() => QueueHandlerRegistry))
16
+ private readonly registry: QueueHandlerRegistry,
17
+ ) {}
18
+
19
+ onModuleInit() {
20
+ this.registry.register(EMIT_CERTIFICATE_JOB, this);
21
+ this.logger.log(`Registered handler for "${EMIT_CERTIFICATE_JOB}"`);
22
+ }
23
+
24
+ async handle(job: { payload: Record<string, any> }) {
25
+ const { enrollmentId, courseId, personId } = job.payload as {
26
+ enrollmentId: number;
27
+ courseId: number;
28
+ personId: number;
29
+ };
30
+
31
+ // Idempotency guard — re-entrancy safe
32
+ const enrollment = await (this.prisma as any).course_enrollment.findUnique({
33
+ where: { id: enrollmentId },
34
+ select: { certificate_issued_at: true, completed_at: true, final_score: true },
35
+ });
36
+
37
+ if (!enrollment) {
38
+ this.logger.warn(`Enrollment ${enrollmentId} not found, skipping certificate`);
39
+ return { skipped: true };
40
+ }
41
+
42
+ if (enrollment.certificate_issued_at) {
43
+ this.logger.log(`Certificate already issued for enrollment ${enrollmentId}`);
44
+ return { skipped: true, alreadyIssued: true };
45
+ }
46
+
47
+ const [person, course] = await Promise.all([
48
+ this.prisma.person.findUnique({
49
+ where: { id: personId },
50
+ select: { name: true },
51
+ }),
52
+ (this.prisma as any).course.findUnique({
53
+ where: { id: courseId },
54
+ select: {
55
+ title: true,
56
+ certificate_workload: true,
57
+ has_certificate: true,
58
+ certificate_template_id: true,
59
+ },
60
+ }),
61
+ ]);
62
+
63
+ if (!person || !course) {
64
+ this.logger.warn(`Person or course not found (enrollment ${enrollmentId})`);
65
+ return { skipped: true };
66
+ }
67
+
68
+ if (!course.has_certificate || !course.certificate_template_id) {
69
+ this.logger.log(`Course ${courseId} does not issue certificates`);
70
+ return { skipped: true };
71
+ }
72
+
73
+ // Compute workload hours from lesson durations when not set on the course
74
+ let workloadHours: number = course.certificate_workload ?? 0;
75
+ if (!workloadHours) {
76
+ const agg = await (this.prisma as any).course_lesson.aggregate({
77
+ where: { published: true, course_module: { course_id: courseId } },
78
+ _sum: { duration_seconds: true },
79
+ });
80
+ const totalSeconds = (agg._sum?.duration_seconds as number) ?? 0;
81
+ workloadHours = Math.max(1, Math.ceil(totalSeconds / 3600));
82
+ }
83
+
84
+ const issuedAt = new Date();
85
+ const verificationCode = randomBytes(12).toString('hex').toUpperCase();
86
+
87
+ await (this.prisma as any).certificate.create({
88
+ data: {
89
+ verification_code: verificationCode,
90
+ student_id: personId,
91
+ course_enrollment_id: enrollmentId,
92
+ course_id: courseId,
93
+ certificate_template_id: course.certificate_template_id,
94
+ certificate_type: 'course',
95
+ student_name: person.name ?? '',
96
+ course_name: course.title ?? '',
97
+ workload_hours: workloadHours,
98
+ issued_at: issuedAt,
99
+ completed_at: enrollment.completed_at ?? issuedAt,
100
+ final_score: enrollment.final_score ?? null,
101
+ public_access: false,
102
+ },
103
+ });
104
+
105
+ // Mark enrollment as issued — prevents duplicate certificates on any future re-run
106
+ await (this.prisma as any).course_enrollment.update({
107
+ where: { id: enrollmentId },
108
+ data: { certificate_issued_at: issuedAt },
109
+ });
110
+
111
+ this.logger.log(
112
+ `Certificate issued — enrollment=${enrollmentId} course=${courseId} code=${verificationCode}`,
113
+ );
114
+
115
+ return { verificationCode };
116
+ }
117
+ }