@hed-hog/lms 0.0.365 → 0.0.366

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 (113) hide show
  1. package/dist/class-group/class-group.controller.d.ts +1 -0
  2. package/dist/class-group/class-group.controller.d.ts.map +1 -1
  3. package/dist/class-group/class-group.service.d.ts +1 -0
  4. package/dist/class-group/class-group.service.d.ts.map +1 -1
  5. package/dist/course/course-structure.controller.d.ts +4 -2
  6. package/dist/course/course-structure.controller.d.ts.map +1 -1
  7. package/dist/course/course-structure.controller.js +6 -3
  8. package/dist/course/course-structure.controller.js.map +1 -1
  9. package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
  10. package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
  11. package/dist/course/course-video-agent-pipeline.service.js +398 -0
  12. package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
  13. package/dist/course/course-video-hls.service.d.ts +14 -0
  14. package/dist/course/course-video-hls.service.d.ts.map +1 -1
  15. package/dist/course/course-video-hls.service.js +25 -8
  16. package/dist/course/course-video-hls.service.js.map +1 -1
  17. package/dist/course/course.controller.d.ts +2 -0
  18. package/dist/course/course.controller.d.ts.map +1 -1
  19. package/dist/course/course.module.d.ts.map +1 -1
  20. package/dist/course/course.module.js +5 -0
  21. package/dist/course/course.module.js.map +1 -1
  22. package/dist/course/course.service.d.ts +2 -0
  23. package/dist/course/course.service.d.ts.map +1 -1
  24. package/dist/course/course.service.js +36 -2
  25. package/dist/course/course.service.js.map +1 -1
  26. package/dist/course/ffmpeg.util.d.ts +10 -0
  27. package/dist/course/ffmpeg.util.d.ts.map +1 -0
  28. package/dist/course/ffmpeg.util.js +79 -0
  29. package/dist/course/ffmpeg.util.js.map +1 -0
  30. package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
  31. package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
  32. package/dist/course/lms-bulk-upload-automation.service.js +7 -3
  33. package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
  34. package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
  35. package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
  36. package/dist/enterprise/training/training-admin.service.d.ts +2 -0
  37. package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
  38. package/dist/lms.module.d.ts.map +1 -1
  39. package/dist/lms.module.js +10 -0
  40. package/dist/lms.module.js.map +1 -1
  41. package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
  42. package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
  43. package/dist/platforma/dto/heartbeat.dto.js +50 -0
  44. package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
  45. package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
  46. package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
  47. package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
  48. package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
  49. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
  50. package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
  51. package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
  52. package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
  53. package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
  54. package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
  55. package/dist/platforma/platforma-heartbeat.service.js +50 -0
  56. package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
  57. package/dist/platforma/platforma-performance.service.d.ts +121 -0
  58. package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
  59. package/dist/platforma/platforma-performance.service.js +500 -0
  60. package/dist/platforma/platforma-performance.service.js.map +1 -0
  61. package/dist/platforma/platforma-search.service.d.ts +21 -0
  62. package/dist/platforma/platforma-search.service.d.ts.map +1 -0
  63. package/dist/platforma/platforma-search.service.js +64 -0
  64. package/dist/platforma/platforma-search.service.js.map +1 -0
  65. package/dist/platforma/platforma.controller.d.ts +115 -1
  66. package/dist/platforma/platforma.controller.d.ts.map +1 -1
  67. package/dist/platforma/platforma.controller.js +50 -2
  68. package/dist/platforma/platforma.controller.js.map +1 -1
  69. package/dist/realtime/lms-realtime.controller.d.ts +2 -0
  70. package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
  71. package/dist/realtime/lms-realtime.controller.js +31 -0
  72. package/dist/realtime/lms-realtime.controller.js.map +1 -1
  73. package/dist/realtime/lms-realtime.service.d.ts +1 -1
  74. package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
  75. package/dist/realtime/lms-realtime.service.js.map +1 -1
  76. package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
  77. package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
  78. package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
  79. package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
  80. package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
  81. package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
  82. package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
  83. package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
  84. package/hedhog/frontend/app/courses/page.tsx.ejs +40 -9
  85. package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
  86. package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
  87. package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
  88. package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
  89. package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
  90. package/hedhog/frontend/app/paths/page.tsx.ejs +38 -4
  91. package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
  92. package/hedhog/frontend/messages/en.json +18 -0
  93. package/hedhog/frontend/messages/pt.json +21 -1
  94. package/hedhog/table/course_enrollment.yaml +3 -0
  95. package/hedhog/table/lesson_view_event.yaml +66 -0
  96. package/package.json +9 -8
  97. package/src/course/course-structure.controller.ts +3 -1
  98. package/src/course/course-video-agent-pipeline.service.ts +471 -0
  99. package/src/course/course-video-hls.service.ts +30 -10
  100. package/src/course/course.module.ts +5 -0
  101. package/src/course/course.service.ts +46 -1
  102. package/src/course/ffmpeg.util.ts +65 -0
  103. package/src/course/lms-bulk-upload-automation.service.ts +4 -1
  104. package/src/lms.module.ts +10 -0
  105. package/src/platforma/dto/heartbeat.dto.ts +30 -0
  106. package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
  107. package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
  108. package/src/platforma/platforma-heartbeat.service.ts +33 -0
  109. package/src/platforma/platforma-performance.service.ts +606 -0
  110. package/src/platforma/platforma-search.service.ts +48 -0
  111. package/src/platforma/platforma.controller.ts +42 -0
  112. package/src/realtime/lms-realtime.controller.ts +27 -1
  113. package/src/realtime/lms-realtime.service.ts +2 -1
