@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.
- package/dist/class-group/class-group.controller.d.ts +1 -0
- package/dist/class-group/class-group.controller.d.ts.map +1 -1
- package/dist/class-group/class-group.service.d.ts +1 -0
- package/dist/class-group/class-group.service.d.ts.map +1 -1
- package/dist/course/course-structure.controller.d.ts +4 -2
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +6 -3
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-video-agent-pipeline.service.d.ts +70 -0
- package/dist/course/course-video-agent-pipeline.service.d.ts.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.js +398 -0
- package/dist/course/course-video-agent-pipeline.service.js.map +1 -0
- package/dist/course/course-video-hls.service.d.ts +14 -0
- package/dist/course/course-video-hls.service.d.ts.map +1 -1
- package/dist/course/course-video-hls.service.js +25 -8
- package/dist/course/course-video-hls.service.js.map +1 -1
- package/dist/course/course.controller.d.ts +2 -0
- package/dist/course/course.controller.d.ts.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +5 -0
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/course.service.d.ts +2 -0
- package/dist/course/course.service.d.ts.map +1 -1
- package/dist/course/course.service.js +36 -2
- package/dist/course/course.service.js.map +1 -1
- package/dist/course/ffmpeg.util.d.ts +10 -0
- package/dist/course/ffmpeg.util.d.ts.map +1 -0
- package/dist/course/ffmpeg.util.js +79 -0
- package/dist/course/ffmpeg.util.js.map +1 -0
- package/dist/course/lms-bulk-upload-automation.service.d.ts +3 -1
- package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.js +7 -3
- package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
- package/dist/enterprise/training/training-admin.controller.d.ts +2 -0
- package/dist/enterprise/training/training-admin.controller.d.ts.map +1 -1
- package/dist/enterprise/training/training-admin.service.d.ts +2 -0
- package/dist/enterprise/training/training-admin.service.d.ts.map +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +10 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/dto/heartbeat.dto.d.ts +9 -0
- package/dist/platforma/dto/heartbeat.dto.d.ts.map +1 -0
- package/dist/platforma/dto/heartbeat.dto.js +50 -0
- package/dist/platforma/dto/heartbeat.dto.js.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts +27 -0
- package/dist/platforma/handlers/emit-certificate.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/emit-certificate.handler.js +117 -0
- package/dist/platforma/handlers/emit-certificate.handler.js.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts +31 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.d.ts.map +1 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js +281 -0
- package/dist/platforma/handlers/lesson-heartbeat.handler.js.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts +10 -0
- package/dist/platforma/platforma-heartbeat.service.d.ts.map +1 -0
- package/dist/platforma/platforma-heartbeat.service.js +50 -0
- package/dist/platforma/platforma-heartbeat.service.js.map +1 -0
- package/dist/platforma/platforma-performance.service.d.ts +121 -0
- package/dist/platforma/platforma-performance.service.d.ts.map +1 -0
- package/dist/platforma/platforma-performance.service.js +500 -0
- package/dist/platforma/platforma-performance.service.js.map +1 -0
- package/dist/platforma/platforma-search.service.d.ts +21 -0
- package/dist/platforma/platforma-search.service.d.ts.map +1 -0
- package/dist/platforma/platforma-search.service.js +64 -0
- package/dist/platforma/platforma-search.service.js.map +1 -0
- package/dist/platforma/platforma.controller.d.ts +115 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +50 -2
- package/dist/platforma/platforma.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.controller.d.ts +2 -0
- package/dist/realtime/lms-realtime.controller.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.controller.js +31 -0
- package/dist/realtime/lms-realtime.controller.js.map +1 -1
- package/dist/realtime/lms-realtime.service.d.ts +1 -1
- package/dist/realtime/lms-realtime.service.d.ts.map +1 -1
- package/dist/realtime/lms-realtime.service.js.map +1 -1
- package/hedhog/frontend/app/certificates/models/page.tsx.ejs +182 -29
- package/hedhog/frontend/app/classes/_components/classes-calendar-view.tsx.ejs +277 -0
- package/hedhog/frontend/app/classes/page.tsx.ejs +127 -20
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-overview-tab.tsx.ejs +141 -30
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-xp-overview-tab.tsx.ejs +13 -13
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson-xp-tab.tsx.ejs +11 -23
- package/hedhog/frontend/app/courses/[id]/structure/_components/xp-premium-pills.tsx.ejs +1 -8
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-content-overview.ts.ejs +2 -0
- package/hedhog/frontend/app/courses/page.tsx.ejs +40 -9
- package/hedhog/frontend/app/enterprise/[id]/page.tsx.ejs +6 -0
- package/hedhog/frontend/app/enterprise/_components/enterprise-classes-calendar-tab.tsx.ejs +264 -0
- package/hedhog/frontend/app/enterprise/page.tsx.ejs +104 -47
- package/hedhog/frontend/app/exams/page.tsx.ejs +38 -4
- package/hedhog/frontend/app/instructors/page.tsx.ejs +87 -46
- package/hedhog/frontend/app/paths/page.tsx.ejs +38 -4
- package/hedhog/frontend/app/training/page.tsx.ejs +38 -4
- package/hedhog/frontend/messages/en.json +18 -0
- package/hedhog/frontend/messages/pt.json +21 -1
- package/hedhog/table/course_enrollment.yaml +3 -0
- package/hedhog/table/lesson_view_event.yaml +66 -0
- package/package.json +9 -8
- package/src/course/course-structure.controller.ts +3 -1
- package/src/course/course-video-agent-pipeline.service.ts +471 -0
- package/src/course/course-video-hls.service.ts +30 -10
- package/src/course/course.module.ts +5 -0
- package/src/course/course.service.ts +46 -1
- package/src/course/ffmpeg.util.ts +65 -0
- package/src/course/lms-bulk-upload-automation.service.ts +4 -1
- package/src/lms.module.ts +10 -0
- package/src/platforma/dto/heartbeat.dto.ts +30 -0
- package/src/platforma/handlers/emit-certificate.handler.ts +117 -0
- package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -0
- package/src/platforma/platforma-heartbeat.service.ts +33 -0
- package/src/platforma/platforma-performance.service.ts +606 -0
- package/src/platforma/platforma-search.service.ts +48 -0
- package/src/platforma/platforma.controller.ts +42 -0
- package/src/realtime/lms-realtime.controller.ts +27 -1
- 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
|
-
|
|
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.
|
|
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
|
+
}
|