@hed-hog/lms 0.0.364 → 0.0.366

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 (290) hide show
  1. package/dist/bitcode-wallet/bitcode-wallet.service.d.ts +1 -0
  2. package/dist/bitcode-wallet/bitcode-wallet.service.d.ts.map +1 -1
  3. package/dist/bitcode-wallet/bitcode-wallet.service.js +22 -3
  4. package/dist/bitcode-wallet/bitcode-wallet.service.js.map +1 -1
  5. package/dist/class-group/class-group.controller.d.ts +1 -0
  6. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  7. package/dist/class-group/class-group.service.d.ts +1 -0
  8. package/dist/class-group/class-group.service.d.ts.map +1 -1
  9. package/dist/course/course-export-scorm12-worker.service.d.ts +21 -0
  10. package/dist/course/course-export-scorm12-worker.service.d.ts.map +1 -0
  11. package/dist/course/course-export-scorm12-worker.service.js +109 -0
  12. package/dist/course/course-export-scorm12-worker.service.js.map +1 -0
  13. package/dist/course/course-export-scorm12.service.d.ts +42 -0
  14. package/dist/course/course-export-scorm12.service.d.ts.map +1 -0
  15. package/dist/course/course-export-scorm12.service.js +628 -0
  16. package/dist/course/course-export-scorm12.service.js.map +1 -0
  17. package/dist/course/course-export.service.d.ts +84 -0
  18. package/dist/course/course-export.service.d.ts.map +1 -0
  19. package/dist/course/course-export.service.js +237 -0
  20. package/dist/course/course-export.service.js.map +1 -0
  21. package/dist/course/course-structure.controller.d.ts +20 -10
  22. package/dist/course/course-structure.controller.d.ts.map +1 -1
  23. package/dist/course/course-structure.controller.js +20 -4
  24. package/dist/course/course-structure.controller.js.map +1 -1
  25. package/dist/course/course-structure.service.d.ts +12 -4
  26. package/dist/course/course-structure.service.d.ts.map +1 -1
  27. package/dist/course/course-structure.service.js +98 -23
  28. package/dist/course/course-structure.service.js.map +1 -1
  29. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  30. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  31. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  32. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  33. package/dist/course/course-video-hls.service.d.ts +71 -0
  34. package/dist/course/course-video-hls.service.d.ts.map +1 -0
  35. package/dist/course/course-video-hls.service.js +784 -0
  36. package/dist/course/course-video-hls.service.js.map +1 -0
  37. package/dist/course/course.controller.d.ts +47 -13
  38. package/dist/course/course.controller.d.ts.map +1 -1
  39. package/dist/course/course.controller.js +40 -26
  40. package/dist/course/course.controller.js.map +1 -1
  41. package/dist/course/course.mcp-tools.js +1 -1
  42. package/dist/course/course.mcp-tools.js.map +1 -1
  43. package/dist/course/course.module.d.ts.map +1 -1
  44. package/dist/course/course.module.js +16 -0
  45. package/dist/course/course.module.js.map +1 -1
  46. package/dist/course/course.service.d.ts +8 -9
  47. package/dist/course/course.service.d.ts.map +1 -1
  48. package/dist/course/course.service.js +93 -50
  49. package/dist/course/course.service.js.map +1 -1
  50. package/dist/course/dto/cleanup-course-storage.dto.d.ts +1 -1
  51. package/dist/course/dto/cleanup-course-storage.dto.d.ts.map +1 -1
  52. package/dist/course/dto/cleanup-course-storage.dto.js +1 -0
  53. package/dist/course/dto/cleanup-course-storage.dto.js.map +1 -1
  54. package/dist/course/dto/cleanup-upload-history.dto.d.ts +1 -1
  55. package/dist/course/dto/cleanup-upload-history.dto.d.ts.map +1 -1
  56. package/dist/course/dto/cleanup-upload-history.dto.js +1 -1
  57. package/dist/course/dto/cleanup-upload-history.dto.js.map +1 -1
  58. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  59. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  60. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  61. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  62. package/dist/course/dto/create-course-export.dto.d.ts +14 -0
  63. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -0
  64. package/dist/course/dto/create-course-export.dto.js +71 -0
  65. package/dist/course/dto/create-course-export.dto.js.map +1 -0
  66. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +2 -2
  67. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  68. package/dist/course/dto/create-course-structure-lesson.dto.js +3 -2
  69. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  70. package/dist/course/ffmpeg.util.d.ts +10 -0
  71. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  72. package/dist/course/ffmpeg.util.js +79 -0
  73. package/dist/course/ffmpeg.util.js.map +1 -0
  74. package/dist/course/lms-bulk-upload-automation.service.d.ts +18 -1
  75. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  76. package/dist/course/lms-bulk-upload-automation.service.js +106 -8
  77. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  78. package/dist/course/lms-bulk-upload-infra.service.d.ts +1 -0
  79. package/dist/course/lms-bulk-upload-infra.service.d.ts.map +1 -1
  80. package/dist/course/lms-bulk-upload-infra.service.js +32 -8
  81. package/dist/course/lms-bulk-upload-infra.service.js.map +1 -1
  82. package/dist/course/lms-bulk-upload.controller.d.ts +30 -3
  83. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  84. package/dist/course/lms-bulk-upload.controller.js +43 -2
  85. package/dist/course/lms-bulk-upload.controller.js.map +1 -1
  86. package/dist/course/lms-bulk-upload.service.d.ts +11 -0
  87. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  88. package/dist/course/lms-bulk-upload.service.js +59 -6
  89. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  90. package/dist/course/lms-setting.controller.d.ts +2 -1
  91. package/dist/course/lms-setting.controller.d.ts.map +1 -1
  92. package/dist/course/lms-setting.controller.js +4 -2
  93. package/dist/course/lms-setting.controller.js.map +1 -1
  94. package/dist/course/scorm12-schemas.d.ts +4 -0
  95. package/dist/course/scorm12-schemas.d.ts.map +1 -0
  96. package/dist/course/scorm12-schemas.js +9 -0
  97. package/dist/course/scorm12-schemas.js.map +1 -0
  98. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  99. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  100. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  101. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  102. package/dist/enterprise/training/training-student.service.d.ts +51 -0
  103. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  104. package/dist/enterprise/training/training-student.service.js +217 -4
  105. package/dist/enterprise/training/training-student.service.js.map +1 -1
  106. package/dist/evaluation/evaluation.service.d.ts +18 -0
  107. package/dist/evaluation/evaluation.service.d.ts.map +1 -1
  108. package/dist/evaluation/evaluation.service.js +125 -0
  109. package/dist/evaluation/evaluation.service.js.map +1 -1
  110. package/dist/exam/dto/create-standalone-question.dto.d.ts +12 -0
  111. package/dist/exam/dto/create-standalone-question.dto.d.ts.map +1 -0
  112. package/dist/exam/dto/create-standalone-question.dto.js +70 -0
  113. package/dist/exam/dto/create-standalone-question.dto.js.map +1 -0
  114. package/dist/exam/exam.module.d.ts.map +1 -1
  115. package/dist/exam/exam.module.js +2 -1
  116. package/dist/exam/exam.module.js.map +1 -1
  117. package/dist/exam/exam.service.d.ts +21 -0
  118. package/dist/exam/exam.service.d.ts.map +1 -1
  119. package/dist/exam/exam.service.js +80 -0
  120. package/dist/exam/exam.service.js.map +1 -1
  121. package/dist/exam/question.controller.d.ts +27 -0
  122. package/dist/exam/question.controller.d.ts.map +1 -0
  123. package/dist/exam/question.controller.js +53 -0
  124. package/dist/exam/question.controller.js.map +1 -0
  125. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +4 -0
  126. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  127. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +161 -25
  128. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  129. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  130. package/dist/lms-commerce-access.subscriber.d.ts +11 -0
  131. package/dist/lms-commerce-access.subscriber.d.ts.map +1 -0
  132. package/dist/lms-commerce-access.subscriber.js +74 -0
  133. package/dist/lms-commerce-access.subscriber.js.map +1 -0
  134. package/dist/lms.module.d.ts.map +1 -1
  135. package/dist/lms.module.js +16 -5
  136. package/dist/lms.module.js.map +1 -1
  137. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  138. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  139. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  140. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  141. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  142. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  143. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  144. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  145. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  146. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  147. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  148. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  149. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  150. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  151. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  152. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  153. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  154. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  155. package/dist/platforma/platforma-performance.service.js +500 -0
  156. package/dist/platforma/platforma-performance.service.js.map +1 -0
  157. package/dist/platforma/platforma-search.service.d.ts +21 -0
  158. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  159. package/dist/platforma/platforma-search.service.js +64 -0
  160. package/dist/platforma/platforma-search.service.js.map +1 -0
  161. package/dist/platforma/platforma-video.service.d.ts +39 -0
  162. package/dist/platforma/platforma-video.service.d.ts.map +1 -0
  163. package/dist/platforma/platforma-video.service.js +301 -0
  164. package/dist/platforma/platforma-video.service.js.map +1 -0
  165. package/dist/platforma/platforma.controller.d.ts +209 -1
  166. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  167. package/dist/platforma/platforma.controller.js +208 -2
  168. package/dist/platforma/platforma.controller.js.map +1 -1
  169. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  170. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  171. package/dist/realtime/lms-realtime.controller.js +31 -0
  172. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  173. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  174. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  175. package/dist/realtime/lms-realtime.service.js.map +1 -1
  176. package/dist/student-xp/dto/grant-skill-card-xp.dto.d.ts +5 -0
  177. package/dist/student-xp/dto/grant-skill-card-xp.dto.d.ts.map +1 -0
  178. package/dist/student-xp/dto/grant-skill-card-xp.dto.js +26 -0
  179. package/dist/student-xp/dto/grant-skill-card-xp.dto.js.map +1 -0
  180. package/dist/student-xp/student-xp.controller.d.ts +15 -0
  181. package/dist/student-xp/student-xp.controller.d.ts.map +1 -1
  182. package/dist/student-xp/student-xp.controller.js +24 -0
  183. package/dist/student-xp/student-xp.controller.js.map +1 -1
  184. package/dist/student-xp/student-xp.service.d.ts +16 -0
  185. package/dist/student-xp/student-xp.service.d.ts.map +1 -1
  186. package/dist/student-xp/student-xp.service.js +51 -1
  187. package/dist/student-xp/student-xp.service.js.map +1 -1
  188. package/hedhog/data/evaluation_topic.yaml +17 -0
  189. package/hedhog/data/menu.yaml +0 -17
  190. package/hedhog/data/queue_definition.yaml +48 -0
  191. package/hedhog/data/route.yaml +94 -124
  192. package/hedhog/data/setting_group.yaml +19 -19
  193. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +337 -41
  194. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  195. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  196. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  197. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +69 -4
  198. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +420 -0
  199. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +308 -0
  200. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +158 -45
  201. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  202. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  203. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +51 -63
  204. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +8 -3
  205. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +31 -8
  206. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +16 -9
  207. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +201 -401
  208. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +378 -690
  209. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +1 -2
  210. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +3 -9
  211. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -1
  212. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  213. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +6 -10
  214. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +49 -0
  215. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -3
  216. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -1
  217. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +106 -0
  218. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +28 -1
  219. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +0 -2
  220. package/hedhog/frontend/app/courses/page.tsx.ejs +85 -9
  221. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  222. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  223. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  224. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  225. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  226. package/hedhog/frontend/app/paths/page.tsx.ejs +38 -4
  227. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  228. package/hedhog/frontend/messages/en.json +44 -28
  229. package/hedhog/frontend/messages/pt.json +47 -29
  230. package/hedhog/table/course_enrollment.yaml +3 -0
  231. package/hedhog/table/course_export.yaml +62 -0
  232. package/hedhog/table/lesson_view_event.yaml +66 -0
  233. package/package.json +14 -9
  234. package/src/bitcode-wallet/bitcode-wallet.service.ts +43 -4
  235. package/src/course/course-export-scorm12-worker.service.ts +124 -0
  236. package/src/course/course-export-scorm12.service.ts +668 -0
  237. package/src/course/course-export.service.ts +280 -0
  238. package/src/course/course-structure.controller.ts +16 -2
  239. package/src/course/course-structure.service.ts +100 -7
  240. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  241. package/src/course/course-video-hls.service.ts +966 -0
  242. package/src/course/course.controller.ts +33 -19
  243. package/src/course/course.mcp-tools.ts +1 -1
  244. package/src/course/course.module.ts +16 -0
  245. package/src/course/course.service.ts +119 -61
  246. package/src/course/dto/cleanup-course-storage.dto.ts +1 -0
  247. package/src/course/dto/cleanup-upload-history.dto.ts +1 -1
  248. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  249. package/src/course/dto/create-course-export.dto.ts +56 -0
  250. package/src/course/dto/create-course-structure-lesson.dto.ts +4 -3
  251. package/src/course/ffmpeg.util.ts +65 -0
  252. package/src/course/lms-bulk-upload-automation.service.ts +156 -6
  253. package/src/course/lms-bulk-upload-infra.service.ts +39 -6
  254. package/src/course/lms-bulk-upload.controller.ts +32 -2
  255. package/src/course/lms-bulk-upload.service.ts +70 -7
  256. package/src/course/lms-setting.controller.ts +4 -2
  257. package/src/course/scorm12-schemas.ts +9 -0
  258. package/src/enterprise/training/training-student.service.ts +221 -2
  259. package/src/evaluation/evaluation.service.ts +123 -0
  260. package/src/exam/dto/create-standalone-question.dto.ts +66 -0
  261. package/src/exam/exam.module.ts +2 -1
  262. package/src/exam/exam.service.ts +86 -0
  263. package/src/exam/question.controller.ts +28 -0
  264. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +205 -31
  265. package/src/lms-commerce-access.subscriber.ts +88 -0
  266. package/src/lms.module.ts +16 -5
  267. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  268. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  269. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  270. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  271. package/src/platforma/platforma-performance.service.ts +606 -0
  272. package/src/platforma/platforma-search.service.ts +48 -0
  273. package/src/platforma/platforma-video.service.ts +346 -0
  274. package/src/platforma/platforma.controller.ts +137 -1
  275. package/src/platforma/platforma.service.ts +268 -268
  276. package/src/realtime/lms-realtime.controller.ts +27 -1
  277. package/src/realtime/lms-realtime.service.ts +2 -1
  278. package/src/student-xp/dto/grant-skill-card-xp.dto.ts +10 -0
  279. package/src/student-xp/student-xp.controller.ts +18 -2
  280. package/src/student-xp/student-xp.service.ts +84 -2
  281. package/hedhog/data/video_resolution_profile.yaml +0 -7
  282. package/hedhog/frontend/app/video-resolution-profiles/page.tsx.ejs +0 -607
  283. package/hedhog/table/course_video_resolution_profile.yaml +0 -22
  284. package/hedhog/table/video_resolution_profile.yaml +0 -18
  285. package/src/video-resolution-profile/dto/create-video-resolution-profile.dto.ts +0 -16
  286. package/src/video-resolution-profile/dto/update-video-resolution-profile.dto.ts +0 -16
  287. package/src/video-resolution-profile/video-resolution-profile.controller.ts +0 -62
  288. package/src/video-resolution-profile/video-resolution-profile.mcp-tools.ts +0 -128
  289. package/src/video-resolution-profile/video-resolution-profile.module.ts +0 -13
  290. package/src/video-resolution-profile/video-resolution-profile.service.ts +0 -117