@@ -1,3 +1,4 @@
1
+ import { AgentModule } from '@hed-hog/agent';
1
2
  import { PrismaModule } from '@hed-hog/api-prisma';
2
3
  import { CoreModule } from '@hed-hog/core';
3
4
  import { QueueModule } from '@hed-hog/queue';
@@ -8,6 +9,7 @@ import { CourseOperationsIntegrationService } from './course-operations-integrat
8
9
  import { CourseOperationsController } from './course-operations.controller';
9
10
  import { CourseStructureController } from './course-structure.controller';
10
11
  import { CourseStructureService } from './course-structure.service';
12
+ import { CourseVideoAgentPipelineService } from './course-video-agent-pipeline.service';
11
13
  import { CourseVideoConversionService } from './course-video-conversion.service';
12
14
  import { CourseVideoHlsService } from './course-video-hls.service';
13
15
  import { CourseExportScorm12Service } from './course-export-scorm12.service';
@@ -29,6 +31,7 @@ import { LmsSettingController } from './lms-setting.controller';
29
31
  forwardRef(() => InstructorModule),
30
32
  forwardRef(() => CoreModule),
31
33
  forwardRef(() => QueueModule),
34
+ forwardRef(() => AgentModule),
32
35
  ],
33
36
  controllers: [
34
37
  CourseController,
@@ -45,6 +48,7 @@ import { LmsSettingController } from './lms-setting.controller';
45
48
  CourseOperationsIntegrationService,
46
49
  CourseService,
47
50
  CourseStructureService,
51
+ CourseVideoAgentPipelineService,
48
52
  CourseVideoConversionService,
49
53
  CourseVideoHlsService,
50
54
  LmsBulkUploadAutomationService,
@@ -58,6 +62,7 @@ import { LmsSettingController } from './lms-setting.controller';
58
62
  forwardRef(() => CourseStructureService),
59
63
  forwardRef(() => CourseVideoConversionService),
60
64
  forwardRef(() => CourseVideoHlsService),
65
+ CourseVideoAgentPipelineService,
61
66
  ],
62
67
  })
63
68
  export class CourseModule {}
