@hed-hog/lms 0.0.304 → 0.0.305

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 (462) hide show
  1. package/README.md +413 -401
  2. package/dist/certificate/certificate.controller.d.ts +90 -0
  3. package/dist/certificate/certificate.controller.d.ts.map +1 -0
  4. package/dist/certificate/certificate.controller.js +121 -0
  5. package/dist/certificate/certificate.controller.js.map +1 -0
  6. package/dist/certificate/certificate.module.d.ts +3 -0
  7. package/dist/certificate/certificate.module.d.ts.map +1 -0
  8. package/dist/certificate/certificate.module.js +26 -0
  9. package/dist/certificate/certificate.module.js.map +1 -0
  10. package/dist/certificate/certificate.service.d.ts +115 -0
  11. package/dist/certificate/certificate.service.d.ts.map +1 -0
  12. package/dist/certificate/certificate.service.js +343 -0
  13. package/dist/certificate/certificate.service.js.map +1 -0
  14. package/dist/certificate/dto/create-certificate-template.dto.d.ts +8 -0
  15. package/dist/certificate/dto/create-certificate-template.dto.d.ts.map +1 -0
  16. package/dist/certificate/dto/create-certificate-template.dto.js +44 -0
  17. package/dist/certificate/dto/create-certificate-template.dto.js.map +1 -0
  18. package/dist/certificate/dto/update-certificate-template.dto.d.ts +6 -0
  19. package/dist/certificate/dto/update-certificate-template.dto.d.ts.map +1 -0
  20. package/dist/certificate/dto/update-certificate-template.dto.js +9 -0
  21. package/dist/certificate/dto/update-certificate-template.dto.js.map +1 -0
  22. package/dist/class-group/class-group.controller.d.ts +305 -0
  23. package/dist/class-group/class-group.controller.d.ts.map +1 -0
  24. package/dist/class-group/class-group.controller.js +257 -0
  25. package/dist/class-group/class-group.controller.js.map +1 -0
  26. package/dist/class-group/class-group.module.d.ts +3 -0
  27. package/dist/class-group/class-group.module.d.ts.map +1 -0
  28. package/dist/class-group/class-group.module.js +25 -0
  29. package/dist/class-group/class-group.module.js.map +1 -0
  30. package/dist/class-group/class-group.service.d.ts +354 -0
  31. package/dist/class-group/class-group.service.d.ts.map +1 -0
  32. package/dist/class-group/class-group.service.js +1356 -0
  33. package/dist/class-group/class-group.service.js.map +1 -0
  34. package/dist/class-group/dto/create-class-group.dto.d.ts +33 -0
  35. package/dist/class-group/dto/create-class-group.dto.d.ts.map +1 -0
  36. package/dist/class-group/dto/create-class-group.dto.js +165 -0
  37. package/dist/class-group/dto/create-class-group.dto.js.map +1 -0
  38. package/dist/class-group/dto/create-session.dto.d.ts +22 -0
  39. package/dist/class-group/dto/create-session.dto.d.ts.map +1 -0
  40. package/dist/class-group/dto/create-session.dto.js +117 -0
  41. package/dist/class-group/dto/create-session.dto.js.map +1 -0
  42. package/dist/class-group/dto/enrollment.dto.d.ts +22 -0
  43. package/dist/class-group/dto/enrollment.dto.d.ts.map +1 -0
  44. package/dist/class-group/dto/enrollment.dto.js +89 -0
  45. package/dist/class-group/dto/enrollment.dto.js.map +1 -0
  46. package/dist/class-group/dto/update-class-group.dto.d.ts +6 -0
  47. package/dist/class-group/dto/update-class-group.dto.d.ts.map +1 -0
  48. package/dist/class-group/dto/update-class-group.dto.js +9 -0
  49. package/dist/class-group/dto/update-class-group.dto.js.map +1 -0
  50. package/dist/class-group/dto/update-session.dto.d.ts +7 -0
  51. package/dist/class-group/dto/update-session.dto.d.ts.map +1 -0
  52. package/dist/class-group/dto/update-session.dto.js +24 -0
  53. package/dist/class-group/dto/update-session.dto.js.map +1 -0
  54. package/dist/course/course-structure.controller.d.ts +127 -0
  55. package/dist/course/course-structure.controller.d.ts.map +1 -0
  56. package/dist/course/course-structure.controller.js +115 -0
  57. package/dist/course/course-structure.controller.js.map +1 -0
  58. package/dist/course/course-structure.service.d.ts +142 -0
  59. package/dist/course/course-structure.service.d.ts.map +1 -0
  60. package/dist/course/course-structure.service.js +445 -0
  61. package/dist/course/course-structure.service.js.map +1 -0
  62. package/dist/course/course.controller.d.ts +195 -0
  63. package/dist/course/course.controller.d.ts.map +1 -0
  64. package/dist/course/course.controller.js +104 -0
  65. package/dist/course/course.controller.js.map +1 -0
  66. package/dist/course/course.module.d.ts +3 -0
  67. package/dist/course/course.module.d.ts.map +1 -0
  68. package/dist/course/course.module.js +28 -0
  69. package/dist/course/course.module.js.map +1 -0
  70. package/dist/course/course.service.d.ts +215 -0
  71. package/dist/course/course.service.d.ts.map +1 -0
  72. package/dist/course/course.service.js +743 -0
  73. package/dist/course/course.service.js.map +1 -0
  74. package/dist/course/dto/create-course-structure-lesson.dto.d.ts +24 -0
  75. package/dist/course/dto/create-course-structure-lesson.dto.d.ts.map +1 -0
  76. package/dist/course/dto/create-course-structure-lesson.dto.js +118 -0
  77. package/dist/course/dto/create-course-structure-lesson.dto.js.map +1 -0
  78. package/dist/course/dto/create-course-structure-session.dto.d.ts +7 -0
  79. package/dist/course/dto/create-course-structure-session.dto.d.ts.map +1 -0
  80. package/dist/course/dto/create-course-structure-session.dto.js +40 -0
  81. package/dist/course/dto/create-course-structure-session.dto.js.map +1 -0
  82. package/dist/course/dto/create-course.dto.d.ts +26 -0
  83. package/dist/course/dto/create-course.dto.d.ts.map +1 -0
  84. package/dist/course/dto/create-course.dto.js +138 -0
  85. package/dist/course/dto/create-course.dto.js.map +1 -0
  86. package/dist/course/dto/update-course-structure-lesson.dto.d.ts +6 -0
  87. package/dist/course/dto/update-course-structure-lesson.dto.d.ts.map +1 -0
  88. package/dist/course/dto/update-course-structure-lesson.dto.js +9 -0
  89. package/dist/course/dto/update-course-structure-lesson.dto.js.map +1 -0
  90. package/dist/course/dto/update-course-structure-session.dto.d.ts +6 -0
  91. package/dist/course/dto/update-course-structure-session.dto.d.ts.map +1 -0
  92. package/dist/course/dto/update-course-structure-session.dto.js +9 -0
  93. package/dist/course/dto/update-course-structure-session.dto.js.map +1 -0
  94. package/dist/course/dto/update-course.dto.d.ts +6 -0
  95. package/dist/course/dto/update-course.dto.d.ts.map +1 -0
  96. package/dist/course/dto/update-course.dto.js +9 -0
  97. package/dist/course/dto/update-course.dto.js.map +1 -0
  98. package/dist/dashboard/dashboard.controller.d.ts +101 -0
  99. package/dist/dashboard/dashboard.controller.d.ts.map +1 -0
  100. package/dist/dashboard/dashboard.controller.js +40 -0
  101. package/dist/dashboard/dashboard.controller.js.map +1 -0
  102. package/dist/dashboard/dashboard.module.d.ts +3 -0
  103. package/dist/dashboard/dashboard.module.d.ts.map +1 -0
  104. package/dist/dashboard/dashboard.module.js +25 -0
  105. package/dist/dashboard/dashboard.module.js.map +1 -0
  106. package/dist/dashboard/dashboard.service.d.ts +130 -0
  107. package/dist/dashboard/dashboard.service.d.ts.map +1 -0
  108. package/dist/dashboard/dashboard.service.js +626 -0
  109. package/dist/dashboard/dashboard.service.js.map +1 -0
  110. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts +4 -0
  111. package/dist/enterprise/dto/add-enterprise-class-group.dto.d.ts.map +1 -0
  112. package/dist/enterprise/dto/add-enterprise-class-group.dto.js +22 -0
  113. package/dist/enterprise/dto/add-enterprise-class-group.dto.js.map +1 -0
  114. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts +5 -0
  115. package/dist/enterprise/dto/add-enterprise-course.dto.d.ts.map +1 -0
  116. package/dist/enterprise/dto/add-enterprise-course.dto.js +27 -0
  117. package/dist/enterprise/dto/add-enterprise-course.dto.js.map +1 -0
  118. package/dist/enterprise/dto/add-enterprise-lead.dto.d.ts +4 -0
  119. package/dist/enterprise/dto/add-enterprise-lead.dto.d.ts.map +1 -0
  120. package/dist/enterprise/dto/add-enterprise-lead.dto.js +22 -0
  121. package/dist/enterprise/dto/add-enterprise-lead.dto.js.map +1 -0
  122. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts +5 -0
  123. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts.map +1 -0
  124. package/dist/enterprise/dto/add-enterprise-student.dto.js +27 -0
  125. package/dist/enterprise/dto/add-enterprise-student.dto.js.map +1 -0
  126. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts +7 -0
  127. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts.map +1 -0
  128. package/dist/enterprise/dto/add-enterprise-user.dto.js +36 -0
  129. package/dist/enterprise/dto/add-enterprise-user.dto.js.map +1 -0
  130. package/dist/enterprise/dto/create-enterprise.dto.d.ts +10 -0
  131. package/dist/enterprise/dto/create-enterprise.dto.d.ts.map +1 -0
  132. package/dist/enterprise/dto/create-enterprise.dto.js +54 -0
  133. package/dist/enterprise/dto/create-enterprise.dto.js.map +1 -0
  134. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts +4 -0
  135. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts.map +1 -0
  136. package/dist/enterprise/dto/update-enterprise-student.dto.js +22 -0
  137. package/dist/enterprise/dto/update-enterprise-student.dto.js.map +1 -0
  138. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts +5 -0
  139. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts.map +1 -0
  140. package/dist/enterprise/dto/update-enterprise-user.dto.js +27 -0
  141. package/dist/enterprise/dto/update-enterprise-user.dto.js.map +1 -0
  142. package/dist/enterprise/dto/update-enterprise.dto.d.ts +6 -0
  143. package/dist/enterprise/dto/update-enterprise.dto.d.ts.map +1 -0
  144. package/dist/enterprise/dto/update-enterprise.dto.js +9 -0
  145. package/dist/enterprise/dto/update-enterprise.dto.js.map +1 -0
  146. package/dist/enterprise/enterprise.controller.d.ts +269 -0
  147. package/dist/enterprise/enterprise.controller.d.ts.map +1 -0
  148. package/dist/enterprise/enterprise.controller.js +311 -0
  149. package/dist/enterprise/enterprise.controller.js.map +1 -0
  150. package/dist/enterprise/enterprise.module.d.ts +3 -0
  151. package/dist/enterprise/enterprise.module.d.ts.map +1 -0
  152. package/dist/enterprise/enterprise.module.js +25 -0
  153. package/dist/enterprise/enterprise.module.js.map +1 -0
  154. package/dist/enterprise/enterprise.service.d.ts +282 -0
  155. package/dist/enterprise/enterprise.service.d.ts.map +1 -0
  156. package/dist/enterprise/enterprise.service.js +627 -0
  157. package/dist/enterprise/enterprise.service.js.map +1 -0
  158. package/dist/evaluation/evaluation.controller.d.ts +56 -0
  159. package/dist/evaluation/evaluation.controller.d.ts.map +1 -0
  160. package/dist/evaluation/evaluation.controller.js +76 -0
  161. package/dist/evaluation/evaluation.controller.js.map +1 -0
  162. package/dist/evaluation/evaluation.module.d.ts +3 -0
  163. package/dist/evaluation/evaluation.module.d.ts.map +1 -0
  164. package/dist/evaluation/evaluation.module.js +25 -0
  165. package/dist/evaluation/evaluation.module.js.map +1 -0
  166. package/dist/evaluation/evaluation.service.d.ts +67 -0
  167. package/dist/evaluation/evaluation.service.d.ts.map +1 -0
  168. package/dist/evaluation/evaluation.service.js +378 -0
  169. package/dist/evaluation/evaluation.service.js.map +1 -0
  170. package/dist/exam/dto/create-exam-question.dto.d.ts +25 -0
  171. package/dist/exam/dto/create-exam-question.dto.d.ts.map +1 -0
  172. package/dist/exam/dto/create-exam-question.dto.js +117 -0
  173. package/dist/exam/dto/create-exam-question.dto.js.map +1 -0
  174. package/dist/exam/dto/create-exam.dto.d.ts +11 -0
  175. package/dist/exam/dto/create-exam.dto.d.ts.map +1 -0
  176. package/dist/exam/dto/create-exam.dto.js +63 -0
  177. package/dist/exam/dto/create-exam.dto.js.map +1 -0
  178. package/dist/exam/dto/reorder-exam-questions.dto.d.ts +4 -0
  179. package/dist/exam/dto/reorder-exam-questions.dto.d.ts.map +1 -0
  180. package/dist/exam/dto/reorder-exam-questions.dto.js +23 -0
  181. package/dist/exam/dto/reorder-exam-questions.dto.js.map +1 -0
  182. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts +14 -0
  183. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts.map +1 -0
  184. package/dist/exam/dto/save-exam-attempt-answers.dto.js +68 -0
  185. package/dist/exam/dto/save-exam-attempt-answers.dto.js.map +1 -0
  186. package/dist/exam/dto/start-exam-attempt.dto.d.ts +4 -0
  187. package/dist/exam/dto/start-exam-attempt.dto.d.ts.map +1 -0
  188. package/dist/exam/dto/start-exam-attempt.dto.js +23 -0
  189. package/dist/exam/dto/start-exam-attempt.dto.js.map +1 -0
  190. package/dist/exam/dto/submit-exam-attempt.dto.d.ts +5 -0
  191. package/dist/exam/dto/submit-exam-attempt.dto.d.ts.map +1 -0
  192. package/dist/exam/dto/submit-exam-attempt.dto.js +23 -0
  193. package/dist/exam/dto/submit-exam-attempt.dto.js.map +1 -0
  194. package/dist/exam/dto/update-exam-question.dto.d.ts +6 -0
  195. package/dist/exam/dto/update-exam-question.dto.d.ts.map +1 -0
  196. package/dist/exam/dto/update-exam-question.dto.js +9 -0
  197. package/dist/exam/dto/update-exam-question.dto.js.map +1 -0
  198. package/dist/exam/dto/update-exam.dto.d.ts +6 -0
  199. package/dist/exam/dto/update-exam.dto.d.ts.map +1 -0
  200. package/dist/exam/dto/update-exam.dto.js +9 -0
  201. package/dist/exam/dto/update-exam.dto.js.map +1 -0
  202. package/dist/exam/exam-attempt.controller.d.ts +273 -0
  203. package/dist/exam/exam-attempt.controller.d.ts.map +1 -0
  204. package/dist/exam/exam-attempt.controller.js +84 -0
  205. package/dist/exam/exam-attempt.controller.js.map +1 -0
  206. package/dist/exam/exam-attempt.service.d.ts +302 -0
  207. package/dist/exam/exam-attempt.service.d.ts.map +1 -0
  208. package/dist/exam/exam-attempt.service.js +776 -0
  209. package/dist/exam/exam-attempt.service.js.map +1 -0
  210. package/dist/exam/exam.controller.d.ts +162 -0
  211. package/dist/exam/exam.controller.d.ts.map +1 -0
  212. package/dist/exam/exam.controller.js +158 -0
  213. package/dist/exam/exam.controller.js.map +1 -0
  214. package/dist/exam/exam.module.d.ts +3 -0
  215. package/dist/exam/exam.module.d.ts.map +1 -0
  216. package/dist/exam/exam.module.js +27 -0
  217. package/dist/exam/exam.module.js.map +1 -0
  218. package/dist/exam/exam.service.d.ts +179 -0
  219. package/dist/exam/exam.service.d.ts.map +1 -0
  220. package/dist/exam/exam.service.js +597 -0
  221. package/dist/exam/exam.service.js.map +1 -0
  222. package/dist/index.d.ts +28 -0
  223. package/dist/index.d.ts.map +1 -1
  224. package/dist/index.js +28 -0
  225. package/dist/index.js.map +1 -1
  226. package/dist/instructor/dto/create-instructor.dto.d.ts +10 -0
  227. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -0
  228. package/dist/instructor/dto/create-instructor.dto.js +55 -0
  229. package/dist/instructor/dto/create-instructor.dto.js.map +1 -0
  230. package/dist/instructor/dto/update-instructor.dto.d.ts +9 -0
  231. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -0
  232. package/dist/instructor/dto/update-instructor.dto.js +51 -0
  233. package/dist/instructor/dto/update-instructor.dto.js.map +1 -0
  234. package/dist/instructor/instructor.controller.d.ts +52 -0
  235. package/dist/instructor/instructor.controller.d.ts.map +1 -0
  236. package/dist/instructor/instructor.controller.js +98 -0
  237. package/dist/instructor/instructor.controller.js.map +1 -0
  238. package/dist/instructor/instructor.module.d.ts +3 -0
  239. package/dist/instructor/instructor.module.d.ts.map +1 -0
  240. package/dist/instructor/instructor.module.js +25 -0
  241. package/dist/instructor/instructor.module.js.map +1 -0
  242. package/dist/instructor/instructor.service.d.ts +79 -0
  243. package/dist/instructor/instructor.service.d.ts.map +1 -0
  244. package/dist/instructor/instructor.service.js +528 -0
  245. package/dist/instructor/instructor.service.js.map +1 -0
  246. package/dist/lms.module.d.ts.map +1 -1
  247. package/dist/lms.module.js +36 -4
  248. package/dist/lms.module.js.map +1 -1
  249. package/dist/reports/reports.controller.d.ts +69 -0
  250. package/dist/reports/reports.controller.d.ts.map +1 -0
  251. package/dist/reports/reports.controller.js +40 -0
  252. package/dist/reports/reports.controller.js.map +1 -0
  253. package/dist/reports/reports.module.d.ts +3 -0
  254. package/dist/reports/reports.module.d.ts.map +1 -0
  255. package/dist/reports/reports.module.js +25 -0
  256. package/dist/reports/reports.module.js.map +1 -0
  257. package/dist/reports/reports.service.d.ts +80 -0
  258. package/dist/reports/reports.service.d.ts.map +1 -0
  259. package/dist/reports/reports.service.js +366 -0
  260. package/dist/reports/reports.service.js.map +1 -0
  261. package/dist/training/dto/create-training.dto.d.ts +19 -0
  262. package/dist/training/dto/create-training.dto.d.ts.map +1 -0
  263. package/dist/training/dto/create-training.dto.js +98 -0
  264. package/dist/training/dto/create-training.dto.js.map +1 -0
  265. package/dist/training/dto/update-training.dto.d.ts +6 -0
  266. package/dist/training/dto/update-training.dto.d.ts.map +1 -0
  267. package/dist/training/dto/update-training.dto.js +9 -0
  268. package/dist/training/dto/update-training.dto.js.map +1 -0
  269. package/dist/training/training.controller.d.ts +195 -0
  270. package/dist/training/training.controller.d.ts.map +1 -0
  271. package/dist/training/training.controller.js +104 -0
  272. package/dist/training/training.controller.js.map +1 -0
  273. package/dist/training/training.module.d.ts +3 -0
  274. package/dist/training/training.module.d.ts.map +1 -0
  275. package/dist/training/training.module.js +25 -0
  276. package/dist/training/training.module.js.map +1 -0
  277. package/dist/training/training.service.d.ts +213 -0
  278. package/dist/training/training.service.d.ts.map +1 -0
  279. package/dist/training/training.service.js +497 -0
  280. package/dist/training/training.service.js.map +1 -0
  281. package/hedhog/data/dashboard.yaml +6 -0
  282. package/hedhog/data/dashboard_component.yaml +153 -0
  283. package/hedhog/data/dashboard_component_role.yaml +97 -0
  284. package/hedhog/data/dashboard_item.yaml +167 -0
  285. package/hedhog/data/dashboard_role.yaml +6 -0
  286. package/hedhog/data/instructor_qualification.yaml +16 -0
  287. package/hedhog/data/menu.yaml +129 -19
  288. package/hedhog/data/role.yaml +25 -1
  289. package/hedhog/data/route.yaml +867 -0
  290. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +1992 -0
  291. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +480 -0
  292. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  293. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +164 -0
  294. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +120 -0
  295. package/hedhog/frontend/app/_components/lms-class-calendar.tsx.ejs +272 -0
  296. package/hedhog/frontend/app/_components/mobile-calendar.tsx.ejs +277 -0
  297. package/hedhog/frontend/app/_lib/editor/canvasInstance.ts.ejs +48 -0
  298. package/hedhog/frontend/app/_lib/editor/pctHelpers.ts.ejs +50 -0
  299. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +268 -0
  300. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +94 -0
  301. package/hedhog/frontend/app/_lib/store/useTemplateStore.ts.ejs +284 -0
  302. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +638 -0
  303. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +916 -0
  304. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +200 -0
  305. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +769 -0
  306. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +104 -0
  307. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +354 -0
  308. package/hedhog/frontend/app/certificates/models/editor/page.tsx.ejs +5 -0
  309. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +883 -0
  310. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +279 -0
  311. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1027 -0
  312. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +3130 -993
  313. package/hedhog/frontend/app/classes/page.tsx.ejs +2731 -759
  314. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +80 -0
  315. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +226 -0
  316. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +71 -0
  317. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +42 -0
  318. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +111 -0
  319. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +113 -0
  320. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +215 -0
  321. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +236 -0
  322. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +141 -0
  323. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +57 -0
  324. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  325. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +33 -0
  326. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +933 -1103
  327. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +699 -117
  328. package/hedhog/frontend/app/courses/page.tsx.ejs +1018 -1042
  329. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +317 -0
  330. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-panel.tsx.ejs +88 -0
  331. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +318 -0
  332. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +332 -0
  333. package/hedhog/frontend/app/enterprise/_components/enterprise-class-create-sheet.tsx.ejs +57 -0
  334. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +390 -0
  335. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +112 -0
  336. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +183 -0
  337. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +363 -0
  338. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-constants.ts.ejs +88 -0
  339. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +548 -0
  340. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-utils.ts.ejs +33 -0
  341. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +266 -0
  342. package/hedhog/frontend/app/enterprise/_components/enterprise-person-picker.ts.ejs +31 -0
  343. package/hedhog/frontend/app/enterprise/_components/enterprise-progress-bar.tsx.ejs +21 -0
  344. package/hedhog/frontend/app/enterprise/_components/enterprise-related-tab.tsx.ejs +187 -0
  345. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +397 -0
  346. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +167 -0
  347. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +267 -0
  348. package/hedhog/frontend/app/enterprise/_components/enterprise-system-user-picker.ts.ejs +42 -0
  349. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +96 -0
  350. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +207 -0
  351. package/hedhog/frontend/app/enterprise/_components/enterprise-user-distribution-chart.tsx.ejs +149 -0
  352. package/hedhog/frontend/app/enterprise/page.tsx.ejs +596 -0
  353. package/hedhog/frontend/app/evaluations/page.tsx.ejs +1250 -0
  354. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +642 -196
  355. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +11 -0
  356. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +1316 -436
  357. package/hedhog/frontend/app/exams/page.tsx.ejs +799 -546
  358. package/hedhog/frontend/app/layout.tsx.ejs +5 -0
  359. package/hedhog/frontend/app/page.tsx.ejs +5 -1222
  360. package/hedhog/frontend/app/reports/courses/page.tsx.ejs +843 -0
  361. package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +890 -0
  362. package/hedhog/frontend/app/reports/page.tsx.ejs +802 -808
  363. package/hedhog/frontend/app/reports/students/page.tsx.ejs +772 -0
  364. package/hedhog/frontend/app/training/page.tsx.ejs +1873 -628
  365. package/hedhog/frontend/messages/en.json +1606 -111
  366. package/hedhog/frontend/messages/pt.json +1636 -134
  367. package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +74 -0
  368. package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +74 -0
  369. package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +81 -0
  370. package/hedhog/frontend/widgets/category-distribution-chart.tsx.ejs +119 -0
  371. package/hedhog/frontend/widgets/class-calendar.tsx.ejs +440 -0
  372. package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +81 -0
  373. package/hedhog/frontend/widgets/engagement-chart.tsx.ejs +120 -0
  374. package/hedhog/frontend/widgets/footer-summary.tsx.ejs +80 -0
  375. package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +74 -0
  376. package/hedhog/frontend/widgets/latest-enrollments.tsx.ejs +166 -0
  377. package/hedhog/frontend/widgets/student-growth-chart.tsx.ejs +89 -0
  378. package/hedhog/frontend/widgets/top-courses-chart.tsx.ejs +104 -0
  379. package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +78 -0
  380. package/hedhog/frontend/widgets/upcoming-classes.tsx.ejs +152 -0
  381. package/hedhog/table/course.yaml +28 -10
  382. package/hedhog/table/course_class_group.yaml +8 -0
  383. package/hedhog/table/course_class_session.yaml +33 -0
  384. package/hedhog/table/course_instructor.yaml +27 -0
  385. package/hedhog/table/enterprise.yaml +29 -0
  386. package/hedhog/table/enterprise_class_group.yaml +20 -0
  387. package/hedhog/table/enterprise_course.yaml +23 -0
  388. package/hedhog/table/enterprise_student.yaml +24 -0
  389. package/hedhog/table/enterprise_user.yaml +35 -0
  390. package/hedhog/table/instructor_qualification.yaml +26 -0
  391. package/hedhog/table/instructor_qualification_assignment.yaml +22 -0
  392. package/hedhog/table/question.yaml +6 -0
  393. package/package.json +7 -7
  394. package/src/certificate/certificate.controller.ts +83 -0
  395. package/src/certificate/certificate.module.ts +13 -0
  396. package/src/certificate/certificate.service.ts +413 -0
  397. package/src/certificate/dto/create-certificate-template.dto.ts +25 -0
  398. package/src/certificate/dto/update-certificate-template.dto.ts +6 -0
  399. package/src/class-group/class-group.controller.ts +189 -0
  400. package/src/class-group/class-group.module.ts +12 -0
  401. package/src/class-group/class-group.service.ts +1802 -0
  402. package/src/class-group/dto/create-class-group.dto.ts +139 -0
  403. package/src/class-group/dto/create-session.dto.ts +102 -0
  404. package/src/class-group/dto/enrollment.dto.ts +70 -0
  405. package/src/class-group/dto/update-class-group.dto.ts +4 -0
  406. package/src/class-group/dto/update-session.dto.ts +9 -0
  407. package/src/course/course-structure.controller.ts +85 -0
  408. package/src/course/course-structure.service.ts +525 -0
  409. package/src/course/course.controller.ts +69 -0
  410. package/src/course/course.module.ts +15 -0
  411. package/src/course/course.service.ts +920 -0
  412. package/src/course/dto/create-course-structure-lesson.dto.ts +97 -0
  413. package/src/course/dto/create-course-structure-session.dto.ts +22 -0
  414. package/src/course/dto/create-course.dto.ts +111 -0
  415. package/src/course/dto/update-course-structure-lesson.dto.ts +6 -0
  416. package/src/course/dto/update-course-structure-session.dto.ts +6 -0
  417. package/src/course/dto/update-course.dto.ts +4 -0
  418. package/src/dashboard/dashboard.controller.ts +14 -0
  419. package/src/dashboard/dashboard.module.ts +12 -0
  420. package/src/dashboard/dashboard.service.ts +726 -0
  421. package/src/enterprise/dto/add-enterprise-class-group.dto.ts +7 -0
  422. package/src/enterprise/dto/add-enterprise-course.dto.ts +11 -0
  423. package/src/enterprise/dto/add-enterprise-student.dto.ts +16 -0
  424. package/src/enterprise/dto/add-enterprise-user.dto.ts +23 -0
  425. package/src/enterprise/dto/create-enterprise.dto.ts +41 -0
  426. package/src/enterprise/dto/update-enterprise-student.dto.ts +7 -0
  427. package/src/enterprise/dto/update-enterprise-user.dto.ts +11 -0
  428. package/src/enterprise/dto/update-enterprise.dto.ts +4 -0
  429. package/src/enterprise/enterprise.controller.ts +233 -0
  430. package/src/enterprise/enterprise.module.ts +12 -0
  431. package/src/enterprise/enterprise.service.ts +712 -0
  432. package/src/evaluation/evaluation.controller.ts +44 -0
  433. package/src/evaluation/evaluation.module.ts +12 -0
  434. package/src/evaluation/evaluation.service.ts +394 -0
  435. package/src/exam/dto/create-exam-question.dto.ts +103 -0
  436. package/src/exam/dto/create-exam.dto.ts +41 -0
  437. package/src/exam/dto/reorder-exam-questions.dto.ts +8 -0
  438. package/src/exam/dto/save-exam-attempt-answers.dto.ts +55 -0
  439. package/src/exam/dto/start-exam-attempt.dto.ts +8 -0
  440. package/src/exam/dto/submit-exam-attempt.dto.ts +8 -0
  441. package/src/exam/dto/update-exam-question.dto.ts +4 -0
  442. package/src/exam/dto/update-exam.dto.ts +4 -0
  443. package/src/exam/exam-attempt.controller.ts +65 -0
  444. package/src/exam/exam-attempt.service.ts +1008 -0
  445. package/src/exam/exam.controller.ts +102 -0
  446. package/src/exam/exam.module.ts +14 -0
  447. package/src/exam/exam.service.ts +784 -0
  448. package/src/index.ts +29 -0
  449. package/src/instructor/dto/create-instructor.dto.ts +43 -0
  450. package/src/instructor/dto/update-instructor.dto.ts +38 -0
  451. package/src/instructor/instructor.controller.ts +73 -0
  452. package/src/instructor/instructor.module.ts +12 -0
  453. package/src/instructor/instructor.service.ts +646 -0
  454. package/src/lms.module.ts +36 -4
  455. package/src/reports/reports.controller.ts +14 -0
  456. package/src/reports/reports.module.ts +12 -0
  457. package/src/reports/reports.service.ts +485 -0
  458. package/src/training/dto/create-training.dto.ts +81 -0
  459. package/src/training/dto/update-training.dto.ts +4 -0
  460. package/src/training/training.controller.ts +68 -0
  461. package/src/training/training.module.ts +12 -0
  462. package/src/training/training.service.ts +574 -0
