@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
@@ -3,6 +3,7 @@ import { FileService } from '@hed-hog/core';
3
3
  import { QueueJobService } from '@hed-hog/queue';
4
4
  import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
5
5
  import { InstructorService } from '../instructor/instructor.service';
6
+ import { CourseAiUsageService } from './course-ai-usage.service';
6
7
  import { CourseOperationsIntegrationService } from './course-operations-integration.service';
7
8
  import { CourseVideoHlsService } from './course-video-hls.service';
8
9
  import { CreateCourseLessonFrameDto } from './dto/create-course-lesson-frame.dto';
@@ -45,8 +46,13 @@ export class CourseStructureService {
45
46
  private readonly operationsIntegration: CourseOperationsIntegrationService,
46
47
  private readonly queueJob: QueueJobService,
47
48
  private readonly courseVideoHlsService: CourseVideoHlsService,
49
+ private readonly aiUsageService: CourseAiUsageService,
48
50
  ) {}
49
51
 
52
+ getCourseAiCosts(courseId: number) {
53
+ return this.aiUsageService.getCourseAiCosts(courseId);
54
+ }
55
+
50
56
  async getStructure(courseId: number) {
51
57
  await this.ensureCourseExists(courseId);
52
58
 
@@ -61,7 +67,9 @@ export class CourseStructureService {
61
67
  course_lesson: {
62
68
  orderBy: { order: 'asc' },
63
69
  include: {
64
- course_lesson_file: true,
70
+ course_lesson_file: {
71
+ include: { file: { select: { size: true } } },
72
+ },
65
73
  course_lesson_video_frame: {
66
74
  orderBy: { id: 'asc' },
67
75
  include: {
@@ -162,11 +170,12 @@ export class CourseStructureService {
162
170
  parsedContent?.statusProducao,
163
171
  ),
164
172
  published: lessonPublishedById.get(lesson.id) ?? false,
173
+ temTranscricao: Boolean((lesson as any).has_transcription),
165
174
  recursos: lesson.course_lesson_file.map((fileLink) => ({
166
175
  id: String(fileLink.id),
167
176
  nome: fileLink.title,
168
177
  fileId: fileLink.file_id,
169
- tamanho: '',
178
+ tamanho: fileLink.file?.size ?? 0,
170
179
  type: this.readLessonFileType(fileLink),
171
180
  is_public: this.readLessonFileVisibility(fileLink),
172
181
  })),
@@ -1114,12 +1123,17 @@ export class CourseStructureService {
1114
1123
  return { lessons: result };
1115
1124
  }
1116
1125
 
1117
- async getTranscriptionSegments(lessonId: number) {
1126
+ async getTranscriptionSegments(lessonId: number, localeId?: number | null) {
1127
+ const where: Record<string, any> = { course_lesson_id: lessonId };
1128
+ if (localeId !== undefined) {
1129
+ where.locale_id = localeId ?? null;
1130
+ }
1118
1131
  return (this.prisma as any).course_lesson_transcription_segment.findMany({
1119
- where: { course_lesson_id: lessonId },
1132
+ where,
1120
1133
  orderBy: { start_seconds: 'asc' },
1121
1134
  select: {
1122
1135
  id: true,
1136
+ locale_id: true,
1123
1137
  start_seconds: true,
1124
1138
  end_seconds: true,
1125
1139
  text: true,
@@ -1127,17 +1141,44 @@ export class CourseStructureService {
1127
1141
  });
1128
1142
  }
1129
1143
 
1144
+ async getAvailableTranscriptionLocales(lessonId: number) {
1145
+ const rows = await (this.prisma as any).course_lesson_transcription_segment.findMany({
1146
+ where: { course_lesson_id: lessonId },
1147
+ select: { locale_id: true },
1148
+ distinct: ['locale_id'],
1149
+ });
1150
+
1151
+ const localeIds: (number | null)[] = rows.map((r: any) => r.locale_id);
1152
+
1153
+ const locales = await Promise.all(
1154
+ localeIds.map(async (id) => {
1155
+ if (id === null) return { id: null, code: null, name: null, region: null };
1156
+ const locale = await (this.prisma as any).locale.findUnique({
1157
+ where: { id },
1158
+ select: { id: true, code: true, name: true, region: true },
1159
+ });
1160
+ return locale ?? { id, code: null, name: null, region: null };
1161
+ }),
1162
+ );
1163
+
1164
+ return locales;
1165
+ }
1166
+
1130
1167
  async updateTranscriptionSegments(
1131
1168
  lessonId: number,
1132
1169
  dto: UpdateTranscriptionSegmentsDTO,
1170
+ localeId?: number | null,
1133
1171
  ) {
1172
+ const where: Record<string, any> = { course_lesson_id: lessonId };
1173
+ if (localeId !== undefined) {
1174
+ where.locale_id = localeId ?? null;
1175
+ }
1134
1176
  return this.prisma.$transaction([
1135
- (this.prisma as any).course_lesson_transcription_segment.deleteMany({
1136
- where: { course_lesson_id: lessonId },
1137
- }),
1177
+ (this.prisma as any).course_lesson_transcription_segment.deleteMany({ where }),
1138
1178
  (this.prisma as any).course_lesson_transcription_segment.createMany({
1139
1179
  data: (dto.segments ?? []).map((segment) => ({
1140
1180
  course_lesson_id: lessonId,
1181
+ locale_id: localeId ?? null,
1141
1182
  start_seconds: segment.startSeconds,
1142
1183
  end_seconds: segment.endSeconds,
1143
1184
  text: segment.text,
@@ -1146,6 +1187,69 @@ export class CourseStructureService {
1146
1187
  ]);
1147
1188
  }
1148
1189
 
1190
+ async deleteTranscriptionLocale(lessonId: number, localeId: number | null) {
1191
+ const result = await (this.prisma as any).course_lesson_transcription_segment.deleteMany({
1192
+ where: { course_lesson_id: lessonId, locale_id: localeId },
1193
+ });
1194
+ return { success: true, deleted: result.count };
1195
+ }
1196
+
1197
+ async deleteAllCourseTranscriptions(courseId: number) {
1198
+ const lessons = await (this.prisma as any).course_lesson.findMany({
1199
+ where: { course_module: { course_id: courseId } },
1200
+ select: { id: true },
1201
+ });
1202
+ const lessonIds = lessons.map((l: any) => l.id);
1203
+ if (lessonIds.length === 0) {
1204
+ return { success: true, deleted: 0 };
1205
+ }
1206
+ const result = await (this.prisma as any).course_lesson_transcription_segment.deleteMany({
1207
+ where: { course_lesson_id: { in: lessonIds } },
1208
+ });
1209
+ return { success: true, deleted: result.count };
1210
+ }
1211
+
1212
+ async translateTranscription(
1213
+ lessonId: number,
1214
+ sourceLocaleId: number | null,
1215
+ targetLocaleId: number,
1216
+ notificationId?: number,
1217
+ notificationUserId?: number,
1218
+ ) {
1219
+ const lesson = await (this.prisma as any).course_lesson.findUnique({
1220
+ where: { id: lessonId },
1221
+ select: { id: true },
1222
+ });
1223
+
1224
+ if (!lesson) {
1225
+ throw new NotFoundException(`Lesson ${lessonId} not found`);
1226
+ }
1227
+
1228
+ const job = await this.queueJob.enqueue({
1229
+ type: 'lms.transcription.translate',
1230
+ queueName: 'lms.transcription.translate',
1231
+ payload: {
1232
+ lessonId,
1233
+ sourceLocaleId,
1234
+ targetLocaleId,
1235
+ ...(notificationId != null ? { notificationId } : {}),
1236
+ ...(notificationUserId != null ? { notificationUserId } : {}),
1237
+ },
1238
+ sourceModule: 'lms',
1239
+ sourceEntity: 'course_lesson',
1240
+ sourceEntityId: `${lessonId}:${targetLocaleId}`,
1241
+ maxAttempts: 3,
1242
+ });
1243
+
1244
+ const startedOnDemand = await this.startTranscriptionOnDemandIfNeeded(job.id);
1245
+
1246
+ if (startedOnDemand) {
1247
+ return { queueJobId: job.id, status: 'processing' };
1248
+ }
1249
+
1250
+ return { queueJobId: job.id, status: job.status };
1251
+ }
1252
+
1149
1253
  async getAudioFiles(lessonId: number) {
1150
1254
  return (this.prisma as any).course_lesson_file.findMany({
1151
1255
  where: {
@@ -1270,9 +1374,10 @@ export class CourseStructureService {
1270
1374
 
1271
1375
  async bulkEnqueueJobs(
1272
1376
  courseId: number,
1273
- jobType: 'transcription' | 'xp_recalculation' | 'video_processing',
1377
+ jobType: 'transcription' | 'xp_recalculation' | 'video_processing' | 'translate_transcription',
1274
1378
  userId: number,
1275
1379
  reprocessAlreadyProcessed?: boolean,
1380
+ targetLocaleId?: number,
1276
1381
  ): Promise<{
1277
1382
  queued: number;
1278
1383
  skipped: number;
@@ -1363,7 +1468,7 @@ export class CourseStructureService {
1363
1468
  skipped++;
1364
1469
  }
1365
1470
  }
1366
- } else {
1471
+ } else if (jobType === 'xp_recalculation') {
1367
1472
  for (const lesson of lessons) {
1368
1473
  const segments = await (this.prisma as any).course_lesson_transcription_segment.findFirst({
1369
1474
  where: { course_lesson_id: lesson.id },
@@ -1409,11 +1514,78 @@ export class CourseStructureService {
1409
1514
  `${skippedWithoutTranscription} aula(s) sem transcrição foram ignoradas. Gere a transcrição antes de recalcular o XP.`,
1410
1515
  );
1411
1516
  }
1517
+ } else if (jobType === 'translate_transcription') {
1518
+ if (!targetLocaleId) {
1519
+ warnings.push('targetLocaleId é obrigatório para o job de tradução em massa.');
1520
+ return { queued, skipped, skippedWithoutTranscription, warnings };
1521
+ }
1522
+
1523
+ for (const lesson of lessons) {
1524
+ const sourceSegment = await (this.prisma as any).course_lesson_transcription_segment.findFirst({
1525
+ where: {
1526
+ course_lesson_id: lesson.id,
1527
+ NOT: { locale_id: targetLocaleId },
1528
+ },
1529
+ select: { id: true, locale_id: true },
1530
+ orderBy: { id: 'asc' },
1531
+ });
1532
+
1533
+ if (!sourceSegment) {
1534
+ skipped++;
1535
+ skippedWithoutTranscription++;
1536
+ continue;
1537
+ }
1538
+
1539
+ await this.queueJob.enqueue({
1540
+ type: 'lms.transcription.translate',
1541
+ queueName: 'lms.transcription.translate',
1542
+ payload: {
1543
+ lessonId: lesson.id,
1544
+ sourceLocaleId: sourceSegment.locale_id ?? null,
1545
+ targetLocaleId,
1546
+ },
1547
+ sourceModule: 'lms',
1548
+ sourceEntity: 'course_lesson',
1549
+ sourceEntityId: `${lesson.id}:${targetLocaleId}`,
1550
+ maxAttempts: 3,
1551
+ });
1552
+ queued++;
1553
+ }
1554
+
1555
+ if (skippedWithoutTranscription > 0) {
1556
+ warnings.push(
1557
+ `${skippedWithoutTranscription} aula(s) sem transcrição foram ignoradas.`,
1558
+ );
1559
+ }
1412
1560
  }
1413
1561
 
1414
1562
  return { queued, skipped, skippedWithoutTranscription, warnings };
1415
1563
  }
1416
1564
 
1565
+ async getCourseTranscriptionLocales(courseId: number) {
1566
+ const rows = await (this.prisma as any).course_lesson_transcription_segment.findMany({
1567
+ where: {
1568
+ course_lesson: {
1569
+ course_module: { course_id: courseId },
1570
+ },
1571
+ },
1572
+ select: { locale_id: true },
1573
+ distinct: ['locale_id'],
1574
+ });
1575
+
1576
+ const localeIds = (rows as Array<{ locale_id: number | null }>)
1577
+ .map((r) => r.locale_id)
1578
+ .filter((id): id is number => id !== null);
1579
+
1580
+ if (localeIds.length === 0) return [];
1581
+
1582
+ return (this.prisma as any).locale.findMany({
1583
+ where: { id: { in: localeIds } },
1584
+ select: { id: true, code: true, name: true, region: true },
1585
+ orderBy: { name: 'asc' },
1586
+ });
1587
+ }
1588
+
1417
1589
  private async syncLessonRelations(
1418
1590
  lessonId: number,
1419
1591
  dto: Partial<
@@ -1510,7 +1682,9 @@ export class CourseStructureService {
1510
1682
  },
1511
1683
  },
1512
1684
  },
1513
- course_lesson_file: true,
1685
+ course_lesson_file: {
1686
+ include: { file: { select: { size: true } } },
1687
+ },
1514
1688
  course_lesson_question: {
1515
1689
  orderBy: { order: 'asc' },
1516
1690
  },
@@ -0,0 +1,293 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { buildAiConfigFromIntegration, NotificationService, SettingService } from '@hed-hog/core';
3
+ import { DatabaseQueueProvider, IJobHandler, NonRetryableError, QueueHandlerRegistry } from '@hed-hog/queue';
4
+ import {
5
+ Inject,
6
+ Injectable,
7
+ Logger,
8
+ OnModuleInit,
9
+ forwardRef,
10
+ } from '@nestjs/common';
11
+ import axios from 'axios';
12
+ import { CourseAiUsageService } from './course-ai-usage.service';
13
+
14
+ const BATCH_SIZE = 50;
15
+
16
+ @Injectable()
17
+ export class CourseTranscriptionTranslationService implements OnModuleInit, IJobHandler {
18
+ private readonly logger = new Logger(CourseTranscriptionTranslationService.name);
19
+
20
+ constructor(
21
+ @Inject(forwardRef(() => PrismaService))
22
+ private readonly prismaService: PrismaService,
23
+ @Inject(forwardRef(() => SettingService))
24
+ private readonly settingService: SettingService,
25
+ @Inject(forwardRef(() => NotificationService))
26
+ private readonly notificationService: NotificationService,
27
+ @Inject(forwardRef(() => QueueHandlerRegistry))
28
+ private readonly registry: QueueHandlerRegistry,
29
+ @Inject(forwardRef(() => DatabaseQueueProvider))
30
+ private readonly dbQueue: DatabaseQueueProvider,
31
+ @Inject(forwardRef(() => CourseAiUsageService))
32
+ private readonly aiUsageService: CourseAiUsageService,
33
+ ) {}
34
+
35
+ onModuleInit() {
36
+ this.registry.register('lms.transcription.translate', this);
37
+ this.logger.log('Registered handler for "lms.transcription.translate"');
38
+ }
39
+
40
+ private async createProgressEvent(
41
+ queueJobId: number,
42
+ message: string,
43
+ metadata?: Record<string, unknown>,
44
+ ): Promise<void> {
45
+ try {
46
+ await (this.prismaService as any).queue_job_event.create({
47
+ data: {
48
+ queue_job_id: queueJobId,
49
+ event_type: 'started',
50
+ message,
51
+ ...(metadata ? { metadata } : {}),
52
+ },
53
+ });
54
+ } catch (error) {
55
+ this.logger.warn(
56
+ `Queue job ${queueJobId}: failed to persist translation progress event "${message}": ${error instanceof Error ? error.message : 'unknown error'}`,
57
+ );
58
+ }
59
+ }
60
+
61
+ private async updateNotification(
62
+ userId: number,
63
+ notificationId: number,
64
+ progress: number,
65
+ body: string,
66
+ success?: boolean,
67
+ ): Promise<void> {
68
+ try {
69
+ await this.notificationService.updateProgress(userId, notificationId, {
70
+ progress,
71
+ body,
72
+ ...(success != null ? { success } : {}),
73
+ });
74
+ } catch (error) {
75
+ this.logger.warn(
76
+ `Failed to update notification ${notificationId} for user ${userId}: ${error instanceof Error ? error.message : 'unknown error'}`,
77
+ );
78
+ }
79
+ }
80
+
81
+ private async translateBatch(
82
+ texts: string[],
83
+ targetLangName: string,
84
+ apiKey: string,
85
+ ): Promise<{ translations: string[]; inputTokens: number; outputTokens: number }> {
86
+ const prompt = [
87
+ `Translate the following subtitle segments to ${targetLangName}.`,
88
+ `Return ONLY a JSON array of strings with the same number of elements, in the same order.`,
89
+ `Do not add explanations. Preserve punctuation and formatting.`,
90
+ ``,
91
+ `Input:`,
92
+ JSON.stringify(texts),
93
+ ].join('\n');
94
+
95
+ const response = await axios.post(
96
+ 'https://api.openai.com/v1/chat/completions',
97
+ {
98
+ model: 'gpt-4o-mini',
99
+ messages: [{ role: 'user', content: prompt }],
100
+ temperature: 0.2,
101
+ },
102
+ {
103
+ headers: {
104
+ Authorization: `Bearer ${apiKey}`,
105
+ 'Content-Type': 'application/json',
106
+ },
107
+ timeout: 120_000,
108
+ },
109
+ );
110
+
111
+ const content: string = response.data?.choices?.[0]?.message?.content ?? '[]';
112
+ const start = content.indexOf('[');
113
+ const end = content.lastIndexOf(']');
114
+ if (start === -1 || end === -1) {
115
+ throw new Error(`AI response did not contain a JSON array: ${content.slice(0, 200)}`);
116
+ }
117
+ const parsed: unknown = JSON.parse(content.slice(start, end + 1));
118
+ if (!Array.isArray(parsed)) {
119
+ throw new Error('AI translation response is not an array');
120
+ }
121
+ const usage = response.data?.usage ?? {};
122
+ return {
123
+ translations: parsed.map((v) => String(v)),
124
+ inputTokens: Number(usage.prompt_tokens ?? 0),
125
+ outputTokens: Number(usage.completion_tokens ?? 0),
126
+ };
127
+ }
128
+
129
+ async handle(job: {
130
+ id: number;
131
+ type: string;
132
+ queue_name: string;
133
+ payload: Record<string, any>;
134
+ attempts: number;
135
+ max_attempts: number;
136
+ source_module?: string | null;
137
+ source_entity?: string | null;
138
+ source_entity_id?: string | null;
139
+ }): Promise<void> {
140
+ const { lessonId, sourceLocaleId, targetLocaleId, notificationId, notificationUserId } =
141
+ job.payload as {
142
+ lessonId: number;
143
+ sourceLocaleId: number | null;
144
+ targetLocaleId: number;
145
+ notificationId?: number;
146
+ notificationUserId?: number;
147
+ };
148
+
149
+ const hasNotification =
150
+ Number.isInteger(Number(notificationId)) &&
151
+ Number.isInteger(Number(notificationUserId));
152
+
153
+ const emitProgress = async (message: string, progress: number, success?: boolean) => {
154
+ await this.createProgressEvent(job.id, message);
155
+ if (hasNotification) {
156
+ await this.updateNotification(
157
+ Number(notificationUserId),
158
+ Number(notificationId),
159
+ progress,
160
+ message,
161
+ success,
162
+ );
163
+ }
164
+ };
165
+
166
+ const settings = await this.settingService.getSettingValues(['ai-openai-profile-id']);
167
+ const profileId = Number(settings['ai-openai-profile-id']);
168
+ let apiKey = '';
169
+
170
+ if (profileId) {
171
+ const profile = await (this.prismaService as any).integration_profile.findUnique({
172
+ where: { id: profileId },
173
+ include: { integration_provider: { select: { slug: true } } },
174
+ });
175
+ if (profile) {
176
+ apiKey = buildAiConfigFromIntegration(
177
+ profile.integration_provider.slug,
178
+ profile.config,
179
+ ).apiKey;
180
+ }
181
+ }
182
+
183
+ if (!apiKey) {
184
+ const msg = profileId
185
+ ? `Perfil de IA (id=${profileId}) não encontrado ou sem chave OpenAI configurada.`
186
+ : `Tradução requer um perfil OpenAI configurado. Acesse Settings → LMS → ai-openai-profile-id.`;
187
+ await emitProgress(msg, 100, false);
188
+ throw new NonRetryableError(msg);
189
+ }
190
+
191
+ const targetLocale = await (this.prismaService as any).locale.findUnique({
192
+ where: { id: targetLocaleId },
193
+ select: { id: true, code: true, name: true },
194
+ });
195
+
196
+ if (!targetLocale) {
197
+ const msg = `Locale alvo (id=${targetLocaleId}) não encontrado.`;
198
+ await emitProgress(msg, 100, false);
199
+ throw new NonRetryableError(msg);
200
+ }
201
+
202
+ const sourceWhere: Record<string, any> = { course_lesson_id: lessonId };
203
+ if (sourceLocaleId !== null && sourceLocaleId !== undefined) {
204
+ sourceWhere.locale_id = sourceLocaleId;
205
+ } else {
206
+ sourceWhere.locale_id = null;
207
+ }
208
+
209
+ const sourceSegments = await (this.prismaService as any).course_lesson_transcription_segment.findMany({
210
+ where: sourceWhere,
211
+ orderBy: { start_seconds: 'asc' },
212
+ select: { id: true, start_seconds: true, end_seconds: true, text: true },
213
+ });
214
+
215
+ if (!sourceSegments.length) {
216
+ const msg = `Nenhum segmento de transcrição encontrado para a aula ${lessonId} no idioma fonte.`;
217
+ await emitProgress(msg, 100, false);
218
+ throw new NonRetryableError(msg);
219
+ }
220
+
221
+ await emitProgress(
222
+ `Iniciando tradução de ${sourceSegments.length} segmento(s) para ${targetLocale.name}...`,
223
+ 10,
224
+ );
225
+
226
+ const targetLangName = targetLocale.name ?? targetLocale.code;
227
+ const translatedTexts: string[] = [];
228
+ const totalBatches = Math.ceil(sourceSegments.length / BATCH_SIZE);
229
+ let totalInputTokens = 0;
230
+ let totalOutputTokens = 0;
231
+
232
+ for (let i = 0; i < sourceSegments.length; i += BATCH_SIZE) {
233
+ const batch = sourceSegments.slice(i, i + BATCH_SIZE);
234
+ const batchIndex = Math.floor(i / BATCH_SIZE) + 1;
235
+
236
+ await emitProgress(
237
+ `Traduzindo lote ${batchIndex}/${totalBatches}...`,
238
+ 10 + Math.round((batchIndex / totalBatches) * 80),
239
+ );
240
+
241
+ const batchTexts: string[] = batch.map((s: any) => s.text);
242
+ const batchResult = await this.translateBatch(batchTexts, targetLangName, apiKey);
243
+ const batchTranslated = batchResult.translations;
244
+ totalInputTokens += batchResult.inputTokens;
245
+ totalOutputTokens += batchResult.outputTokens;
246
+
247
+ if (batchTranslated.length !== batchTexts.length) {
248
+ this.logger.warn(
249
+ `Batch ${batchIndex}: expected ${batchTexts.length} translations, got ${batchTranslated.length} — padding missing entries with source text`,
250
+ );
251
+ while (batchTranslated.length < batchTexts.length) {
252
+ batchTranslated.push(batchTexts[batchTranslated.length] ?? '');
253
+ }
254
+ batchTranslated.splice(batchTexts.length);
255
+ }
256
+
257
+ translatedTexts.push(...batchTranslated);
258
+ }
259
+
260
+ await emitProgress('Salvando segmentos traduzidos...', 95);
261
+
262
+ const translatedSegments = sourceSegments.map((seg: any, idx: number) => ({
263
+ course_lesson_id: lessonId,
264
+ locale_id: targetLocaleId,
265
+ start_seconds: seg.start_seconds,
266
+ end_seconds: seg.end_seconds,
267
+ text: translatedTexts[idx] ?? seg.text,
268
+ }));
269
+
270
+ await this.prismaService.$transaction([
271
+ (this.prismaService as any).course_lesson_transcription_segment.deleteMany({
272
+ where: { course_lesson_id: lessonId, locale_id: targetLocaleId },
273
+ }),
274
+ (this.prismaService as any).course_lesson_transcription_segment.createMany({
275
+ data: translatedSegments,
276
+ }),
277
+ ]);
278
+
279
+ // Record AI cost (gpt-4o-mini, billed per token).
280
+ await this.aiUsageService.recordChatUsage({
281
+ lessonId,
282
+ jobType: 'translation',
283
+ provider: 'openai',
284
+ model: 'gpt-4o-mini',
285
+ inputTokens: totalInputTokens,
286
+ outputTokens: totalOutputTokens,
287
+ });
288
+
289
+ const doneMsg = `Tradução concluída: ${translatedSegments.length} segmento(s) em ${targetLocale.name}.`;
290
+ this.logger.log(doneMsg);
291
+ await emitProgress(doneMsg, 100, true);
292
+ }
293
+ }