@@ -1905,6 +1905,7 @@ export class CourseService implements OnModuleInit, IJobHandler {
1905
1905
  extractedImageCount,
1906
1906
  resourceFileCount,
1907
1907
  storageRows,
1908
+ videoFileRows,
1908
1909
  ] = await Promise.all([
1909
1910
  this.prisma.course_module.count({ where: { course_id: courseId } }),
1910
1911
  this.prisma.course_lesson.findMany({
@@ -1930,6 +1931,17 @@ export class CourseService implements OnModuleInit, IJobHandler {
1930
1931
  },
1931
1932
  }),
1932
1933
  this.getCourseStorageRows(courseId),
1934
+ this.prisma.course_lesson_file.findMany({
1935
+ where: {
1936
+ course_lesson: { course_module: { course_id: courseId } },
1937
+ OR: [
1938
+ { type: 'video_original' },
1939
+ { type: { startsWith: 'video_profile:' } },
1940
+ ],
1941
+ file_id: { not: null },
1942
+ },
1943
+ select: { course_lesson_id: true, type: true },
1944
+ }),
1933
1945
  ]);
1934
1946
 
1935
1947
  const transcriptionLessonIds = new Set(
@@ -1937,10 +1949,23 @@ export class CourseService implements OnModuleInit, IJobHandler {
1937
1949
  );
1938
1950
  const xpLessonIds = new Set(xpMapRows.map((r) => r.course_lesson_id));
1939
1951
 
1952
+ const videoOriginalLessonIds = new Set(
1953
+ videoFileRows
1954
+ .filter((r) => r.type === 'video_original')
1955
+ .map((r) => r.course_lesson_id),
1956
+ );
1957
+ const videoProfileLessonIds = new Set(
1958
+ videoFileRows
1959
+ .filter((r) => r.type?.startsWith('video_profile:'))
1960
+ .map((r) => r.course_lesson_id),
1961
+ );
1962
+
1940
1963
  const lessonsByType = { video: 0, questao: 0, post: 0 };
1941
1964
  let publishedLessonCount = 0;
1942
1965
  let videoWithTranscription = 0;
1943
1966
  let videoWithXp = 0;
1967
+ let videoWithVideo = 0;
1968
+ let videoWithProcessedVideo = 0;
1944
1969
 
1945
1970
  const categoryOrder = [
1946
1971
  'video_original',
@@ -2009,8 +2034,9 @@ export class CourseService implements OnModuleInit, IJobHandler {
2009
2034
  if (lesson.published) publishedLessonCount++;
2010
2035
 
2011
2036
  let sourceType: string | undefined;
2037
+ let parsed: Record<string, unknown> | null = null;
2012
2038
  try {
2013
- const parsed = lesson.content
2039
+ parsed = lesson.content
2014
2040
  ? (JSON.parse(lesson.content as string) as Record<string, unknown>)
2015
2041
  : null;
2016
2042
  sourceType =
@@ -2041,6 +2067,23 @@ export class CourseService implements OnModuleInit, IJobHandler {
2041
2067
  if (uiType === 'video') {
2042
2068
  if (transcriptionLessonIds.has(lesson.id)) videoWithTranscription++;
2043
2069
  if (xpLessonIds.has(lesson.id)) videoWithXp++;
2070
+
2071
+ const videoUrl =
2072
+ typeof parsed?.videoUrl === 'string' ? parsed.videoUrl : '';
2073
+ const videoProvedor =
2074
+ typeof parsed?.videoProvedor === 'string'
2075
+ ? parsed.videoProvedor
2076
+ : 'file_storage';
2077
+
2078
+ const hasVideo =
2079
+ videoUrl.length > 0 || videoOriginalLessonIds.has(lesson.id);
2080
+ const isProcessed =
2081
+ videoProvedor !== 'file_storage'
2082
+ ? videoUrl.length > 0
2083
+ : videoProfileLessonIds.has(lesson.id);
2084
+
2085
+ if (hasVideo) videoWithVideo++;
2086
+ if (isProcessed) videoWithProcessedVideo++;
2044
2087
  }
2045
2088
  }
2046
2089
 
@@ -2055,6 +2098,8 @@ export class CourseService implements OnModuleInit, IJobHandler {
2055
2098
  lessonCount: lessonsByType.video,
2056
2099
  withTranscription: videoWithTranscription,
2057
2100
  withXp: videoWithXp,
2101
+ withVideo: videoWithVideo,
2102
+ withProcessedVideo: videoWithProcessedVideo,
2058
2103
  },
2059
2104
  media: { extractedImageCount },
2060
2105
  resources: { fileCount: resourceFileCount },
@@ -0,0 +1,65 @@
1
+ import { existsSync, readdirSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ /**
5
+ * Resolves the ffmpeg/ffprobe binaries across environments: explicit env override first,
6
+ * then a WinGet (Gyan.FFmpeg) lookup on Windows, finally the bare command on PATH.
7
+ * Mirrors the resolution baked into CourseVideoHlsService so the decomposed pipeline jobs
8
+ * (extract / split) behave identically.
9
+ */
10
+ export function getFfmpegCommand(): string {
11
+ const fromEnv = process.env.FFMPEG_PATH?.trim();
12
+ if (fromEnv) return fromEnv;
13
+ if (process.platform === 'win32') {
14
+ const found = findWindowsBinary('ffmpeg');
15
+ if (found) return found;
16
+ }
17
+ return 'ffmpeg';
18
+ }
19
+
20
+ export function getFfprobeCommand(): string {
21
+ const fromEnv = process.env.FFPROBE_PATH?.trim();
22
+ if (fromEnv) return fromEnv;
23
+ const ffmpegEnv = process.env.FFMPEG_PATH?.trim();
24
+ if (ffmpegEnv) {
25
+ const candidate = ffmpegEnv.replace(/ffmpeg(\.exe)?$/i, (_, ext) => `ffprobe${ext ?? ''}`);
26
+ if (existsSync(candidate)) return candidate;
27
+ }
28
+ if (process.platform === 'win32') {
29
+ const found = findWindowsBinary('ffprobe');
30
+ if (found) return found;
31
+ }
32
+ return 'ffprobe';
33
+ }
34
+
35
+ export function findWindowsBinary(name: 'ffmpeg' | 'ffprobe'): string | null {
36
+ const localAppData = process.env.LOCALAPPDATA;
37
+ if (!localAppData) return null;
38
+ const packagesRoot = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
39
+ try {
40
+ const packageDirs = readdirSync(packagesRoot, { withFileTypes: true })
41
+ .filter((e) => e.isDirectory() && e.name.startsWith('Gyan.FFmpeg'))
42
+ .map((e) => join(packagesRoot, e.name))
43
+ .sort((a, b) => b.localeCompare(a));
44
+
45
+ for (const dir of packageDirs) {
46
+ const direct = join(dir, 'bin', `${name}.exe`);
47
+ if (existsSync(direct)) return direct;
48
+ try {
49
+ const versionDirs = readdirSync(dir, { withFileTypes: true })
50
+ .filter((e) => e.isDirectory() && e.name.toLowerCase().startsWith('ffmpeg-'))
51
+ .map((e) => join(dir, e.name))
52
+ .sort((a, b) => b.localeCompare(a));
53
+ for (const vd of versionDirs) {
54
+ const candidate = join(vd, 'bin', `${name}.exe`);
55
+ if (existsSync(candidate)) return candidate;
56
+ }
57
+ } catch {
58
+ /* skip */
59
+ }
60
+ }
61
+ } catch {
62
+ /* skip */
63
+ }
64
+ return null;
65
+ }
@@ -21,6 +21,7 @@ import { tmpdir } from 'os';
21
21
  import { basename, extname, join } from 'path';
22
22
  import { Readable } from 'stream';
23
23
  import { pipeline } from 'stream/promises';
24
+ import { CourseVideoAgentPipelineService } from './course-video-agent-pipeline.service';
24
25
  import { CourseVideoConversionService } from './course-video-conversion.service';
25
26
  import { CourseVideoHlsService } from './course-video-hls.service';
26
27
  import {
@@ -62,6 +63,8 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
62
63
  private readonly courseVideoConversionService: CourseVideoConversionService,
63
64
  @Inject(forwardRef(() => CourseVideoHlsService))
64
65
  private readonly courseVideoHlsService: CourseVideoHlsService,
66
+ @Inject(forwardRef(() => CourseVideoAgentPipelineService))
67
+ private readonly courseVideoAgentPipelineService: CourseVideoAgentPipelineService,
65
68
  ) {}
66
69
 
67
70
  onModuleInit(): void {
@@ -257,7 +260,7 @@ export class LmsBulkUploadAutomationService implements OnModuleInit {
257
260
  },
258
261
  );
259
262
 
260
- await this.courseVideoHlsService.enqueueHls({
263
+ await this.courseVideoAgentPipelineService.startProcessing({
261
264
  userId,
262
265
  courseId,
263
266
  sessionId,
package/src/lms.module.ts CHANGED
@@ -22,7 +22,12 @@ import { EvaluationModule } from './evaluation/evaluation.module';
22
22
  import { ExamModule } from './exam/exam.module';
23
23
  import { InstructorModule } from './instructor/instructor.module';
24
24
  import { LessonXpMapModule } from './lesson-xp-map/lesson-xp-map.module';
25
+ import { EmitCertificateHandler } from './platforma/handlers/emit-certificate.handler';
26
+ import { LessonHeartbeatHandler } from './platforma/handlers/lesson-heartbeat.handler';
25
27
  import { PlataformaController } from './platforma/platforma.controller';
28
+ import { PlatformaHeartbeatService } from './platforma/platforma-heartbeat.service';
29
+ import { PlatformaPerformanceService } from './platforma/platforma-performance.service';
30
+ import { PlatformaSearchService } from './platforma/platforma-search.service';
26
31
  import { PlatformaVideoService } from './platforma/platforma-video.service';
27
32
  import { StudentXpModule } from './student-xp/student-xp.module';
28
33
  import { XpCatalogModule } from './xp-catalog/xp-catalog.module';
@@ -65,6 +70,11 @@ import { TrainingModule } from './training/training.module';
65
70
  CourseAudioTranscriptionService,
66
71
  PlatformaService,
67
72
  PlatformaVideoService,
73
+ PlatformaHeartbeatService,
74
+ PlatformaPerformanceService,
75
+ PlatformaSearchService,
76
+ LessonHeartbeatHandler,
77
+ EmitCertificateHandler,
68
78
  LmsCommerceAccessSubscriber,
69
79
  ],
70
80
  exports: [
@@ -0,0 +1,30 @@
1
+ import { IsBoolean, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
2
+
3
+ export class HeartbeatDto {
4
+ @IsInt()
5
+ lessonId: number;
6
+
7
+ @IsInt()
8
+ @Min(0)
9
+ positionSeconds: number;
10
+
11
+ @IsOptional()
12
+ @IsString()
13
+ sessionId?: string;
14
+
15
+ @IsOptional()
16
+ @IsInt()
17
+ @Min(1)
18
+ @Max(10000)
19
+ screenWidth?: number;
20
+
21
+ @IsOptional()
22
+ @IsInt()
23
+ @Min(1)
24
+ @Max(10000)
25
+ screenHeight?: number;
26
+
27
+ @IsOptional()
28
+ @IsBoolean()
29
+ isTouch?: boolean;
30
+ }
@@ -0,0 +1,117 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { IJobHandler, QueueHandlerRegistry } from '@hed-hog/queue';
3
+ import { Inject, Injectable, Logger, OnModuleInit, forwardRef } from '@nestjs/common';
4
+ import { randomBytes } from 'crypto';
5
+
6
+ export const EMIT_CERTIFICATE_JOB = 'lms.emit_certificate';
7
+
8
+ @Injectable()
9
+ export class EmitCertificateHandler implements OnModuleInit, IJobHandler {
10
+ private readonly logger = new Logger(EmitCertificateHandler.name);
11
+
12
+ constructor(
13
+ @Inject(forwardRef(() => PrismaService))
14
+ private readonly prisma: PrismaService,
15
+ @Inject(forwardRef(() => QueueHandlerRegistry))
16
+ private readonly registry: QueueHandlerRegistry,
17
+ ) {}
18
+
19
+ onModuleInit() {
20
+ this.registry.register(EMIT_CERTIFICATE_JOB, this);
21
+ this.logger.log(`Registered handler for "${EMIT_CERTIFICATE_JOB}"`);
22
+ }
23
+
24
+ async handle(job: { payload: Record<string, any> }) {
25
+ const { enrollmentId, courseId, personId } = job.payload as {
26
+ enrollmentId: number;
27
+ courseId: number;
28
+ personId: number;
29
+ };
30
+
31
+ // Idempotency guard — re-entrancy safe
32
+ const enrollment = await (this.prisma as any).course_enrollment.findUnique({
33
+ where: { id: enrollmentId },
34
+ select: { certificate_issued_at: true, completed_at: true, final_score: true },
35
+ });
36
+
37
+ if (!enrollment) {
38
+ this.logger.warn(`Enrollment ${enrollmentId} not found, skipping certificate`);
39
+ return { skipped: true };
40
+ }
41
+
42
+ if (enrollment.certificate_issued_at) {
43
+ this.logger.log(`Certificate already issued for enrollment ${enrollmentId}`);
44
+ return { skipped: true, alreadyIssued: true };
45
+ }
46
+
47
+ const [person, course] = await Promise.all([
48
+ this.prisma.person.findUnique({
49
+ where: { id: personId },
50
+ select: { name: true },
51
+ }),
52
+ (this.prisma as any).course.findUnique({
53
+ where: { id: courseId },
54
+ select: {
55
+ title: true,
56
+ certificate_workload: true,
57
+ has_certificate: true,
58
+ certificate_template_id: true,
59
+ },
60
+ }),
61
+ ]);
62
+
63
+ if (!person || !course) {
64
+ this.logger.warn(`Person or course not found (enrollment ${enrollmentId})`);
65
+ return { skipped: true };
66
+ }
67
+
68
+ if (!course.has_certificate || !course.certificate_template_id) {
69
+ this.logger.log(`Course ${courseId} does not issue certificates`);
70
+ return { skipped: true };
71
+ }
72
+
73
+ // Compute workload hours from lesson durations when not set on the course
74
+ let workloadHours: number = course.certificate_workload ?? 0;
75
+ if (!workloadHours) {
76
+ const agg = await (this.prisma as any).course_lesson.aggregate({
77
+ where: { published: true, course_module: { course_id: courseId } },
78
+ _sum: { duration_seconds: true },
79
+ });
80
+ const totalSeconds = (agg._sum?.duration_seconds as number) ?? 0;
81
+ workloadHours = Math.max(1, Math.ceil(totalSeconds / 3600));
82
+ }
83
+
84
+ const issuedAt = new Date();
85
+ const verificationCode = randomBytes(12).toString('hex').toUpperCase();
86
+
87
+ await (this.prisma as any).certificate.create({
88
+ data: {
89
+ verification_code: verificationCode,
90
+ student_id: personId,
91
+ course_enrollment_id: enrollmentId,
92
+ course_id: courseId,
93
+ certificate_template_id: course.certificate_template_id,
94
+ certificate_type: 'course',
95
+ student_name: person.name ?? '',
96
+ course_name: course.title ?? '',
97
+ workload_hours: workloadHours,
98
+ issued_at: issuedAt,
99
+ completed_at: enrollment.completed_at ?? issuedAt,
100
+ final_score: enrollment.final_score ?? null,
101
+ public_access: false,
102
+ },
103
+ });
104
+
105
+ // Mark enrollment as issued — prevents duplicate certificates on any future re-run
106
+ await (this.prisma as any).course_enrollment.update({
107
+ where: { id: enrollmentId },
108
+ data: { certificate_issued_at: issuedAt },
109
+ });
110
+
111
+ this.logger.log(
112
+ `Certificate issued — enrollment=${enrollmentId} course=${courseId} code=${verificationCode}`,
113
+ );
114
+
115
+ return { verificationCode };
116
+ }
117
+ }