@hed-hog/lms 0.0.365 → 0.0.370

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 (243) hide show
  1. package/dist/certificate/certificate.controller.d.ts +1 -1
  2. package/dist/certificate/certificate.controller.d.ts.map +1 -1
  3. package/dist/certificate/certificate.controller.js +4 -2
  4. package/dist/certificate/certificate.controller.js.map +1 -1
  5. package/dist/certificate/certificate.service.d.ts +50 -0
  6. package/dist/certificate/certificate.service.d.ts.map +1 -1
  7. package/dist/certificate/certificate.service.js +73 -0
  8. package/dist/certificate/certificate.service.js.map +1 -1
  9. package/dist/class-group/class-group.controller.d.ts +1 -0
  10. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  11. package/dist/class-group/class-group.service.d.ts +1 -0
  12. package/dist/class-group/class-group.service.d.ts.map +1 -1
  13. package/dist/course/course-ai-usage.service.d.ts +58 -0
  14. package/dist/course/course-ai-usage.service.d.ts.map +1 -0
  15. package/dist/course/course-ai-usage.service.js +176 -0
  16. package/dist/course/course-ai-usage.service.js.map +1 -0
  17. package/dist/course/course-audio-transcription.service.d.ts +65 -1
  18. package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
  19. package/dist/course/course-audio-transcription.service.js +381 -29
  20. package/dist/course/course-audio-transcription.service.js.map +1 -1
  21. package/dist/course/course-export-scorm12.service.d.ts +3 -0
  22. package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
  23. package/dist/course/course-export-scorm12.service.js +141 -6
  24. package/dist/course/course-export-scorm12.service.js.map +1 -1
  25. package/dist/course/course-export.service.d.ts.map +1 -1
  26. package/dist/course/course-export.service.js +2 -1
  27. package/dist/course/course-export.service.js.map +1 -1
  28. package/dist/course/course-lesson.controller.d.ts +25 -3
  29. package/dist/course/course-lesson.controller.d.ts.map +1 -1
  30. package/dist/course/course-lesson.controller.js +71 -8
  31. package/dist/course/course-lesson.controller.js.map +1 -1
  32. package/dist/course/course-structure.controller.d.ts +30 -7
  33. package/dist/course/course-structure.controller.d.ts.map +1 -1
  34. package/dist/course/course-structure.controller.js +37 -4
  35. package/dist/course/course-structure.controller.js.map +1 -1
  36. package/dist/course/course-structure.service.d.ts +37 -5
  37. package/dist/course/course-structure.service.d.ts.map +1 -1
  38. package/dist/course/course-structure.service.js +165 -20
  39. package/dist/course/course-structure.service.js.map +1 -1
  40. package/dist/course/course-transcription-translation.service.d.ts +31 -0
  41. package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
  42. package/dist/course/course-transcription-translation.service.js +227 -0
  43. package/dist/course/course-transcription-translation.service.js.map +1 -0
  44. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  45. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  46. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  47. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  48. package/dist/course/course-video-hls.service.d.ts +14 -0
  49. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  50. package/dist/course/course-video-hls.service.js +25 -8
  51. package/dist/course/course-video-hls.service.js.map +1 -1
  52. package/dist/course/course.controller.d.ts +2 -0
  53. package/dist/course/course.controller.d.ts.map +1 -1
  54. package/dist/course/course.module.d.ts.map +1 -1
  55. package/dist/course/course.module.js +9 -0
  56. package/dist/course/course.module.js.map +1 -1
  57. package/dist/course/course.service.d.ts +2 -0
  58. package/dist/course/course.service.d.ts.map +1 -1
  59. package/dist/course/course.service.js +36 -2
  60. package/dist/course/course.service.js.map +1 -1
  61. package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
  62. package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
  63. package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
  64. package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
  65. package/dist/course/dto/create-course-export.dto.d.ts +1 -0
  66. package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
  67. package/dist/course/dto/create-course-export.dto.js +6 -0
  68. package/dist/course/dto/create-course-export.dto.js.map +1 -1
  69. package/dist/course/ffmpeg.util.d.ts +10 -0
  70. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  71. package/dist/course/ffmpeg.util.js +79 -0
  72. package/dist/course/ffmpeg.util.js.map +1 -0
  73. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  74. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  75. package/dist/course/lms-bulk-upload-automation.service.js +33 -16
  76. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  77. package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
  78. package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
  79. package/dist/course/lms-bulk-upload.service.d.ts +3 -0
  80. package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
  81. package/dist/course/lms-bulk-upload.service.js +48 -29
  82. package/dist/course/lms-bulk-upload.service.js.map +1 -1
  83. package/dist/course/subtitle.util.d.ts +46 -0
  84. package/dist/course/subtitle.util.d.ts.map +1 -0
  85. package/dist/course/subtitle.util.js +206 -0
  86. package/dist/course/subtitle.util.js.map +1 -0
  87. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  88. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  89. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  90. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  91. package/dist/enterprise/training/training-student.service.d.ts +27 -0
  92. package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
  93. package/dist/enterprise/training/training-student.service.js +197 -10
  94. package/dist/enterprise/training/training-student.service.js.map +1 -1
  95. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
  96. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
  97. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
  98. package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
  99. package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
  100. package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
  101. package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
  102. package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
  103. package/dist/lms.module.d.ts.map +1 -1
  104. package/dist/lms.module.js +14 -0
  105. package/dist/lms.module.js.map +1 -1
  106. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  107. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  108. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  109. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  110. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  111. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  112. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  113. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  114. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  115. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  116. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  117. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  118. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  119. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  120. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  121. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  122. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  123. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  124. package/dist/platforma/platforma-performance.service.js +500 -0
  125. package/dist/platforma/platforma-performance.service.js.map +1 -0
  126. package/dist/platforma/platforma-search.service.d.ts +21 -0
  127. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  128. package/dist/platforma/platforma-search.service.js +64 -0
  129. package/dist/platforma/platforma-search.service.js.map +1 -0
  130. package/dist/platforma/platforma-video.service.d.ts +8 -0
  131. package/dist/platforma/platforma-video.service.d.ts.map +1 -1
  132. package/dist/platforma/platforma-video.service.js +45 -2
  133. package/dist/platforma/platforma-video.service.js.map +1 -1
  134. package/dist/platforma/platforma.controller.d.ts +213 -1
  135. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  136. package/dist/platforma/platforma.controller.js +159 -2
  137. package/dist/platforma/platforma.controller.js.map +1 -1
  138. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  139. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  140. package/dist/realtime/lms-realtime.controller.js +31 -0
  141. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  142. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  143. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  144. package/dist/realtime/lms-realtime.service.js.map +1 -1
  145. package/dist/training/dto/create-training.dto.d.ts +9 -0
  146. package/dist/training/dto/create-training.dto.d.ts.map +1 -1
  147. package/dist/training/dto/create-training.dto.js +45 -1
  148. package/dist/training/dto/create-training.dto.js.map +1 -1
  149. package/dist/training/training.controller.d.ts +144 -0
  150. package/dist/training/training.controller.d.ts.map +1 -1
  151. package/dist/training/training.service.d.ts +149 -0
  152. package/dist/training/training.service.d.ts.map +1 -1
  153. package/dist/training/training.service.js +332 -167
  154. package/dist/training/training.service.js.map +1 -1
  155. package/hedhog/data/image_type.yaml +10 -0
  156. package/hedhog/data/route.yaml +251 -0
  157. package/hedhog/data/setting_group.yaml +97 -0
  158. package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
  159. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  160. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  161. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  162. package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
  163. package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
  164. package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
  165. package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
  166. package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
  167. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  168. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  169. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
  170. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  171. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
  172. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
  173. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
  174. package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
  175. package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
  176. package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
  177. package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
  178. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  179. package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
  180. package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
  181. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
  182. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  183. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
  184. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
  185. package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
  186. package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
  187. package/hedhog/frontend/app/courses/page.tsx.ejs +66 -13
  188. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  189. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  190. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  191. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  192. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  193. package/hedhog/frontend/app/paths/page.tsx.ejs +650 -168
  194. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  195. package/hedhog/frontend/messages/en.json +41 -12
  196. package/hedhog/frontend/messages/pt.json +44 -13
  197. package/hedhog/query/triggers.sql +33 -0
  198. package/hedhog/table/course_ai_usage.yaml +46 -0
  199. package/hedhog/table/course_enrollment.yaml +3 -0
  200. package/hedhog/table/course_lesson.yaml +3 -0
  201. package/hedhog/table/course_lesson_answer.yaml +37 -0
  202. package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
  203. package/hedhog/table/learning_path.yaml +6 -0
  204. package/hedhog/table/learning_path_module.yaml +22 -0
  205. package/hedhog/table/learning_path_step.yaml +9 -6
  206. package/hedhog/table/lesson_view_event.yaml +66 -0
  207. package/package.json +8 -7
  208. package/src/certificate/certificate.controller.ts +2 -0
  209. package/src/certificate/certificate.service.ts +99 -0
  210. package/src/course/course-ai-usage.service.ts +221 -0
  211. package/src/course/course-audio-transcription.service.ts +471 -43
  212. package/src/course/course-export-scorm12.service.ts +149 -5
  213. package/src/course/course-export.service.ts +1 -0
  214. package/src/course/course-lesson.controller.ts +59 -6
  215. package/src/course/course-structure.controller.ts +19 -1
  216. package/src/course/course-structure.service.ts +184 -10
  217. package/src/course/course-transcription-translation.service.ts +293 -0
  218. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  219. package/src/course/course-video-hls.service.ts +30 -10
  220. package/src/course/course.module.ts +9 -0
  221. package/src/course/course.service.ts +46 -1
  222. package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
  223. package/src/course/dto/create-course-export.dto.ts +6 -0
  224. package/src/course/ffmpeg.util.ts +65 -0
  225. package/src/course/lms-bulk-upload-automation.service.ts +33 -8
  226. package/src/course/lms-bulk-upload.service.ts +20 -1
  227. package/src/course/subtitle.util.ts +220 -0
  228. package/src/enterprise/training/training-student.service.ts +224 -4
  229. package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
  230. package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
  231. package/src/lms.module.ts +14 -0
  232. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  233. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  234. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  235. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  236. package/src/platforma/platforma-performance.service.ts +606 -0
  237. package/src/platforma/platforma-search.service.ts +48 -0
  238. package/src/platforma/platforma-video.service.ts +59 -3
  239. package/src/platforma/platforma.controller.ts +130 -0
  240. package/src/realtime/lms-realtime.controller.ts +27 -1
  241. package/src/realtime/lms-realtime.service.ts +2 -1
  242. package/src/training/dto/create-training.dto.ts +36 -0
  243. package/src/training/training.service.ts +360 -163
