@hed-hog/lms 0.0.304 → 0.0.306

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 (458) 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-student.dto.d.ts +5 -0
  119. package/dist/enterprise/dto/add-enterprise-student.dto.d.ts.map +1 -0
  120. package/dist/enterprise/dto/add-enterprise-student.dto.js +27 -0
  121. package/dist/enterprise/dto/add-enterprise-student.dto.js.map +1 -0
  122. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts +7 -0
  123. package/dist/enterprise/dto/add-enterprise-user.dto.d.ts.map +1 -0
  124. package/dist/enterprise/dto/add-enterprise-user.dto.js +36 -0
  125. package/dist/enterprise/dto/add-enterprise-user.dto.js.map +1 -0
  126. package/dist/enterprise/dto/create-enterprise.dto.d.ts +10 -0
  127. package/dist/enterprise/dto/create-enterprise.dto.d.ts.map +1 -0
  128. package/dist/enterprise/dto/create-enterprise.dto.js +54 -0
  129. package/dist/enterprise/dto/create-enterprise.dto.js.map +1 -0
  130. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts +4 -0
  131. package/dist/enterprise/dto/update-enterprise-student.dto.d.ts.map +1 -0
  132. package/dist/enterprise/dto/update-enterprise-student.dto.js +22 -0
  133. package/dist/enterprise/dto/update-enterprise-student.dto.js.map +1 -0
  134. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts +5 -0
  135. package/dist/enterprise/dto/update-enterprise-user.dto.d.ts.map +1 -0
  136. package/dist/enterprise/dto/update-enterprise-user.dto.js +27 -0
  137. package/dist/enterprise/dto/update-enterprise-user.dto.js.map +1 -0
  138. package/dist/enterprise/dto/update-enterprise.dto.d.ts +6 -0
  139. package/dist/enterprise/dto/update-enterprise.dto.d.ts.map +1 -0
  140. package/dist/enterprise/dto/update-enterprise.dto.js +9 -0
  141. package/dist/enterprise/dto/update-enterprise.dto.js.map +1 -0
  142. package/dist/enterprise/enterprise.controller.d.ts +269 -0
  143. package/dist/enterprise/enterprise.controller.d.ts.map +1 -0
  144. package/dist/enterprise/enterprise.controller.js +311 -0
  145. package/dist/enterprise/enterprise.controller.js.map +1 -0
  146. package/dist/enterprise/enterprise.module.d.ts +3 -0
  147. package/dist/enterprise/enterprise.module.d.ts.map +1 -0
  148. package/dist/enterprise/enterprise.module.js +25 -0
  149. package/dist/enterprise/enterprise.module.js.map +1 -0
  150. package/dist/enterprise/enterprise.service.d.ts +282 -0
  151. package/dist/enterprise/enterprise.service.d.ts.map +1 -0
  152. package/dist/enterprise/enterprise.service.js +627 -0
  153. package/dist/enterprise/enterprise.service.js.map +1 -0
  154. package/dist/evaluation/evaluation.controller.d.ts +56 -0
  155. package/dist/evaluation/evaluation.controller.d.ts.map +1 -0
  156. package/dist/evaluation/evaluation.controller.js +76 -0
  157. package/dist/evaluation/evaluation.controller.js.map +1 -0
  158. package/dist/evaluation/evaluation.module.d.ts +3 -0
  159. package/dist/evaluation/evaluation.module.d.ts.map +1 -0
  160. package/dist/evaluation/evaluation.module.js +25 -0
  161. package/dist/evaluation/evaluation.module.js.map +1 -0
  162. package/dist/evaluation/evaluation.service.d.ts +67 -0
  163. package/dist/evaluation/evaluation.service.d.ts.map +1 -0
  164. package/dist/evaluation/evaluation.service.js +378 -0
  165. package/dist/evaluation/evaluation.service.js.map +1 -0
  166. package/dist/exam/dto/create-exam-question.dto.d.ts +25 -0
  167. package/dist/exam/dto/create-exam-question.dto.d.ts.map +1 -0
  168. package/dist/exam/dto/create-exam-question.dto.js +117 -0
  169. package/dist/exam/dto/create-exam-question.dto.js.map +1 -0
  170. package/dist/exam/dto/create-exam.dto.d.ts +11 -0
  171. package/dist/exam/dto/create-exam.dto.d.ts.map +1 -0
  172. package/dist/exam/dto/create-exam.dto.js +63 -0
  173. package/dist/exam/dto/create-exam.dto.js.map +1 -0
  174. package/dist/exam/dto/reorder-exam-questions.dto.d.ts +4 -0
  175. package/dist/exam/dto/reorder-exam-questions.dto.d.ts.map +1 -0
  176. package/dist/exam/dto/reorder-exam-questions.dto.js +23 -0
  177. package/dist/exam/dto/reorder-exam-questions.dto.js.map +1 -0
  178. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts +14 -0
  179. package/dist/exam/dto/save-exam-attempt-answers.dto.d.ts.map +1 -0
  180. package/dist/exam/dto/save-exam-attempt-answers.dto.js +68 -0
  181. package/dist/exam/dto/save-exam-attempt-answers.dto.js.map +1 -0
  182. package/dist/exam/dto/start-exam-attempt.dto.d.ts +4 -0
  183. package/dist/exam/dto/start-exam-attempt.dto.d.ts.map +1 -0
  184. package/dist/exam/dto/start-exam-attempt.dto.js +23 -0
  185. package/dist/exam/dto/start-exam-attempt.dto.js.map +1 -0
  186. package/dist/exam/dto/submit-exam-attempt.dto.d.ts +5 -0
  187. package/dist/exam/dto/submit-exam-attempt.dto.d.ts.map +1 -0
  188. package/dist/exam/dto/submit-exam-attempt.dto.js +23 -0
  189. package/dist/exam/dto/submit-exam-attempt.dto.js.map +1 -0
  190. package/dist/exam/dto/update-exam-question.dto.d.ts +6 -0
  191. package/dist/exam/dto/update-exam-question.dto.d.ts.map +1 -0
  192. package/dist/exam/dto/update-exam-question.dto.js +9 -0
  193. package/dist/exam/dto/update-exam-question.dto.js.map +1 -0
  194. package/dist/exam/dto/update-exam.dto.d.ts +6 -0
  195. package/dist/exam/dto/update-exam.dto.d.ts.map +1 -0
  196. package/dist/exam/dto/update-exam.dto.js +9 -0
  197. package/dist/exam/dto/update-exam.dto.js.map +1 -0
  198. package/dist/exam/exam-attempt.controller.d.ts +273 -0
  199. package/dist/exam/exam-attempt.controller.d.ts.map +1 -0
  200. package/dist/exam/exam-attempt.controller.js +84 -0
  201. package/dist/exam/exam-attempt.controller.js.map +1 -0
  202. package/dist/exam/exam-attempt.service.d.ts +302 -0
  203. package/dist/exam/exam-attempt.service.d.ts.map +1 -0
  204. package/dist/exam/exam-attempt.service.js +776 -0
  205. package/dist/exam/exam-attempt.service.js.map +1 -0
  206. package/dist/exam/exam.controller.d.ts +162 -0
  207. package/dist/exam/exam.controller.d.ts.map +1 -0
  208. package/dist/exam/exam.controller.js +158 -0
  209. package/dist/exam/exam.controller.js.map +1 -0
  210. package/dist/exam/exam.module.d.ts +3 -0
  211. package/dist/exam/exam.module.d.ts.map +1 -0
  212. package/dist/exam/exam.module.js +27 -0
  213. package/dist/exam/exam.module.js.map +1 -0
  214. package/dist/exam/exam.service.d.ts +179 -0
  215. package/dist/exam/exam.service.d.ts.map +1 -0
  216. package/dist/exam/exam.service.js +597 -0
  217. package/dist/exam/exam.service.js.map +1 -0
  218. package/dist/index.d.ts +28 -0
  219. package/dist/index.d.ts.map +1 -1
  220. package/dist/index.js +28 -0
  221. package/dist/index.js.map +1 -1
  222. package/dist/instructor/dto/create-instructor.dto.d.ts +10 -0
  223. package/dist/instructor/dto/create-instructor.dto.d.ts.map +1 -0
  224. package/dist/instructor/dto/create-instructor.dto.js +55 -0
  225. package/dist/instructor/dto/create-instructor.dto.js.map +1 -0
  226. package/dist/instructor/dto/update-instructor.dto.d.ts +9 -0
  227. package/dist/instructor/dto/update-instructor.dto.d.ts.map +1 -0
  228. package/dist/instructor/dto/update-instructor.dto.js +51 -0
  229. package/dist/instructor/dto/update-instructor.dto.js.map +1 -0
  230. package/dist/instructor/instructor.controller.d.ts +52 -0
  231. package/dist/instructor/instructor.controller.d.ts.map +1 -0
  232. package/dist/instructor/instructor.controller.js +98 -0
  233. package/dist/instructor/instructor.controller.js.map +1 -0
  234. package/dist/instructor/instructor.module.d.ts +3 -0
  235. package/dist/instructor/instructor.module.d.ts.map +1 -0
  236. package/dist/instructor/instructor.module.js +25 -0
  237. package/dist/instructor/instructor.module.js.map +1 -0
  238. package/dist/instructor/instructor.service.d.ts +79 -0
  239. package/dist/instructor/instructor.service.d.ts.map +1 -0
  240. package/dist/instructor/instructor.service.js +528 -0
  241. package/dist/instructor/instructor.service.js.map +1 -0
  242. package/dist/lms.module.d.ts.map +1 -1
  243. package/dist/lms.module.js +36 -4
  244. package/dist/lms.module.js.map +1 -1
  245. package/dist/reports/reports.controller.d.ts +69 -0
  246. package/dist/reports/reports.controller.d.ts.map +1 -0
  247. package/dist/reports/reports.controller.js +40 -0
  248. package/dist/reports/reports.controller.js.map +1 -0
  249. package/dist/reports/reports.module.d.ts +3 -0
  250. package/dist/reports/reports.module.d.ts.map +1 -0
  251. package/dist/reports/reports.module.js +25 -0
  252. package/dist/reports/reports.module.js.map +1 -0
  253. package/dist/reports/reports.service.d.ts +80 -0
  254. package/dist/reports/reports.service.d.ts.map +1 -0
  255. package/dist/reports/reports.service.js +366 -0
  256. package/dist/reports/reports.service.js.map +1 -0
  257. package/dist/training/dto/create-training.dto.d.ts +19 -0
  258. package/dist/training/dto/create-training.dto.d.ts.map +1 -0
  259. package/dist/training/dto/create-training.dto.js +98 -0
  260. package/dist/training/dto/create-training.dto.js.map +1 -0
  261. package/dist/training/dto/update-training.dto.d.ts +6 -0
  262. package/dist/training/dto/update-training.dto.d.ts.map +1 -0
  263. package/dist/training/dto/update-training.dto.js +9 -0
  264. package/dist/training/dto/update-training.dto.js.map +1 -0
  265. package/dist/training/training.controller.d.ts +195 -0
  266. package/dist/training/training.controller.d.ts.map +1 -0
  267. package/dist/training/training.controller.js +104 -0
  268. package/dist/training/training.controller.js.map +1 -0
  269. package/dist/training/training.module.d.ts +3 -0
  270. package/dist/training/training.module.d.ts.map +1 -0
  271. package/dist/training/training.module.js +25 -0
  272. package/dist/training/training.module.js.map +1 -0
  273. package/dist/training/training.service.d.ts +213 -0
  274. package/dist/training/training.service.d.ts.map +1 -0
  275. package/dist/training/training.service.js +497 -0
  276. package/dist/training/training.service.js.map +1 -0
  277. package/hedhog/data/dashboard.yaml +6 -0
  278. package/hedhog/data/dashboard_component.yaml +153 -0
  279. package/hedhog/data/dashboard_component_role.yaml +97 -0
  280. package/hedhog/data/dashboard_item.yaml +167 -0
  281. package/hedhog/data/dashboard_role.yaml +6 -0
  282. package/hedhog/data/instructor_qualification.yaml +16 -0
  283. package/hedhog/data/menu.yaml +129 -19
  284. package/hedhog/data/role.yaml +25 -1
  285. package/hedhog/data/route.yaml +867 -0
  286. package/hedhog/frontend/app/_components/class-form-sheet.tsx.ejs +1992 -0
  287. package/hedhog/frontend/app/_components/course-form-sheet.tsx.ejs +480 -0
  288. package/hedhog/frontend/app/_components/create-lms-instructor-sheet.tsx.ejs +591 -0
  289. package/hedhog/frontend/app/_components/create-lms-person-sheet.tsx.ejs +164 -0
  290. package/hedhog/frontend/app/_components/create-lms-student-person-sheet.tsx.ejs +120 -0
  291. package/hedhog/frontend/app/_components/lms-class-calendar.tsx.ejs +272 -0
  292. package/hedhog/frontend/app/_components/mobile-calendar.tsx.ejs +277 -0
  293. package/hedhog/frontend/app/_lib/editor/canvasInstance.ts.ejs +48 -0
  294. package/hedhog/frontend/app/_lib/editor/pctHelpers.ts.ejs +50 -0
  295. package/hedhog/frontend/app/_lib/editor/templateSerializer.ts.ejs +268 -0
  296. package/hedhog/frontend/app/_lib/editor/types.ts.ejs +94 -0
  297. package/hedhog/frontend/app/_lib/store/useTemplateStore.ts.ejs +284 -0
  298. package/hedhog/frontend/app/certificates/issued/page.tsx.ejs +638 -0
  299. package/hedhog/frontend/app/certificates/models/CanvasStage.tsx.ejs +916 -0
  300. package/hedhog/frontend/app/certificates/models/LeftPanel.tsx.ejs +200 -0
  301. package/hedhog/frontend/app/certificates/models/RightPanel.tsx.ejs +769 -0
  302. package/hedhog/frontend/app/certificates/models/TemplateEditorPage.tsx.ejs +104 -0
  303. package/hedhog/frontend/app/certificates/models/TopBar.tsx.ejs +354 -0
  304. package/hedhog/frontend/app/certificates/models/editor/page.tsx.ejs +5 -0
  305. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +883 -0
  306. package/hedhog/frontend/app/classes/[id]/_components/event-summary-popover.tsx.ejs +279 -0
  307. package/hedhog/frontend/app/classes/[id]/_components/quick-create-session-popover.tsx.ejs +1027 -0
  308. package/hedhog/frontend/app/classes/[id]/page.tsx.ejs +3130 -993
  309. package/hedhog/frontend/app/classes/page.tsx.ejs +2731 -759
  310. package/hedhog/frontend/app/courses/[id]/_components/CourseCertificateCard.tsx.ejs +80 -0
  311. package/hedhog/frontend/app/courses/[id]/_components/CourseClassificationCard.tsx.ejs +226 -0
  312. package/hedhog/frontend/app/courses/[id]/_components/CourseContentCard.tsx.ejs +71 -0
  313. package/hedhog/frontend/app/courses/[id]/_components/CourseDangerZoneCard.tsx.ejs +42 -0
  314. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +111 -0
  315. package/hedhog/frontend/app/courses/[id]/_components/CourseMainInfoCard.tsx.ejs +113 -0
  316. package/hedhog/frontend/app/courses/[id]/_components/CourseMediaCard.tsx.ejs +215 -0
  317. package/hedhog/frontend/app/courses/[id]/_components/CourseMultiEntityPicker.tsx.ejs +236 -0
  318. package/hedhog/frontend/app/courses/[id]/_components/CourseRelationsCard.tsx.ejs +141 -0
  319. package/hedhog/frontend/app/courses/[id]/_components/CourseSectionCard.tsx.ejs +57 -0
  320. package/hedhog/frontend/app/courses/[id]/_components/CourseSummaryCard.tsx.ejs +60 -0
  321. package/hedhog/frontend/app/courses/[id]/_components/course-edit-types.ts.ejs +33 -0
  322. package/hedhog/frontend/app/courses/[id]/page.tsx.ejs +933 -1103
  323. package/hedhog/frontend/app/courses/[id]/structure/page.tsx.ejs +699 -117
  324. package/hedhog/frontend/app/courses/page.tsx.ejs +1018 -1042
  325. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +317 -0
  326. package/hedhog/frontend/app/enterprise/_components/enterprise-activity-panel.tsx.ejs +88 -0
  327. package/hedhog/frontend/app/enterprise/_components/enterprise-admin-create-sheet.tsx.ejs +318 -0
  328. package/hedhog/frontend/app/enterprise/_components/enterprise-administrators-tab.tsx.ejs +332 -0
  329. package/hedhog/frontend/app/enterprise/_components/enterprise-class-create-sheet.tsx.ejs +58 -0
  330. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-tab.tsx.ejs +390 -0
  331. package/hedhog/frontend/app/enterprise/_components/enterprise-company-identity-card.tsx.ejs +112 -0
  332. package/hedhog/frontend/app/enterprise/_components/enterprise-course-create-sheet.tsx.ejs +183 -0
  333. package/hedhog/frontend/app/enterprise/_components/enterprise-courses-tab.tsx.ejs +363 -0
  334. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-constants.ts.ejs +88 -0
  335. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-sheet.tsx.ejs +548 -0
  336. package/hedhog/frontend/app/enterprise/_components/enterprise-detail-utils.ts.ejs +33 -0
  337. package/hedhog/frontend/app/enterprise/_components/enterprise-mocks.ts.ejs +277 -0
  338. package/hedhog/frontend/app/enterprise/_components/enterprise-person-picker.ts.ejs +31 -0
  339. package/hedhog/frontend/app/enterprise/_components/enterprise-progress-bar.tsx.ejs +21 -0
  340. package/hedhog/frontend/app/enterprise/_components/enterprise-related-tab.tsx.ejs +224 -0
  341. package/hedhog/frontend/app/enterprise/_components/enterprise-sheet.tsx.ejs +397 -0
  342. package/hedhog/frontend/app/enterprise/_components/enterprise-student-create-sheet.tsx.ejs +167 -0
  343. package/hedhog/frontend/app/enterprise/_components/enterprise-students-tab.tsx.ejs +267 -0
  344. package/hedhog/frontend/app/enterprise/_components/enterprise-system-user-picker.ts.ejs +42 -0
  345. package/hedhog/frontend/app/enterprise/_components/enterprise-types.ts.ejs +96 -0
  346. package/hedhog/frontend/app/enterprise/_components/enterprise-user-create-sheet.tsx.ejs +207 -0
  347. package/hedhog/frontend/app/enterprise/_components/enterprise-user-distribution-chart.tsx.ejs +149 -0
  348. package/hedhog/frontend/app/enterprise/page.tsx.ejs +596 -0
  349. package/hedhog/frontend/app/evaluations/page.tsx.ejs +1250 -0
  350. package/hedhog/frontend/app/exams/[id]/attempt/page.tsx.ejs +642 -196
  351. package/hedhog/frontend/app/exams/[id]/page.tsx.ejs +11 -0
  352. package/hedhog/frontend/app/exams/[id]/questions/page.tsx.ejs +1316 -436
  353. package/hedhog/frontend/app/exams/page.tsx.ejs +799 -546
  354. package/hedhog/frontend/app/layout.tsx.ejs +5 -0
  355. package/hedhog/frontend/app/page.tsx.ejs +3 -1220
  356. package/hedhog/frontend/app/reports/courses/page.tsx.ejs +843 -0
  357. package/hedhog/frontend/app/reports/dashboard/page.tsx.ejs +890 -0
  358. package/hedhog/frontend/app/reports/page.tsx.ejs +802 -808
  359. package/hedhog/frontend/app/reports/students/page.tsx.ejs +772 -0
  360. package/hedhog/frontend/app/training/page.tsx.ejs +1873 -628
  361. package/hedhog/frontend/messages/en.json +1606 -111
  362. package/hedhog/frontend/messages/pt.json +1636 -134
  363. package/hedhog/frontend/widgets/active-classes-kpi.tsx.ejs +74 -0
  364. package/hedhog/frontend/widgets/active-courses-kpi.tsx.ejs +74 -0
  365. package/hedhog/frontend/widgets/approval-rate-kpi.tsx.ejs +81 -0
  366. package/hedhog/frontend/widgets/category-distribution-chart.tsx.ejs +119 -0
  367. package/hedhog/frontend/widgets/class-calendar.tsx.ejs +440 -0
  368. package/hedhog/frontend/widgets/completion-rate-kpi.tsx.ejs +81 -0
  369. package/hedhog/frontend/widgets/engagement-chart.tsx.ejs +120 -0
  370. package/hedhog/frontend/widgets/footer-summary.tsx.ejs +80 -0
  371. package/hedhog/frontend/widgets/issued-certificates-kpi.tsx.ejs +74 -0
  372. package/hedhog/frontend/widgets/latest-enrollments.tsx.ejs +166 -0
  373. package/hedhog/frontend/widgets/student-growth-chart.tsx.ejs +89 -0
  374. package/hedhog/frontend/widgets/top-courses-chart.tsx.ejs +104 -0
  375. package/hedhog/frontend/widgets/total-students-kpi.tsx.ejs +78 -0
  376. package/hedhog/frontend/widgets/upcoming-classes.tsx.ejs +152 -0
  377. package/hedhog/table/course.yaml +19 -1
  378. package/hedhog/table/course_class_group.yaml +8 -0
  379. package/hedhog/table/course_class_session.yaml +33 -0
  380. package/hedhog/table/course_instructor.yaml +27 -0
  381. package/hedhog/table/enterprise.yaml +29 -0
  382. package/hedhog/table/enterprise_class_group.yaml +20 -0
  383. package/hedhog/table/enterprise_course.yaml +23 -0
  384. package/hedhog/table/enterprise_student.yaml +24 -0
  385. package/hedhog/table/enterprise_user.yaml +35 -0
  386. package/hedhog/table/instructor_qualification.yaml +26 -0
  387. package/hedhog/table/instructor_qualification_assignment.yaml +22 -0
  388. package/hedhog/table/question.yaml +6 -0
  389. package/package.json +6 -6
  390. package/src/certificate/certificate.controller.ts +83 -0
  391. package/src/certificate/certificate.module.ts +13 -0
  392. package/src/certificate/certificate.service.ts +413 -0
  393. package/src/certificate/dto/create-certificate-template.dto.ts +25 -0
  394. package/src/certificate/dto/update-certificate-template.dto.ts +6 -0
  395. package/src/class-group/class-group.controller.ts +189 -0
  396. package/src/class-group/class-group.module.ts +12 -0
  397. package/src/class-group/class-group.service.ts +1802 -0
  398. package/src/class-group/dto/create-class-group.dto.ts +139 -0
  399. package/src/class-group/dto/create-session.dto.ts +102 -0
  400. package/src/class-group/dto/enrollment.dto.ts +70 -0
  401. package/src/class-group/dto/update-class-group.dto.ts +4 -0
  402. package/src/class-group/dto/update-session.dto.ts +9 -0
  403. package/src/course/course-structure.controller.ts +85 -0
  404. package/src/course/course-structure.service.ts +525 -0
  405. package/src/course/course.controller.ts +69 -0
  406. package/src/course/course.module.ts +15 -0
  407. package/src/course/course.service.ts +920 -0
  408. package/src/course/dto/create-course-structure-lesson.dto.ts +97 -0
  409. package/src/course/dto/create-course-structure-session.dto.ts +22 -0
  410. package/src/course/dto/create-course.dto.ts +111 -0
  411. package/src/course/dto/update-course-structure-lesson.dto.ts +6 -0
  412. package/src/course/dto/update-course-structure-session.dto.ts +6 -0
  413. package/src/course/dto/update-course.dto.ts +4 -0
  414. package/src/dashboard/dashboard.controller.ts +14 -0
  415. package/src/dashboard/dashboard.module.ts +12 -0
  416. package/src/dashboard/dashboard.service.ts +726 -0
  417. package/src/enterprise/dto/add-enterprise-class-group.dto.ts +7 -0
  418. package/src/enterprise/dto/add-enterprise-course.dto.ts +11 -0
  419. package/src/enterprise/dto/add-enterprise-student.dto.ts +16 -0
  420. package/src/enterprise/dto/add-enterprise-user.dto.ts +23 -0
  421. package/src/enterprise/dto/create-enterprise.dto.ts +41 -0
  422. package/src/enterprise/dto/update-enterprise-student.dto.ts +7 -0
  423. package/src/enterprise/dto/update-enterprise-user.dto.ts +11 -0
  424. package/src/enterprise/dto/update-enterprise.dto.ts +4 -0
  425. package/src/enterprise/enterprise.controller.ts +233 -0
  426. package/src/enterprise/enterprise.module.ts +12 -0
  427. package/src/enterprise/enterprise.service.ts +712 -0
  428. package/src/evaluation/evaluation.controller.ts +44 -0
  429. package/src/evaluation/evaluation.module.ts +12 -0
  430. package/src/evaluation/evaluation.service.ts +394 -0
  431. package/src/exam/dto/create-exam-question.dto.ts +103 -0
  432. package/src/exam/dto/create-exam.dto.ts +41 -0
  433. package/src/exam/dto/reorder-exam-questions.dto.ts +8 -0
  434. package/src/exam/dto/save-exam-attempt-answers.dto.ts +55 -0
  435. package/src/exam/dto/start-exam-attempt.dto.ts +8 -0
  436. package/src/exam/dto/submit-exam-attempt.dto.ts +8 -0
  437. package/src/exam/dto/update-exam-question.dto.ts +4 -0
  438. package/src/exam/dto/update-exam.dto.ts +4 -0
  439. package/src/exam/exam-attempt.controller.ts +65 -0
  440. package/src/exam/exam-attempt.service.ts +1008 -0
  441. package/src/exam/exam.controller.ts +102 -0
  442. package/src/exam/exam.module.ts +14 -0
  443. package/src/exam/exam.service.ts +784 -0
  444. package/src/index.ts +29 -0
  445. package/src/instructor/dto/create-instructor.dto.ts +43 -0
  446. package/src/instructor/dto/update-instructor.dto.ts +38 -0
  447. package/src/instructor/instructor.controller.ts +73 -0
  448. package/src/instructor/instructor.module.ts +12 -0
  449. package/src/instructor/instructor.service.ts +646 -0
  450. package/src/lms.module.ts +36 -4
  451. package/src/reports/reports.controller.ts +14 -0
  452. package/src/reports/reports.module.ts +12 -0
  453. package/src/reports/reports.service.ts +485 -0
  454. package/src/training/dto/create-training.dto.ts +81 -0
  455. package/src/training/dto/update-training.dto.ts +4 -0
  456. package/src/training/training.controller.ts +68 -0
  457. package/src/training/training.module.ts +12 -0
  458. package/src/training/training.service.ts +574 -0
