@hed-hog/lms 0.0.361 → 0.0.364

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