@hed-hog/lms 0.0.361 → 0.0.365

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 (434) hide show
  1. package/dist/bitcode-wallet/bitcode-wallet.service.d.ts +66 -0
  2. package/dist/bitcode-wallet/bitcode-wallet.service.d.ts.map +1 -1
  3. package/dist/bitcode-wallet/bitcode-wallet.service.js +91 -0
  4. package/dist/bitcode-wallet/bitcode-wallet.service.js.map +1 -1
  5. package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.d.ts +8 -0
  6. package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.d.ts.map +1 -0
  7. package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.js +40 -0
  8. package/dist/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.js.map +1 -0
  9. package/dist/class-group/class-group.controller.d.ts +16 -16
  10. package/dist/class-group/class-group.service.d.ts +12 -12
  11. package/dist/course/course-audio-transcription.service.d.ts +3 -2
  12. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  13. package/dist/course/course-audio-transcription.service.js +49 -8
  14. package/dist/course/course-audio-transcription.service.js.map +1 -1
  15. package/dist/course/course-export-scorm12-worker.service.d.ts +21 -0
  16. package/dist/course/course-export-scorm12-worker.service.d.ts.map +1 -0
  17. package/dist/course/course-export-scorm12-worker.service.js +109 -0
  18. package/dist/course/course-export-scorm12-worker.service.js.map +1 -0
  19. package/dist/course/course-export-scorm12.service.d.ts +42 -0
  20. package/dist/course/course-export-scorm12.service.d.ts.map +1 -0
  21. package/dist/course/course-export-scorm12.service.js +628 -0
  22. package/dist/course/course-export-scorm12.service.js.map +1 -0
  23. package/dist/course/course-export.service.d.ts +84 -0
  24. package/dist/course/course-export.service.d.ts.map +1 -0
  25. package/dist/course/course-export.service.js +237 -0
  26. package/dist/course/course-export.service.js.map +1 -0
  27. package/dist/course/course-lesson.controller.d.ts +4 -0
  28. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  29. package/dist/course/course-lesson.controller.js +10 -0
  30. package/dist/course/course-lesson.controller.js.map +1 -1
  31. package/dist/course/course-structure.controller.d.ts +24 -9
  32. package/dist/course/course-structure.controller.d.ts.map +1 -1
  33. package/dist/course/course-structure.controller.js +30 -3
  34. package/dist/course/course-structure.controller.js.map +1 -1
  35. package/dist/course/course-structure.service.d.ts +25 -3
  36. package/dist/course/course-structure.service.d.ts.map +1 -1
  37. package/dist/course/course-structure.service.js +234 -24
  38. package/dist/course/course-structure.service.js.map +1 -1
  39. package/dist/course/course-video-conversion.service.d.ts +8 -0
  40. package/dist/course/course-video-conversion.service.d.ts.map +1 -1
  41. package/dist/course/course-video-conversion.service.js +87 -51
  42. package/dist/course/course-video-conversion.service.js.map +1 -1
  43. package/dist/course/course-video-hls.service.d.ts +57 -0
  44. package/dist/course/course-video-hls.service.d.ts.map +1 -0
  45. package/dist/course/course-video-hls.service.js +767 -0
  46. package/dist/course/course-video-hls.service.js.map +1 -0
  47. package/dist/course/course.controller.d.ts +115 -11
  48. package/dist/course/course.controller.d.ts.map +1 -1
  49. package/dist/course/course.controller.js +66 -28
  50. package/dist/course/course.controller.js.map +1 -1
  51. package/dist/course/course.mcp-tools.js +1 -1
  52. package/dist/course/course.mcp-tools.js.map +1 -1
  53. package/dist/course/course.module.d.ts.map +1 -1
  54. package/dist/course/course.module.js +13 -0
  55. package/dist/course/course.module.js.map +1 -1
  56. package/dist/course/course.service.d.ts +112 -11
  57. package/dist/course/course.service.d.ts.map +1 -1
  58. package/dist/course/course.service.js +682 -72
  59. package/dist/course/course.service.js.map +1 -1
  60. package/dist/course/dto/cleanup-course-storage.dto.d.ts +6 -0
  61. package/dist/course/dto/cleanup-course-storage.dto.d.ts.map +1 -0
  62. package/dist/course/dto/cleanup-course-storage.dto.js +34 -0
  63. package/dist/course/dto/cleanup-course-storage.dto.js.map +1 -0
  64. package/dist/course/dto/cleanup-upload-history.dto.d.ts +9 -0
  65. package/dist/course/dto/cleanup-upload-history.dto.d.ts.map +1 -0
  66. package/dist/course/dto/cleanup-upload-history.dto.js +36 -0
  67. package/dist/course/dto/cleanup-upload-history.dto.js.map +1 -0
  68. package/dist/course/dto/create-course-bulk-job.dto.d.ts +5 -0
  69. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -0
  70. package/dist/course/dto/create-course-bulk-job.dto.js +26 -0
  71. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -0
  72. package/dist/course/dto/create-course-export.dto.d.ts +14 -0
  73. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -0
  74. package/dist/course/dto/create-course-export.dto.js +71 -0
  75. package/dist/course/dto/create-course-export.dto.js.map +1 -0
  76. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +2 -2
  77. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -1
  78. package/dist/course/dto/create-course-structure-lesson.dto.js +3 -2
  79. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -1
  80. package/dist/course/lms-bulk-upload-automation.service.d.ts +54 -0
  81. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -0
  82. package/dist/course/lms-bulk-upload-automation.service.js +537 -0
  83. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -0
  84. package/dist/course/lms-bulk-upload-infra.service.d.ts +32 -0
  85. package/dist/course/lms-bulk-upload-infra.service.d.ts.map +1 -0
  86. package/dist/course/lms-bulk-upload-infra.service.js +301 -0
  87. package/dist/course/lms-bulk-upload-infra.service.js.map +1 -0
  88. package/dist/course/lms-bulk-upload.constants.d.ts +4 -0
  89. package/dist/course/lms-bulk-upload.constants.d.ts.map +1 -0
  90. package/dist/course/lms-bulk-upload.constants.js +7 -0
  91. package/dist/course/lms-bulk-upload.constants.js.map +1 -0
  92. package/dist/course/lms-bulk-upload.controller.d.ts +144 -1
  93. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  94. package/dist/course/lms-bulk-upload.controller.js +114 -4
  95. package/dist/course/lms-bulk-upload.controller.js.map +1 -1
  96. package/dist/course/lms-bulk-upload.service.d.ts +153 -3
  97. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  98. package/dist/course/lms-bulk-upload.service.js +659 -21
  99. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  100. package/dist/course/lms-setting.controller.d.ts +6 -2
  101. package/dist/course/lms-setting.controller.d.ts.map +1 -1
  102. package/dist/course/lms-setting.controller.js +25 -8
  103. package/dist/course/lms-setting.controller.js.map +1 -1
  104. package/dist/course/scorm12-schemas.d.ts +4 -0
  105. package/dist/course/scorm12-schemas.d.ts.map +1 -0
  106. package/dist/course/scorm12-schemas.js +9 -0
  107. package/dist/course/scorm12-schemas.js.map +1 -0
  108. package/dist/enterprise/enterprise.controller.d.ts +20 -20
  109. package/dist/enterprise/enterprise.service.d.ts +20 -20
  110. package/dist/enterprise/training/training-admin.controller.d.ts +11 -11
  111. package/dist/enterprise/training/training-admin.service.d.ts +11 -11
  112. package/dist/enterprise/training/training-instructor.controller.d.ts +2 -2
  113. package/dist/enterprise/training/training-instructor.service.d.ts +2 -2
  114. package/dist/enterprise/training/training-student.controller.d.ts +1 -1
  115. package/dist/enterprise/training/training-student.service.d.ts +52 -1
  116. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  117. package/dist/enterprise/training/training-student.service.js +217 -4
  118. package/dist/enterprise/training/training-student.service.js.map +1 -1
  119. package/dist/enterprise/training/training-viewer.controller.d.ts +2 -2
  120. package/dist/evaluation/evaluation.controller.d.ts +8 -8
  121. package/dist/evaluation/evaluation.service.d.ts +26 -8
  122. package/dist/evaluation/evaluation.service.d.ts.map +1 -1
  123. package/dist/evaluation/evaluation.service.js +125 -0
  124. package/dist/evaluation/evaluation.service.js.map +1 -1
  125. package/dist/exam/dto/create-standalone-question.dto.d.ts +12 -0
  126. package/dist/exam/dto/create-standalone-question.dto.d.ts.map +1 -0
  127. package/dist/exam/dto/create-standalone-question.dto.js +70 -0
  128. package/dist/exam/dto/create-standalone-question.dto.js.map +1 -0
  129. package/dist/exam/exam.module.d.ts.map +1 -1
  130. package/dist/exam/exam.module.js +2 -1
  131. package/dist/exam/exam.module.js.map +1 -1
  132. package/dist/exam/exam.service.d.ts +21 -0
  133. package/dist/exam/exam.service.d.ts.map +1 -1
  134. package/dist/exam/exam.service.js +80 -0
  135. package/dist/exam/exam.service.js.map +1 -1
  136. package/dist/exam/question.controller.d.ts +27 -0
  137. package/dist/exam/question.controller.d.ts.map +1 -0
  138. package/dist/exam/question.controller.js +53 -0
  139. package/dist/exam/question.controller.js.map +1 -0
  140. package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.d.ts +6 -0
  141. package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.d.ts.map +1 -0
  142. package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.js +34 -0
  143. package/dist/lesson-xp-map/dto/create-lesson-xp-map.dto.js.map +1 -0
  144. package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.d.ts +28 -0
  145. package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.d.ts.map +1 -0
  146. package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.js +123 -0
  147. package/dist/lesson-xp-map/dto/create-lesson-xp-segment.dto.js.map +1 -0
  148. package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.d.ts +4 -0
  149. package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.d.ts.map +1 -0
  150. package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.js +22 -0
  151. package/dist/lesson-xp-map/dto/review-lesson-xp-map.dto.js.map +1 -0
  152. package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.d.ts +10 -0
  153. package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.d.ts.map +1 -0
  154. package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.js +52 -0
  155. package/dist/lesson-xp-map/dto/update-lesson-xp-map.dto.js.map +1 -0
  156. package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.d.ts +15 -0
  157. package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.d.ts.map +1 -0
  158. package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.js +86 -0
  159. package/dist/lesson-xp-map/dto/update-lesson-xp-segment.dto.js.map +1 -0
  160. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +30 -0
  161. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -0
  162. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +440 -0
  163. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -0
  164. package/dist/lesson-xp-map/lesson-xp-map.controller.d.ts +87 -0
  165. package/dist/lesson-xp-map/lesson-xp-map.controller.d.ts.map +1 -0
  166. package/dist/lesson-xp-map/lesson-xp-map.controller.js +185 -0
  167. package/dist/lesson-xp-map/lesson-xp-map.controller.js.map +1 -0
  168. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts +3 -0
  169. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -0
  170. package/dist/lesson-xp-map/lesson-xp-map.module.js +34 -0
  171. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -0
  172. package/dist/lesson-xp-map/lesson-xp-map.service.d.ts +84 -0
  173. package/dist/lesson-xp-map/lesson-xp-map.service.d.ts.map +1 -0
  174. package/dist/lesson-xp-map/lesson-xp-map.service.js +353 -0
  175. package/dist/lesson-xp-map/lesson-xp-map.service.js.map +1 -0
  176. package/dist/lesson-xp-map/lesson-xp-segment.controller.d.ts +10 -0
  177. package/dist/lesson-xp-map/lesson-xp-segment.controller.d.ts.map +1 -0
  178. package/dist/lesson-xp-map/lesson-xp-segment.controller.js +63 -0
  179. package/dist/lesson-xp-map/lesson-xp-segment.controller.js.map +1 -0
  180. package/dist/lesson-xp-map/lesson-xp-segment.service.d.ts +27 -0
  181. package/dist/lesson-xp-map/lesson-xp-segment.service.d.ts.map +1 -0
  182. package/dist/lesson-xp-map/lesson-xp-segment.service.js +194 -0
  183. package/dist/lesson-xp-map/lesson-xp-segment.service.js.map +1 -0
  184. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -0
  185. package/dist/lms-commerce-access.subscriber.d.ts +11 -0
  186. package/dist/lms-commerce-access.subscriber.d.ts.map +1 -0
  187. package/dist/lms-commerce-access.subscriber.js +74 -0
  188. package/dist/lms-commerce-access.subscriber.js.map +1 -0
  189. package/dist/lms.module.d.ts.map +1 -1
  190. package/dist/lms.module.js +21 -5
  191. package/dist/lms.module.js.map +1 -1
  192. package/dist/platforma/dto/update-profile.dto.d.ts +17 -0
  193. package/dist/platforma/dto/update-profile.dto.d.ts.map +1 -0
  194. package/dist/platforma/dto/update-profile.dto.js +87 -0
  195. package/dist/platforma/dto/update-profile.dto.js.map +1 -0
  196. package/dist/platforma/platforma-video.service.d.ts +39 -0
  197. package/dist/platforma/platforma-video.service.d.ts.map +1 -0
  198. package/dist/platforma/platforma-video.service.js +301 -0
  199. package/dist/platforma/platforma-video.service.js.map +1 -0
  200. package/dist/platforma/platforma.controller.d.ts +182 -1
  201. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  202. package/dist/platforma/platforma.controller.js +243 -2
  203. package/dist/platforma/platforma.controller.js.map +1 -1
  204. package/dist/platforma/platforma.service.d.ts +27 -0
  205. package/dist/platforma/platforma.service.d.ts.map +1 -0
  206. package/dist/platforma/platforma.service.js +274 -0
  207. package/dist/platforma/platforma.service.js.map +1 -0
  208. package/dist/student-xp/dto/grant-skill-card-xp.dto.d.ts +5 -0
  209. package/dist/student-xp/dto/grant-skill-card-xp.dto.d.ts.map +1 -0
  210. package/dist/student-xp/dto/grant-skill-card-xp.dto.js +26 -0
  211. package/dist/student-xp/dto/grant-skill-card-xp.dto.js.map +1 -0
  212. package/dist/student-xp/student-xp.controller.d.ts +56 -0
  213. package/dist/student-xp/student-xp.controller.d.ts.map +1 -0
  214. package/dist/student-xp/student-xp.controller.js +138 -0
  215. package/dist/student-xp/student-xp.controller.js.map +1 -0
  216. package/dist/student-xp/student-xp.module.d.ts +3 -0
  217. package/dist/student-xp/student-xp.module.d.ts.map +1 -0
  218. package/dist/student-xp/student-xp.module.js +25 -0
  219. package/dist/student-xp/student-xp.module.js.map +1 -0
  220. package/dist/student-xp/student-xp.service.d.ts +81 -0
  221. package/dist/student-xp/student-xp.service.d.ts.map +1 -0
  222. package/dist/student-xp/student-xp.service.js +247 -0
  223. package/dist/student-xp/student-xp.service.js.map +1 -0
  224. package/dist/xp-catalog/dto/create-xp-area.dto.d.ts +12 -0
  225. package/dist/xp-catalog/dto/create-xp-area.dto.d.ts.map +1 -0
  226. package/dist/xp-catalog/dto/create-xp-area.dto.js +63 -0
  227. package/dist/xp-catalog/dto/create-xp-area.dto.js.map +1 -0
  228. package/dist/xp-catalog/dto/create-xp-learning-type.dto.d.ts +11 -0
  229. package/dist/xp-catalog/dto/create-xp-learning-type.dto.d.ts.map +1 -0
  230. package/dist/xp-catalog/dto/create-xp-learning-type.dto.js +57 -0
  231. package/dist/xp-catalog/dto/create-xp-learning-type.dto.js.map +1 -0
  232. package/dist/xp-catalog/dto/create-xp-skill.dto.d.ts +11 -0
  233. package/dist/xp-catalog/dto/create-xp-skill.dto.d.ts.map +1 -0
  234. package/dist/xp-catalog/dto/create-xp-skill.dto.js +57 -0
  235. package/dist/xp-catalog/dto/create-xp-skill.dto.js.map +1 -0
  236. package/dist/xp-catalog/dto/update-xp-area.dto.d.ts +12 -0
  237. package/dist/xp-catalog/dto/update-xp-area.dto.d.ts.map +1 -0
  238. package/dist/xp-catalog/dto/update-xp-area.dto.js +66 -0
  239. package/dist/xp-catalog/dto/update-xp-area.dto.js.map +1 -0
  240. package/dist/xp-catalog/dto/update-xp-learning-type.dto.d.ts +11 -0
  241. package/dist/xp-catalog/dto/update-xp-learning-type.dto.d.ts.map +1 -0
  242. package/dist/xp-catalog/dto/update-xp-learning-type.dto.js +60 -0
  243. package/dist/xp-catalog/dto/update-xp-learning-type.dto.js.map +1 -0
  244. package/dist/xp-catalog/dto/update-xp-skill.dto.d.ts +11 -0
  245. package/dist/xp-catalog/dto/update-xp-skill.dto.d.ts.map +1 -0
  246. package/dist/xp-catalog/dto/update-xp-skill.dto.js +60 -0
  247. package/dist/xp-catalog/dto/update-xp-skill.dto.js.map +1 -0
  248. package/dist/xp-catalog/xp-area.controller.d.ts +25 -0
  249. package/dist/xp-catalog/xp-area.controller.d.ts.map +1 -0
  250. package/dist/xp-catalog/xp-area.controller.js +105 -0
  251. package/dist/xp-catalog/xp-area.controller.js.map +1 -0
  252. package/dist/xp-catalog/xp-area.service.d.ts +35 -0
  253. package/dist/xp-catalog/xp-area.service.d.ts.map +1 -0
  254. package/dist/xp-catalog/xp-area.service.js +168 -0
  255. package/dist/xp-catalog/xp-area.service.js.map +1 -0
  256. package/dist/xp-catalog/xp-catalog.module.d.ts +3 -0
  257. package/dist/xp-catalog/xp-catalog.module.d.ts.map +1 -0
  258. package/dist/xp-catalog/xp-catalog.module.js +29 -0
  259. package/dist/xp-catalog/xp-catalog.module.js.map +1 -0
  260. package/dist/xp-catalog/xp-learning-type.controller.d.ts +20 -0
  261. package/dist/xp-catalog/xp-learning-type.controller.d.ts.map +1 -0
  262. package/dist/xp-catalog/xp-learning-type.controller.js +96 -0
  263. package/dist/xp-catalog/xp-learning-type.controller.js.map +1 -0
  264. package/dist/xp-catalog/xp-learning-type.service.d.ts +30 -0
  265. package/dist/xp-catalog/xp-learning-type.service.d.ts.map +1 -0
  266. package/dist/xp-catalog/xp-learning-type.service.js +146 -0
  267. package/dist/xp-catalog/xp-learning-type.service.js.map +1 -0
  268. package/dist/xp-catalog/xp-skill.controller.d.ts +26 -0
  269. package/dist/xp-catalog/xp-skill.controller.d.ts.map +1 -0
  270. package/dist/xp-catalog/xp-skill.controller.js +113 -0
  271. package/dist/xp-catalog/xp-skill.controller.js.map +1 -0
  272. package/dist/xp-catalog/xp-skill.service.d.ts +37 -0
  273. package/dist/xp-catalog/xp-skill.service.d.ts.map +1 -0
  274. package/dist/xp-catalog/xp-skill.service.js +174 -0
  275. package/dist/xp-catalog/xp-skill.service.js.map +1 -0
  276. package/hedhog/data/evaluation_topic.yaml +17 -0
  277. package/hedhog/data/menu.yaml +91 -7
  278. package/hedhog/data/queue_definition.yaml +48 -0
  279. package/hedhog/data/route.yaml +511 -29
  280. package/hedhog/data/setting_group.yaml +20 -20
  281. package/hedhog/data/xp_area.yaml +164 -0
  282. package/hedhog/data/xp_learning_type.yaml +131 -0
  283. package/hedhog/data/xp_skill.yaml +1834 -0
  284. package/hedhog/frontend/app/achievements/page.tsx.ejs +108 -118
  285. package/hedhog/frontend/app/bitcodes/page.tsx.ejs +22 -34
  286. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +1749 -0
  287. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +21 -45
  288. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +40 -74
  289. package/hedhog/frontend/app/classes/page.tsx.ejs +56 -85
  290. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +3 -2
  291. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +48 -5
  292. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +73 -8
  293. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +420 -0
  294. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +308 -0
  295. package/hedhog/frontend/app/courses/[id]/structure/_components/course-operations-tab.tsx.ejs +19 -2
  296. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +1172 -0
  297. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree-dnd.tsx.ejs +16 -0
  298. package/hedhog/frontend/app/courses/[id]/structure/_components/course-tree.tsx.ejs +2 -0
  299. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +623 -0
  300. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +1458 -0
  301. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +87 -46
  302. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-panel.tsx.ejs +8 -3
  303. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-session.tsx.ejs +31 -8
  304. package/hedhog/frontend/app/courses/[id]/structure/_components/drag-overlay.tsx.ejs +16 -9
  305. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +618 -480
  306. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +672 -737
  307. package/hedhog/frontend/app/courses/[id]/structure/_components/mock-data.ts.ejs +1 -2
  308. package/hedhog/frontend/app/courses/[id]/structure/_components/sortable-tree-row.tsx.ejs +3 -0
  309. package/hedhog/frontend/app/courses/[id]/structure/_components/store.ts.ejs +1 -0
  310. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-display-settings-popover.tsx.ejs +101 -85
  311. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +24 -10
  312. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row.tsx.ejs +3 -0
  313. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +1 -1
  314. package/hedhog/frontend/app/courses/[id]/structure/_components/use-tree-display-settings.ts.ejs +7 -1
  315. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +44 -0
  316. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +6 -10
  317. package/hedhog/frontend/app/courses/[id]/structure/_data/services/course-structure.service.ts.ejs +49 -0
  318. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -3
  319. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +53 -0
  320. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +106 -0
  321. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +80 -1
  322. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-xp-overview.ts.ejs +76 -0
  323. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lesson-xp-map.ts.ejs +128 -0
  324. package/hedhog/frontend/app/courses/[id]/structure/_data/use-lms-settings-query.ts.ejs +0 -2
  325. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +30 -0
  326. package/hedhog/frontend/app/courses/[id]/structure/_utils/xp-color-config.ts.ejs +115 -0
  327. package/hedhog/frontend/app/courses/_components/CourseDeleteDialog.tsx.ejs +223 -0
  328. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +89 -0
  329. package/hedhog/frontend/app/courses/page.tsx.ejs +445 -230
  330. package/hedhog/frontend/app/enterprise/page.tsx.ejs +39 -63
  331. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +53 -77
  332. package/hedhog/frontend/app/exams/page.tsx.ejs +54 -90
  333. package/hedhog/frontend/app/instructor-skills/page.tsx.ejs +23 -36
  334. package/hedhog/frontend/app/instructors/page.tsx.ejs +72 -81
  335. package/hedhog/frontend/app/paths/page.tsx.ejs +40 -68
  336. package/hedhog/frontend/app/training/page.tsx.ejs +40 -68
  337. package/hedhog/frontend/app/xp/areas/page.tsx.ejs +782 -0
  338. package/hedhog/frontend/app/xp/learning-types/page.tsx.ejs +690 -0
  339. package/hedhog/frontend/app/xp/skills/page.tsx.ejs +811 -0
  340. package/hedhog/frontend/messages/en.json +412 -31
  341. package/hedhog/frontend/messages/pt.json +412 -31
  342. package/hedhog/table/course_export.yaml +62 -0
  343. package/hedhog/table/lesson_xp_map.yaml +50 -0
  344. package/hedhog/table/lesson_xp_segment.yaml +40 -0
  345. package/hedhog/table/lesson_xp_segment_area.yaml +24 -0
  346. package/hedhog/table/lesson_xp_segment_learning_type.yaml +24 -0
  347. package/hedhog/table/lesson_xp_segment_skill.yaml +24 -0
  348. package/hedhog/table/lms_bulk_upload_item.yaml +44 -0
  349. package/hedhog/table/lms_bulk_upload_session.yaml +42 -0
  350. package/hedhog/table/student_area_xp.yaml +30 -0
  351. package/hedhog/table/student_learning_type_xp.yaml +30 -0
  352. package/hedhog/table/student_skill_xp.yaml +30 -0
  353. package/hedhog/table/student_xp_event.yaml +34 -0
  354. package/hedhog/table/xp_area.yaml +39 -0
  355. package/hedhog/table/xp_learning_type.yaml +34 -0
  356. package/hedhog/table/xp_skill.yaml +39 -0
  357. package/package.json +13 -8
  358. package/src/bitcode-wallet/bitcode-wallet.service.ts +152 -0
  359. package/src/bitcode-wallet/dto/create-current-bitcode-wallet-transaction.dto.ts +32 -0
  360. package/src/course/course-audio-transcription.service.ts +58 -21
  361. package/src/course/course-export-scorm12-worker.service.ts +124 -0
  362. package/src/course/course-export-scorm12.service.ts +668 -0
  363. package/src/course/course-export.service.ts +280 -0
  364. package/src/course/course-lesson.controller.ts +6 -1
  365. package/src/course/course-structure.controller.ts +23 -1
  366. package/src/course/course-structure.service.ts +273 -7
  367. package/src/course/course-video-conversion.service.ts +113 -75
  368. package/src/course/course-video-hls.service.ts +946 -0
  369. package/src/course/course.controller.ts +54 -21
  370. package/src/course/course.mcp-tools.ts +1 -1
  371. package/src/course/course.module.ts +13 -0
  372. package/src/course/course.service.ts +906 -76
  373. package/src/course/dto/cleanup-course-storage.dto.ts +23 -0
  374. package/src/course/dto/cleanup-upload-history.dto.ts +26 -0
  375. package/src/course/dto/create-course-bulk-job.dto.ts +10 -0
  376. package/src/course/dto/create-course-export.dto.ts +56 -0
  377. package/src/course/dto/create-course-structure-lesson.dto.ts +4 -3
  378. package/src/course/lms-bulk-upload-automation.service.ts +707 -0
  379. package/src/course/lms-bulk-upload-infra.service.ts +360 -0
  380. package/src/course/lms-bulk-upload.constants.ts +5 -0
  381. package/src/course/lms-bulk-upload.controller.ts +110 -4
  382. package/src/course/lms-bulk-upload.service.ts +1092 -204
  383. package/src/course/lms-setting.controller.ts +26 -8
  384. package/src/course/scorm12-schemas.ts +9 -0
  385. package/src/enterprise/training/training-student.service.ts +221 -2
  386. package/src/evaluation/evaluation.service.ts +123 -0
  387. package/src/exam/dto/create-standalone-question.dto.ts +66 -0
  388. package/src/exam/exam.module.ts +2 -1
  389. package/src/exam/exam.service.ts +86 -0
  390. package/src/exam/question.controller.ts +28 -0
  391. package/src/lesson-xp-map/dto/create-lesson-xp-map.dto.ts +17 -0
  392. package/src/lesson-xp-map/dto/create-lesson-xp-segment.dto.ts +102 -0
  393. package/src/lesson-xp-map/dto/review-lesson-xp-map.dto.ts +7 -0
  394. package/src/lesson-xp-map/dto/update-lesson-xp-map.dto.ts +36 -0
  395. package/src/lesson-xp-map/dto/update-lesson-xp-segment.dto.ts +78 -0
  396. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +570 -0
  397. package/src/lesson-xp-map/lesson-xp-map.controller.ts +116 -0
  398. package/src/lesson-xp-map/lesson-xp-map.module.ts +21 -0
  399. package/src/lesson-xp-map/lesson-xp-map.service.ts +442 -0
  400. package/src/lesson-xp-map/lesson-xp-segment.controller.ts +36 -0
  401. package/src/lesson-xp-map/lesson-xp-segment.service.ts +229 -0
  402. package/src/lms-commerce-access.subscriber.ts +88 -0
  403. package/src/lms.module.ts +21 -5
  404. package/src/platforma/dto/update-profile.dto.ts +59 -0
  405. package/src/platforma/platforma-video.service.ts +346 -0
  406. package/src/platforma/platforma.controller.ts +152 -3
  407. package/src/platforma/platforma.service.ts +268 -0
  408. package/src/student-xp/dto/grant-skill-card-xp.dto.ts +10 -0
  409. package/src/student-xp/student-xp.controller.ts +92 -0
  410. package/src/student-xp/student-xp.module.ts +12 -0
  411. package/src/student-xp/student-xp.service.ts +318 -0
  412. package/src/xp-catalog/dto/create-xp-area.dto.ts +40 -0
  413. package/src/xp-catalog/dto/create-xp-learning-type.dto.ts +35 -0
  414. package/src/xp-catalog/dto/create-xp-skill.dto.ts +35 -0
  415. package/src/xp-catalog/dto/update-xp-area.dto.ts +43 -0
  416. package/src/xp-catalog/dto/update-xp-learning-type.dto.ts +38 -0
  417. package/src/xp-catalog/dto/update-xp-skill.dto.ts +38 -0
  418. package/src/xp-catalog/xp-area.controller.ts +64 -0
  419. package/src/xp-catalog/xp-area.service.ts +196 -0
  420. package/src/xp-catalog/xp-catalog.module.ts +16 -0
  421. package/src/xp-catalog/xp-learning-type.controller.ts +59 -0
  422. package/src/xp-catalog/xp-learning-type.service.ts +170 -0
  423. package/src/xp-catalog/xp-skill.controller.ts +71 -0
  424. package/src/xp-catalog/xp-skill.service.ts +205 -0
  425. package/hedhog/data/video_resolution_profile.yaml +0 -7
  426. package/hedhog/frontend/app/video-resolution-profiles/page.tsx.ejs +0 -607
  427. package/hedhog/table/course_video_resolution_profile.yaml +0 -22
  428. package/hedhog/table/video_resolution_profile.yaml +0 -18
  429. package/src/video-resolution-profile/dto/create-video-resolution-profile.dto.ts +0 -16
  430. package/src/video-resolution-profile/dto/update-video-resolution-profile.dto.ts +0 -16
  431. package/src/video-resolution-profile/video-resolution-profile.controller.ts +0 -62
  432. package/src/video-resolution-profile/video-resolution-profile.mcp-tools.ts +0 -128
  433. package/src/video-resolution-profile/video-resolution-profile.module.ts +0 -13
  434. package/src/video-resolution-profile/video-resolution-profile.service.ts +0 -117