@@ -0,0 +1,1008 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import {
3
+ BadRequestException,
4
+ Injectable,
5
+ NotFoundException,
6
+ } from '@nestjs/common';
7
+ import {
8
+ OBJECTIVE_EXAM_QUESTION_TYPES,
9
+ type ExamQuestionType,
10
+ } from './dto/create-exam-question.dto';
11
+ import { SaveExamAttemptAnswersDto } from './dto/save-exam-attempt-answers.dto';
12
+ import { StartExamAttemptDto } from './dto/start-exam-attempt.dto';
13
+ import { SubmitExamAttemptDto } from './dto/submit-exam-attempt.dto';
14
+
15
+ @Injectable()
16
+ export class ExamAttemptService {
17
+ constructor(private readonly prisma: PrismaService) {}
18
+
19
+ async getAttemptState(
20
+ examId: number,
21
+ userId: number,
22
+ explicitStudentId?: number,
23
+ ) {
24
+ const exam = await this.findExamOrThrow(examId);
25
+ const studentId = await this.resolveStudentId(userId, explicitStudentId);
26
+ let attempt = await this.findRelevantAttempt(examId, studentId);
27
+
28
+ if (attempt) {
29
+ attempt = await this.maybeFinalizeExpiredAttempt(exam, attempt);
30
+ }
31
+
32
+ return this.buildAttemptPayload(exam, studentId, attempt);
33
+ }
34
+
35
+ async startAttempt(
36
+ examId: number,
37
+ userId: number,
38
+ ipAddress: string,
39
+ dto: StartExamAttemptDto,
40
+ ) {
41
+ const exam = await this.findExamOrThrow(examId);
42
+ const studentId = await this.resolveStudentId(userId, dto.studentId);
43
+ let activeAttempt = await this.findActiveAttempt(examId, studentId);
44
+
45
+ if (activeAttempt) {
46
+ activeAttempt = await this.maybeFinalizeExpiredAttempt(exam, activeAttempt);
47
+ if (activeAttempt.status === 'in_progress') {
48
+ return this.buildAttemptPayload(exam, studentId, activeAttempt);
49
+ }
50
+ }
51
+
52
+ const usedAttempts = await this.countAttempts(examId, studentId);
53
+ const attemptsAllowed = Math.max(exam.attempts_allowed ?? 1, 1);
54
+
55
+ if (usedAttempts >= attemptsAllowed) {
56
+ throw new BadRequestException('No attempts remaining for this exam');
57
+ }
58
+
59
+ if ((exam.exam_question ?? []).length === 0) {
60
+ throw new BadRequestException('Exam has no questions');
61
+ }
62
+
63
+ const enrollment = await this.findMatchingEnrollment(exam, studentId);
64
+ const created = await this.prisma.exam_attempt.create({
65
+ data: {
66
+ exam_id: exam.id,
67
+ student_id: studentId,
68
+ course_enrollment_id: enrollment?.id ?? null,
69
+ attempt_number: usedAttempts + 1,
70
+ started_at: new Date(),
71
+ ip_address: ipAddress || null,
72
+ },
73
+ include: {
74
+ exam_answer: true,
75
+ },
76
+ });
77
+
78
+ return this.buildAttemptPayload(exam, studentId, created);
79
+ }
80
+
81
+ async saveAnswers(
82
+ examId: number,
83
+ attemptId: number,
84
+ userId: number,
85
+ dto: SaveExamAttemptAnswersDto,
86
+ ) {
87
+ const exam = await this.findExamOrThrow(examId);
88
+ const studentId = await this.resolveStudentId(userId);
89
+ let attempt = await this.findAttemptOrThrow(examId, attemptId, studentId);
90
+
91
+ attempt = await this.maybeFinalizeExpiredAttempt(exam, attempt);
92
+ if (attempt.status !== 'in_progress') {
93
+ return this.buildAttemptPayload(exam, studentId, attempt);
94
+ }
95
+
96
+ await this.persistAnswers(attempt.id, exam, dto.answers);
97
+ const updated = await this.findAttemptOrThrow(examId, attemptId, studentId);
98
+
99
+ return this.buildAttemptPayload(exam, studentId, updated);
100
+ }
101
+
102
+ async submitAttempt(
103
+ examId: number,
104
+ attemptId: number,
105
+ userId: number,
106
+ dto: SubmitExamAttemptDto,
107
+ ) {
108
+ const exam = await this.findExamOrThrow(examId);
109
+ const studentId = await this.resolveStudentId(userId);
110
+ let attempt = await this.findAttemptOrThrow(examId, attemptId, studentId);
111
+
112
+ attempt = await this.maybeFinalizeExpiredAttempt(exam, attempt);
113
+ if (attempt.status === 'completed') {
114
+ return this.buildAttemptPayload(exam, studentId, attempt);
115
+ }
116
+
117
+ await this.persistAnswers(attempt.id, exam, dto.answers ?? []);
118
+ const completed = await this.completeAttempt(exam, attempt.id, dto.force ?? false);
119
+
120
+ return this.buildAttemptPayload(exam, studentId, completed);
121
+ }
122
+
123
+ private async persistAnswers(attemptId: number, exam: any, answers: any[]) {
124
+ const normalizedAnswers = new Map<
125
+ number,
126
+ {
127
+ examOptionId: number | null;
128
+ answerText: string | null;
129
+ matchingPairs: Array<{ leftId: string; rightId: string }>;
130
+ }
131
+ >();
132
+ for (const answer of answers ?? []) {
133
+ normalizedAnswers.set(answer.questionId, {
134
+ examOptionId: answer.examOptionId ?? null,
135
+ answerText: answer.answerText?.trim() || null,
136
+ matchingPairs: (answer.matchingPairs ?? []).map((pair: any) => ({
137
+ leftId: String(pair.leftId ?? '').trim(),
138
+ rightId: String(pair.rightId ?? '').trim(),
139
+ })),
140
+ });
141
+ }
142
+
143
+ const questionsById = new Map<number, any>(
144
+ (exam.exam_question ?? []).map((item: any) => [item.question_id, item]),
145
+ );
146
+
147
+ for (const questionId of normalizedAnswers.keys()) {
148
+ if (!questionsById.has(questionId)) {
149
+ throw new BadRequestException(`Question ${questionId} does not belong to exam`);
150
+ }
151
+ }
152
+
153
+ const questionIds = [...normalizedAnswers.keys()];
154
+ if (questionIds.length === 0) {
155
+ return;
156
+ }
157
+
158
+ await this.prisma.$transaction(async (tx) => {
159
+ const existingAnswers = await tx.exam_answer.findMany({
160
+ where: {
161
+ exam_attempt_id: attemptId,
162
+ question_id: { in: questionIds },
163
+ },
164
+ select: {
165
+ id: true,
166
+ question_id: true,
167
+ },
168
+ });
169
+
170
+ const existingByQuestionId = new Map(
171
+ existingAnswers.map((item) => [item.question_id, item.id]),
172
+ );
173
+
174
+ for (const [questionId, answerValue] of normalizedAnswers.entries()) {
175
+ const question = questionsById.get(questionId) as any;
176
+ const existingId = existingByQuestionId.get(questionId);
177
+
178
+ if (!question) {
179
+ throw new BadRequestException(
180
+ `Question ${questionId} does not belong to exam`,
181
+ );
182
+ }
183
+
184
+ const questionType = this.resolveQuestionType(
185
+ question.question?.question_type,
186
+ );
187
+
188
+ if (questionType === 'essay') {
189
+ if (!answerValue.answerText) {
190
+ if (existingId) {
191
+ await tx.exam_answer.delete({ where: { id: existingId } });
192
+ }
193
+ continue;
194
+ }
195
+
196
+ const payload = {
197
+ exam_option_id: null,
198
+ answer_text: answerValue.answerText,
199
+ is_correct: null,
200
+ points_awarded: 0,
201
+ };
202
+
203
+ if (existingId) {
204
+ await tx.exam_answer.update({
205
+ where: { id: existingId },
206
+ data: payload,
207
+ });
208
+ } else {
209
+ await tx.exam_answer.create({
210
+ data: {
211
+ exam_attempt_id: attemptId,
212
+ question_id: questionId,
213
+ ...payload,
214
+ },
215
+ });
216
+ }
217
+ continue;
218
+ }
219
+
220
+ if (questionType === 'fill_blank') {
221
+ if (!answerValue.answerText) {
222
+ if (existingId) {
223
+ await tx.exam_answer.delete({ where: { id: existingId } });
224
+ }
225
+ continue;
226
+ }
227
+
228
+ const parsedOptions = this.parseSpecialOptions(
229
+ question.question?.exam_option ?? [],
230
+ );
231
+ const expectedAnswers = this.extractFillBlankExpectedAnswers(
232
+ parsedOptions.fillBlankAnswers,
233
+ );
234
+
235
+ if (expectedAnswers.length === 0) {
236
+ throw new BadRequestException(
237
+ `Fill-blank question ${questionId} has no expected answers`,
238
+ );
239
+ }
240
+
241
+ const isCorrect = this.isFillBlankCorrect(
242
+ answerValue.answerText,
243
+ expectedAnswers,
244
+ );
245
+
246
+ const payload = {
247
+ exam_option_id: null,
248
+ answer_text: answerValue.answerText,
249
+ is_correct: isCorrect,
250
+ points_awarded: isCorrect ? question.question.points ?? 0 : 0,
251
+ };
252
+
253
+ if (existingId) {
254
+ await tx.exam_answer.update({
255
+ where: { id: existingId },
256
+ data: payload,
257
+ });
258
+ } else {
259
+ await tx.exam_answer.create({
260
+ data: {
261
+ exam_attempt_id: attemptId,
262
+ question_id: questionId,
263
+ ...payload,
264
+ },
265
+ });
266
+ }
267
+ continue;
268
+ }
269
+
270
+ if (questionType === 'matching') {
271
+ const parsedOptions = this.parseSpecialOptions(
272
+ question.question?.exam_option ?? [],
273
+ );
274
+ const expectedPairs = this.extractMatchingPairs(
275
+ parsedOptions.matchingPairs,
276
+ );
277
+
278
+ if (expectedPairs.length < 2) {
279
+ throw new BadRequestException(
280
+ `Matching question ${questionId} has no valid pairs`,
281
+ );
282
+ }
283
+
284
+ const sanitizedPairs = answerValue.matchingPairs.filter(
285
+ (pair) => pair.leftId.length > 0 && pair.rightId.length > 0,
286
+ );
287
+
288
+ if (sanitizedPairs.length === 0) {
289
+ if (existingId) {
290
+ await tx.exam_answer.delete({ where: { id: existingId } });
291
+ }
292
+ continue;
293
+ }
294
+
295
+ const isCorrect = this.isMatchingCorrect(sanitizedPairs, expectedPairs);
296
+
297
+ const payload = {
298
+ exam_option_id: null,
299
+ answer_text: JSON.stringify({ matchingPairs: sanitizedPairs }),
300
+ is_correct: isCorrect,
301
+ points_awarded: isCorrect ? question.question.points ?? 0 : 0,
302
+ };
303
+
304
+ if (existingId) {
305
+ await tx.exam_answer.update({
306
+ where: { id: existingId },
307
+ data: payload,
308
+ });
309
+ } else {
310
+ await tx.exam_answer.create({
311
+ data: {
312
+ exam_attempt_id: attemptId,
313
+ question_id: questionId,
314
+ ...payload,
315
+ },
316
+ });
317
+ }
318
+ continue;
319
+ }
320
+
321
+ if (!answerValue.examOptionId) {
322
+ if (existingId) {
323
+ await tx.exam_answer.delete({ where: { id: existingId } });
324
+ }
325
+ continue;
326
+ }
327
+
328
+ const option = (question?.question?.exam_option ?? []).find(
329
+ (item: any) => item.id === answerValue.examOptionId,
330
+ );
331
+
332
+ if (!option) {
333
+ throw new BadRequestException(
334
+ `Option ${answerValue.examOptionId} does not belong to question ${questionId}`,
335
+ );
336
+ }
337
+
338
+ const payload = {
339
+ exam_option_id: option.id,
340
+ answer_text: null,
341
+ is_correct: Boolean(option.is_correct),
342
+ points_awarded: option.is_correct ? question.question.points ?? 0 : 0,
343
+ };
344
+
345
+ if (existingId) {
346
+ await tx.exam_answer.update({
347
+ where: { id: existingId },
348
+ data: payload,
349
+ });
350
+ } else {
351
+ await tx.exam_answer.create({
352
+ data: {
353
+ exam_attempt_id: attemptId,
354
+ question_id: questionId,
355
+ ...payload,
356
+ },
357
+ });
358
+ }
359
+ }
360
+ });
361
+ }
362
+
363
+ private async completeAttempt(exam: any, attemptId: number, force: boolean) {
364
+ const currentAttempt = await this.prisma.exam_attempt.findUnique({
365
+ where: { id: attemptId },
366
+ include: {
367
+ exam_answer: true,
368
+ },
369
+ });
370
+
371
+ if (!currentAttempt) {
372
+ throw new NotFoundException('Attempt not found');
373
+ }
374
+
375
+ if (currentAttempt.status === 'completed') {
376
+ return currentAttempt;
377
+ }
378
+
379
+ const maxPoints = this.getExamMaxPoints(exam);
380
+ const answeredCount = currentAttempt.exam_answer.length;
381
+ const questionCount = (exam.exam_question ?? []).length;
382
+
383
+ if (
384
+ !force &&
385
+ exam.require_all_questions_answered_to_finish &&
386
+ answeredCount < questionCount
387
+ ) {
388
+ throw new BadRequestException('All questions must be answered before finishing');
389
+ }
390
+
391
+ const totalPoints = currentAttempt.exam_answer.reduce(
392
+ (sum, answer) => sum + (answer.points_awarded ?? 0),
393
+ 0,
394
+ );
395
+
396
+ const maxScore = Math.max(exam.max_score ?? 100, 1);
397
+ const scaledScore =
398
+ maxPoints > 0 ? Math.round((totalPoints / maxPoints) * maxScore) : 0;
399
+
400
+ return this.prisma.exam_attempt.update({
401
+ where: { id: attemptId },
402
+ data: {
403
+ status: 'completed',
404
+ finished_at: new Date(),
405
+ duration_seconds: Math.max(
406
+ 0,
407
+ Math.floor((Date.now() - currentAttempt.started_at.getTime()) / 1000),
408
+ ),
409
+ score: scaledScore,
410
+ },
411
+ include: {
412
+ exam_answer: true,
413
+ },
414
+ });
415
+ }
416
+
417
+ private async buildAttemptPayload(exam: any, studentId: number, attempt: any | null) {
418
+ const attemptsAllowed = Math.max(exam.attempts_allowed ?? 1, 1);
419
+ const attemptsUsed = await this.countAttempts(exam.id, studentId);
420
+ const maxPoints = this.getExamMaxPoints(exam);
421
+ const questions = this.mapQuestions(exam, attempt?.id ?? exam.id);
422
+
423
+ return {
424
+ exam: {
425
+ id: exam.id,
426
+ title: exam.title,
427
+ description: exam.description,
428
+ instructions: exam.instructions,
429
+ questionCount: questions.length,
430
+ maxPoints,
431
+ timeLimitMinutes: exam.time_limit_minutes,
432
+ passingScore: this.scoreToTen(exam.min_score ?? 0),
433
+ passingPercent: this.toPercent(exam.min_score ?? 0, exam.max_score ?? 100),
434
+ requireAllQuestionsAnsweredToFinish: Boolean(
435
+ exam.require_all_questions_answered_to_finish,
436
+ ),
437
+ showResult: Boolean(exam.show_result ?? true),
438
+ },
439
+ attempts: {
440
+ allowed: attemptsAllowed,
441
+ used: attemptsUsed,
442
+ remaining: Math.max(attemptsAllowed - attemptsUsed, 0),
443
+ canStart: attemptsUsed < attemptsAllowed,
444
+ },
445
+ questions,
446
+ attempt: attempt
447
+ ? {
448
+ id: attempt.id,
449
+ attemptNumber: attempt.attempt_number,
450
+ status: attempt.status,
451
+ startedAt: attempt.started_at,
452
+ finishedAt: attempt.finished_at,
453
+ timeRemainingSeconds: this.getRemainingSeconds(exam, attempt),
454
+ answeredCount: attempt.exam_answer.length,
455
+ answers: (attempt.exam_answer ?? []).map((answer: any) => ({
456
+ questionId: answer.question_id,
457
+ examOptionId: answer.exam_option_id,
458
+ answerText: answer.answer_text,
459
+ matchingPairs: this.extractMatchingPairsFromAnswerText(
460
+ answer.answer_text,
461
+ ),
462
+ })),
463
+ result:
464
+ attempt.status === 'completed'
465
+ ? this.mapAttemptResult(exam, attempt)
466
+ : null,
467
+ }
468
+ : null,
469
+ };
470
+ }
471
+
472
+ private mapAttemptResult(exam: any, attempt: any) {
473
+ const maxPoints = this.getExamMaxPoints(exam);
474
+ const maxScore = Math.max(exam.max_score ?? 100, 1);
475
+ const score = attempt.score ?? 0;
476
+ const answersByQuestion = new Map<number, any>(
477
+ (attempt.exam_answer ?? []).map((answer: any) => [answer.question_id, answer]),
478
+ );
479
+ const hasPendingReview = (exam.exam_question ?? []).some((item: any) => {
480
+ const answer = answersByQuestion.get(item.question_id);
481
+ return this.requiresManualReview(item.question?.question_type) && Boolean(answer);
482
+ });
483
+
484
+ return {
485
+ totalPoints: (attempt.exam_answer ?? []).reduce(
486
+ (sum: number, answer: any) => sum + (answer.points_awarded ?? 0),
487
+ 0,
488
+ ),
489
+ maxPoints,
490
+ score,
491
+ scoreDisplay: this.scoreToTen(score),
492
+ percent: this.toPercent(score, maxScore),
493
+ passed: hasPendingReview ? null : score >= (exam.min_score ?? 0),
494
+ hasPendingReview,
495
+ pendingReviewCount: (exam.exam_question ?? []).filter((item: any) => {
496
+ const answer = answersByQuestion.get(item.question_id);
497
+ return this.requiresManualReview(item.question?.question_type) && Boolean(answer);
498
+ }).length,
499
+ answersSummary: (exam.exam_question ?? []).map((item: any) => {
500
+ const answer = answersByQuestion.get(item.question_id);
501
+ return {
502
+ questionId: item.question_id,
503
+ hasAnswer: Boolean(answer),
504
+ isCorrect:
505
+ typeof answer?.is_correct === 'boolean' ? Boolean(answer.is_correct) : null,
506
+ pointsAwarded: answer?.points_awarded ?? 0,
507
+ requiresManualReview: this.requiresManualReview(
508
+ item.question?.question_type,
509
+ ),
510
+ };
511
+ }),
512
+ };
513
+ }
514
+
515
+ private mapQuestions(exam: any, seedBase: number) {
516
+ const orderedQuestions = this.sortWithSeed(
517
+ exam.shuffle_questions,
518
+ (exam.exam_question ?? []) as any[],
519
+ (item: any) => item.question_id,
520
+ `${seedBase}:questions`,
521
+ );
522
+
523
+ return orderedQuestions.map((item: any, index: number) => {
524
+ const questionType = this.resolveQuestionType(item.question?.question_type);
525
+ const parsedOptions = this.parseSpecialOptions(
526
+ item.question?.exam_option ?? [],
527
+ );
528
+ const objectiveOptions = (item.question?.exam_option ?? []).filter(
529
+ (option: any) => !this.isJsonString(option.option_text),
530
+ );
531
+ const orderedOptions = this.sortWithSeed(
532
+ exam.shuffle_options,
533
+ objectiveOptions,
534
+ (option: any) => option.id,
535
+ `${seedBase}:question:${item.question_id}:options`,
536
+ );
537
+
538
+ return {
539
+ id: item.question_id,
540
+ order: index,
541
+ questionType,
542
+ statement: item.question.statement,
543
+ points: item.question.points,
544
+ fillBlankAnswers: parsedOptions.fillBlankAnswers,
545
+ matchingPairs: parsedOptions.matchingPairs,
546
+ matchingOptions:
547
+ questionType === 'matching'
548
+ ? this.sortWithSeed(
549
+ true,
550
+ parsedOptions.matchingPairs.map((pair: any) => ({
551
+ id: pair.id,
552
+ text: pair.rightText,
553
+ })),
554
+ (option: any) => this.hash(option.id),
555
+ `${seedBase}:question:${item.question_id}:matching-options`,
556
+ )
557
+ : [],
558
+ alternatives:
559
+ questionType === 'multiple_choice' || questionType === 'true_false'
560
+ ? orderedOptions.map((option: any) => ({
561
+ id: option.id,
562
+ text: option.option_text,
563
+ }))
564
+ : [],
565
+ };
566
+ });
567
+ }
568
+
569
+ private sortWithSeed<T>(
570
+ enabled: boolean,
571
+ items: T[],
572
+ getId: (item: T) => number,
573
+ seed: string,
574
+ ) {
575
+ const list = [...items];
576
+ if (!enabled) {
577
+ return list;
578
+ }
579
+
580
+ return list.sort((left, right) => {
581
+ const leftHash = this.hash(`${seed}:${getId(left)}`);
582
+ const rightHash = this.hash(`${seed}:${getId(right)}`);
583
+
584
+ if (leftHash === rightHash) {
585
+ return getId(left) - getId(right);
586
+ }
587
+
588
+ return leftHash - rightHash;
589
+ });
590
+ }
591
+
592
+ private hash(value: string) {
593
+ let hash = 0;
594
+ for (let index = 0; index < value.length; index += 1) {
595
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
596
+ }
597
+ return hash;
598
+ }
599
+
600
+ private async maybeFinalizeExpiredAttempt(exam: any, attempt: any) {
601
+ const remainingSeconds = this.getRemainingSeconds(exam, attempt);
602
+ if (attempt.status !== 'in_progress' || remainingSeconds === null || remainingSeconds > 0) {
603
+ return attempt;
604
+ }
605
+
606
+ return this.completeAttempt(exam, attempt.id, true);
607
+ }
608
+
609
+ private getRemainingSeconds(exam: any, attempt: any) {
610
+ if (!exam.time_limit_minutes) {
611
+ return null;
612
+ }
613
+
614
+ const endsAt =
615
+ attempt.started_at.getTime() + exam.time_limit_minutes * 60 * 1000;
616
+
617
+ return Math.max(0, Math.floor((endsAt - Date.now()) / 1000));
618
+ }
619
+
620
+ private getExamMaxPoints(exam: any) {
621
+ return (exam.exam_question ?? []).reduce(
622
+ (sum: number, item: any) => sum + (item.question?.points ?? 0),
623
+ 0,
624
+ );
625
+ }
626
+
627
+ private isObjectiveQuestion(questionType?: string) {
628
+ return OBJECTIVE_EXAM_QUESTION_TYPES.includes(
629
+ this.resolveQuestionType(questionType),
630
+ );
631
+ }
632
+
633
+ private requiresManualReview(questionType?: string) {
634
+ return this.resolveQuestionType(questionType) === 'essay';
635
+ }
636
+
637
+ private resolveQuestionType(questionType?: string): ExamQuestionType {
638
+ return (questionType as ExamQuestionType | undefined) ?? 'multiple_choice';
639
+ }
640
+
641
+ private isJsonString(value: string) {
642
+ try {
643
+ const parsed = JSON.parse(value);
644
+ return typeof parsed === 'object' && parsed !== null;
645
+ } catch {
646
+ return false;
647
+ }
648
+ }
649
+
650
+ private parseSpecialOptions(options: any[]) {
651
+ const fillBlankAnswers: Array<{ answer: string; alternatives?: string[] }> = [];
652
+ const matchingPairs: Array<{ id: string; leftText: string; rightText: string }> = [];
653
+
654
+ for (const option of options) {
655
+ try {
656
+ const parsed = JSON.parse(option.option_text);
657
+
658
+ if (parsed?.kind === 'fill_blank' && typeof parsed.answer === 'string') {
659
+ fillBlankAnswers.push({
660
+ answer: parsed.answer,
661
+ alternatives: Array.isArray(parsed.alternatives)
662
+ ? parsed.alternatives
663
+ : [],
664
+ });
665
+ continue;
666
+ }
667
+
668
+ if (
669
+ parsed?.kind === 'matching' &&
670
+ typeof parsed.id === 'string' &&
671
+ typeof parsed.leftText === 'string' &&
672
+ typeof parsed.rightText === 'string'
673
+ ) {
674
+ matchingPairs.push({
675
+ id: parsed.id,
676
+ leftText: parsed.leftText,
677
+ rightText: parsed.rightText,
678
+ });
679
+ }
680
+ } catch {
681
+ // Ignore objective options
682
+ }
683
+ }
684
+
685
+ return {
686
+ fillBlankAnswers,
687
+ matchingPairs,
688
+ };
689
+ }
690
+
691
+ private extractFillBlankExpectedAnswers(rawValue: any): string[] {
692
+ if (!Array.isArray(rawValue)) {
693
+ return [];
694
+ }
695
+
696
+ return rawValue.flatMap((item) => {
697
+ const primary = typeof item?.answer === 'string' ? [item.answer] : [];
698
+ const alternatives = Array.isArray(item?.alternatives)
699
+ ? item.alternatives.filter(
700
+ (alternative: unknown) => typeof alternative === 'string',
701
+ )
702
+ : [];
703
+
704
+ return [...primary, ...alternatives]
705
+ .map((value) => value.trim())
706
+ .filter((value) => value.length > 0);
707
+ });
708
+ }
709
+
710
+ private isFillBlankCorrect(answerText: string, expectedAnswers: string[]) {
711
+ const normalizedAnswer = answerText.trim().toLowerCase();
712
+ return expectedAnswers.some(
713
+ (expected) => expected.trim().toLowerCase() === normalizedAnswer,
714
+ );
715
+ }
716
+
717
+ private extractMatchingPairs(rawValue: any) {
718
+ if (!Array.isArray(rawValue)) {
719
+ return [] as Array<{ id: string; leftText: string; rightText: string }>;
720
+ }
721
+
722
+ return rawValue
723
+ .map((pair) => ({
724
+ id: String(pair?.id ?? '').trim(),
725
+ leftText: String(pair?.leftText ?? '').trim(),
726
+ rightText: String(pair?.rightText ?? '').trim(),
727
+ }))
728
+ .filter(
729
+ (pair) =>
730
+ pair.id.length > 0 &&
731
+ pair.leftText.length > 0 &&
732
+ pair.rightText.length > 0,
733
+ );
734
+ }
735
+
736
+ private isMatchingCorrect(
737
+ submittedPairs: Array<{ leftId: string; rightId: string }>,
738
+ expectedPairs: Array<{ id: string; leftText: string; rightText: string }>,
739
+ ) {
740
+ if (submittedPairs.length !== expectedPairs.length) {
741
+ return false;
742
+ }
743
+
744
+ const expectedMap = new Map(expectedPairs.map((pair) => [pair.id, pair.id]));
745
+ const submittedMap = new Map(
746
+ submittedPairs.map((pair) => [pair.leftId, pair.rightId]),
747
+ );
748
+
749
+ if (submittedMap.size !== expectedMap.size) {
750
+ return false;
751
+ }
752
+
753
+ for (const [leftId, expectedRightId] of expectedMap.entries()) {
754
+ if (submittedMap.get(leftId) !== expectedRightId) {
755
+ return false;
756
+ }
757
+ }
758
+
759
+ return true;
760
+ }
761
+
762
+ private extractMatchingPairsFromAnswerText(answerText: string | null) {
763
+ if (!answerText) {
764
+ return [] as Array<{ leftId: string; rightId: string }>;
765
+ }
766
+
767
+ try {
768
+ const parsed = JSON.parse(answerText);
769
+ if (!Array.isArray(parsed?.matchingPairs)) {
770
+ return [];
771
+ }
772
+
773
+ return parsed.matchingPairs
774
+ .map((pair: any) => ({
775
+ leftId: String(pair?.leftId ?? '').trim(),
776
+ rightId: String(pair?.rightId ?? '').trim(),
777
+ }))
778
+ .filter((pair: any) => pair.leftId.length > 0 && pair.rightId.length > 0);
779
+ } catch {
780
+ return [];
781
+ }
782
+ }
783
+
784
+ private async findExamOrThrow(examId: number) {
785
+ const exam = await this.prisma.exam.findUnique({
786
+ where: { id: examId },
787
+ include: {
788
+ exam_question: {
789
+ orderBy: { order: 'asc' },
790
+ include: {
791
+ question: {
792
+ include: {
793
+ exam_option: {
794
+ orderBy: { position: 'asc' },
795
+ },
796
+ },
797
+ },
798
+ },
799
+ },
800
+ },
801
+ });
802
+
803
+ if (!exam) {
804
+ throw new NotFoundException('Exam not found');
805
+ }
806
+
807
+ return exam;
808
+ }
809
+
810
+ private async findRelevantAttempt(examId: number, studentId: number) {
811
+ const activeAttempt = await this.findActiveAttempt(examId, studentId);
812
+ if (activeAttempt) {
813
+ return activeAttempt;
814
+ }
815
+
816
+ return this.prisma.exam_attempt.findFirst({
817
+ where: {
818
+ exam_id: examId,
819
+ student_id: studentId,
820
+ },
821
+ orderBy: { created_at: 'desc' },
822
+ include: {
823
+ exam_answer: true,
824
+ },
825
+ });
826
+ }
827
+
828
+ private findActiveAttempt(examId: number, studentId: number) {
829
+ return this.prisma.exam_attempt.findFirst({
830
+ where: {
831
+ exam_id: examId,
832
+ student_id: studentId,
833
+ status: 'in_progress',
834
+ },
835
+ orderBy: { created_at: 'desc' },
836
+ include: {
837
+ exam_answer: true,
838
+ },
839
+ });
840
+ }
841
+
842
+ private async findAttemptOrThrow(
843
+ examId: number,
844
+ attemptId: number,
845
+ studentId: number,
846
+ ) {
847
+ const attempt = await this.prisma.exam_attempt.findFirst({
848
+ where: {
849
+ id: attemptId,
850
+ exam_id: examId,
851
+ student_id: studentId,
852
+ },
853
+ include: {
854
+ exam_answer: true,
855
+ },
856
+ });
857
+
858
+ if (!attempt) {
859
+ throw new NotFoundException('Attempt not found');
860
+ }
861
+
862
+ return attempt;
863
+ }
864
+
865
+ private countAttempts(examId: number, studentId: number) {
866
+ return this.prisma.exam_attempt.count({
867
+ where: {
868
+ exam_id: examId,
869
+ student_id: studentId,
870
+ status: {
871
+ not: 'voided',
872
+ },
873
+ },
874
+ });
875
+ }
876
+
877
+ private async findMatchingEnrollment(exam: any, studentId: number) {
878
+ if (exam.course_class_group_id) {
879
+ return this.prisma.course_enrollment.findFirst({
880
+ where: {
881
+ person_id: studentId,
882
+ course_class_group_id: exam.course_class_group_id,
883
+ },
884
+ orderBy: { updated_at: 'desc' },
885
+ select: { id: true },
886
+ });
887
+ }
888
+
889
+ if (exam.course_id) {
890
+ return this.prisma.course_enrollment.findFirst({
891
+ where: {
892
+ person_id: studentId,
893
+ course_id: exam.course_id,
894
+ },
895
+ orderBy: { updated_at: 'desc' },
896
+ select: { id: true },
897
+ });
898
+ }
899
+
900
+ return null;
901
+ }
902
+
903
+ private async resolveStudentId(userId: number, explicitStudentId?: number) {
904
+ if (explicitStudentId) {
905
+ const person = await this.prisma.person.findUnique({
906
+ where: { id: explicitStudentId },
907
+ select: { id: true },
908
+ });
909
+
910
+ if (!person) {
911
+ throw new NotFoundException('Student not found');
912
+ }
913
+
914
+ return person.id;
915
+ }
916
+
917
+ const user = await this.prisma.user.findUnique({
918
+ where: { id: userId },
919
+ select: {
920
+ id: true,
921
+ name: true,
922
+ user_identifier: {
923
+ where: {
924
+ type: 'email',
925
+ enabled: true,
926
+ },
927
+ select: {
928
+ value: true,
929
+ },
930
+ take: 1,
931
+ },
932
+ },
933
+ });
934
+
935
+ if (!user) {
936
+ throw new NotFoundException('Authenticated user not found');
937
+ }
938
+
939
+ const userEmail = user.user_identifier?.[0]?.value?.trim().toLowerCase();
940
+
941
+ if (userEmail) {
942
+ const matchingPersons = await this.prisma.person.findMany({
943
+ where: {
944
+ contact: {
945
+ some: {
946
+ value: { equals: userEmail, mode: 'insensitive' },
947
+ contact_type: {
948
+ code: 'email',
949
+ },
950
+ },
951
+ },
952
+ },
953
+ select: { id: true },
954
+ take: 2,
955
+ });
956
+
957
+ if (matchingPersons.length === 1) {
958
+ return matchingPersons[0].id;
959
+ }
960
+ }
961
+
962
+ const sameIdPerson = await this.prisma.person.findUnique({
963
+ where: { id: userId },
964
+ select: { id: true },
965
+ });
966
+
967
+ if (sameIdPerson) {
968
+ return sameIdPerson.id;
969
+ }
970
+
971
+ const emailType = await this.prisma.contact_type.findFirst({
972
+ where: { code: 'email' },
973
+ select: { id: true },
974
+ });
975
+
976
+ const createdPerson = await this.prisma.person.create({
977
+ data: {
978
+ name: user.name,
979
+ type: 'individual',
980
+ status: 'active',
981
+ ...(userEmail && emailType
982
+ ? {
983
+ contact: {
984
+ create: {
985
+ contact_type_id: emailType.id,
986
+ value: userEmail,
987
+ is_primary: true,
988
+ },
989
+ },
990
+ }
991
+ : {}),
992
+ },
993
+ select: { id: true },
994
+ });
995
+
996
+ return createdPerson.id;
997
+ }
998
+
999
+ private toPercent(value: number, max: number) {
1000
+ if (!max) return 0;
1001
+ return Math.round((value / max) * 100);
1002
+ }
1003
+
1004
+ private scoreToTen(score: number) {
1005
+ if (!score) return 0;
1006
+ return score > 10 ? Number((score / 10).toFixed(1)) : Number(score.toFixed(1));
1007
+ }
1008
+ }