@@ -24,6 +24,13 @@ type XpSegmentAiResult = {
24
24
  learningTypes: { xpLearningTypeId: number; weightPercent: number }[];
25
25
  };
26
26
 
27
+ type LessonMeta = {
28
+ type: 'video' | 'text' | 'quiz';
29
+ content: string | null;
30
+ description: string | null;
31
+ durationSeconds: number;
32
+ };
33
+
27
34
  @Injectable()
28
35
  export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
29
36
  private readonly logger = new Logger(LessonXpAiCalculationService.name);
@@ -45,15 +52,45 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
45
52
  }
46
53
 
47
54
  async triggerCalculation(lessonId: number, userId: number) {
48
- const segments = await this.prisma.$queryRawUnsafe<{ id: number }[]>(
49
- `SELECT id FROM course_lesson_transcription_segment WHERE course_lesson_id = $1::int LIMIT 1`,
50
- lessonId,
51
- );
55
+ const lesson = await this.fetchLessonMeta(lessonId);
52
56
 
53
- if (!segments.length) {
54
- throw new BadRequestException(
55
- 'Esta aula ainda não possui transcrição. Gere ou importe a transcrição antes de calcular o XP.',
57
+ if (!lesson) {
58
+ throw new BadRequestException('Aula não encontrada.');
59
+ }
60
+
61
+ if (lesson.type === 'video') {
62
+ const segments = await this.prisma.$queryRawUnsafe<{ id: number }[]>(
63
+ `SELECT id FROM course_lesson_transcription_segment WHERE course_lesson_id = $1::int LIMIT 1`,
64
+ lessonId,
65
+ );
66
+ if (!segments.length) {
67
+ throw new BadRequestException(
68
+ 'Esta aula ainda não possui transcrição. Gere ou importe a transcrição antes de calcular o XP.',
69
+ );
70
+ }
71
+ } else if (lesson.type === 'text') {
72
+ if (!lesson.durationSeconds || lesson.durationSeconds <= 0) {
73
+ throw new BadRequestException(
74
+ 'Esta aula do tipo post precisa ter uma duração prevista configurada antes de calcular o XP.',
75
+ );
76
+ }
77
+ let parsed: any = {};
78
+ try { parsed = JSON.parse(lesson.content ?? '{}'); } catch { /* ignore */ }
79
+ if (!parsed.conteudoPost && !lesson.description) {
80
+ throw new BadRequestException(
81
+ 'Esta aula do tipo post não possui conteúdo nem descrição pública. Adicione o conteúdo antes de calcular o XP.',
82
+ );
83
+ }
84
+ } else if (lesson.type === 'quiz') {
85
+ const questions = await this.prisma.$queryRawUnsafe<{ id: number }[]>(
86
+ `SELECT id FROM course_lesson_question WHERE course_lesson_id = $1::int LIMIT 1`,
87
+ lessonId,
56
88
  );
89
+ if (!questions.length) {
90
+ throw new BadRequestException(
91
+ 'Esta aula do tipo questão não possui questões vinculadas. Adicione questões antes de calcular o XP.',
92
+ );
93
+ }
57
94
  }
58
95
 
59
96
  const existing = await this.prisma.$queryRawUnsafe<{ id: number }[]>(
@@ -129,18 +166,53 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
129
166
  this.logger.log(`Starting XP calculation for lesson ${lessonId}, map ${mapId}`);
130
167
 
131
168
  try {
132
- const transcriptionRows = await this.prisma.$queryRawUnsafe<
133
- { startSeconds: number; endSeconds: number; text: string }[]
134
- >(
135
- `SELECT start_seconds::float AS "startSeconds", end_seconds::float AS "endSeconds", text
136
- FROM course_lesson_transcription_segment
137
- WHERE course_lesson_id = $1
138
- ORDER BY start_seconds ASC`,
139
- lessonId,
140
- );
169
+ const lesson = await this.fetchLessonMeta(lessonId);
141
170
 
142
- if (!transcriptionRows.length) {
143
- await this.markError(mapId, 'Aula sem segmentos de transcrição.');
171
+ if (!lesson) {
172
+ await this.markError(mapId, 'Aula não encontrada.');
173
+ return;
174
+ }
175
+
176
+ let contentText: string;
177
+ const durationSeconds = lesson.durationSeconds ?? 0;
178
+
179
+ if (lesson.type === 'video') {
180
+ const transcriptionRows = await this.prisma.$queryRawUnsafe<
181
+ { startSeconds: number; endSeconds: number; text: string }[]
182
+ >(
183
+ `SELECT start_seconds::float AS "startSeconds", end_seconds::float AS "endSeconds", text
184
+ FROM course_lesson_transcription_segment
185
+ WHERE course_lesson_id = $1
186
+ ORDER BY start_seconds ASC`,
187
+ lessonId,
188
+ );
189
+
190
+ if (!transcriptionRows.length) {
191
+ await this.markError(mapId, 'Aula sem segmentos de transcrição.');
192
+ return;
193
+ }
194
+
195
+ contentText = transcriptionRows
196
+ .map((s) => {
197
+ const start = this.formatSeconds(s.startSeconds);
198
+ const end = this.formatSeconds(s.endSeconds);
199
+ return `[${start} - ${end}] ${s.text}`;
200
+ })
201
+ .join('\n');
202
+ } else if (lesson.type === 'text') {
203
+ contentText = this.buildPostContentText(lesson.content, lesson.description);
204
+ if (!contentText.trim()) {
205
+ await this.markError(mapId, 'Aula do tipo post sem conteúdo disponível para análise.');
206
+ return;
207
+ }
208
+ } else if (lesson.type === 'quiz') {
209
+ contentText = await this.buildQuizContentText(lessonId);
210
+ if (!contentText.trim()) {
211
+ await this.markError(mapId, 'Aula do tipo questão sem questões disponíveis para análise.');
212
+ return;
213
+ }
214
+ } else {
215
+ await this.markError(mapId, `Tipo de aula não suportado: ${(lesson as any).type}`);
144
216
  return;
145
217
  }
146
218
 
@@ -191,15 +263,7 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
191
263
 
192
264
  const aiConfig = buildAiConfigFromIntegration(profile.integration_provider.slug, profile.config);
193
265
 
194
- const transcriptionText = transcriptionRows
195
- .map((s) => {
196
- const start = this.formatSeconds(s.startSeconds);
197
- const end = this.formatSeconds(s.endSeconds);
198
- return `[${start} - ${end}] ${s.text}`;
199
- })
200
- .join('\n');
201
-
202
- const prompt = this.buildPrompt(transcriptionText, areas, skills, learningTypes);
266
+ const prompt = this.buildPrompt(contentText, areas, skills, learningTypes, lesson.type, durationSeconds);
203
267
 
204
268
  const response = await axios.post(
205
269
  'https://api.openai.com/v1/chat/completions',
@@ -330,6 +394,85 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
330
394
  }
331
395
  }
332
396
 
397
+ private async fetchLessonMeta(lessonId: number): Promise<LessonMeta | null> {
398
+ const rows = await this.prisma.$queryRawUnsafe<LessonMeta[]>(
399
+ `SELECT type::text AS type, content, description, duration_seconds AS "durationSeconds"
400
+ FROM course_lesson WHERE id = $1`,
401
+ lessonId,
402
+ );
403
+ return rows[0] ?? null;
404
+ }
405
+
406
+ private buildPostContentText(content: string | null, description: string | null): string {
407
+ const parts: string[] = [];
408
+
409
+ if (description?.trim()) {
410
+ parts.push(`Descrição pública:\n${description.trim()}`);
411
+ }
412
+
413
+ let postContent = '';
414
+ try {
415
+ const parsed = JSON.parse(content ?? '{}');
416
+ postContent = String(parsed.conteudoPost ?? '').trim();
417
+ } catch { /* ignore */ }
418
+
419
+ if (postContent) {
420
+ parts.push(`Conteúdo do post:\n${this.stripHtml(postContent)}`);
421
+ }
422
+
423
+ return parts.join('\n\n');
424
+ }
425
+
426
+ private async buildQuizContentText(lessonId: number): Promise<string> {
427
+ const questions = await this.prisma.$queryRawUnsafe<{
428
+ statement: string;
429
+ explanation: string | null;
430
+ options: { text: string; isCorrect: boolean; feedback: string | null }[] | null;
431
+ }[]>(
432
+ `SELECT
433
+ q.statement,
434
+ q.explanation,
435
+ COALESCE(
436
+ json_agg(
437
+ json_build_object('text', o.option_text, 'isCorrect', o.is_correct, 'feedback', o.feedback)
438
+ ORDER BY o.position ASC
439
+ ) FILTER (WHERE o.id IS NOT NULL),
440
+ '[]'
441
+ ) AS options
442
+ FROM course_lesson_question clq
443
+ JOIN question q ON q.id = clq.question_id
444
+ LEFT JOIN exam_option o ON o.question_id = q.id
445
+ WHERE clq.course_lesson_id = $1
446
+ GROUP BY clq.order, q.statement, q.explanation
447
+ ORDER BY clq.order ASC`,
448
+ lessonId,
449
+ );
450
+
451
+ if (!questions.length) return '';
452
+
453
+ return questions.map((q, i) => {
454
+ const lines: string[] = [`Questão ${i + 1}:`];
455
+ lines.push(`Enunciado: ${q.statement}`);
456
+
457
+ const opts = Array.isArray(q.options) ? q.options : [];
458
+ if (opts.length) {
459
+ const labels = 'abcdefghijklmnopqrstuvwxyz';
460
+ lines.push(
461
+ 'Alternativas: ' +
462
+ opts
463
+ .map((o, idx) => `${labels[idx] ?? idx + 1}) ${o.text}${o.isCorrect ? ' [CORRETA]' : ''}`)
464
+ .join('; '),
465
+ );
466
+ }
467
+
468
+ if (q.explanation?.trim()) {
469
+ lines.push(`Explicação: ${q.explanation.trim()}`);
470
+ }
471
+
472
+ return lines.join('\n');
473
+ }).join('\n\n');
474
+ }
475
+
333
476
  private async markError(mapId: number, message: string) {
334
477
  await this.prisma.$executeRawUnsafe(
335
478
  `UPDATE lesson_xp_map
@@ -343,12 +486,30 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
343
486
  }
344
487
 
345
488
  private buildPrompt(
346
- transcription: string,
489
+ content: string,
347
490
  areas: { id: number; slug: string; name: string }[],
348
491
  skills: { id: number; slug: string; name: string; areaId: number | null }[],
349
492
  learningTypes: { id: number; slug: string; name: string; multiplier: number }[],
493
+ lessonType: 'video' | 'text' | 'quiz',
494
+ durationSeconds: number,
350
495
  ): string {
351
- return `Analise a transcrição abaixo e mapeie as competências técnicas de cada segmento de ~60 segundos.
496
+ const totalMinutes = Math.max(Math.ceil(durationSeconds / 60), 1);
497
+
498
+ const contentHeader =
499
+ lessonType === 'video'
500
+ ? 'TRANSCRIÇÃO DA AULA'
501
+ : lessonType === 'text'
502
+ ? 'CONTEÚDO DO POST'
503
+ : 'QUESTÕES DA AULA';
504
+
505
+ const segmentInstruction =
506
+ lessonType === 'video'
507
+ ? 'Analise a transcrição abaixo e mapeie as competências técnicas de cada segmento de ~60 segundos.'
508
+ : lessonType === 'text'
509
+ ? `Analise o conteúdo do post abaixo (duração estimada: ${totalMinutes} minutos) e mapeie as competências técnicas. Divida o conteúdo em segmentos de ~60 segundos cobrindo a duração total. Os valores de startSeconds e endSeconds devem cobrir de 0 até ${durationSeconds} segundos.`
510
+ : `Analise as questões da aula abaixo (duração estimada: ${totalMinutes} minutos). Cada questão representa um segmento; distribua o tempo total de ${durationSeconds} segundos igualmente entre as questões. Os valores de startSeconds e endSeconds devem cobrir de 0 até ${durationSeconds} segundos.`;
511
+
512
+ return `${segmentInstruction}
352
513
 
353
514
  CATÁLOGO DE ÁREAS MACRO (use APENAS estes IDs):
354
515
  ${JSON.stringify(areas.map((a) => ({ id: a.id, slug: a.slug, name: a.name })))}
@@ -359,8 +520,8 @@ ${JSON.stringify(skills.map((s) => ({ id: s.id, slug: s.slug, name: s.name, area
359
520
  CATÁLOGO DE TIPOS DE APRENDIZADO (use APENAS estes IDs):
360
521
  ${JSON.stringify(learningTypes.map((l) => ({ id: l.id, slug: l.slug, name: l.name, multiplier: l.multiplier })))}
361
522
 
362
- TRANSCRIÇÃO DA AULA:
363
- ${transcription}
523
+ ${contentHeader}:
524
+ ${content}
364
525
 
365
526
  Retorne um JSON com a chave "segments" contendo um array onde cada item representa um segmento de ~60s:
366
527
  {
@@ -388,6 +549,19 @@ Regras obrigatórias:
388
549
  - aiConfidence: 0 a 1, indica certeza do mapeamento.`;
389
550
  }
390
551
 
552
+ private stripHtml(html: string): string {
553
+ return html
554
+ .replace(/<[^>]+>/g, ' ')
555
+ .replace(/&amp;/g, '&')
556
+ .replace(/&lt;/g, '<')
557
+ .replace(/&gt;/g, '>')
558
+ .replace(/&quot;/g, '"')
559
+ .replace(/&#39;/g, "'")
560
+ .replace(/&nbsp;/g, ' ')
561
+ .replace(/\s{2,}/g, ' ')
562
+ .trim();
563
+ }
564
+
391
565
  private formatSeconds(s: number): string {
392
566
  const m = Math.floor(s / 60);
393
567
  const sec = Math.floor(s % 60);
@@ -0,0 +1,88 @@
1
+ import { IntegrationDeveloperApiService } from '@hed-hog/core';
2
+ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
3
+
4
+ type SubscriptionActivatedPayload = {
5
+ subscription_id?: number | null;
6
+ customer_id?: number | null;
7
+ user_id?: number | null;
8
+ product_id?: number | null;
9
+ plan_version_id?: number | null;
10
+ price_id?: number | null;
11
+ current_period_start?: string | null;
12
+ current_period_end?: string | null;
13
+ };
14
+
15
+ type SubscriptionCanceledPayload = {
16
+ subscription_id?: number | null;
17
+ reason?: string | null;
18
+ canceled_at?: string | null;
19
+ entitlement_revoked?: boolean | null;
20
+ };
21
+
22
+ @Injectable()
23
+ export class LmsCommerceAccessSubscriber implements OnModuleInit {
24
+ private readonly logger = new Logger(LmsCommerceAccessSubscriber.name);
25
+
26
+ constructor(
27
+ private readonly integrationApi: IntegrationDeveloperApiService,
28
+ ) {}
29
+
30
+ onModuleInit(): void {
31
+ this.integrationApi.subscribeMany([
32
+ {
33
+ eventName: 'commerce.subscription.activated',
34
+ consumerName: 'lms.commerce-subscription-activated',
35
+ priority: 0,
36
+ handler: async (event) => this.onSubscriptionActivated(event),
37
+ },
38
+ {
39
+ eventName: 'commerce.subscription.canceled',
40
+ consumerName: 'lms.commerce-subscription-canceled',
41
+ priority: 0,
42
+ handler: async (event) => this.onSubscriptionCanceled(event),
43
+ },
44
+ ]);
45
+ }
46
+
47
+ private async onSubscriptionActivated(event: any): Promise<void> {
48
+ const payload = (event.payload ?? {}) as SubscriptionActivatedPayload;
49
+ const userId = Number(payload.user_id ?? 0);
50
+ const productId = Number(payload.product_id ?? 0);
51
+ const subscriptionId = Number(payload.subscription_id ?? 0);
52
+
53
+ if (!userId || !productId) {
54
+ this.logger.warn(
55
+ `commerce.subscription.activated: missing user_id or product_id (subscription ${subscriptionId}) — skipping LMS side effects`,
56
+ );
57
+ return;
58
+ }
59
+
60
+ this.logger.log(
61
+ `LMS access granted: subscription ${subscriptionId} activated for user ${userId} / product ${productId}`,
62
+ );
63
+
64
+ // Extension point: look up integration links product_id → lms course/plan
65
+ // and auto-enroll the student in the corresponding class group.
66
+ // Example shape of what would go here:
67
+ //
68
+ // const links = await this.integrationApi.findLinksBySource({
69
+ // module: 'commerce',
70
+ // entityType: 'product',
71
+ // entityId: String(productId),
72
+ // });
73
+ // for (const link of links.filter(l => l.targetModule === 'lms' && l.targetEntityType === 'course')) {
74
+ // await this.classGroupService.enrollStudent(Number(link.targetEntityId), userId);
75
+ // }
76
+ }
77
+
78
+ private async onSubscriptionCanceled(event: any): Promise<void> {
79
+ const payload = (event.payload ?? {}) as SubscriptionCanceledPayload;
80
+ const subscriptionId = Number(payload.subscription_id ?? 0);
81
+
82
+ this.logger.log(
83
+ `LMS access revoked: subscription ${subscriptionId} canceled (reason: ${payload.reason ?? 'none'}, entitlement_revoked: ${payload.entitlement_revoked})`,
84
+ );
85
+
86
+ // Extension point: remove student from active class groups linked to this subscription.
87
+ }
88
+ }
package/src/lms.module.ts CHANGED
@@ -2,6 +2,7 @@ import { LocaleModule } from '@hed-hog/api-locale';
2
2
  import { PaginationModule } from '@hed-hog/api-pagination';
3
3
  import { PrismaModule } from '@hed-hog/api-prisma';
4
4
  import { CoreModule } from '@hed-hog/core';
5
+ import { CommerceModule } from '@hed-hog/commerce';
5
6
  import { QueueModule } from '@hed-hog/queue';
6
7
  import { forwardRef, Module } from '@nestjs/common';
7
8
  import { ConfigModule } from '@nestjs/config';
@@ -11,8 +12,8 @@ import { LmsCertificateModule } from './certificate/certificate.module';
11
12
  import { ClassGroupModule } from './class-group/class-group.module';
12
13
  import { CourseLessonDiscussionModule } from './course-lesson-discussion/course-lesson-discussion.module';
13
14
  import { CourseLessonNoteModule } from './course-lesson-note/course-lesson-note.module';
15
+ import { LmsCommerceAccessSubscriber } from './lms-commerce-access.subscriber';
14
16
  import { CourseAudioTranscriptionService } from './course/course-audio-transcription.service';
15
- import { LmsBulkUploadAutomationService } from './course/lms-bulk-upload-automation.service';
16
17
  import { CourseModule } from './course/course.module';
17
18
  import { LmsDashboardModule } from './dashboard/dashboard.module';
18
19
  import { EnterpriseModule } from './enterprise/enterprise.module';
@@ -21,14 +22,19 @@ import { EvaluationModule } from './evaluation/evaluation.module';
21
22
  import { ExamModule } from './exam/exam.module';
22
23
  import { InstructorModule } from './instructor/instructor.module';
23
24
  import { LessonXpMapModule } from './lesson-xp-map/lesson-xp-map.module';
25
+ import { EmitCertificateHandler } from './platforma/handlers/emit-certificate.handler';
26
+ import { LessonHeartbeatHandler } from './platforma/handlers/lesson-heartbeat.handler';
24
27
  import { PlataformaController } from './platforma/platforma.controller';
28
+ import { PlatformaHeartbeatService } from './platforma/platforma-heartbeat.service';
29
+ import { PlatformaPerformanceService } from './platforma/platforma-performance.service';
30
+ import { PlatformaSearchService } from './platforma/platforma-search.service';
31
+ import { PlatformaVideoService } from './platforma/platforma-video.service';
25
32
  import { StudentXpModule } from './student-xp/student-xp.module';
26
33
  import { XpCatalogModule } from './xp-catalog/xp-catalog.module';
27
34
  import { PlatformaService } from './platforma/platforma.service';
28
35
  import { LmsRealtimeModule } from './realtime/lms-realtime.module';
29
36
  import { LmsReportsModule } from './reports/reports.module';
30
37
  import { TrainingModule } from './training/training.module';
31
- import { VideoResolutionProfileModule } from './video-resolution-profile/video-resolution-profile.module';
32
38
 
33
39
  @Module({
34
40
  imports: [
@@ -37,6 +43,7 @@ import { VideoResolutionProfileModule } from './video-resolution-profile/video-r
37
43
  forwardRef(() => PrismaModule),
38
44
  forwardRef(() => CoreModule),
39
45
  forwardRef(() => QueueModule),
46
+ forwardRef(() => CommerceModule),
40
47
  forwardRef(() => LocaleModule),
41
48
  forwardRef(() => AchievementModule),
42
49
  forwardRef(() => BitcodeWalletModule),
@@ -54,7 +61,6 @@ import { VideoResolutionProfileModule } from './video-resolution-profile/video-r
54
61
  forwardRef(() => LmsRealtimeModule),
55
62
  forwardRef(() => LmsReportsModule),
56
63
  forwardRef(() => TrainingModule),
57
- forwardRef(() => VideoResolutionProfileModule),
58
64
  forwardRef(() => XpCatalogModule),
59
65
  forwardRef(() => LessonXpMapModule),
60
66
  forwardRef(() => StudentXpModule),
@@ -62,8 +68,14 @@ import { VideoResolutionProfileModule } from './video-resolution-profile/video-r
62
68
  controllers: [PlataformaController],
63
69
  providers: [
64
70
  CourseAudioTranscriptionService,
65
- LmsBulkUploadAutomationService,
66
71
  PlatformaService,
72
+ PlatformaVideoService,
73
+ PlatformaHeartbeatService,
74
+ PlatformaPerformanceService,
75
+ PlatformaSearchService,
76
+ LessonHeartbeatHandler,
77
+ EmitCertificateHandler,
78
+ LmsCommerceAccessSubscriber,
67
79
  ],
68
80
  exports: [
69
81
  forwardRef(() => AchievementModule),
@@ -82,7 +94,6 @@ import { VideoResolutionProfileModule } from './video-resolution-profile/video-r
82
94
  forwardRef(() => LmsRealtimeModule),
83
95
  forwardRef(() => LmsReportsModule),
84
96
  forwardRef(() => TrainingModule),
85
- forwardRef(() => VideoResolutionProfileModule),
86
97
  forwardRef(() => XpCatalogModule),
87
98
  forwardRef(() => LessonXpMapModule),
88
99
  forwardRef(() => StudentXpModule),
@@ -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
+ }