@@ -1,204 +1,1092 @@
1
- import { FileService } from '@hed-hog/core';
2
- import { PrismaService } from '@hed-hog/api-prisma';
3
- import { SettingService } from '@hed-hog/core';
4
- import {
5
- BadRequestException,
6
- Inject,
7
- Injectable,
8
- NotFoundException,
9
- forwardRef,
10
- } from '@nestjs/common';
11
-
12
- type IntegrationProfileConfig = {
13
- access_key_id?: string;
14
- secret_access_key?: string;
15
- session_token?: string;
16
- region?: string;
17
- bucket?: string;
18
- role_arn?: string;
19
- external_id?: string;
20
- };
21
-
22
- @Injectable()
23
- export class LmsBulkUploadService {
24
- constructor(
25
- @Inject(forwardRef(() => SettingService))
26
- private readonly settingService: SettingService,
27
- @Inject(forwardRef(() => PrismaService))
28
- private readonly prismaService: PrismaService,
29
- @Inject(forwardRef(() => FileService))
30
- private readonly fileService: FileService,
31
- ) {}
32
-
33
- async getBulkUploadSettings() {
34
- const settings = await this.settingService.getSettingValues([
35
- 'lms-bulk-upload-storage-profile-id',
36
- 'lms-bulk-upload-s3-bucket',
37
- 'lms-bulk-upload-sts-duration-seconds',
38
- ]);
39
-
40
- return {
41
- storageProfileId: Number(settings['lms-bulk-upload-storage-profile-id'] || 0) || null,
42
- bucketName:
43
- typeof settings['lms-bulk-upload-s3-bucket'] === 'string'
44
- ? settings['lms-bulk-upload-s3-bucket'].trim()
45
- : '',
46
- sessionDurationSeconds:
47
- Number(settings['lms-bulk-upload-sts-duration-seconds'] || 3600) || 3600,
48
- };
49
- }
50
-
51
- async getTemporaryCredentials(userId: number) {
52
- const { profile, region, bucket, durationSeconds, prefix } =
53
- await this.resolveStorageProfileAndBucket(userId);
54
-
55
- const credentials = profile.config as IntegrationProfileConfig;
56
- const accessKeyId = String(credentials.access_key_id ?? '').trim();
57
- const secretAccessKey = String(credentials.secret_access_key ?? '').trim();
58
- const sessionToken = String(credentials.session_token ?? '').trim() || undefined;
59
-
60
- if (!accessKeyId || !secretAccessKey) {
61
- throw new BadRequestException(
62
- 'Storage profile does not contain AWS credentials (access_key_id/secret_access_key).',
63
- );
64
- }
65
-
66
- const roleArn = String(credentials.role_arn ?? '').trim();
67
- const externalId = String(credentials.external_id ?? '').trim();
68
-
69
- const tempCredentials = await this.fileService.getTemporaryCredentials({
70
- accessKeyId,
71
- secretAccessKey,
72
- sessionToken,
73
- region,
74
- roleArn: roleArn || undefined,
75
- externalId: externalId || undefined,
76
- durationSeconds,
77
- sessionName: `hedhog-desktop-u${userId}-${Date.now()}`,
78
- });
79
-
80
- return {
81
- provider: 'aws-s3',
82
- bucket,
83
- region,
84
- keyPrefix: prefix,
85
- credentials: {
86
- accessKeyId: tempCredentials.AccessKeyId,
87
- secretAccessKey: tempCredentials.SecretAccessKey,
88
- sessionToken: tempCredentials.SessionToken,
89
- expiresAt: tempCredentials.Expiration?.toISOString() ?? null,
90
- },
91
- };
92
- }
93
-
94
- async verifyFileOnS3(userId: number, payload: { key?: string; fileName?: string }) {
95
- const { profile, region, bucket, prefix } = await this.resolveStorageProfileAndBucket(userId);
96
-
97
- const credentials = profile.config as IntegrationProfileConfig;
98
- const accessKeyId = String(credentials.access_key_id ?? '').trim();
99
- const secretAccessKey = String(credentials.secret_access_key ?? '').trim();
100
- const sessionToken = String(credentials.session_token ?? '').trim() || undefined;
101
-
102
- if (!accessKeyId || !secretAccessKey) {
103
- throw new BadRequestException(
104
- 'Storage profile does not contain AWS credentials (access_key_id/secret_access_key).',
105
- );
106
- }
107
-
108
- const objectKey = String(payload.key ?? '').trim() || `${prefix}${String(payload.fileName ?? '').trim()}`;
109
- if (!objectKey) {
110
- throw new BadRequestException('Provide file key or fileName to verify on S3.');
111
- }
112
-
113
- try {
114
- const result = await this.fileService.headS3Object({
115
- accessKeyId,
116
- secretAccessKey,
117
- sessionToken,
118
- region,
119
- bucket,
120
- key: objectKey,
121
- });
122
-
123
- return {
124
- isValid: true,
125
- key: objectKey,
126
- sizeBytes: result.ContentLength ?? null,
127
- etag: result.ETag ?? null,
128
- };
129
- } catch {
130
- return {
131
- isValid: false,
132
- key: objectKey,
133
- };
134
- }
135
- }
136
-
137
- private async resolveStorageProfileAndBucket(userId: number) {
138
- const settings = await this.settingService.getSettingValues([
139
- 'lms-bulk-upload-storage-profile-id',
140
- 'lms-bulk-upload-s3-bucket',
141
- 'lms-bulk-upload-sts-duration-seconds',
142
- ]);
143
-
144
- const profileId = Number(settings['lms-bulk-upload-storage-profile-id'] || 0);
145
- if (!Number.isFinite(profileId) || profileId <= 0) {
146
- throw new BadRequestException(
147
- 'LMS bulk upload storage profile is not configured (lms-bulk-upload-storage-profile-id).',
148
- );
149
- }
150
-
151
- const profile = await this.prismaService.integration_profile.findUnique({
152
- where: { id: profileId },
153
- include: {
154
- integration_type: { select: { slug: true } },
155
- integration_provider: { select: { slug: true } },
156
- },
157
- });
158
-
159
- if (!profile) {
160
- throw new NotFoundException(`Storage integration profile ${profileId} was not found.`);
161
- }
162
-
163
- if (profile.integration_type.slug !== 'storage') {
164
- throw new BadRequestException(
165
- `Integration profile ${profileId} is not a storage profile.`,
166
- );
167
- }
168
-
169
- const providerSlug = String(profile.integration_provider.slug ?? '').toLowerCase();
170
- if (providerSlug !== 's3') {
171
- throw new BadRequestException(
172
- `Integration provider ${providerSlug} is not supported for desktop bulk upload. Use AWS S3 profile.`,
173
- );
174
- }
175
-
176
- const config = (profile.config as IntegrationProfileConfig) ?? {};
177
- const region = String(config.region ?? '').trim() || 'us-east-1';
178
-
179
- const bucketFromSettings = String(settings['lms-bulk-upload-s3-bucket'] ?? '').trim();
180
- const bucketFromProfile = String(config.bucket ?? '').trim();
181
- const bucket = bucketFromSettings || bucketFromProfile;
182
-
183
- if (!bucket) {
184
- throw new BadRequestException(
185
- 'LMS bulk upload bucket is not configured. Set lms-bulk-upload-s3-bucket or profile bucket.',
186
- );
187
- }
188
-
189
- const durationSeconds = Math.max(
190
- 900,
191
- Math.min(43200, Number(settings['lms-bulk-upload-sts-duration-seconds'] || 3600) || 3600),
192
- );
193
-
194
- const prefix = `desktop/lms/u${userId}/`;
195
-
196
- return {
197
- profile,
198
- region,
199
- bucket,
200
- durationSeconds,
201
- prefix,
202
- };
203
- }
204
- }
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { FileService, SettingService, WebhookIntegrationService } from '@hed-hog/core';
3
+ import {
4
+ BadRequestException,
5
+ Inject,
6
+ Injectable,
7
+ NotFoundException,
8
+ forwardRef,
9
+ } from '@nestjs/common';
10
+ import {
11
+ BULK_UPLOAD_CLEANUP_STATUSES,
12
+ BULK_UPLOAD_CLEANUP_WINDOWS,
13
+ BulkUploadCleanupStatus,
14
+ BulkUploadCleanupWindow,
15
+ } from './dto/cleanup-upload-history.dto';
16
+ import { LmsBulkUploadInfraService } from './lms-bulk-upload-infra.service';
17
+ import {
18
+ LMS_BULK_UPLOAD_RECEIVE_VIDEO_COMMAND,
19
+ LMS_BULK_UPLOAD_WEBHOOK_SLUG,
20
+ } from './lms-bulk-upload.constants';
21
+
22
+ type IntegrationProfileConfig = {
23
+ access_key_id?: string;
24
+ secret_access_key?: string;
25
+ session_token?: string;
26
+ region?: string;
27
+ bucket?: string;
28
+ role_arn?: string;
29
+ external_id?: string;
30
+ };
31
+
32
+ type BulkUploadSessionStatus =
33
+ | 'started'
34
+ | 'uploading'
35
+ | 'paused'
36
+ | 'cancelling'
37
+ | 'completed'
38
+ | 'failed'
39
+ | 'cancelled';
40
+
41
+ type BulkUploadItemStatus =
42
+ | 'queued'
43
+ | 'uploading'
44
+ | 'cancelling'
45
+ | 'received'
46
+ | 'done'
47
+ | 'error'
48
+ | 'cancelled'
49
+ | 'lesson_not_found';
50
+
51
+ type StartBulkUploadPayload = {
52
+ appName?: string;
53
+ folderPath?: string | null;
54
+ files?: Array<{
55
+ uploadId?: string;
56
+ fileName?: string;
57
+ sizeBytes?: number;
58
+ }>;
59
+ };
60
+
61
+ type BulkUploadHeartbeatPayload = {
62
+ sessionId?: number;
63
+ status?: string;
64
+ progressPercent?: number;
65
+ items?: Array<{
66
+ uploadId?: string;
67
+ status?: string;
68
+ progressPercent?: number;
69
+ errorMessage?: string | null;
70
+ uploadedKey?: string | null;
71
+ }>;
72
+ };
73
+
74
+ @Injectable()
75
+ export class LmsBulkUploadService {
76
+ constructor(
77
+ @Inject(forwardRef(() => SettingService))
78
+ private readonly settingService: SettingService,
79
+ @Inject(forwardRef(() => PrismaService))
80
+ private readonly prismaService: PrismaService,
81
+ @Inject(forwardRef(() => FileService))
82
+ private readonly fileService: FileService,
83
+ @Inject(forwardRef(() => WebhookIntegrationService))
84
+ private readonly webhookIntegrationService: WebhookIntegrationService,
85
+ @Inject(forwardRef(() => LmsBulkUploadInfraService))
86
+ private readonly bulkUploadInfraService: LmsBulkUploadInfraService,
87
+ ) {}
88
+
89
+ async configureBulkUpload(
90
+ userId: number,
91
+ payload: { storageProfileId?: number },
92
+ ) {
93
+ const storageProfileId = Number(payload?.storageProfileId ?? 0);
94
+ const settings = await this.settingService.getSettingValues([
95
+ 'lms-bulk-upload-lambda-role-arn',
96
+ ]);
97
+ const lambdaRoleArn = String(settings['lms-bulk-upload-lambda-role-arn'] ?? '').trim();
98
+
99
+ if (!Number.isFinite(storageProfileId) || storageProfileId <= 0) {
100
+ throw new BadRequestException('Select a valid storage integration profile.');
101
+ }
102
+
103
+ const { profile, region, bucket, durationSeconds } =
104
+ await this.resolveStorageProfileAndBucket(userId, {
105
+ storageProfileId,
106
+ });
107
+
108
+ const credentials = profile.config as IntegrationProfileConfig;
109
+ const accessKeyId = String(credentials.access_key_id ?? '').trim();
110
+ const secretAccessKey = String(credentials.secret_access_key ?? '').trim();
111
+ const sessionToken = String(credentials.session_token ?? '').trim() || undefined;
112
+
113
+ if (!accessKeyId || !secretAccessKey) {
114
+ throw new BadRequestException(
115
+ 'Storage profile does not contain AWS credentials (access_key_id/secret_access_key).',
116
+ );
117
+ }
118
+
119
+ await this.fileService.headS3Bucket({
120
+ accessKeyId,
121
+ secretAccessKey,
122
+ sessionToken,
123
+ region,
124
+ bucket,
125
+ });
126
+
127
+ const roleArn = String(credentials.role_arn ?? '').trim();
128
+ const externalId = String(credentials.external_id ?? '').trim();
129
+ const tempCredentials = await this.fileService.getTemporaryCredentials({
130
+ accessKeyId,
131
+ secretAccessKey,
132
+ sessionToken,
133
+ region,
134
+ roleArn: roleArn || undefined,
135
+ externalId: externalId || undefined,
136
+ durationSeconds,
137
+ sessionName: `hedhog-desktop-u${userId}-${Date.now()}`,
138
+ });
139
+
140
+ await this.fileService.headS3Bucket({
141
+ accessKeyId: tempCredentials.AccessKeyId,
142
+ secretAccessKey: tempCredentials.SecretAccessKey,
143
+ sessionToken: tempCredentials.SessionToken,
144
+ region,
145
+ bucket,
146
+ });
147
+
148
+ const webhook = await this.ensureBulkUploadWebhook();
149
+
150
+ if (!webhook.public_url || !webhook.plainToken) {
151
+ throw new BadRequestException(
152
+ 'Bulk upload webhook could not be provisioned with URL/token.',
153
+ );
154
+ }
155
+
156
+ const infrastructure = await this.bulkUploadInfraService.syncDesktopUploadInfrastructure({
157
+ bucketName: bucket,
158
+ region,
159
+ webhookUrl: String(webhook.public_url),
160
+ webhookSecret: String(webhook.plainToken),
161
+ accessKeyId,
162
+ secretAccessKey,
163
+ sessionToken,
164
+ lambdaRoleArn,
165
+ });
166
+
167
+ await this.settingService.setManySettings({
168
+ setting: [
169
+ {
170
+ slug: 'lms-bulk-upload-storage-profile-id',
171
+ value: String(storageProfileId),
172
+ },
173
+ ],
174
+ });
175
+
176
+ return {
177
+ success: true,
178
+ storageProfileId,
179
+ bucketName: bucket,
180
+ region,
181
+ credentials: {
182
+ expiresAt: tempCredentials.Expiration?.toISOString() ?? null,
183
+ },
184
+ infrastructure,
185
+ webhook,
186
+ };
187
+ }
188
+
189
+ async regenerateWebhookTokenAndSync(userId: number) {
190
+ const { profile, region, bucket } = await this.resolveStorageProfileAndBucket(userId);
191
+ const settings = await this.settingService.getSettingValues([
192
+ 'lms-bulk-upload-lambda-role-arn',
193
+ ]);
194
+ const lambdaRoleArn = String(settings['lms-bulk-upload-lambda-role-arn'] ?? '').trim();
195
+
196
+ const credentials = profile.config as IntegrationProfileConfig;
197
+ const accessKeyId = String(credentials.access_key_id ?? '').trim();
198
+ const secretAccessKey = String(credentials.secret_access_key ?? '').trim();
199
+ const sessionToken = String(credentials.session_token ?? '').trim() || undefined;
200
+
201
+ if (!accessKeyId || !secretAccessKey) {
202
+ throw new BadRequestException(
203
+ 'Storage profile does not contain AWS credentials (access_key_id/secret_access_key).',
204
+ );
205
+ }
206
+
207
+ await this.fileService.headS3Bucket({
208
+ accessKeyId,
209
+ secretAccessKey,
210
+ sessionToken,
211
+ region,
212
+ bucket,
213
+ });
214
+
215
+ const webhook = await this.ensureBulkUploadWebhook();
216
+
217
+ if (!webhook.public_url || !webhook.plainToken) {
218
+ throw new BadRequestException(
219
+ 'Bulk upload webhook could not be provisioned with URL/token.',
220
+ );
221
+ }
222
+
223
+ const infrastructure = await this.bulkUploadInfraService.syncDesktopUploadInfrastructure({
224
+ bucketName: bucket,
225
+ region,
226
+ webhookUrl: String(webhook.public_url),
227
+ webhookSecret: String(webhook.plainToken),
228
+ accessKeyId,
229
+ secretAccessKey,
230
+ sessionToken,
231
+ lambdaRoleArn,
232
+ });
233
+
234
+ return {
235
+ success: true,
236
+ storageProfileId: Number(profile.id),
237
+ bucketName: bucket,
238
+ region,
239
+ infrastructure,
240
+ webhook,
241
+ };
242
+ }
243
+
244
+ async setBulkUploadLambdaRoleArn(value: string | null | undefined) {
245
+ const lambdaRoleArn = String(value ?? '').trim();
246
+
247
+ if (lambdaRoleArn) {
248
+ const iamRolePattern = /^arn:(aws[a-zA-Z-]*)?:iam::\d{12}:role\/?[a-zA-Z_0-9+=,.@\-_/]+$/;
249
+ if (!iamRolePattern.test(lambdaRoleArn)) {
250
+ throw new BadRequestException(
251
+ 'lambdaRoleArn must be an IAM Role ARN (arn:aws:iam::<account-id>:role/<role-name>). Lambda function ARNs are not valid here.',
252
+ );
253
+ }
254
+ }
255
+
256
+ await this.settingService.setManySettings({
257
+ setting: [
258
+ {
259
+ slug: 'lms-bulk-upload-lambda-role-arn',
260
+ value: lambdaRoleArn,
261
+ },
262
+ ],
263
+ });
264
+
265
+ return {
266
+ success: true,
267
+ lambdaRoleArn,
268
+ };
269
+ }
270
+
271
+ async getBulkUploadSettings() {
272
+ const settings = await this.settingService.getSettingValues([
273
+ 'lms-bulk-upload-storage-profile-id',
274
+ 'lms-bulk-upload-lambda-role-arn',
275
+ 'lms-bulk-upload-sts-duration-seconds',
276
+ ]);
277
+
278
+ const storageProfileId = Number(settings['lms-bulk-upload-storage-profile-id'] || 0) || null;
279
+
280
+ let bucketName = '';
281
+ if (storageProfileId) {
282
+ const profile = await this.prismaService.integration_profile.findUnique({
283
+ where: { id: storageProfileId },
284
+ select: { config: true },
285
+ });
286
+
287
+ const config = (profile?.config as IntegrationProfileConfig | null) ?? null;
288
+ bucketName = String(config?.bucket ?? '').trim();
289
+ }
290
+
291
+ const webhook = await this.prismaService.webhook_integration.findFirst({
292
+ where: {
293
+ OR: [
294
+ { slug: LMS_BULK_UPLOAD_WEBHOOK_SLUG },
295
+ { slug: { startsWith: `${LMS_BULK_UPLOAD_WEBHOOK_SLUG}-` } },
296
+ ],
297
+ },
298
+ orderBy: { id: 'asc' },
299
+ select: { id: true, status: true, public_uuid: true },
300
+ });
301
+
302
+ const webhookConfigured =
303
+ webhook?.status === 'active' && Boolean(webhook?.public_uuid);
304
+
305
+ return {
306
+ storageProfileId,
307
+ bucketName,
308
+ lambdaRoleArn: String(settings['lms-bulk-upload-lambda-role-arn'] ?? '').trim(),
309
+ sessionDurationSeconds:
310
+ Number(settings['lms-bulk-upload-sts-duration-seconds'] || 3600) || 3600,
311
+ webhookConfigured,
312
+ };
313
+ }
314
+
315
+ async getTemporaryCredentials(userId: number) {
316
+ const { profile, region, bucket, durationSeconds, prefix } =
317
+ await this.resolveStorageProfileAndBucket(userId);
318
+
319
+ const credentials = profile.config as IntegrationProfileConfig;
320
+ const accessKeyId = String(credentials.access_key_id ?? '').trim();
321
+ const secretAccessKey = String(credentials.secret_access_key ?? '').trim();
322
+ const sessionToken = String(credentials.session_token ?? '').trim() || undefined;
323
+
324
+ if (!accessKeyId || !secretAccessKey) {
325
+ throw new BadRequestException(
326
+ 'Storage profile does not contain AWS credentials (access_key_id/secret_access_key).',
327
+ );
328
+ }
329
+
330
+ const roleArn = String(credentials.role_arn ?? '').trim();
331
+ const externalId = String(credentials.external_id ?? '').trim();
332
+
333
+ const tempCredentials = await this.fileService.getTemporaryCredentials({
334
+ accessKeyId,
335
+ secretAccessKey,
336
+ sessionToken,
337
+ region,
338
+ roleArn: roleArn || undefined,
339
+ externalId: externalId || undefined,
340
+ durationSeconds,
341
+ sessionName: `hedhog-desktop-u${userId}-${Date.now()}`,
342
+ });
343
+
344
+ return {
345
+ provider: 'aws-s3',
346
+ bucket,
347
+ region,
348
+ keyPrefix: prefix,
349
+ credentials: {
350
+ accessKeyId: tempCredentials.AccessKeyId,
351
+ secretAccessKey: tempCredentials.SecretAccessKey,
352
+ sessionToken: tempCredentials.SessionToken,
353
+ expiresAt: tempCredentials.Expiration?.toISOString() ?? null,
354
+ },
355
+ };
356
+ }
357
+
358
+ async verifyFileOnS3(userId: number, payload: { key?: string; fileName?: string }) {
359
+ const { profile, region, bucket, prefix } = await this.resolveStorageProfileAndBucket(userId);
360
+
361
+ const credentials = profile.config as IntegrationProfileConfig;
362
+ const accessKeyId = String(credentials.access_key_id ?? '').trim();
363
+ const secretAccessKey = String(credentials.secret_access_key ?? '').trim();
364
+ const sessionToken = String(credentials.session_token ?? '').trim() || undefined;
365
+
366
+ if (!accessKeyId || !secretAccessKey) {
367
+ throw new BadRequestException(
368
+ 'Storage profile does not contain AWS credentials (access_key_id/secret_access_key).',
369
+ );
370
+ }
371
+
372
+ const objectKey = String(payload.key ?? '').trim() || `${prefix}${String(payload.fileName ?? '').trim()}`;
373
+ if (!objectKey) {
374
+ throw new BadRequestException('Provide file key or fileName to verify on S3.');
375
+ }
376
+
377
+ try {
378
+ const result = await this.fileService.headS3Object({
379
+ accessKeyId,
380
+ secretAccessKey,
381
+ sessionToken,
382
+ region,
383
+ bucket,
384
+ key: objectKey,
385
+ });
386
+
387
+ return {
388
+ isValid: true,
389
+ key: objectKey,
390
+ sizeBytes: result.ContentLength ?? null,
391
+ etag: result.ETag ?? null,
392
+ };
393
+ } catch {
394
+ return {
395
+ isValid: false,
396
+ key: objectKey,
397
+ };
398
+ }
399
+ }
400
+
401
+ private async ensureBulkUploadWebhook() {
402
+ const existing = await this.prismaService.webhook_integration.findFirst({
403
+ where: {
404
+ OR: [
405
+ { slug: LMS_BULK_UPLOAD_WEBHOOK_SLUG },
406
+ { slug: { startsWith: `${LMS_BULK_UPLOAD_WEBHOOK_SLUG}-` } },
407
+ ],
408
+ },
409
+ orderBy: { id: 'asc' },
410
+ select: {
411
+ id: true,
412
+ slug: true,
413
+ status: true,
414
+ },
415
+ });
416
+
417
+ const webhook = !existing
418
+ ? await this.webhookIntegrationService.create({
419
+ slug: LMS_BULK_UPLOAD_WEBHOOK_SLUG,
420
+ name: 'LMS Bulk Upload',
421
+ description: 'Webhook para integração de upload em massa do LMS',
422
+ status: 'active',
423
+ require_token: true,
424
+ })
425
+ : existing.status !== 'active'
426
+ ? await this.webhookIntegrationService.update(existing.id, {
427
+ status: 'active',
428
+ require_token: true,
429
+ })
430
+ : await this.webhookIntegrationService.get(existing.id);
431
+
432
+ const actions = await this.webhookIntegrationService.listActions(webhook.id);
433
+ const receiveAction = actions.find(
434
+ (action) =>
435
+ action.type === 'app_command' &&
436
+ action.app_command_slug === LMS_BULK_UPLOAD_RECEIVE_VIDEO_COMMAND,
437
+ );
438
+
439
+ if (!receiveAction) {
440
+ await this.webhookIntegrationService.createAction(webhook.id, {
441
+ type: 'app_command',
442
+ name: 'Receber vídeo do upload em massa',
443
+ status: 'active',
444
+ order: 1,
445
+ app_command_slug: LMS_BULK_UPLOAD_RECEIVE_VIDEO_COMMAND,
446
+ app_command_params: {},
447
+ });
448
+ } else if (receiveAction.status !== 'active' || receiveAction.order !== 1) {
449
+ await this.webhookIntegrationService.updateAction(webhook.id, receiveAction.id, {
450
+ type: 'app_command',
451
+ name: 'Receber vídeo do upload em massa',
452
+ status: 'active',
453
+ order: 1,
454
+ app_command_slug: LMS_BULK_UPLOAD_RECEIVE_VIDEO_COMMAND,
455
+ app_command_params: {},
456
+ });
457
+ }
458
+
459
+ // Always regenerate the token on every save so the Lambda env vars stay in sync
460
+ // with the latest webhook credentials.
461
+ return this.webhookIntegrationService.regenerateToken(webhook.id);
462
+ }
463
+
464
+ async startUploadSession(userId: number, payload: StartBulkUploadPayload) {
465
+ const files = (payload?.files ?? [])
466
+ .map((file) => ({
467
+ uploadId: String(file?.uploadId ?? '').trim(),
468
+ fileName: String(file?.fileName ?? '').trim(),
469
+ sizeBytes: Math.max(0, Number(file?.sizeBytes ?? 0) || 0),
470
+ }))
471
+ .filter((file) => file.uploadId.length > 0 && file.fileName.length > 0);
472
+
473
+ if (files.length === 0) {
474
+ throw new BadRequestException('Provide at least one file to register bulk upload session.');
475
+ }
476
+
477
+ const appName = String(payload?.appName ?? 'hedhog-desktop').trim().slice(0, 80) || 'hedhog-desktop';
478
+ const folderPath = String(payload?.folderPath ?? '').trim() || null;
479
+ const totalFiles = files.length;
480
+ const totalBytes = files.reduce((sum, file) => sum + file.sizeBytes, 0);
481
+ const now = new Date();
482
+
483
+ const insertedSession = await this.prismaService.$queryRawUnsafe<Array<{ id: number }>>(
484
+ `INSERT INTO "lms_bulk_upload_session"
485
+ ("user_id", "app_name", "folder_path", "status", "progress_percent", "total_files", "total_bytes", "heartbeat_at", "started_at", "created_at", "updated_at")
486
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
487
+ RETURNING "id"`,
488
+ userId,
489
+ appName,
490
+ folderPath,
491
+ 'started',
492
+ 0,
493
+ totalFiles,
494
+ totalBytes,
495
+ now,
496
+ now,
497
+ now,
498
+ now,
499
+ );
500
+
501
+ const sessionId = Number(insertedSession?.[0]?.id ?? 0);
502
+ if (!Number.isFinite(sessionId) || sessionId <= 0) {
503
+ throw new BadRequestException('Could not register bulk upload session.');
504
+ }
505
+
506
+ for (const file of files) {
507
+ await this.prismaService.$executeRawUnsafe(
508
+ `INSERT INTO "lms_bulk_upload_item"
509
+ ("session_id", "upload_id", "file_name", "size_bytes", "status", "progress_percent", "last_heartbeat_at", "created_at", "updated_at")
510
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
511
+ sessionId,
512
+ file.uploadId,
513
+ file.fileName,
514
+ file.sizeBytes,
515
+ 'queued',
516
+ 0,
517
+ now,
518
+ now,
519
+ now,
520
+ );
521
+ }
522
+
523
+ return {
524
+ sessionId,
525
+ totalFiles,
526
+ totalBytes,
527
+ status: 'started' as BulkUploadSessionStatus,
528
+ };
529
+ }
530
+
531
+ async heartbeatUploadSession(userId: number, payload: BulkUploadHeartbeatPayload) {
532
+ const sessionId = Number(payload?.sessionId ?? 0);
533
+ if (!Number.isFinite(sessionId) || sessionId <= 0) {
534
+ throw new BadRequestException('Provide a valid sessionId for heartbeat.');
535
+ }
536
+
537
+ const sessionRows = await this.prismaService.$queryRawUnsafe<Array<{ id: number; user_id: number }>>(
538
+ `SELECT "id", "user_id"
539
+ FROM "lms_bulk_upload_session"
540
+ WHERE "id" = $1
541
+ LIMIT 1`,
542
+ sessionId,
543
+ );
544
+
545
+ const session = sessionRows?.[0] ?? null;
546
+ if (!session) {
547
+ throw new NotFoundException(`Bulk upload session ${sessionId} was not found.`);
548
+ }
549
+
550
+ if (Number(session.user_id) !== userId) {
551
+ throw new BadRequestException('You can only update your own bulk upload session.');
552
+ }
553
+
554
+ const now = new Date();
555
+ const normalizedItems = (payload?.items ?? []).map((item) => {
556
+ const status = this.normalizeItemStatus(item?.status);
557
+ return {
558
+ uploadId: String(item?.uploadId ?? '').trim(),
559
+ status,
560
+ progressPercent: this.clampPercent(item?.progressPercent),
561
+ errorMessage: String(item?.errorMessage ?? '').trim() || null,
562
+ uploadedKey: String(item?.uploadedKey ?? '').trim() || null,
563
+ };
564
+ }).filter((item) => item.uploadId.length > 0);
565
+
566
+ for (const item of normalizedItems) {
567
+ await this.prismaService.$executeRawUnsafe(
568
+ `UPDATE "lms_bulk_upload_item"
569
+ SET
570
+ "status" = $3,
571
+ "progress_percent" = $4,
572
+ "error_message" = $5,
573
+ "uploaded_key" = $6,
574
+ "last_heartbeat_at" = $7,
575
+ "completed_at" = CASE WHEN $3 IN ('received', 'done', 'error', 'cancelled') THEN COALESCE("completed_at", $7) ELSE "completed_at" END,
576
+ "updated_at" = $7
577
+ WHERE "session_id" = $1
578
+ AND "upload_id" = $2`,
579
+ sessionId,
580
+ item.uploadId,
581
+ item.status,
582
+ item.progressPercent,
583
+ item.errorMessage,
584
+ item.uploadedKey,
585
+ now,
586
+ );
587
+ }
588
+
589
+ const summaryRows = await this.prismaService.$queryRawUnsafe<Array<{
590
+ total: number;
591
+ done_count: number;
592
+ error_count: number;
593
+ cancelled_count: number;
594
+ uploading_count: number;
595
+ queued_count: number;
596
+ average_progress: number;
597
+ }>>(
598
+ `SELECT
599
+ COUNT(*)::int AS total,
600
+ COUNT(*) FILTER (WHERE "status" = 'done')::int AS done_count,
601
+ COUNT(*) FILTER (WHERE "status" = 'error')::int AS error_count,
602
+ COUNT(*) FILTER (WHERE "status" = 'cancelled')::int AS cancelled_count,
603
+ COUNT(*) FILTER (WHERE "status" = 'uploading')::int AS uploading_count,
604
+ COUNT(*) FILTER (WHERE "status" = 'queued')::int AS queued_count,
605
+ COALESCE(AVG("progress_percent"), 0)::float AS average_progress
606
+ FROM "lms_bulk_upload_item"
607
+ WHERE "session_id" = $1`,
608
+ sessionId,
609
+ );
610
+
611
+ const summary = summaryRows?.[0] ?? {
612
+ total: 0,
613
+ done_count: 0,
614
+ error_count: 0,
615
+ cancelled_count: 0,
616
+ uploading_count: 0,
617
+ queued_count: 0,
618
+ average_progress: 0,
619
+ };
620
+
621
+ const requestedStatus = this.normalizeSessionStatus(payload?.status);
622
+ const computedStatus = this.computeSessionStatus(summary);
623
+ const finalStatus = requestedStatus ?? computedStatus;
624
+ const progressPercent = this.clampPercent(payload?.progressPercent ?? summary.average_progress ?? 0);
625
+ const isTerminal = ['completed', 'failed', 'cancelled'].includes(finalStatus);
626
+
627
+ await this.prismaService.$executeRawUnsafe(
628
+ `UPDATE "lms_bulk_upload_session"
629
+ SET
630
+ "status" = $2,
631
+ "progress_percent" = $3,
632
+ "heartbeat_at" = $4,
633
+ "finished_at" = CASE WHEN $5 THEN COALESCE("finished_at", $4) ELSE "finished_at" END,
634
+ "updated_at" = $4
635
+ WHERE "id" = $1`,
636
+ sessionId,
637
+ finalStatus,
638
+ progressPercent,
639
+ now,
640
+ isTerminal,
641
+ );
642
+
643
+ return {
644
+ sessionId,
645
+ status: finalStatus,
646
+ progressPercent,
647
+ totals: {
648
+ total: Number(summary.total || 0),
649
+ done: Number(summary.done_count || 0),
650
+ error: Number(summary.error_count || 0),
651
+ cancelled: Number(summary.cancelled_count || 0),
652
+ },
653
+ };
654
+ }
655
+
656
+ async listUploadItems(params: { page?: number; pageSize?: number; search?: string; status?: string }) {
657
+ const page = Math.max(Number(params.page) || 1, 1);
658
+ const pageSize = Math.min(Math.max(Number(params.pageSize) || 20, 1), 100);
659
+ const offset = (page - 1) * pageSize;
660
+ const search = String(params.search ?? '').trim();
661
+ const status = this.normalizeItemStatus(params.status, true);
662
+
663
+ const where: string[] = [];
664
+ const args: Array<string | number> = [];
665
+
666
+ if (status) {
667
+ args.push(status);
668
+ where.push(`i."status" = $${args.length}`);
669
+ }
670
+
671
+ if (search) {
672
+ args.push(`%${search}%`);
673
+ const searchParam = `$${args.length}`;
674
+ where.push(
675
+ `(i."file_name" ILIKE ${searchParam} OR s."app_name" ILIKE ${searchParam} OR COALESCE(sender."name", u."name", '') ILIKE ${searchParam} OR CAST(s."user_id" AS TEXT) ILIKE ${searchParam} OR COALESCE(mc."title", '') ILIKE ${searchParam} OR COALESCE(ml."title", '') ILIKE ${searchParam})`,
676
+ );
677
+ }
678
+
679
+ const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
680
+
681
+ const countRows = await this.prismaService.$queryRawUnsafe<Array<{ total: number }>>(
682
+ `SELECT COUNT(*)::int AS total
683
+ FROM "lms_bulk_upload_item" i
684
+ JOIN "lms_bulk_upload_session" s ON s."id" = i."session_id"
685
+ LEFT JOIN "user" u ON u."id" = s."user_id"
686
+ LEFT JOIN LATERAL (
687
+ SELECT p."name"
688
+ FROM "person_user" pu
689
+ JOIN "person" p ON p."id" = pu."person_id"
690
+ WHERE pu."user_id" = s."user_id"
691
+ ORDER BY pu."id" DESC
692
+ LIMIT 1
693
+ ) sender ON TRUE
694
+ LEFT JOIN "course" mc ON mc."id" = i."matched_course_id"
695
+ LEFT JOIN "course_module" ms ON ms."id" = i."matched_session_id"
696
+ LEFT JOIN "course_lesson" ml ON ml."id" = i."matched_lesson_id"
697
+ ${whereSql}`,
698
+ ...args,
699
+ );
700
+
701
+ const total = Number(countRows?.[0]?.total ?? 0);
702
+ const dataRows = await this.prismaService.$queryRawUnsafe<Array<{
703
+ id: number;
704
+ session_id: number;
705
+ upload_id: string;
706
+ file_name: string;
707
+ size_bytes: number;
708
+ status: string;
709
+ progress_percent: number;
710
+ error_message: string | null;
711
+ uploaded_key: string | null;
712
+ received_at: Date | null;
713
+ updated_at: Date;
714
+ completed_at: Date | null;
715
+ app_name: string;
716
+ user_id: number;
717
+ user_photo_id: number | null;
718
+ user_name: string | null;
719
+ session_status: string;
720
+ session_started_at: Date;
721
+ matched_course_id: number | null;
722
+ matched_course_title: string | null;
723
+ matched_course_slug: string | null;
724
+ matched_course_logo_file_id: number | null;
725
+ matched_session_id: number | null;
726
+ matched_session_title: string | null;
727
+ matched_lesson_id: number | null;
728
+ matched_lesson_title: string | null;
729
+ }>>(
730
+ `SELECT
731
+ i."id",
732
+ i."session_id",
733
+ i."upload_id",
734
+ i."file_name",
735
+ i."size_bytes",
736
+ i."status",
737
+ i."progress_percent",
738
+ i."error_message",
739
+ i."uploaded_key",
740
+ i."received_at",
741
+ i."updated_at",
742
+ i."completed_at",
743
+ s."app_name",
744
+ s."user_id",
745
+ u."photo_id" AS user_photo_id,
746
+ COALESCE(sender."name", u."name") AS user_name,
747
+ s."status" AS session_status,
748
+ s."started_at" AS session_started_at,
749
+ mc."id" AS matched_course_id,
750
+ mc."title" AS matched_course_title,
751
+ mc."slug" AS matched_course_slug,
752
+ logo."file_id" AS matched_course_logo_file_id,
753
+ ms."id" AS matched_session_id,
754
+ ms."title" AS matched_session_title,
755
+ ml."id" AS matched_lesson_id,
756
+ ml."title" AS matched_lesson_title
757
+ FROM "lms_bulk_upload_item" i
758
+ JOIN "lms_bulk_upload_session" s ON s."id" = i."session_id"
759
+ LEFT JOIN "user" u ON u."id" = s."user_id"
760
+ LEFT JOIN LATERAL (
761
+ SELECT p."name"
762
+ FROM "person_user" pu
763
+ JOIN "person" p ON p."id" = pu."person_id"
764
+ WHERE pu."user_id" = s."user_id"
765
+ ORDER BY pu."id" DESC
766
+ LIMIT 1
767
+ ) sender ON TRUE
768
+ LEFT JOIN "course" mc ON mc."id" = i."matched_course_id"
769
+ LEFT JOIN "course_module" ms ON ms."id" = i."matched_session_id"
770
+ LEFT JOIN "course_lesson" ml ON ml."id" = i."matched_lesson_id"
771
+ LEFT JOIN LATERAL (
772
+ SELECT ci."file_id"
773
+ FROM "course_image" ci
774
+ INNER JOIN "image_type" it ON it."id" = ci."image_type_id"
775
+ WHERE ci."course_id" = mc."id"
776
+ AND it."slug" = 'course-logo'
777
+ ORDER BY ci."id" DESC
778
+ LIMIT 1
779
+ ) logo ON TRUE
780
+ ${whereSql}
781
+ ORDER BY i."updated_at" DESC
782
+ LIMIT $${args.length + 1}
783
+ OFFSET $${args.length + 2}`,
784
+ ...args,
785
+ pageSize,
786
+ offset,
787
+ );
788
+
789
+ return {
790
+ data: dataRows.map((row) => ({
791
+ id: row.id,
792
+ sessionId: row.session_id,
793
+ uploadId: row.upload_id,
794
+ fileName: row.file_name,
795
+ sizeBytes: Number(row.size_bytes || 0),
796
+ status: row.status,
797
+ progressPercent: Number(row.progress_percent || 0),
798
+ errorMessage: row.error_message,
799
+ uploadedKey: row.uploaded_key,
800
+ receivedAt: row.received_at,
801
+ updatedAt: row.updated_at,
802
+ completedAt: row.completed_at,
803
+ appName: row.app_name,
804
+ userId: row.user_id,
805
+ userPhotoId: row.user_photo_id ? Number(row.user_photo_id) : null,
806
+ userName: row.user_name,
807
+ sessionStatus: row.session_status,
808
+ startedAt: row.session_started_at,
809
+ matchedCourseId: row.matched_course_id ? Number(row.matched_course_id) : null,
810
+ matchedCourseTitle: row.matched_course_title,
811
+ matchedCourseSlug: row.matched_course_slug,
812
+ matchedCourseLogoFileId: row.matched_course_logo_file_id ? Number(row.matched_course_logo_file_id) : null,
813
+ matchedSessionId: row.matched_session_id ? Number(row.matched_session_id) : null,
814
+ matchedSessionTitle: row.matched_session_title,
815
+ matchedLessonId: row.matched_lesson_id ? Number(row.matched_lesson_id) : null,
816
+ matchedLessonTitle: row.matched_lesson_title,
817
+ })),
818
+ total,
819
+ page,
820
+ pageSize,
821
+ lastPage: Math.max(1, Math.ceil(total / pageSize)),
822
+ };
823
+ }
824
+
825
+ async checkUploadedFiles(files: Array<{ fileName?: string; sizeBytes?: number }>) {
826
+ const normalized = files
827
+ .map((f) => ({
828
+ fileName: String(f?.fileName ?? '').trim(),
829
+ sizeBytes: Math.max(0, Number(f?.sizeBytes ?? 0) || 0),
830
+ }))
831
+ .filter((f) => f.fileName.length > 0);
832
+
833
+ if (normalized.length === 0) return { uploaded: [] };
834
+
835
+ const conditions = normalized
836
+ .map((_, i) => `(file_name = $${i * 2 + 1} AND size_bytes = $${i * 2 + 2}::bigint)`)
837
+ .join(' OR ');
838
+ const args: Array<string | number> = normalized.flatMap((f) => [f.fileName, f.sizeBytes]);
839
+
840
+ const rows = await this.prismaService.$queryRawUnsafe<
841
+ Array<{ file_name: string; size_bytes: number; uploaded_at: Date | null }>
842
+ >(
843
+ `SELECT file_name, size_bytes,
844
+ MAX(COALESCE(received_at, completed_at, updated_at)) AS uploaded_at
845
+ FROM "lms_bulk_upload_item"
846
+ WHERE status IN ('received', 'done')
847
+ AND (${conditions})
848
+ GROUP BY file_name, size_bytes`,
849
+ ...args,
850
+ );
851
+
852
+ return {
853
+ uploaded: rows.map((row) => ({
854
+ fileName: row.file_name,
855
+ sizeBytes: Number(row.size_bytes),
856
+ uploadedAt: row.uploaded_at?.toISOString() ?? null,
857
+ })),
858
+ };
859
+ }
860
+
861
+ async cleanupUploadHistory(payload: {
862
+ statuses?: BulkUploadCleanupStatus[];
863
+ timeWindow?: BulkUploadCleanupWindow;
864
+ }) {
865
+ const statusSet = new Set<string>();
866
+
867
+ for (const rawStatus of payload?.statuses ?? []) {
868
+ const normalized = String(rawStatus ?? '').trim().toLowerCase();
869
+ if (BULK_UPLOAD_CLEANUP_STATUSES.includes(normalized as BulkUploadCleanupStatus)) {
870
+ statusSet.add(normalized);
871
+ }
872
+ }
873
+
874
+ if (statusSet.size === 0) {
875
+ statusSet.add('done');
876
+ }
877
+
878
+ const statuses = Array.from(statusSet);
879
+ const allowedWindows = new Set<string>(BULK_UPLOAD_CLEANUP_WINDOWS);
880
+ const rawWindow = String(payload?.timeWindow ?? '').trim().toLowerCase();
881
+ const timeWindow = allowedWindows.has(rawWindow) ? rawWindow : 'last_day';
882
+
883
+ let cutoffDate: Date | null = null;
884
+ if (timeWindow === 'last_hour') {
885
+ cutoffDate = new Date(Date.now() - 60 * 60 * 1000);
886
+ } else if (timeWindow === 'last_day') {
887
+ cutoffDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
888
+ } else if (timeWindow === 'last_week') {
889
+ cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
890
+ }
891
+
892
+ const statusPlaceholders = statuses.map((_, index) => `$${index + 1}`).join(', ');
893
+ const args: Array<string | Date> = [...statuses];
894
+ let timeFilterSql = '';
895
+
896
+ if (cutoffDate) {
897
+ args.push(cutoffDate);
898
+ timeFilterSql = ` AND i."updated_at" >= $${args.length}`;
899
+ }
900
+
901
+ const deletedRows = await this.prismaService.$queryRawUnsafe<Array<{ session_id: number }>>(
902
+ `DELETE FROM "lms_bulk_upload_item" i
903
+ USING "lms_bulk_upload_session" s
904
+ WHERE i."session_id" = s."id"
905
+ AND i."status" IN (${statusPlaceholders})
906
+ ${timeFilterSql}
907
+ RETURNING i."session_id"`,
908
+ ...args,
909
+ );
910
+
911
+ const deletedItems = deletedRows.length;
912
+ const sessionIds = Array.from(
913
+ new Set(
914
+ deletedRows
915
+ .map((row) => Number(row.session_id || 0))
916
+ .filter((sessionId) => Number.isFinite(sessionId) && sessionId > 0),
917
+ ),
918
+ );
919
+
920
+ let deletedSessions = 0;
921
+ if (sessionIds.length > 0) {
922
+ const sessionPlaceholders = sessionIds
923
+ .map((_, index) => `$${index + 1}`)
924
+ .join(', ');
925
+
926
+ const deletedSessionRows = await this.prismaService.$queryRawUnsafe<Array<{ id: number }>>(
927
+ `DELETE FROM "lms_bulk_upload_session" s
928
+ WHERE s."id" IN (${sessionPlaceholders})
929
+ AND NOT EXISTS (
930
+ SELECT 1
931
+ FROM "lms_bulk_upload_item" i
932
+ WHERE i."session_id" = s."id"
933
+ )
934
+ RETURNING s."id"`,
935
+ ...sessionIds,
936
+ );
937
+
938
+ deletedSessions = deletedSessionRows.length;
939
+ }
940
+
941
+ return {
942
+ success: true,
943
+ deletedItems,
944
+ deletedSessions,
945
+ filtersApplied: {
946
+ statuses,
947
+ timeWindow,
948
+ },
949
+ };
950
+ }
951
+
952
+ private clampPercent(value: unknown) {
953
+ const parsed = Number(value);
954
+ if (!Number.isFinite(parsed)) return 0;
955
+ return Math.max(0, Math.min(100, Math.round(parsed)));
956
+ }
957
+
958
+ private normalizeSessionStatus(value: unknown): BulkUploadSessionStatus | null {
959
+ const normalized = String(value ?? '').trim().toLowerCase();
960
+ if (!normalized) return null;
961
+
962
+ if (
963
+ normalized === 'started' ||
964
+ normalized === 'uploading' ||
965
+ normalized === 'paused' ||
966
+ normalized === 'cancelling' ||
967
+ normalized === 'completed' ||
968
+ normalized === 'failed' ||
969
+ normalized === 'cancelled'
970
+ ) {
971
+ return normalized;
972
+ }
973
+
974
+ return null;
975
+ }
976
+
977
+ private normalizeItemStatus(value: unknown, allowEmpty = false): BulkUploadItemStatus | null {
978
+ const normalized = String(value ?? '').trim().toLowerCase();
979
+ if (!normalized && allowEmpty) return null;
980
+
981
+ if (
982
+ normalized === 'queued' ||
983
+ normalized === 'uploading' ||
984
+ normalized === 'cancelling' ||
985
+ normalized === 'received' ||
986
+ normalized === 'done' ||
987
+ normalized === 'error' ||
988
+ normalized === 'cancelled' ||
989
+ normalized === 'lesson_not_found'
990
+ ) {
991
+ return normalized;
992
+ }
993
+
994
+ return allowEmpty ? null : 'queued';
995
+ }
996
+
997
+ private computeSessionStatus(summary: {
998
+ total: number;
999
+ done_count: number;
1000
+ error_count: number;
1001
+ cancelled_count: number;
1002
+ uploading_count: number;
1003
+ queued_count: number;
1004
+ }): BulkUploadSessionStatus {
1005
+ const total = Number(summary.total || 0);
1006
+ const done = Number(summary.done_count || 0);
1007
+ const error = Number(summary.error_count || 0);
1008
+ const cancelled = Number(summary.cancelled_count || 0);
1009
+ const uploading = Number(summary.uploading_count || 0);
1010
+ const queued = Number(summary.queued_count || 0);
1011
+
1012
+ if (total === 0) return 'started';
1013
+ if (done === total) return 'completed';
1014
+ if (done + error + cancelled === total && error > 0) return 'failed';
1015
+ if (done + error + cancelled === total && cancelled > 0 && error === 0) return 'cancelled';
1016
+ if (uploading > 0) return 'uploading';
1017
+ if (queued === total) return 'started';
1018
+ return 'uploading';
1019
+ }
1020
+
1021
+ private async resolveStorageProfileAndBucket(
1022
+ userId: number,
1023
+ overrides?: { storageProfileId?: number },
1024
+ ) {
1025
+ const settings = await this.settingService.getSettingValues([
1026
+ 'lms-bulk-upload-storage-profile-id',
1027
+ 'lms-bulk-upload-sts-duration-seconds',
1028
+ ]);
1029
+
1030
+ const profileId = Number(
1031
+ overrides?.storageProfileId ??
1032
+ settings['lms-bulk-upload-storage-profile-id'] ??
1033
+ 0,
1034
+ );
1035
+ if (!Number.isFinite(profileId) || profileId <= 0) {
1036
+ throw new BadRequestException(
1037
+ 'LMS bulk upload storage profile is not configured (lms-bulk-upload-storage-profile-id).',
1038
+ );
1039
+ }
1040
+
1041
+ const profile = await this.prismaService.integration_profile.findUnique({
1042
+ where: { id: profileId },
1043
+ include: {
1044
+ integration_type: { select: { slug: true } },
1045
+ integration_provider: { select: { slug: true } },
1046
+ },
1047
+ });
1048
+
1049
+ if (!profile) {
1050
+ throw new NotFoundException(`Storage integration profile ${profileId} was not found.`);
1051
+ }
1052
+
1053
+ if (profile.integration_type.slug !== 'storage') {
1054
+ throw new BadRequestException(
1055
+ `Integration profile ${profileId} is not a storage profile.`,
1056
+ );
1057
+ }
1058
+
1059
+ const providerSlug = String(profile.integration_provider.slug ?? '').toLowerCase();
1060
+ if (providerSlug !== 's3') {
1061
+ throw new BadRequestException(
1062
+ `Integration provider ${providerSlug} is not supported for desktop bulk upload. Use AWS S3 profile.`,
1063
+ );
1064
+ }
1065
+
1066
+ const config = (profile.config as IntegrationProfileConfig) ?? {};
1067
+ const region = String(config.region ?? '').trim() || 'us-east-1';
1068
+
1069
+ const bucket = String(config.bucket ?? '').trim();
1070
+
1071
+ if (!bucket) {
1072
+ throw new BadRequestException(
1073
+ `Storage integration profile ${profileId} does not define a bucket in config.bucket.`,
1074
+ );
1075
+ }
1076
+
1077
+ const durationSeconds = Math.max(
1078
+ 900,
1079
+ Math.min(43200, Number(settings['lms-bulk-upload-sts-duration-seconds'] || 3600) || 3600),
1080
+ );
1081
+
1082
+ const prefix = `desktop/lms/u${userId}/`;
1083
+
1084
+ return {
1085
+ profile,
1086
+ region,
1087
+ bucket,
1088
+ durationSeconds,
1089
+ prefix,
1090
+ };
1091
+ }
1092
+ }