@@ -0,0 +1,1802 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import {
3
+ BadRequestException,
4
+ ConflictException,
5
+ Injectable,
6
+ NotFoundException,
7
+ } from '@nestjs/common';
8
+ import { Prisma } from '@prisma/client';
9
+ import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library';
10
+ import { randomUUID } from 'node:crypto';
11
+ import {
12
+ CreateClassGroupDto,
13
+ type ClassGroupSessionRecurrenceDto,
14
+ type ClassGroupSessionTemplateDto,
15
+ } from './dto/create-class-group.dto';
16
+ import { CreateSessionDto, type SessionRecurrenceDto } from './dto/create-session.dto';
17
+ import { UpdateClassGroupDto } from './dto/update-class-group.dto';
18
+ import { UpdateSessionDto } from './dto/update-session.dto';
19
+
20
+ type SessionScope = 'single' | 'series';
21
+ type SessionRecurrenceDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
22
+
23
+ type NormalizedSessionRecurrence = {
24
+ frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
25
+ interval: number;
26
+ until: string;
27
+ daysOfWeek?: SessionRecurrenceDay[];
28
+ };
29
+
30
+ type ClassSessionTemplateSummary = {
31
+ title: string;
32
+ isRecurring: boolean;
33
+ recurrence?: NormalizedSessionRecurrence | null;
34
+ };
35
+
36
+ @Injectable()
37
+ export class ClassGroupService {
38
+ private static readonly DAY_ORDER: SessionRecurrenceDay[] = [
39
+ 'MO',
40
+ 'TU',
41
+ 'WE',
42
+ 'TH',
43
+ 'FR',
44
+ 'SA',
45
+ 'SU',
46
+ ];
47
+
48
+ private static readonly UTC_DAY_TO_CODE: SessionRecurrenceDay[] = [
49
+ 'SU',
50
+ 'MO',
51
+ 'TU',
52
+ 'WE',
53
+ 'TH',
54
+ 'FR',
55
+ 'SA',
56
+ ];
57
+
58
+ constructor(private readonly prisma: PrismaService) {}
59
+
60
+ private isEmailCode(code?: string | null) {
61
+ return (code ?? '').toLowerCase() === 'email';
62
+ }
63
+
64
+ private isPhoneCode(code?: string | null) {
65
+ const normalized = (code ?? '').toLowerCase();
66
+ return normalized === 'phone' || normalized === 'mobile';
67
+ }
68
+
69
+ private getClassGroupInclude() {
70
+ const include: any = {
71
+ course: {
72
+ select: {
73
+ id: true,
74
+ title: true,
75
+ primary_color: true,
76
+ },
77
+ },
78
+ _count: {
79
+ select: {
80
+ course_enrollment: {
81
+ where: {
82
+ status: { not: 'cancelled' },
83
+ },
84
+ },
85
+ course_class_session: true,
86
+ },
87
+ },
88
+ instructor: {
89
+ select: {
90
+ id: true,
91
+ person: {
92
+ select: {
93
+ name: true,
94
+ },
95
+ },
96
+ },
97
+ },
98
+ };
99
+
100
+ return include;
101
+ }
102
+
103
+ private getSessionInclude() {
104
+ return {
105
+ course_class_session_instructor: {
106
+ orderBy: { id: 'asc' as const },
107
+ select: {
108
+ instructor_id: true,
109
+ role: true,
110
+ instructor: {
111
+ select: {
112
+ person: {
113
+ select: {
114
+ name: true,
115
+ },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ },
121
+ };
122
+ }
123
+
124
+ async list(params: {
125
+ page?: number;
126
+ pageSize?: number;
127
+ search?: string;
128
+ status?: string;
129
+ deliveryMode?: string;
130
+ courseId?: number;
131
+ }) {
132
+ const page = Math.max(Number(params.page) || 1, 1);
133
+ const pageSize = Math.max(Number(params.pageSize) || 12, 1);
134
+ const skip = (page - 1) * pageSize;
135
+
136
+ const where: any = {};
137
+
138
+ const status = this.normalizeStatus(params.status);
139
+ if (status) {
140
+ where.status = status;
141
+ }
142
+
143
+ const deliveryMode = this.normalizeDeliveryMode(params.deliveryMode);
144
+ if (deliveryMode) {
145
+ where.delivery_mode = deliveryMode;
146
+ }
147
+
148
+ if (params.courseId) {
149
+ where.course_id = params.courseId;
150
+ }
151
+
152
+ if (params.search?.trim()) {
153
+ const search = params.search.trim();
154
+ where.OR = [
155
+ { code: { contains: search, mode: 'insensitive' } },
156
+ { title: { contains: search, mode: 'insensitive' } },
157
+ { course: { title: { contains: search, mode: 'insensitive' } } },
158
+ ];
159
+ }
160
+
161
+ const [classes, total] = await Promise.all([
162
+ this.prisma.course_class_group.findMany({
163
+ skip,
164
+ take: pageSize,
165
+ where,
166
+ orderBy: { created_at: 'desc' },
167
+ include: this.getClassGroupInclude(),
168
+ }),
169
+ this.prisma.course_class_group.count({ where }),
170
+ ]);
171
+
172
+ return {
173
+ total,
174
+ page,
175
+ pageSize,
176
+ lastPage: Math.max(1, Math.ceil(total / pageSize)),
177
+ data: classes.map((item) => this.mapClassGroup(item)),
178
+ };
179
+ }
180
+
181
+ async stats() {
182
+ const [totalClasses, ongoingClasses, classes] = await Promise.all([
183
+ this.prisma.course_class_group.count(),
184
+ this.prisma.course_class_group.count({ where: { status: 'ongoing' } }),
185
+ this.prisma.course_class_group.findMany({
186
+ select: {
187
+ id: true,
188
+ status: true,
189
+ capacity: true,
190
+ _count: {
191
+ select: {
192
+ course_enrollment: {
193
+ where: {
194
+ status: { not: 'cancelled' },
195
+ },
196
+ },
197
+ },
198
+ },
199
+ },
200
+ }),
201
+ ]);
202
+
203
+ const openVacancies = classes
204
+ .filter((item) => item.status === 'open')
205
+ .reduce(
206
+ (sum, item) =>
207
+ sum + Math.max((item.capacity ?? 0) - item._count.course_enrollment, 0),
208
+ 0,
209
+ );
210
+
211
+ const occupancyRate =
212
+ classes.length > 0
213
+ ? Math.round(
214
+ (classes.reduce(
215
+ (sum, item) =>
216
+ sum +
217
+ item._count.course_enrollment / Math.max(item.capacity ?? 1, 1),
218
+ 0,
219
+ ) /
220
+ classes.length) *
221
+ 100,
222
+ )
223
+ : 0;
224
+
225
+ return {
226
+ totalClasses,
227
+ ongoingClasses,
228
+ openVacancies,
229
+ occupancyRate,
230
+ };
231
+ }
232
+
233
+ async getById(id: number) {
234
+ const item = await this.prisma.course_class_group.findUnique({
235
+ where: { id },
236
+ include: this.getClassGroupInclude(),
237
+ });
238
+
239
+ if (!item) {
240
+ return null;
241
+ }
242
+
243
+ const sessionTemplateSummary = await this.getClassSessionTemplateSummary(id);
244
+
245
+ return {
246
+ ...this.mapClassGroup(item),
247
+ sessionTitle: sessionTemplateSummary?.title ?? null,
248
+ sessionRecurrenceSummary: sessionTemplateSummary
249
+ ? {
250
+ ...(sessionTemplateSummary.recurrence ?? {}),
251
+ isRecurring: sessionTemplateSummary.isRecurring,
252
+ }
253
+ : null,
254
+ };
255
+ }
256
+
257
+ async create(dto: CreateClassGroupDto) {
258
+ await this.assertCourseExists(dto.courseId);
259
+ if (dto.instructorId) {
260
+ await this.assertInstructorExists(dto.instructorId);
261
+ }
262
+ this.assertDatesAndTimes({
263
+ startDate: dto.startDate,
264
+ endDate: dto.endDate,
265
+ startTime: dto.startTime,
266
+ endTime: dto.endTime,
267
+ });
268
+
269
+ try {
270
+ const created = await this.prisma.$transaction(async (tx) => {
271
+ const createdClass = await tx.course_class_group.create({
272
+ data: {
273
+ code: dto.code,
274
+ course_id: dto.courseId,
275
+ instructor_id: dto.instructorId,
276
+ title: dto.title,
277
+ description: dto.description,
278
+ delivery_mode: dto.deliveryMode,
279
+ status: dto.status ?? 'open',
280
+ start_date: new Date(dto.startDate),
281
+ end_date: dto.endDate ? new Date(dto.endDate) : null,
282
+ start_time: dto.startTime,
283
+ end_time: dto.endTime,
284
+ week_days: dto.weekDays,
285
+ capacity: dto.capacity ?? 30,
286
+ location: dto.location,
287
+ virtual_room_url: dto.virtualRoomUrl,
288
+ },
289
+ include: this.getClassGroupInclude(),
290
+ });
291
+
292
+ if (dto.sessionTemplate) {
293
+ await this.syncClassSessionsFromTemplate(
294
+ tx,
295
+ createdClass.id,
296
+ createdClass,
297
+ dto.sessionTemplate,
298
+ );
299
+ }
300
+
301
+ return createdClass;
302
+ });
303
+
304
+ return this.mapClassGroup(created);
305
+ } catch (error) {
306
+ this.handlePrismaError(error);
307
+ }
308
+ }
309
+
310
+ async update(id: number, dto: UpdateClassGroupDto) {
311
+ const existing = await this.prisma.course_class_group.findUnique({
312
+ where: { id },
313
+ select: {
314
+ id: true,
315
+ course_id: true,
316
+ code: true,
317
+ title: true,
318
+ description: true,
319
+ delivery_mode: true,
320
+ instructor_id: true,
321
+ start_date: true,
322
+ end_date: true,
323
+ start_time: true,
324
+ end_time: true,
325
+ location: true,
326
+ virtual_room_url: true,
327
+ },
328
+ });
329
+
330
+ if (!existing) {
331
+ throw new NotFoundException('Class not found');
332
+ }
333
+
334
+ const courseId = dto.courseId ?? existing.course_id;
335
+ await this.assertCourseExists(courseId);
336
+ if (dto.instructorId) {
337
+ await this.assertInstructorExists(dto.instructorId);
338
+ }
339
+
340
+ this.assertDatesAndTimes({
341
+ startDate: dto.startDate ?? existing.start_date.toISOString(),
342
+ endDate:
343
+ dto.endDate !== undefined
344
+ ? dto.endDate
345
+ : existing.end_date?.toISOString(),
346
+ startTime:
347
+ dto.startTime !== undefined ? dto.startTime : existing.start_time ?? undefined,
348
+ endTime:
349
+ dto.endTime !== undefined ? dto.endTime : existing.end_time ?? undefined,
350
+ });
351
+
352
+ const status =
353
+ dto.status !== undefined ? this.normalizeStatus(dto.status) : undefined;
354
+ const deliveryMode =
355
+ dto.deliveryMode !== undefined
356
+ ? this.normalizeDeliveryMode(dto.deliveryMode)
357
+ : undefined;
358
+
359
+ try {
360
+ const updated = await this.prisma.$transaction(async (tx) => {
361
+ const updatedClass = await tx.course_class_group.update({
362
+ where: { id },
363
+ data: {
364
+ ...(dto.code !== undefined && { code: dto.code }),
365
+ ...(dto.courseId !== undefined && { course_id: dto.courseId }),
366
+ ...(dto.instructorId !== undefined && {
367
+ instructor_id: dto.instructorId,
368
+ }),
369
+ ...(dto.title !== undefined && { title: dto.title }),
370
+ ...(dto.description !== undefined && { description: dto.description }),
371
+ ...(deliveryMode !== undefined && { delivery_mode: deliveryMode }),
372
+ ...(status !== undefined && { status }),
373
+ ...(dto.startDate !== undefined && { start_date: new Date(dto.startDate) }),
374
+ ...(dto.endDate !== undefined && {
375
+ end_date: dto.endDate ? new Date(dto.endDate) : null,
376
+ }),
377
+ ...(dto.startTime !== undefined && { start_time: dto.startTime }),
378
+ ...(dto.endTime !== undefined && { end_time: dto.endTime }),
379
+ ...(dto.weekDays !== undefined && { week_days: dto.weekDays }),
380
+ ...(dto.capacity !== undefined && { capacity: dto.capacity }),
381
+ ...(dto.location !== undefined && { location: dto.location }),
382
+ ...(dto.virtualRoomUrl !== undefined && {
383
+ virtual_room_url: dto.virtualRoomUrl,
384
+ }),
385
+ },
386
+ include: this.getClassGroupInclude(),
387
+ });
388
+
389
+ if (dto.sessionTemplate) {
390
+ await this.syncClassSessionsFromTemplate(
391
+ tx,
392
+ id,
393
+ updatedClass,
394
+ dto.sessionTemplate,
395
+ { futureOnly: true },
396
+ );
397
+ }
398
+
399
+ return updatedClass;
400
+ });
401
+
402
+ return this.mapClassGroup(updated);
403
+ } catch (error) {
404
+ this.handlePrismaError(error);
405
+ }
406
+ }
407
+
408
+ async remove(id: number) {
409
+ await this.prisma.course_class_group.delete({ where: { id } });
410
+ return { success: true };
411
+ }
412
+
413
+ private async getClassSessionTemplateSummary(
414
+ classGroupId: number,
415
+ ): Promise<ClassSessionTemplateSummary | null> {
416
+ const today = this.getSessionMutationFloorDate();
417
+ const session =
418
+ (await this.prisma.course_class_session.findFirst({
419
+ where: {
420
+ course_class_group_id: classGroupId,
421
+ session_date: { gte: today },
422
+ status: { notIn: ['completed', 'cancelled'] },
423
+ },
424
+ orderBy: [{ session_date: 'asc' }, { occurrence_index: 'asc' }],
425
+ select: {
426
+ title: true,
427
+ recurrence_rule: true,
428
+ recurrence_id: true,
429
+ },
430
+ })) ??
431
+ (await this.prisma.course_class_session.findFirst({
432
+ where: { course_class_group_id: classGroupId },
433
+ orderBy: [{ session_date: 'asc' }, { occurrence_index: 'asc' }],
434
+ select: {
435
+ title: true,
436
+ recurrence_rule: true,
437
+ recurrence_id: true,
438
+ },
439
+ }));
440
+
441
+ if (!session) {
442
+ return null;
443
+ }
444
+
445
+ return {
446
+ title: session.title,
447
+ recurrence: this.parseRecurrenceRule(session.recurrence_rule),
448
+ isRecurring: Boolean(session.recurrence_id),
449
+ };
450
+ }
451
+
452
+ private getSessionMutationFloorDate() {
453
+ return this.normalizeSessionDate(new Date());
454
+ }
455
+
456
+ private buildClassSessionDto(
457
+ classGroup: any,
458
+ sessionTemplate: ClassGroupSessionTemplateDto,
459
+ ): CreateSessionDto {
460
+ if (!classGroup.start_date) {
461
+ throw new BadRequestException('Class start date is required to generate sessions');
462
+ }
463
+
464
+ if (!classGroup.start_time || !classGroup.end_time) {
465
+ throw new BadRequestException('Class start and end times are required to generate sessions');
466
+ }
467
+
468
+ const deliveryMode = classGroup.delivery_mode;
469
+ const isOnline = deliveryMode === 'online';
470
+ const isHybrid = deliveryMode === 'hybrid';
471
+
472
+ return {
473
+ title: sessionTemplate.title.trim(),
474
+ description: sessionTemplate.description ?? classGroup.description ?? undefined,
475
+ sessionDate: this.toDateKey(this.normalizeSessionDate(classGroup.start_date)),
476
+ startTime: classGroup.start_time,
477
+ endTime: classGroup.end_time,
478
+ location:
479
+ isOnline && !isHybrid
480
+ ? undefined
481
+ : sessionTemplate.location ?? classGroup.location ?? undefined,
482
+ meetingUrl:
483
+ !isOnline && !isHybrid
484
+ ? undefined
485
+ : sessionTemplate.meetingUrl ?? classGroup.virtual_room_url ?? undefined,
486
+ color: sessionTemplate.color ?? undefined,
487
+ status: 'scheduled',
488
+ instructorId: classGroup.instructor_id ?? undefined,
489
+ recurrence: sessionTemplate.recurrence
490
+ ? this.toSessionRecurrenceDto(sessionTemplate.recurrence)
491
+ : undefined,
492
+ };
493
+ }
494
+
495
+ private toSessionRecurrenceDto(
496
+ recurrence: ClassGroupSessionRecurrenceDto,
497
+ ): SessionRecurrenceDto {
498
+ return {
499
+ frequency: recurrence.frequency,
500
+ interval: recurrence.interval,
501
+ until: recurrence.until,
502
+ daysOfWeek: recurrence.daysOfWeek,
503
+ };
504
+ }
505
+
506
+ private async syncClassSessionsFromTemplate(
507
+ tx: Prisma.TransactionClient,
508
+ classGroupId: number,
509
+ classGroup: any,
510
+ sessionTemplate: ClassGroupSessionTemplateDto,
511
+ options?: { futureOnly?: boolean },
512
+ ) {
513
+ const createDto = this.buildClassSessionDto(classGroup, sessionTemplate);
514
+ const mutableWhere: any = {
515
+ course_class_group_id: classGroupId,
516
+ };
517
+
518
+ if (options?.futureOnly) {
519
+ mutableWhere.session_date = { gte: this.getSessionMutationFloorDate() };
520
+ mutableWhere.status = { notIn: ['completed', 'cancelled'] };
521
+ }
522
+
523
+ const mutableSessions = await tx.course_class_session.findMany({
524
+ where: mutableWhere,
525
+ select: { id: true },
526
+ });
527
+
528
+ if (mutableSessions.length > 0) {
529
+ const sessionIds = mutableSessions.map((session) => session.id);
530
+
531
+ await tx.course_class_attendance.deleteMany({
532
+ where: { course_class_session_id: { in: sessionIds } },
533
+ });
534
+ await tx.course_class_session_instructor.deleteMany({
535
+ where: { course_class_session_id: { in: sessionIds } },
536
+ });
537
+ await tx.course_class_session.deleteMany({
538
+ where: { id: { in: sessionIds } },
539
+ });
540
+ }
541
+
542
+ await this.createSessionSeriesInTransaction(tx, classGroupId, createDto, {
543
+ minDate: options?.futureOnly ? this.getSessionMutationFloorDate() : undefined,
544
+ });
545
+ }
546
+
547
+ private mapClassGroup(item: any) {
548
+ const instructorName =
549
+ item.instructor?.person?.name?.trim() ??
550
+ item.instructorName?.trim?.() ??
551
+ item.professorName?.trim?.() ??
552
+ '';
553
+
554
+ return {
555
+ id: item.id,
556
+ code: item.code,
557
+ title: item.title,
558
+ description: item.description ?? '',
559
+ deliveryMode: item.delivery_mode,
560
+ status: item.status,
561
+ startDate: item.start_date,
562
+ endDate: item.end_date,
563
+ startTime: item.start_time,
564
+ endTime: item.end_time,
565
+ weekDays: item.week_days,
566
+ capacity: item.capacity,
567
+ location: item.location,
568
+ virtualRoomUrl: item.virtual_room_url,
569
+ courseId: item.course_id,
570
+ instructorId: item.instructor_id ?? item.instructor?.id ?? null,
571
+ instructorName,
572
+ instructor: instructorName,
573
+ professorName: instructorName,
574
+ professor: instructorName,
575
+ courseTitle: item.course?.title ?? '',
576
+ primaryColor: item.course?.primary_color ?? null,
577
+ enrolledCount: item._count?.course_enrollment ?? 0,
578
+ sessionCount: item._count?.course_class_session ?? 0,
579
+ occupancyRate: Math.round(
580
+ ((item._count?.course_enrollment ?? 0) / Math.max(item.capacity ?? 1, 1)) *
581
+ 100,
582
+ ),
583
+ createdAt: item.created_at,
584
+ updatedAt: item.updated_at,
585
+ };
586
+ }
587
+
588
+ private normalizeStatus(value?: string) {
589
+ if (!value) return undefined;
590
+
591
+ const normalized = value.trim().toLowerCase();
592
+ const valid = ['open', 'ongoing', 'completed', 'cancelled'];
593
+
594
+ if (!valid.includes(normalized)) {
595
+ throw new BadRequestException('Invalid status');
596
+ }
597
+
598
+ return normalized as 'open' | 'ongoing' | 'completed' | 'cancelled';
599
+ }
600
+
601
+ private normalizeDeliveryMode(value?: string) {
602
+ if (!value) return undefined;
603
+
604
+ const normalized = value.trim().toLowerCase();
605
+ const valid = ['presential', 'online', 'hybrid'];
606
+
607
+ if (!valid.includes(normalized)) {
608
+ throw new BadRequestException('Invalid delivery mode');
609
+ }
610
+
611
+ return normalized as 'presential' | 'online' | 'hybrid';
612
+ }
613
+
614
+ private assertDatesAndTimes(params: {
615
+ startDate: string;
616
+ endDate?: string;
617
+ startTime?: string;
618
+ endTime?: string;
619
+ }) {
620
+ const startDate = new Date(params.startDate);
621
+ if (Number.isNaN(startDate.getTime())) {
622
+ throw new BadRequestException('Invalid start date');
623
+ }
624
+
625
+ if (params.endDate) {
626
+ const endDate = new Date(params.endDate);
627
+ if (Number.isNaN(endDate.getTime())) {
628
+ throw new BadRequestException('Invalid end date');
629
+ }
630
+ if (endDate.getTime() < startDate.getTime()) {
631
+ throw new BadRequestException('End date cannot be before start date');
632
+ }
633
+ }
634
+
635
+ if (params.startTime && params.endTime) {
636
+ const start = this.timeToMinutes(params.startTime);
637
+ const end = this.timeToMinutes(params.endTime);
638
+
639
+ if (start >= end) {
640
+ throw new BadRequestException('End time must be after start time');
641
+ }
642
+ }
643
+ }
644
+
645
+ private timeToMinutes(value: string) {
646
+ const [hours, minutes] = value.split(':').map(Number);
647
+ return hours * 60 + minutes;
648
+ }
649
+
650
+ private async assertCourseExists(courseId: number) {
651
+ const exists = await this.prisma.course.findUnique({
652
+ where: { id: courseId },
653
+ select: { id: true },
654
+ });
655
+
656
+ if (!exists) {
657
+ throw new NotFoundException('Course not found');
658
+ }
659
+ }
660
+
661
+ // ── Students ────────────────────────────────────────────────────────────────
662
+
663
+ async listStudents(classGroupId: number, search?: string) {
664
+ const group = await this.prisma.course_class_group.findUnique({
665
+ where: { id: classGroupId },
666
+ select: { id: true, course_id: true },
667
+ });
668
+ if (!group) throw new NotFoundException('Class not found');
669
+
670
+ const enrollments = await this.prisma.course_enrollment.findMany({
671
+ where: {
672
+ course_class_group_id: classGroupId,
673
+ status: { not: 'cancelled' },
674
+ },
675
+ include: {
676
+ person: {
677
+ select: {
678
+ id: true,
679
+ name: true,
680
+ contact: {
681
+ select: { value: true, contact_type: { select: { code: true } } },
682
+ },
683
+ },
684
+ },
685
+ },
686
+ orderBy: { enrolled_at: 'asc' },
687
+ });
688
+
689
+ const mapped = enrollments.map((e) => {
690
+ const contacts = e.person.contact;
691
+ const email =
692
+ contacts.find((c) => this.isEmailCode(c.contact_type.code))?.value ?? '';
693
+ const phone =
694
+ contacts.find((c) => this.isPhoneCode(c.contact_type.code))?.value ?? '';
695
+ return {
696
+ id: e.person.id,
697
+ enrollmentId: e.id,
698
+ nome: e.person.name,
699
+ email,
700
+ telefone: phone,
701
+ matriculadoEm: e.enrolled_at,
702
+ progresso: e.progress_percent,
703
+ status: e.status,
704
+ };
705
+ });
706
+
707
+ if (search?.trim()) {
708
+ const q = search.trim().toLowerCase();
709
+ return mapped.filter(
710
+ (s) =>
711
+ s.nome.toLowerCase().includes(q) ||
712
+ s.email.toLowerCase().includes(q),
713
+ );
714
+ }
715
+
716
+ return mapped;
717
+ }
718
+
719
+ async enrollStudent(classGroupId: number, personId: number) {
720
+ const group = await this.prisma.course_class_group.findUnique({
721
+ where: { id: classGroupId },
722
+ select: { id: true, course_id: true, capacity: true },
723
+ });
724
+ if (!group) throw new NotFoundException('Class not found');
725
+
726
+ const existing = await this.prisma.course_enrollment.findFirst({
727
+ where: { course_class_group_id: classGroupId, person_id: personId },
728
+ });
729
+ if (existing) {
730
+ if (existing.status === 'cancelled') {
731
+ return this.prisma.course_enrollment.update({
732
+ where: { id: existing.id },
733
+ data: { status: 'active', enrolled_at: new Date() },
734
+ });
735
+ }
736
+ throw new ConflictException('Student already enrolled');
737
+ }
738
+
739
+ return this.prisma.course_enrollment.create({
740
+ data: {
741
+ person_id: personId,
742
+ course_class_group_id: classGroupId,
743
+ course_id: group.course_id,
744
+ status: 'active',
745
+ enrolled_at: new Date(),
746
+ },
747
+ });
748
+ }
749
+
750
+ async createAndEnrollStudent(
751
+ classGroupId: number,
752
+ dto: { name: string; email?: string; phone?: string },
753
+ ) {
754
+ const group = await this.prisma.course_class_group.findUnique({
755
+ where: { id: classGroupId },
756
+ select: { id: true },
757
+ });
758
+
759
+ if (!group) {
760
+ throw new NotFoundException('Class not found');
761
+ }
762
+
763
+ const name = dto.name.trim();
764
+ const email = dto.email?.trim();
765
+ const phone = dto.phone?.trim();
766
+
767
+ if (!name) {
768
+ throw new BadRequestException('Name is required');
769
+ }
770
+
771
+ let personId: number;
772
+
773
+ if (email) {
774
+ const existingByEmail = await this.prisma.person.findFirst({
775
+ where: {
776
+ contact: {
777
+ some: {
778
+ value: { equals: email, mode: 'insensitive' },
779
+ contact_type: { code: { in: ['email', 'EMAIL'] } },
780
+ },
781
+ },
782
+ },
783
+ select: { id: true },
784
+ });
785
+
786
+ if (existingByEmail) {
787
+ personId = existingByEmail.id;
788
+ }
789
+ }
790
+
791
+ if (!personId) {
792
+ const [emailType, phoneType] = await Promise.all([
793
+ this.prisma.contact_type.findFirst({
794
+ where: { code: { in: ['email', 'EMAIL'] } },
795
+ select: { id: true },
796
+ }),
797
+ this.prisma.contact_type.findFirst({
798
+ where: { code: { in: ['mobile', 'MOBILE', 'phone', 'PHONE'] } },
799
+ orderBy: { id: 'asc' },
800
+ select: { id: true },
801
+ }),
802
+ ]);
803
+
804
+ const createdPerson = await this.prisma.person.create({
805
+ data: {
806
+ name,
807
+ type: 'individual',
808
+ status: 'active',
809
+ },
810
+ select: { id: true },
811
+ });
812
+
813
+ personId = createdPerson.id;
814
+
815
+ const contactsToCreate: Array<{
816
+ person_id: number;
817
+ contact_type_id: number;
818
+ value: string;
819
+ is_primary: boolean;
820
+ }> = [];
821
+
822
+ if (email && emailType) {
823
+ contactsToCreate.push({
824
+ person_id: personId,
825
+ contact_type_id: emailType.id,
826
+ value: email,
827
+ is_primary: true,
828
+ });
829
+ }
830
+
831
+ if (phone && phoneType) {
832
+ contactsToCreate.push({
833
+ person_id: personId,
834
+ contact_type_id: phoneType.id,
835
+ value: phone,
836
+ is_primary: true,
837
+ });
838
+ }
839
+
840
+ if (contactsToCreate.length > 0) {
841
+ await this.prisma.contact.createMany({
842
+ data: contactsToCreate,
843
+ });
844
+ }
845
+ }
846
+
847
+ const person = await this.prisma.person.findUnique({
848
+ where: { id: personId },
849
+ include: {
850
+ contact: {
851
+ select: {
852
+ value: true,
853
+ contact_type: { select: { code: true } },
854
+ },
855
+ },
856
+ },
857
+ });
858
+
859
+ return {
860
+ id: person?.id,
861
+ nome: person?.name ?? '',
862
+ email:
863
+ person?.contact.find((c) => this.isEmailCode(c.contact_type.code))
864
+ ?.value ?? '',
865
+ };
866
+ }
867
+
868
+ async removeStudent(classGroupId: number, personId: number) {
869
+ const enrollment = await this.prisma.course_enrollment.findFirst({
870
+ where: {
871
+ course_class_group_id: classGroupId,
872
+ person_id: personId,
873
+ status: { not: 'cancelled' },
874
+ },
875
+ });
876
+ if (!enrollment) throw new NotFoundException('Enrollment not found');
877
+
878
+ await this.prisma.course_enrollment.update({
879
+ where: { id: enrollment.id },
880
+ data: { status: 'cancelled' },
881
+ });
882
+ return { success: true };
883
+ }
884
+
885
+ async getStudentProfile(classGroupId: number, personId: number) {
886
+ const enrollment = await this.prisma.course_enrollment.findFirst({
887
+ where: {
888
+ course_class_group_id: classGroupId,
889
+ person_id: personId,
890
+ status: { not: 'cancelled' },
891
+ },
892
+ include: {
893
+ person: {
894
+ select: {
895
+ id: true,
896
+ name: true,
897
+ contact: {
898
+ select: {
899
+ id: true,
900
+ value: true,
901
+ contact_type: { select: { code: true } },
902
+ },
903
+ },
904
+ },
905
+ },
906
+ },
907
+ });
908
+
909
+ if (!enrollment) {
910
+ throw new NotFoundException('Student not found in this class');
911
+ }
912
+
913
+ const email =
914
+ enrollment.person.contact.find((c) => this.isEmailCode(c.contact_type.code))
915
+ ?.value ?? '';
916
+ const phone =
917
+ enrollment.person.contact.find((c) => this.isPhoneCode(c.contact_type.code))
918
+ ?.value ?? '';
919
+
920
+ return {
921
+ id: enrollment.person.id,
922
+ enrollmentId: enrollment.id,
923
+ nome: enrollment.person.name,
924
+ email,
925
+ telefone: phone,
926
+ matriculadoEm: enrollment.enrolled_at,
927
+ progresso: enrollment.progress_percent,
928
+ status: enrollment.status,
929
+ };
930
+ }
931
+
932
+ async updateStudentProfile(
933
+ classGroupId: number,
934
+ personId: number,
935
+ dto: { name?: string; email?: string; phone?: string },
936
+ ) {
937
+ const enrollment = await this.prisma.course_enrollment.findFirst({
938
+ where: {
939
+ course_class_group_id: classGroupId,
940
+ person_id: personId,
941
+ status: { not: 'cancelled' },
942
+ },
943
+ select: { id: true },
944
+ });
945
+
946
+ if (!enrollment) {
947
+ throw new NotFoundException('Student not found in this class');
948
+ }
949
+
950
+ const name = dto.name?.trim();
951
+ const email = dto.email?.trim();
952
+ const phone = dto.phone?.trim();
953
+
954
+ if (dto.name !== undefined && !name) {
955
+ throw new BadRequestException('Name is required');
956
+ }
957
+
958
+ if (name) {
959
+ await this.prisma.person.update({
960
+ where: { id: personId },
961
+ data: { name },
962
+ });
963
+ }
964
+
965
+ const [emailType, phoneType] = await Promise.all([
966
+ this.prisma.contact_type.findFirst({
967
+ where: { code: { in: ['email', 'EMAIL'] } },
968
+ select: { id: true },
969
+ }),
970
+ this.prisma.contact_type.findFirst({
971
+ where: { code: { in: ['mobile', 'MOBILE', 'phone', 'PHONE'] } },
972
+ orderBy: { id: 'asc' },
973
+ select: { id: true },
974
+ }),
975
+ ]);
976
+
977
+ if (dto.email !== undefined && emailType) {
978
+ const existingEmail = await this.prisma.contact.findFirst({
979
+ where: { person_id: personId, contact_type_id: emailType.id },
980
+ select: { id: true },
981
+ });
982
+
983
+ if (email) {
984
+ if (existingEmail) {
985
+ await this.prisma.contact.update({
986
+ where: { id: existingEmail.id },
987
+ data: { value: email },
988
+ });
989
+ } else {
990
+ await this.prisma.contact.create({
991
+ data: {
992
+ person_id: personId,
993
+ contact_type_id: emailType.id,
994
+ value: email,
995
+ is_primary: true,
996
+ },
997
+ });
998
+ }
999
+ } else if (existingEmail) {
1000
+ await this.prisma.contact.delete({ where: { id: existingEmail.id } });
1001
+ }
1002
+ }
1003
+
1004
+ if (dto.phone !== undefined) {
1005
+ const existingPhone = await this.prisma.contact.findFirst({
1006
+ where: {
1007
+ person_id: personId,
1008
+ contact_type: { code: { in: ['mobile', 'MOBILE', 'phone', 'PHONE'] } },
1009
+ },
1010
+ orderBy: { id: 'asc' },
1011
+ select: { id: true },
1012
+ });
1013
+
1014
+ if (phone) {
1015
+ if (existingPhone) {
1016
+ await this.prisma.contact.update({
1017
+ where: { id: existingPhone.id },
1018
+ data: { value: phone },
1019
+ });
1020
+ } else if (phoneType) {
1021
+ await this.prisma.contact.create({
1022
+ data: {
1023
+ person_id: personId,
1024
+ contact_type_id: phoneType.id,
1025
+ value: phone,
1026
+ is_primary: true,
1027
+ },
1028
+ });
1029
+ }
1030
+ } else if (existingPhone) {
1031
+ await this.prisma.contact.delete({ where: { id: existingPhone.id } });
1032
+ }
1033
+ }
1034
+
1035
+ return this.getStudentProfile(classGroupId, personId);
1036
+ }
1037
+
1038
+ async searchPeople(search: string, classGroupId?: number) {
1039
+ const q = search.trim();
1040
+ if (!q) return [];
1041
+
1042
+ const enrolledIds = classGroupId
1043
+ ? (
1044
+ await this.prisma.course_enrollment.findMany({
1045
+ where: {
1046
+ course_class_group_id: classGroupId,
1047
+ status: { not: 'cancelled' },
1048
+ },
1049
+ select: { person_id: true },
1050
+ })
1051
+ ).map((e) => e.person_id)
1052
+ : [];
1053
+
1054
+ const people = await this.prisma.person.findMany({
1055
+ where: {
1056
+ AND: [
1057
+ {
1058
+ OR: [
1059
+ { name: { contains: q, mode: 'insensitive' } },
1060
+ {
1061
+ contact: {
1062
+ some: {
1063
+ value: { contains: q, mode: 'insensitive' },
1064
+ contact_type: { code: { in: ['email', 'EMAIL'] } },
1065
+ },
1066
+ },
1067
+ },
1068
+ ],
1069
+ },
1070
+ enrolledIds.length > 0 ? { id: { notIn: enrolledIds } } : {},
1071
+ ],
1072
+ },
1073
+ include: {
1074
+ contact: {
1075
+ select: { value: true, contact_type: { select: { code: true } } },
1076
+ },
1077
+ },
1078
+ take: 20,
1079
+ });
1080
+
1081
+ const personIds = people.map((person) => person.id);
1082
+
1083
+ const [instructors, enrolledPeople] =
1084
+ personIds.length > 0
1085
+ ? await Promise.all([
1086
+ this.prisma.instructor.findMany({
1087
+ where: {
1088
+ person_id: { in: personIds },
1089
+ status: 'active',
1090
+ },
1091
+ select: {
1092
+ person_id: true,
1093
+ can_teach_courses: true,
1094
+ },
1095
+ }),
1096
+ this.prisma.course_enrollment.findMany({
1097
+ where: {
1098
+ person_id: { in: personIds },
1099
+ status: { not: 'cancelled' },
1100
+ },
1101
+ select: { person_id: true },
1102
+ distinct: ['person_id'],
1103
+ }),
1104
+ ])
1105
+ : [[], []];
1106
+
1107
+ const instructorByPersonId = new Map(
1108
+ instructors.map((instructor) => [instructor.person_id, instructor]),
1109
+ );
1110
+ const enrolledPersonIds = new Set(
1111
+ enrolledPeople.map((enrollment) => enrollment.person_id),
1112
+ );
1113
+
1114
+ return people.map((p) => {
1115
+ const instructor = instructorByPersonId.get(p.id);
1116
+ const isInstructor = Boolean(instructor);
1117
+ const canTeachCourses = Boolean(instructor?.can_teach_courses);
1118
+ const isStudentByEnrollment = enrolledPersonIds.has(p.id);
1119
+
1120
+ return {
1121
+ id: p.id,
1122
+ nome: p.name,
1123
+ email:
1124
+ p.contact.find((c) => this.isEmailCode(c.contact_type.code))
1125
+ ?.value ?? '',
1126
+ isInstructor,
1127
+ canTeachCourses,
1128
+ isStudentByEnrollment,
1129
+ };
1130
+ });
1131
+ }
1132
+
1133
+ // ── Sessions ─────────────────────────────────────────────────────────────────
1134
+
1135
+ async listSessions(classGroupId: number) {
1136
+ const group = await this.prisma.course_class_group.findUnique({
1137
+ where: { id: classGroupId },
1138
+ select: { id: true },
1139
+ });
1140
+ if (!group) throw new NotFoundException('Class not found');
1141
+
1142
+ const sessions = await this.prisma.course_class_session.findMany({
1143
+ where: { course_class_group_id: classGroupId },
1144
+ orderBy: [{ session_date: 'asc' }, { occurrence_index: 'asc' }],
1145
+ include: this.getSessionInclude(),
1146
+ });
1147
+
1148
+ return sessions.map((s) => this.mapSession(s));
1149
+ }
1150
+
1151
+ async createSession(classGroupId: number, dto: CreateSessionDto) {
1152
+ const group = await this.prisma.course_class_group.findUnique({
1153
+ where: { id: classGroupId },
1154
+ select: { id: true },
1155
+ });
1156
+ if (!group) throw new NotFoundException('Class not found');
1157
+
1158
+ if (dto.instructorId) {
1159
+ await this.assertInstructorExists(dto.instructorId);
1160
+ }
1161
+
1162
+ const created = await this.prisma.$transaction((tx) =>
1163
+ this.createSessionSeriesInTransaction(tx, classGroupId, dto),
1164
+ );
1165
+
1166
+ if (!created) {
1167
+ throw new BadRequestException('No sessions were generated for the provided recurrence');
1168
+ }
1169
+
1170
+ return this.mapSession(created);
1171
+ }
1172
+
1173
+ private async createSessionSeriesInTransaction(
1174
+ tx: Prisma.TransactionClient,
1175
+ classGroupId: number,
1176
+ dto: CreateSessionDto,
1177
+ options?: { minDate?: Date },
1178
+ ) {
1179
+ const normalizedRecurrence = dto.recurrence
1180
+ ? this.normalizeSessionRecurrence(dto.recurrence, dto.sessionDate)
1181
+ : null;
1182
+ const fullOccurrenceDates = normalizedRecurrence
1183
+ ? this.buildRecurringSessionDates(dto.sessionDate, normalizedRecurrence)
1184
+ : [this.toSessionDate(dto.sessionDate)];
1185
+ const occurrenceDates = options?.minDate
1186
+ ? fullOccurrenceDates.filter(
1187
+ (date) =>
1188
+ this.normalizeSessionDate(date).getTime() >=
1189
+ this.normalizeSessionDate(options.minDate!).getTime(),
1190
+ )
1191
+ : fullOccurrenceDates;
1192
+
1193
+ if (occurrenceDates.length === 0) {
1194
+ return null;
1195
+ }
1196
+
1197
+ const recurrenceId = normalizedRecurrence ? randomUUID() : null;
1198
+ const recurrenceRule = normalizedRecurrence
1199
+ ? JSON.stringify(normalizedRecurrence)
1200
+ : null;
1201
+
1202
+ const first = await tx.course_class_session.create({
1203
+ data: {
1204
+ course_class_group_id: classGroupId,
1205
+ title: dto.title,
1206
+ description: dto.description ?? null,
1207
+ session_date: occurrenceDates[0],
1208
+ start_time: dto.startTime,
1209
+ end_time: dto.endTime,
1210
+ location: dto.location ?? null,
1211
+ meeting_url: dto.meetingUrl ?? null,
1212
+ color: dto.color ?? null,
1213
+ status: dto.status ?? 'scheduled',
1214
+ recurrence_id: recurrenceId,
1215
+ recurrence_rule: recurrenceRule,
1216
+ occurrence_index: normalizedRecurrence ? 0 : null,
1217
+ is_exception: false,
1218
+ ...(dto.instructorId && {
1219
+ course_class_session_instructor: {
1220
+ create: {
1221
+ instructor_id: dto.instructorId,
1222
+ role: 'lead',
1223
+ },
1224
+ },
1225
+ }),
1226
+ },
1227
+ include: this.getSessionInclude(),
1228
+ });
1229
+
1230
+ for (let index = 1; index < occurrenceDates.length; index += 1) {
1231
+ await tx.course_class_session.create({
1232
+ data: {
1233
+ course_class_group_id: classGroupId,
1234
+ title: dto.title,
1235
+ description: dto.description ?? null,
1236
+ session_date: occurrenceDates[index],
1237
+ start_time: dto.startTime,
1238
+ end_time: dto.endTime,
1239
+ location: dto.location ?? null,
1240
+ meeting_url: dto.meetingUrl ?? null,
1241
+ color: dto.color ?? null,
1242
+ status: dto.status ?? 'scheduled',
1243
+ recurrence_id: recurrenceId,
1244
+ recurrence_rule: recurrenceRule,
1245
+ occurrence_index: normalizedRecurrence ? index : null,
1246
+ is_exception: false,
1247
+ parent_session_id: first.id,
1248
+ ...(dto.instructorId && {
1249
+ course_class_session_instructor: {
1250
+ create: {
1251
+ instructor_id: dto.instructorId,
1252
+ role: 'lead',
1253
+ },
1254
+ },
1255
+ }),
1256
+ },
1257
+ });
1258
+ }
1259
+
1260
+ return first;
1261
+ }
1262
+
1263
+ async updateSession(
1264
+ classGroupId: number,
1265
+ sessionId: number,
1266
+ dto: UpdateSessionDto,
1267
+ ) {
1268
+ const session = await this.prisma.course_class_session.findFirst({
1269
+ where: { id: sessionId, course_class_group_id: classGroupId },
1270
+ });
1271
+ if (!session) throw new NotFoundException('Session not found');
1272
+
1273
+ if (dto.instructorId) {
1274
+ await this.assertInstructorExists(dto.instructorId);
1275
+ }
1276
+
1277
+ const scope: SessionScope = dto.applyScope ?? 'single';
1278
+ const normalizedRecurrence = dto.recurrence
1279
+ ? this.normalizeSessionRecurrence(
1280
+ dto.recurrence,
1281
+ dto.sessionDate ?? this.toDateKey(session.session_date),
1282
+ )
1283
+ : null;
1284
+ const nextRecurrenceRule = normalizedRecurrence
1285
+ ? JSON.stringify(normalizedRecurrence)
1286
+ : dto.recurrence === undefined
1287
+ ? undefined
1288
+ : null;
1289
+
1290
+ if (scope === 'series' && session.recurrence_id) {
1291
+ const seriesSessions = await this.prisma.course_class_session.findMany({
1292
+ where: {
1293
+ course_class_group_id: classGroupId,
1294
+ recurrence_id: session.recurrence_id,
1295
+ },
1296
+ orderBy: [{ session_date: 'asc' }, { occurrence_index: 'asc' }],
1297
+ });
1298
+
1299
+ const dayDelta = dto.sessionDate
1300
+ ? this.diffInDays(
1301
+ this.toSessionDate(dto.sessionDate),
1302
+ this.normalizeSessionDate(session.session_date),
1303
+ )
1304
+ : 0;
1305
+
1306
+ await this.prisma.$transaction(async (tx) => {
1307
+ for (const seriesSession of seriesSessions) {
1308
+ await tx.course_class_session.update({
1309
+ where: { id: seriesSession.id },
1310
+ data: {
1311
+ ...(dto.title !== undefined && { title: dto.title }),
1312
+ ...(dto.description !== undefined && {
1313
+ description: dto.description,
1314
+ }),
1315
+ ...(dto.startTime !== undefined && { start_time: dto.startTime }),
1316
+ ...(dto.endTime !== undefined && { end_time: dto.endTime }),
1317
+ ...(dto.location !== undefined && { location: dto.location }),
1318
+ ...(dto.meetingUrl !== undefined && {
1319
+ meeting_url: dto.meetingUrl,
1320
+ }),
1321
+ ...(dto.color !== undefined && { color: dto.color }),
1322
+ ...(dto.status !== undefined && { status: dto.status }),
1323
+ ...(dto.sessionDate !== undefined && {
1324
+ session_date: this.addDaysUtc(seriesSession.session_date, dayDelta),
1325
+ }),
1326
+ ...(nextRecurrenceRule !== undefined && {
1327
+ recurrence_rule: nextRecurrenceRule,
1328
+ }),
1329
+ },
1330
+ });
1331
+
1332
+ if (dto.instructorId !== undefined) {
1333
+ await tx.course_class_session_instructor.deleteMany({
1334
+ where: { course_class_session_id: seriesSession.id },
1335
+ });
1336
+
1337
+ if (dto.instructorId) {
1338
+ await tx.course_class_session_instructor.create({
1339
+ data: {
1340
+ course_class_session_id: seriesSession.id,
1341
+ instructor_id: dto.instructorId,
1342
+ role: 'lead',
1343
+ },
1344
+ });
1345
+ }
1346
+ }
1347
+ }
1348
+ });
1349
+
1350
+ const updated = await this.prisma.course_class_session.findUnique({
1351
+ where: { id: sessionId },
1352
+ include: this.getSessionInclude(),
1353
+ });
1354
+
1355
+ if (!updated) {
1356
+ throw new NotFoundException('Session not found');
1357
+ }
1358
+
1359
+ return this.mapSession(updated);
1360
+ }
1361
+
1362
+ const updated = await this.prisma.course_class_session.update({
1363
+ where: { id: sessionId },
1364
+ data: {
1365
+ ...(dto.title !== undefined && { title: dto.title }),
1366
+ ...(dto.description !== undefined && { description: dto.description }),
1367
+ ...(dto.sessionDate !== undefined && {
1368
+ session_date: this.toSessionDate(dto.sessionDate),
1369
+ }),
1370
+ ...(dto.startTime !== undefined && { start_time: dto.startTime }),
1371
+ ...(dto.endTime !== undefined && { end_time: dto.endTime }),
1372
+ ...(dto.location !== undefined && { location: dto.location }),
1373
+ ...(dto.meetingUrl !== undefined && { meeting_url: dto.meetingUrl }),
1374
+ ...(dto.color !== undefined && { color: dto.color }),
1375
+ ...(dto.status !== undefined && { status: dto.status }),
1376
+ ...(nextRecurrenceRule !== undefined && {
1377
+ recurrence_rule: nextRecurrenceRule,
1378
+ }),
1379
+ ...(session.recurrence_id && { is_exception: true }),
1380
+ ...(dto.instructorId !== undefined && {
1381
+ course_class_session_instructor: {
1382
+ deleteMany: {},
1383
+ ...(dto.instructorId
1384
+ ? {
1385
+ create: {
1386
+ instructor_id: dto.instructorId,
1387
+ role: 'lead',
1388
+ },
1389
+ }
1390
+ : {}),
1391
+ },
1392
+ }),
1393
+ },
1394
+ include: this.getSessionInclude(),
1395
+ });
1396
+
1397
+ return this.mapSession(updated);
1398
+ }
1399
+
1400
+ async deleteSession(
1401
+ classGroupId: number,
1402
+ sessionId: number,
1403
+ scope: SessionScope = 'single',
1404
+ ) {
1405
+ const session = await this.prisma.course_class_session.findFirst({
1406
+ where: { id: sessionId, course_class_group_id: classGroupId },
1407
+ });
1408
+ if (!session) throw new NotFoundException('Session not found');
1409
+
1410
+ if (scope === 'series' && session.recurrence_id) {
1411
+ await this.prisma.course_class_session.deleteMany({
1412
+ where: {
1413
+ course_class_group_id: classGroupId,
1414
+ recurrence_id: session.recurrence_id,
1415
+ },
1416
+ });
1417
+ return { success: true };
1418
+ }
1419
+
1420
+ await this.prisma.course_class_session.delete({ where: { id: sessionId } });
1421
+ return { success: true };
1422
+ }
1423
+
1424
+ // ── Attendance ───────────────────────────────────────────────────────────────
1425
+
1426
+ async getAttendance(classGroupId: number, sessionId: number) {
1427
+ const session = await this.prisma.course_class_session.findFirst({
1428
+ where: { id: sessionId, course_class_group_id: classGroupId },
1429
+ });
1430
+ if (!session) throw new NotFoundException('Session not found');
1431
+
1432
+ return this.prisma.course_class_attendance.findMany({
1433
+ where: { course_class_session_id: sessionId },
1434
+ include: {
1435
+ person_course_class_attendance_student_idToperson: {
1436
+ select: { id: true, name: true },
1437
+ },
1438
+ },
1439
+ });
1440
+ }
1441
+
1442
+ async saveAttendance(
1443
+ classGroupId: number,
1444
+ sessionId: number,
1445
+ entries: Array<{ studentId: number; present: boolean; justification?: string }>,
1446
+ ) {
1447
+ const session = await this.prisma.course_class_session.findFirst({
1448
+ where: { id: sessionId, course_class_group_id: classGroupId },
1449
+ });
1450
+ if (!session) throw new NotFoundException('Session not found');
1451
+
1452
+ await this.prisma.$transaction(async (tx) => {
1453
+ const normalizedEntries = Array.from(
1454
+ new Map(entries.map((entry) => [entry.studentId, entry])).values(),
1455
+ );
1456
+ const selectedStudentIds = normalizedEntries.map((entry) => entry.studentId);
1457
+
1458
+ if (selectedStudentIds.length > 0) {
1459
+ await tx.course_class_attendance.deleteMany({
1460
+ where: {
1461
+ course_class_session_id: sessionId,
1462
+ student_id: { notIn: selectedStudentIds },
1463
+ },
1464
+ });
1465
+ } else {
1466
+ await tx.course_class_attendance.deleteMany({
1467
+ where: { course_class_session_id: sessionId },
1468
+ });
1469
+ }
1470
+
1471
+ for (const e of normalizedEntries) {
1472
+ const existing = await tx.course_class_attendance.findFirst({
1473
+ where: {
1474
+ course_class_session_id: sessionId,
1475
+ student_id: e.studentId,
1476
+ },
1477
+ select: { id: true },
1478
+ });
1479
+
1480
+ if (existing) {
1481
+ await tx.course_class_attendance.update({
1482
+ where: { id: existing.id },
1483
+ data: {
1484
+ present: e.present,
1485
+ justification: e.justification ?? null,
1486
+ },
1487
+ });
1488
+ continue;
1489
+ }
1490
+
1491
+ await tx.course_class_attendance.create({
1492
+ data: {
1493
+ course_class_session_id: sessionId,
1494
+ student_id: e.studentId,
1495
+ present: e.present,
1496
+ justification: e.justification ?? null,
1497
+ },
1498
+ });
1499
+ }
1500
+ });
1501
+
1502
+ return { success: true };
1503
+ }
1504
+
1505
+ private mapSession(s: any) {
1506
+ const leadInstructor = s.course_class_session_instructor?.find(
1507
+ (entry: any) => entry.role === 'lead',
1508
+ );
1509
+ const fallbackInstructor = s.course_class_session_instructor?.[0];
1510
+ const sessionInstructor = leadInstructor ?? fallbackInstructor;
1511
+
1512
+ const recurrence = this.parseRecurrenceRule(s.recurrence_rule);
1513
+
1514
+ return {
1515
+ id: s.id,
1516
+ titulo: s.title,
1517
+ descricao: s.description ?? '',
1518
+ data: s.session_date,
1519
+ horaInicio: s.start_time,
1520
+ horaFim: s.end_time,
1521
+ local: s.location ?? '',
1522
+ meetingUrl: s.meeting_url ?? '',
1523
+ cor: s.color ?? undefined,
1524
+ status: s.status,
1525
+ tipo: s.meeting_url ? 'online' : ('presencial' as const),
1526
+ instructorId: sessionInstructor?.instructor_id ?? null,
1527
+ instructorName:
1528
+ sessionInstructor?.instructor?.person?.name ?? '',
1529
+ recurrenceId: s.recurrence_id ?? null,
1530
+ occurrenceIndex: s.occurrence_index ?? null,
1531
+ isException: Boolean(s.is_exception),
1532
+ recurrence,
1533
+ isRecurring: Boolean(s.recurrence_id),
1534
+ };
1535
+ }
1536
+
1537
+ private normalizeSessionRecurrence(
1538
+ recurrence: SessionRecurrenceDto,
1539
+ sessionDate: string,
1540
+ ): NormalizedSessionRecurrence {
1541
+ const startDate = this.toSessionDate(sessionDate);
1542
+ const untilDate = this.toSessionDate(recurrence.until);
1543
+
1544
+ if (untilDate.getTime() < startDate.getTime()) {
1545
+ throw new BadRequestException('Recurrence end date must be on or after session date');
1546
+ }
1547
+
1548
+ const normalizedDays = recurrence.daysOfWeek?.length
1549
+ ? [...new Set(recurrence.daysOfWeek)].sort(
1550
+ (left, right) =>
1551
+ ClassGroupService.DAY_ORDER.indexOf(left) -
1552
+ ClassGroupService.DAY_ORDER.indexOf(right),
1553
+ )
1554
+ : recurrence.frequency === 'weekly'
1555
+ ? [this.getDayCode(startDate)]
1556
+ : undefined;
1557
+
1558
+ return {
1559
+ frequency: recurrence.frequency,
1560
+ interval: recurrence.interval ?? 1,
1561
+ until: this.toDateKey(untilDate),
1562
+ ...(normalizedDays?.length ? { daysOfWeek: normalizedDays } : {}),
1563
+ };
1564
+ }
1565
+
1566
+ private buildRecurringSessionDates(
1567
+ sessionDate: string,
1568
+ recurrence: NormalizedSessionRecurrence,
1569
+ ) {
1570
+ const startDate = this.toSessionDate(sessionDate);
1571
+ const untilDate = this.toSessionDate(recurrence.until);
1572
+
1573
+ switch (recurrence.frequency) {
1574
+ case 'daily':
1575
+ return this.buildDailyOccurrences(startDate, untilDate, recurrence.interval);
1576
+ case 'weekly':
1577
+ return this.buildWeeklyOccurrences(
1578
+ startDate,
1579
+ untilDate,
1580
+ recurrence.interval,
1581
+ recurrence.daysOfWeek,
1582
+ );
1583
+ case 'monthly':
1584
+ return this.buildMonthlyOccurrences(startDate, untilDate, recurrence.interval);
1585
+ case 'yearly':
1586
+ return this.buildYearlyOccurrences(startDate, untilDate, recurrence.interval);
1587
+ default:
1588
+ return [startDate];
1589
+ }
1590
+ }
1591
+
1592
+ private buildDailyOccurrences(startDate: Date, untilDate: Date, interval: number) {
1593
+ const dates: Date[] = [];
1594
+
1595
+ for (
1596
+ let current = startDate;
1597
+ current.getTime() <= untilDate.getTime();
1598
+ current = this.addDaysUtc(current, interval)
1599
+ ) {
1600
+ dates.push(current);
1601
+ }
1602
+
1603
+ return dates;
1604
+ }
1605
+
1606
+ private buildWeeklyOccurrences(
1607
+ startDate: Date,
1608
+ untilDate: Date,
1609
+ interval: number,
1610
+ daysOfWeek?: SessionRecurrenceDay[],
1611
+ ) {
1612
+ const dates: Date[] = [];
1613
+ const weekStart = this.startOfWeekMonday(startDate);
1614
+ const normalizedDays = daysOfWeek?.length
1615
+ ? daysOfWeek
1616
+ : [this.getDayCode(startDate)];
1617
+
1618
+ for (
1619
+ let currentWeek = weekStart;
1620
+ currentWeek.getTime() <= untilDate.getTime();
1621
+ currentWeek = this.addDaysUtc(currentWeek, interval * 7)
1622
+ ) {
1623
+ for (const dayCode of normalizedDays) {
1624
+ const candidate = this.addDaysUtc(
1625
+ currentWeek,
1626
+ ClassGroupService.DAY_ORDER.indexOf(dayCode),
1627
+ );
1628
+
1629
+ if (candidate.getTime() < startDate.getTime()) {
1630
+ continue;
1631
+ }
1632
+
1633
+ if (candidate.getTime() > untilDate.getTime()) {
1634
+ continue;
1635
+ }
1636
+
1637
+ dates.push(candidate);
1638
+ }
1639
+ }
1640
+
1641
+ return dates.sort((left, right) => left.getTime() - right.getTime());
1642
+ }
1643
+
1644
+ private buildMonthlyOccurrences(startDate: Date, untilDate: Date, interval: number) {
1645
+ const dates: Date[] = [];
1646
+
1647
+ for (let offset = 0; ; offset += interval) {
1648
+ const candidate = this.addMonthsUtc(startDate, offset);
1649
+
1650
+ if (!candidate) {
1651
+ continue;
1652
+ }
1653
+
1654
+ if (candidate.getTime() > untilDate.getTime()) {
1655
+ break;
1656
+ }
1657
+
1658
+ dates.push(candidate);
1659
+ }
1660
+
1661
+ return dates;
1662
+ }
1663
+
1664
+ private buildYearlyOccurrences(startDate: Date, untilDate: Date, interval: number) {
1665
+ const dates: Date[] = [];
1666
+
1667
+ for (let offset = 0; ; offset += interval) {
1668
+ const candidate = this.addYearsUtc(startDate, offset);
1669
+
1670
+ if (!candidate) {
1671
+ continue;
1672
+ }
1673
+
1674
+ if (candidate.getTime() > untilDate.getTime()) {
1675
+ break;
1676
+ }
1677
+
1678
+ dates.push(candidate);
1679
+ }
1680
+
1681
+ return dates;
1682
+ }
1683
+
1684
+ private parseRecurrenceRule(value?: string | null): NormalizedSessionRecurrence | null {
1685
+ if (!value) {
1686
+ return null;
1687
+ }
1688
+
1689
+ try {
1690
+ const parsed = JSON.parse(value) as NormalizedSessionRecurrence;
1691
+ return parsed?.frequency ? parsed : null;
1692
+ } catch {
1693
+ return null;
1694
+ }
1695
+ }
1696
+
1697
+ private toSessionDate(value: string | Date) {
1698
+ if (value instanceof Date) {
1699
+ return this.normalizeSessionDate(value);
1700
+ }
1701
+
1702
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value ?? '');
1703
+
1704
+ if (!match) {
1705
+ throw new BadRequestException('Invalid session date');
1706
+ }
1707
+
1708
+ return new Date(
1709
+ Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12, 0, 0, 0),
1710
+ );
1711
+ }
1712
+
1713
+ private normalizeSessionDate(value: Date) {
1714
+ return new Date(
1715
+ Date.UTC(
1716
+ value.getUTCFullYear(),
1717
+ value.getUTCMonth(),
1718
+ value.getUTCDate(),
1719
+ 12,
1720
+ 0,
1721
+ 0,
1722
+ 0,
1723
+ ),
1724
+ );
1725
+ }
1726
+
1727
+ private toDateKey(value: Date) {
1728
+ return value.toISOString().slice(0, 10);
1729
+ }
1730
+
1731
+ private getDayCode(value: Date): SessionRecurrenceDay {
1732
+ return ClassGroupService.UTC_DAY_TO_CODE[value.getUTCDay()];
1733
+ }
1734
+
1735
+ private startOfWeekMonday(value: Date) {
1736
+ const day = value.getUTCDay();
1737
+ const diff = day === 0 ? -6 : 1 - day;
1738
+ return this.addDaysUtc(value, diff);
1739
+ }
1740
+
1741
+ private addDaysUtc(value: Date, amount: number) {
1742
+ const next = new Date(value.getTime());
1743
+ next.setUTCDate(next.getUTCDate() + amount);
1744
+ return this.normalizeSessionDate(next);
1745
+ }
1746
+
1747
+ private addMonthsUtc(value: Date, amount: number) {
1748
+ const year = value.getUTCFullYear();
1749
+ const month = value.getUTCMonth() + amount;
1750
+ const day = value.getUTCDate();
1751
+ const candidate = new Date(Date.UTC(year, month, day, 12, 0, 0, 0));
1752
+
1753
+ if (candidate.getUTCDate() !== day) {
1754
+ return null;
1755
+ }
1756
+
1757
+ return candidate;
1758
+ }
1759
+
1760
+ private addYearsUtc(value: Date, amount: number) {
1761
+ const year = value.getUTCFullYear() + amount;
1762
+ const month = value.getUTCMonth();
1763
+ const day = value.getUTCDate();
1764
+ const candidate = new Date(Date.UTC(year, month, day, 12, 0, 0, 0));
1765
+
1766
+ if (candidate.getUTCMonth() !== month || candidate.getUTCDate() !== day) {
1767
+ return null;
1768
+ }
1769
+
1770
+ return candidate;
1771
+ }
1772
+
1773
+ private diffInDays(left: Date, right: Date) {
1774
+ const msPerDay = 24 * 60 * 60 * 1000;
1775
+ return Math.round(
1776
+ (this.normalizeSessionDate(left).getTime() - this.normalizeSessionDate(right).getTime()) /
1777
+ msPerDay,
1778
+ );
1779
+ }
1780
+
1781
+ private async assertInstructorExists(instructorId: number) {
1782
+ const instructor = await this.prisma.instructor.findUnique({
1783
+ where: { id: instructorId },
1784
+ select: { id: true },
1785
+ });
1786
+
1787
+ if (!instructor) {
1788
+ throw new NotFoundException('Instructor not found');
1789
+ }
1790
+ }
1791
+
1792
+ private handlePrismaError(error: unknown): never {
1793
+ if (
1794
+ error instanceof PrismaClientKnownRequestError &&
1795
+ error.code === 'P2002'
1796
+ ) {
1797
+ throw new ConflictException('Class code already exists');
1798
+ }
1799
+
1800
+ throw error;
1801
+ }
1802
+ }