@@ -2,6 +2,7 @@ import { PrismaService } from '@hed-hog/api-prisma';
2
2
  import { FileService, IntegrationDeveloperApiService } from '@hed-hog/core';
3
3
  import {
4
4
  BadRequestException,
5
+ ForbiddenException,
5
6
  Inject,
6
7
  Injectable,
7
8
  NotFoundException,
@@ -18,6 +19,7 @@ type ListIssuedCertificatesParams = {
18
19
  pageSize?: number;
19
20
  search?: string;
20
21
  type?: string;
22
+ courseId?: number;
21
23
  };
22
24
 
23
25
  type ListCertificateTemplatesParams = {
@@ -218,6 +220,10 @@ export class LmsCertificateService {
218
220
  where.certificate_type = type;
219
221
  }
220
222
 
223
+ if (params.courseId) {
224
+ where.course_id = Number(params.courseId);
225
+ }
226
+
221
227
  if (params.search?.trim()) {
222
228
  const search = params.search.trim();
223
229
  where.OR = [
@@ -577,6 +583,99 @@ export class LmsCertificateService {
577
583
  }
578
584
  }
579
585
 
586
+ async listIssuedForStudent(
587
+ userId: number,
588
+ params: ListIssuedCertificatesParams,
589
+ ) {
590
+ const personId = await this.resolvePersonIdFromUser(userId);
591
+
592
+ const page = Math.max(Number(params.page) || 1, 1);
593
+ const pageSize = Math.max(Number(params.pageSize) || 12, 1);
594
+ const skip = (page - 1) * pageSize;
595
+
596
+ const where: Record<string, any> = { student_id: personId };
597
+ const type = this.normalizeType(params.type);
598
+ if (type) {
599
+ where.certificate_type = type;
600
+ }
601
+
602
+ const [certificates, total] = await Promise.all([
603
+ this.prisma.certificate.findMany({
604
+ skip,
605
+ take: pageSize,
606
+ where,
607
+ orderBy: [{ issued_at: 'desc' }, { id: 'desc' }],
608
+ include: {
609
+ certificate_template: {
610
+ select: {
611
+ id: true,
612
+ name: true,
613
+ status: true,
614
+ template_content: true,
615
+ },
616
+ },
617
+ course: { select: { id: true, title: true, primary_color: true } },
618
+ exam: { select: { id: true, title: true } },
619
+ course_class_group: { select: { id: true, title: true } },
620
+ learning_path: { select: { id: true, title: true, primary_color: true } },
621
+ person: { select: { id: true, name: true } },
622
+ },
623
+ }),
624
+ this.prisma.certificate.count({ where }),
625
+ ]);
626
+
627
+ return {
628
+ total,
629
+ page,
630
+ pageSize,
631
+ lastPage: Math.max(1, Math.ceil(total / pageSize)),
632
+ data: certificates.map((cert) => this.mapIssuedCertificateWithTemplate(cert)),
633
+ };
634
+ }
635
+
636
+ async updatePublicAccessForStudent(
637
+ userId: number,
638
+ certificateId: number,
639
+ publicAccess: boolean,
640
+ ) {
641
+ const personId = await this.resolvePersonIdFromUser(userId);
642
+
643
+ const cert = await this.prisma.certificate.findUnique({
644
+ where: { id: certificateId },
645
+ select: { id: true, student_id: true },
646
+ });
647
+
648
+ if (!cert) {
649
+ throw new NotFoundException('Issued certificate not found');
650
+ }
651
+
652
+ if (cert.student_id !== personId) {
653
+ throw new ForbiddenException('Access denied');
654
+ }
655
+
656
+ return this.updatePublicAccess(certificateId, publicAccess);
657
+ }
658
+
659
+ private async resolvePersonIdFromUser(userId: number) {
660
+ const personUser = await this.prisma.person_user.findFirst({
661
+ where: { user_id: userId },
662
+ select: { person_id: true },
663
+ });
664
+
665
+ if (!personUser) {
666
+ throw new NotFoundException('No person profile found for this user');
667
+ }
668
+
669
+ return personUser.person_id;
670
+ }
671
+
672
+ private mapIssuedCertificateWithTemplate(certificate: any) {
673
+ return {
674
+ ...this.mapIssuedCertificate(certificate),
675
+ templateContent: certificate.certificate_template?.template_content ?? null,
676
+ };
677
+ }
678
+
580
679
  private normalizeType(value?: string) {
581
680
  if (!value || value === 'all') {
582
681
  return undefined;
@@ -0,0 +1,221 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { SettingService } from '@hed-hog/core';
3
+ import { Inject, Injectable, Logger, forwardRef } from '@nestjs/common';
4
+
5
+ export type AiJobType = 'transcription' | 'translation' | 'xp_calculation';
6
+
7
+ /**
8
+ * Records AI usage and cost into the `course_ai_usage` ledger so the course can
9
+ * report how much it spent on transcription, translation and XP jobs.
10
+ *
11
+ * Token-billed models (chat: gpt-4o-mini) are priced from the existing
12
+ * `ai_model_pricing` table — the OpenAI API returns token counts (`usage`), not
13
+ * a dollar amount, so the cost is tokens × table price. Whisper is billed per
14
+ * minute of audio, so it uses the `lms-whisper-price-per-minute-usd` setting.
15
+ *
16
+ * Recording never throws: cost tracking must not break the underlying job.
17
+ */
18
+ @Injectable()
19
+ export class CourseAiUsageService {
20
+ private readonly logger = new Logger(CourseAiUsageService.name);
21
+
22
+ constructor(
23
+ @Inject(forwardRef(() => PrismaService))
24
+ private readonly prisma: PrismaService,
25
+ @Inject(forwardRef(() => SettingService))
26
+ private readonly settingService: SettingService,
27
+ ) {}
28
+
29
+ async resolveCourseIdFromLesson(lessonId: number): Promise<number | null> {
30
+ try {
31
+ const rows = await this.prisma.$queryRawUnsafe<{ course_id: number }[]>(
32
+ `SELECT cm.course_id
33
+ FROM course_lesson cl
34
+ JOIN course_module cm ON cm.id = cl.course_module_id
35
+ WHERE cl.id = $1
36
+ LIMIT 1`,
37
+ lessonId,
38
+ );
39
+ return rows[0]?.course_id ?? null;
40
+ } catch (error) {
41
+ this.logger.warn(
42
+ `Failed to resolve course for lesson ${lessonId}: ${error instanceof Error ? error.message : String(error)}`,
43
+ );
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** Resolves the active per-million token price for a provider/model. */
49
+ private async getTokenPrice(
50
+ provider: string,
51
+ model: string,
52
+ ): Promise<{ input: number; output: number } | null> {
53
+ try {
54
+ const row = await (this.prisma as any).ai_model_pricing.findFirst({
55
+ where: { provider, model, is_active: true },
56
+ orderBy: { valid_from: 'desc' },
57
+ });
58
+ if (!row) return null;
59
+ return {
60
+ input: Number(row.price_input_per_million ?? 0),
61
+ output: Number(row.price_output_per_million ?? 0),
62
+ };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ private async insert(params: {
69
+ courseId: number;
70
+ lessonId: number | null;
71
+ jobType: AiJobType;
72
+ provider: string;
73
+ model: string;
74
+ inputTokens: number | null;
75
+ outputTokens: number | null;
76
+ audioSeconds: number | null;
77
+ costUsd: number;
78
+ }): Promise<void> {
79
+ await this.prisma.$executeRawUnsafe(
80
+ `INSERT INTO course_ai_usage
81
+ (course_id, course_lesson_id, job_type, provider, model,
82
+ input_tokens, output_tokens, audio_seconds, cost_usd, created_at)
83
+ VALUES ($1, $2, $3::course_ai_usage_job_type_enum, $4, $5, $6, $7, $8, $9, NOW())`,
84
+ params.courseId,
85
+ params.lessonId,
86
+ params.jobType,
87
+ params.provider,
88
+ params.model,
89
+ params.inputTokens,
90
+ params.outputTokens,
91
+ params.audioSeconds,
92
+ params.costUsd,
93
+ );
94
+ }
95
+
96
+ /** Records usage for a token-billed chat model (translation, XP). */
97
+ async recordChatUsage(params: {
98
+ courseId?: number | null;
99
+ lessonId: number;
100
+ jobType: AiJobType;
101
+ provider: string;
102
+ model: string;
103
+ inputTokens: number;
104
+ outputTokens: number;
105
+ }): Promise<void> {
106
+ try {
107
+ const courseId =
108
+ params.courseId ?? (await this.resolveCourseIdFromLesson(params.lessonId));
109
+ if (!courseId) return;
110
+
111
+ const price = await this.getTokenPrice(params.provider, params.model);
112
+ const costUsd = price
113
+ ? (params.inputTokens / 1_000_000) * price.input +
114
+ (params.outputTokens / 1_000_000) * price.output
115
+ : 0;
116
+
117
+ await this.insert({
118
+ courseId,
119
+ lessonId: params.lessonId,
120
+ jobType: params.jobType,
121
+ provider: params.provider,
122
+ model: params.model,
123
+ inputTokens: params.inputTokens,
124
+ outputTokens: params.outputTokens,
125
+ audioSeconds: null,
126
+ costUsd,
127
+ });
128
+ } catch (error) {
129
+ this.logger.warn(
130
+ `Failed to record chat AI usage for lesson ${params.lessonId}: ${error instanceof Error ? error.message : String(error)}`,
131
+ );
132
+ }
133
+ }
134
+
135
+ /** Records usage for Whisper transcription (billed per minute of audio). */
136
+ async recordTranscriptionUsage(params: {
137
+ courseId: number;
138
+ lessonId: number;
139
+ model: string;
140
+ audioSeconds: number;
141
+ }): Promise<void> {
142
+ try {
143
+ const priceSlug = `lms-transcription-price-per-second-usd-${params.model}`;
144
+ const settings = await this.settingService.getSettingValues([priceSlug]);
145
+ const pricePerSecond = Number(settings[priceSlug] ?? 0.0001);
146
+ const costUsd = params.audioSeconds * (Number.isFinite(pricePerSecond) && pricePerSecond > 0 ? pricePerSecond : 0.0001);
147
+
148
+ await this.insert({
149
+ courseId: params.courseId,
150
+ lessonId: params.lessonId,
151
+ jobType: 'transcription',
152
+ provider: 'openai',
153
+ model: params.model,
154
+ inputTokens: null,
155
+ outputTokens: null,
156
+ audioSeconds: params.audioSeconds,
157
+ costUsd,
158
+ });
159
+ } catch (error) {
160
+ this.logger.warn(
161
+ `Failed to record transcription AI usage for lesson ${params.lessonId}: ${error instanceof Error ? error.message : String(error)}`,
162
+ );
163
+ }
164
+ }
165
+
166
+ /** Aggregated cost report for a course: total, by job type and by lesson. */
167
+ async getCourseAiCosts(courseId: number): Promise<{
168
+ currency: string;
169
+ totalCostUsd: number;
170
+ byJobType: Array<{ jobType: string; costUsd: number; runs: number }>;
171
+ byLesson: Array<{
172
+ lessonId: number | null;
173
+ lessonTitle: string | null;
174
+ costUsd: number;
175
+ runs: number;
176
+ }>;
177
+ }> {
178
+ const byJobType = await this.prisma.$queryRawUnsafe<
179
+ Array<{ job_type: string; cost_usd: number; runs: bigint }>
180
+ >(
181
+ `SELECT job_type, COALESCE(SUM(cost_usd), 0)::float8 AS cost_usd, COUNT(*) AS runs
182
+ FROM course_ai_usage
183
+ WHERE course_id = $1
184
+ GROUP BY job_type`,
185
+ courseId,
186
+ );
187
+
188
+ const byLesson = await this.prisma.$queryRawUnsafe<
189
+ Array<{ lesson_id: number | null; lesson_title: string | null; cost_usd: number; runs: bigint }>
190
+ >(
191
+ `SELECT u.course_lesson_id AS lesson_id,
192
+ cl.title AS lesson_title,
193
+ COALESCE(SUM(u.cost_usd), 0)::float8 AS cost_usd,
194
+ COUNT(*) AS runs
195
+ FROM course_ai_usage u
196
+ LEFT JOIN course_lesson cl ON cl.id = u.course_lesson_id
197
+ WHERE u.course_id = $1
198
+ GROUP BY u.course_lesson_id, cl.title
199
+ ORDER BY cost_usd DESC`,
200
+ courseId,
201
+ );
202
+
203
+ const totalCostUsd = byJobType.reduce((sum, r) => sum + Number(r.cost_usd ?? 0), 0);
204
+
205
+ return {
206
+ currency: 'USD',
207
+ totalCostUsd,
208
+ byJobType: byJobType.map((r) => ({
209
+ jobType: r.job_type,
210
+ costUsd: Number(r.cost_usd ?? 0),
211
+ runs: Number(r.runs ?? 0),
212
+ })),
213
+ byLesson: byLesson.map((r) => ({
214
+ lessonId: r.lesson_id,
215
+ lessonTitle: r.lesson_title,
216
+ costUsd: Number(r.cost_usd ?? 0),
217
+ runs: Number(r.runs ?? 0),
218
+ })),
219
+ };
220
+ }
221
+ }