@hed-hog/lms 0.0.366 → 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.
- package/dist/certificate/certificate.controller.d.ts +1 -1
- package/dist/certificate/certificate.controller.d.ts.map +1 -1
- package/dist/certificate/certificate.controller.js +4 -2
- package/dist/certificate/certificate.controller.js.map +1 -1
- package/dist/certificate/certificate.service.d.ts +50 -0
- package/dist/certificate/certificate.service.d.ts.map +1 -1
- package/dist/certificate/certificate.service.js +73 -0
- package/dist/certificate/certificate.service.js.map +1 -1
- package/dist/course/course-ai-usage.service.d.ts +58 -0
- package/dist/course/course-ai-usage.service.d.ts.map +1 -0
- package/dist/course/course-ai-usage.service.js +176 -0
- package/dist/course/course-ai-usage.service.js.map +1 -0
- package/dist/course/course-audio-transcription.service.d.ts +65 -1
- package/dist/course/course-audio-transcription.service.d.ts.map +1 -1
- package/dist/course/course-audio-transcription.service.js +381 -29
- package/dist/course/course-audio-transcription.service.js.map +1 -1
- package/dist/course/course-export-scorm12.service.d.ts +3 -0
- package/dist/course/course-export-scorm12.service.d.ts.map +1 -1
- package/dist/course/course-export-scorm12.service.js +141 -6
- package/dist/course/course-export-scorm12.service.js.map +1 -1
- package/dist/course/course-export.service.d.ts.map +1 -1
- package/dist/course/course-export.service.js +2 -1
- package/dist/course/course-export.service.js.map +1 -1
- package/dist/course/course-lesson.controller.d.ts +25 -3
- package/dist/course/course-lesson.controller.d.ts.map +1 -1
- package/dist/course/course-lesson.controller.js +71 -8
- package/dist/course/course-lesson.controller.js.map +1 -1
- package/dist/course/course-structure.controller.d.ts +26 -5
- package/dist/course/course-structure.controller.d.ts.map +1 -1
- package/dist/course/course-structure.controller.js +31 -1
- package/dist/course/course-structure.controller.js.map +1 -1
- package/dist/course/course-structure.service.d.ts +37 -5
- package/dist/course/course-structure.service.d.ts.map +1 -1
- package/dist/course/course-structure.service.js +165 -20
- package/dist/course/course-structure.service.js.map +1 -1
- package/dist/course/course-transcription-translation.service.d.ts +31 -0
- package/dist/course/course-transcription-translation.service.d.ts.map +1 -0
- package/dist/course/course-transcription-translation.service.js +227 -0
- package/dist/course/course-transcription-translation.service.js.map +1 -0
- package/dist/course/course-video-agent-pipeline.service.js +7 -7
- package/dist/course/course-video-agent-pipeline.service.js.map +1 -1
- package/dist/course/course.module.d.ts.map +1 -1
- package/dist/course/course.module.js +4 -0
- package/dist/course/course.module.js.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts +2 -1
- package/dist/course/dto/create-course-bulk-job.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-bulk-job.dto.js +6 -1
- package/dist/course/dto/create-course-bulk-job.dto.js.map +1 -1
- package/dist/course/dto/create-course-export.dto.d.ts +1 -0
- package/dist/course/dto/create-course-export.dto.d.ts.map +1 -1
- package/dist/course/dto/create-course-export.dto.js +6 -0
- package/dist/course/dto/create-course-export.dto.js.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload-automation.service.js +26 -13
- package/dist/course/lms-bulk-upload-automation.service.js.map +1 -1
- package/dist/course/lms-bulk-upload.controller.d.ts +3 -0
- package/dist/course/lms-bulk-upload.controller.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.d.ts +3 -0
- package/dist/course/lms-bulk-upload.service.d.ts.map +1 -1
- package/dist/course/lms-bulk-upload.service.js +48 -29
- package/dist/course/lms-bulk-upload.service.js.map +1 -1
- package/dist/course/subtitle.util.d.ts +46 -0
- package/dist/course/subtitle.util.d.ts.map +1 -0
- package/dist/course/subtitle.util.js +206 -0
- package/dist/course/subtitle.util.js.map +1 -0
- package/dist/enterprise/training/training-student.service.d.ts +27 -0
- package/dist/enterprise/training/training-student.service.d.ts.map +1 -1
- package/dist/enterprise/training/training-student.service.js +197 -10
- package/dist/enterprise/training/training-student.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts +3 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js +19 -5
- package/dist/lesson-xp-map/lesson-xp-ai-calculation.service.js.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.d.ts.map +1 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js +2 -1
- package/dist/lesson-xp-map/lesson-xp-map.module.js.map +1 -1
- package/dist/libraries/lms/tsconfig.tsbuildinfo +1 -1
- package/dist/lms.module.d.ts.map +1 -1
- package/dist/lms.module.js +4 -0
- package/dist/lms.module.js.map +1 -1
- package/dist/platforma/platforma-performance.service.js +121 -121
- package/dist/platforma/platforma-video.service.d.ts +8 -0
- package/dist/platforma/platforma-video.service.d.ts.map +1 -1
- package/dist/platforma/platforma-video.service.js +45 -2
- package/dist/platforma/platforma-video.service.js.map +1 -1
- package/dist/platforma/platforma.controller.d.ts +99 -1
- package/dist/platforma/platforma.controller.d.ts.map +1 -1
- package/dist/platforma/platforma.controller.js +111 -2
- package/dist/platforma/platforma.controller.js.map +1 -1
- package/dist/training/dto/create-training.dto.d.ts +9 -0
- package/dist/training/dto/create-training.dto.d.ts.map +1 -1
- package/dist/training/dto/create-training.dto.js +45 -1
- package/dist/training/dto/create-training.dto.js.map +1 -1
- package/dist/training/training.controller.d.ts +144 -0
- package/dist/training/training.controller.d.ts.map +1 -1
- package/dist/training/training.service.d.ts +149 -0
- package/dist/training/training.service.d.ts.map +1 -1
- package/dist/training/training.service.js +332 -167
- package/dist/training/training.service.js.map +1 -1
- package/hedhog/data/image_type.yaml +10 -0
- package/hedhog/data/route.yaml +251 -0
- package/hedhog/data/setting_group.yaml +97 -0
- package/hedhog/frontend/app/bulk-upload-sessions/page.tsx.ejs +139 -27
- package/hedhog/frontend/app/courses/[id]/_components/CourseFlagsCard.tsx.ejs +69 -57
- package/hedhog/frontend/app/courses/[id]/_components/CourseIssuedCertificatesCard.tsx.ejs +168 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-ai-costs-tab.tsx.ejs +191 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-export-sheet.tsx.ejs +81 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/course-exports-tab.tsx.ejs +12 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-course.tsx.ejs +69 -1
- package/hedhog/frontend/app/courses/[id]/structure/_components/detail-lesson.tsx.ejs +267 -19
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-bulk.tsx.ejs +114 -86
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-course.tsx.ejs +239 -31
- package/hedhog/frontend/app/courses/[id]/structure/_components/editor-lesson.tsx.ejs +344 -59
- package/hedhog/frontend/app/courses/[id]/structure/_components/lesson-video-preview.tsx.ejs +200 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/tree-row-lesson.tsx.ejs +1 -0
- package/hedhog/frontend/app/courses/[id]/structure/_components/types.ts.ejs +3 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/adapters/course-structure.adapter.ts.ejs +19 -7
- package/hedhog/frontend/app/courses/[id]/structure/_data/types/api-course.types.ts.ejs +4 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-ai-costs.ts.ejs +40 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-exports.ts.ejs +25 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-course-structure-mutations.ts.ejs +148 -0
- package/hedhog/frontend/app/courses/[id]/structure/_data/use-transcription-segments.ts.ejs +157 -8
- package/hedhog/frontend/app/courses/_components/CourseRowActions.tsx.ejs +1 -22
- package/hedhog/frontend/app/courses/page.tsx.ejs +26 -4
- package/hedhog/frontend/app/paths/page.tsx.ejs +612 -164
- package/hedhog/frontend/messages/en.json +23 -12
- package/hedhog/frontend/messages/pt.json +23 -12
- package/hedhog/query/triggers.sql +33 -0
- package/hedhog/table/course_ai_usage.yaml +46 -0
- package/hedhog/table/course_lesson.yaml +3 -0
- package/hedhog/table/course_lesson_answer.yaml +37 -0
- package/hedhog/table/course_lesson_transcription_segment.yaml +8 -0
- package/hedhog/table/learning_path.yaml +6 -0
- package/hedhog/table/learning_path_module.yaml +22 -0
- package/hedhog/table/learning_path_step.yaml +9 -6
- package/hedhog/table/lesson_view_event.yaml +66 -66
- package/package.json +9 -9
- package/src/certificate/certificate.controller.ts +2 -0
- package/src/certificate/certificate.service.ts +99 -0
- package/src/course/course-ai-usage.service.ts +221 -0
- package/src/course/course-audio-transcription.service.ts +471 -43
- package/src/course/course-export-scorm12.service.ts +149 -5
- package/src/course/course-export.service.ts +1 -0
- package/src/course/course-lesson.controller.ts +59 -6
- package/src/course/course-structure.controller.ts +16 -0
- package/src/course/course-structure.service.ts +184 -10
- package/src/course/course-transcription-translation.service.ts +293 -0
- package/src/course/course-video-agent-pipeline.service.ts +471 -471
- package/src/course/course.module.ts +4 -0
- package/src/course/dto/create-course-bulk-job.dto.ts +7 -3
- package/src/course/dto/create-course-export.dto.ts +6 -0
- package/src/course/ffmpeg.util.ts +65 -65
- package/src/course/lms-bulk-upload-automation.service.ts +29 -7
- package/src/course/lms-bulk-upload.service.ts +20 -1
- package/src/course/subtitle.util.ts +220 -0
- package/src/enterprise/training/training-student.service.ts +224 -4
- package/src/lesson-xp-map/lesson-xp-ai-calculation.service.ts +14 -0
- package/src/lesson-xp-map/lesson-xp-map.module.ts +2 -1
- package/src/lms.module.ts +4 -0
- package/src/platforma/dto/heartbeat.dto.ts +30 -30
- package/src/platforma/handlers/emit-certificate.handler.ts +117 -117
- package/src/platforma/handlers/lesson-heartbeat.handler.ts +343 -343
- package/src/platforma/platforma-heartbeat.service.ts +33 -33
- package/src/platforma/platforma-performance.service.ts +606 -606
- package/src/platforma/platforma-search.service.ts +48 -48
- package/src/platforma/platforma-video.service.ts +59 -3
- package/src/platforma/platforma.controller.ts +88 -0
- package/src/training/dto/create-training.dto.ts +36 -0
- package/src/training/training.service.ts +360 -163
|
@@ -302,6 +302,8 @@ export class TrainingStudentService {
|
|
|
302
302
|
|
|
303
303
|
let completedLessonIds: number[] = [];
|
|
304
304
|
let progressPercent = 0;
|
|
305
|
+
let lastLessonId: number | null = null;
|
|
306
|
+
let lastPositionSeconds = 0;
|
|
305
307
|
|
|
306
308
|
if (userId) {
|
|
307
309
|
const personUser = await this.prisma.person_user.findFirst({
|
|
@@ -315,11 +317,30 @@ export class TrainingStudentService {
|
|
|
315
317
|
});
|
|
316
318
|
if (enrollment) {
|
|
317
319
|
progressPercent = enrollment.progress_percent ?? 0;
|
|
318
|
-
const completedRows = await
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
320
|
+
const [completedRows, lastProgressRow] = await Promise.all([
|
|
321
|
+
this.prisma.course_lesson_progress.findMany({
|
|
322
|
+
where: { course_enrollment_id: enrollment.id, status: 'completed' },
|
|
323
|
+
select: { course_lesson_id: true },
|
|
324
|
+
}),
|
|
325
|
+
this.prisma.course_lesson_progress.findFirst({
|
|
326
|
+
where: {
|
|
327
|
+
course_enrollment_id: enrollment.id,
|
|
328
|
+
status: { not: 'not_started' },
|
|
329
|
+
},
|
|
330
|
+
orderBy: { updated_at: 'desc' },
|
|
331
|
+
select: {
|
|
332
|
+
course_lesson_id: true,
|
|
333
|
+
video_progress_seconds: true,
|
|
334
|
+
status: true,
|
|
335
|
+
},
|
|
336
|
+
}),
|
|
337
|
+
]);
|
|
322
338
|
completedLessonIds = completedRows.map((r) => r.course_lesson_id);
|
|
339
|
+
lastLessonId = lastProgressRow?.course_lesson_id ?? null;
|
|
340
|
+
lastPositionSeconds =
|
|
341
|
+
lastProgressRow?.status === 'in_progress'
|
|
342
|
+
? (lastProgressRow.video_progress_seconds ?? 0)
|
|
343
|
+
: 0;
|
|
323
344
|
}
|
|
324
345
|
}
|
|
325
346
|
}
|
|
@@ -338,6 +359,8 @@ export class TrainingStudentService {
|
|
|
338
359
|
categoryLabel: this.formatCategoryLabel(categorySlug),
|
|
339
360
|
completedLessonIds,
|
|
340
361
|
progressPercent,
|
|
362
|
+
lastLessonId,
|
|
363
|
+
lastPositionSeconds,
|
|
341
364
|
modules: row.course_module.map((m) => ({
|
|
342
365
|
id: m.id,
|
|
343
366
|
title: m.title,
|
|
@@ -355,6 +378,172 @@ export class TrainingStudentService {
|
|
|
355
378
|
};
|
|
356
379
|
}
|
|
357
380
|
|
|
381
|
+
async submitLessonAnswer(
|
|
382
|
+
userId: number,
|
|
383
|
+
lessonId: number,
|
|
384
|
+
questionId: number,
|
|
385
|
+
optionId: number | null,
|
|
386
|
+
answerText: string | null,
|
|
387
|
+
) {
|
|
388
|
+
// Determine correctness from the selected option
|
|
389
|
+
let isCorrect: boolean | null = null;
|
|
390
|
+
if (optionId != null) {
|
|
391
|
+
const option = await (this.prisma as any).exam_option.findUnique({
|
|
392
|
+
where: { id: optionId },
|
|
393
|
+
select: { is_correct: true },
|
|
394
|
+
});
|
|
395
|
+
isCorrect = option?.is_correct ?? null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Upsert the answer
|
|
399
|
+
await (this.prisma as any).course_lesson_answer.upsert({
|
|
400
|
+
where: {
|
|
401
|
+
user_id_course_lesson_id_question_id: {
|
|
402
|
+
user_id: userId,
|
|
403
|
+
course_lesson_id: lessonId,
|
|
404
|
+
question_id: questionId,
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
update: {
|
|
408
|
+
exam_option_id: optionId,
|
|
409
|
+
answer_text: answerText,
|
|
410
|
+
is_correct: isCorrect,
|
|
411
|
+
updated_at: new Date(),
|
|
412
|
+
},
|
|
413
|
+
create: {
|
|
414
|
+
user_id: userId,
|
|
415
|
+
course_lesson_id: lessonId,
|
|
416
|
+
question_id: questionId,
|
|
417
|
+
exam_option_id: optionId,
|
|
418
|
+
answer_text: answerText,
|
|
419
|
+
is_correct: isCorrect,
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Check if all questions in this lesson are now answered
|
|
424
|
+
const [totalQuestions, answeredQuestions] = await Promise.all([
|
|
425
|
+
(this.prisma as any).course_lesson_question.count({
|
|
426
|
+
where: { course_lesson_id: lessonId },
|
|
427
|
+
}),
|
|
428
|
+
(this.prisma as any).course_lesson_answer.count({
|
|
429
|
+
where: { user_id: userId, course_lesson_id: lessonId },
|
|
430
|
+
}),
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
const allAnswered = totalQuestions > 0 && answeredQuestions >= totalQuestions;
|
|
434
|
+
|
|
435
|
+
if (allAnswered) {
|
|
436
|
+
await this.markLessonComplete(userId, lessonId);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return { isCorrect, allAnswered };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private async markLessonComplete(userId: number, lessonId: number) {
|
|
443
|
+
const lesson = await (this.prisma as any).course_lesson.findUnique({
|
|
444
|
+
where: { id: lessonId },
|
|
445
|
+
select: { course_module: { select: { course_id: true } } },
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
const personUser = await this.prisma.person_user.findFirst({
|
|
449
|
+
where: { user_id: userId },
|
|
450
|
+
select: { person_id: true },
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
if (!personUser || !lesson) return;
|
|
454
|
+
|
|
455
|
+
const personId = personUser.person_id;
|
|
456
|
+
const courseId: number = lesson.course_module?.course_id;
|
|
457
|
+
|
|
458
|
+
const enrollment = await (this.prisma as any).course_enrollment.findFirst({
|
|
459
|
+
where: { person_id: personId, course_id: courseId, status: { in: ['active', 'completed'] } },
|
|
460
|
+
select: { id: true },
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
if (!enrollment) return;
|
|
464
|
+
|
|
465
|
+
const enrollmentId: number = enrollment.id;
|
|
466
|
+
|
|
467
|
+
const existing = await (this.prisma as any).course_lesson_progress.findUnique({
|
|
468
|
+
where: {
|
|
469
|
+
course_enrollment_id_course_lesson_id: {
|
|
470
|
+
course_enrollment_id: enrollmentId,
|
|
471
|
+
course_lesson_id: lessonId,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
select: { status: true },
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
if (existing?.status === 'completed') return;
|
|
478
|
+
|
|
479
|
+
await (this.prisma as any).course_lesson_progress.upsert({
|
|
480
|
+
where: {
|
|
481
|
+
course_enrollment_id_course_lesson_id: {
|
|
482
|
+
course_enrollment_id: enrollmentId,
|
|
483
|
+
course_lesson_id: lessonId,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
create: {
|
|
487
|
+
course_enrollment_id: enrollmentId,
|
|
488
|
+
course_lesson_id: lessonId,
|
|
489
|
+
status: 'completed',
|
|
490
|
+
progress_percent: 100,
|
|
491
|
+
started_at: new Date(),
|
|
492
|
+
completed_at: new Date(),
|
|
493
|
+
time_spent_seconds: 0,
|
|
494
|
+
},
|
|
495
|
+
update: {
|
|
496
|
+
status: 'completed',
|
|
497
|
+
progress_percent: 100,
|
|
498
|
+
completed_at: new Date(),
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// Sync course-level progress
|
|
503
|
+
const [totalLessons, completedLessons] = await Promise.all([
|
|
504
|
+
(this.prisma as any).course_lesson.count({
|
|
505
|
+
where: { published: true, course_module: { course_id: courseId } },
|
|
506
|
+
}),
|
|
507
|
+
(this.prisma as any).course_lesson_progress.count({
|
|
508
|
+
where: {
|
|
509
|
+
course_enrollment_id: enrollmentId,
|
|
510
|
+
status: 'completed',
|
|
511
|
+
course_lesson: { published: true, course_module: { course_id: courseId } },
|
|
512
|
+
},
|
|
513
|
+
}),
|
|
514
|
+
]);
|
|
515
|
+
|
|
516
|
+
const progressPercent =
|
|
517
|
+
totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
|
|
518
|
+
|
|
519
|
+
await (this.prisma as any).course_enrollment.update({
|
|
520
|
+
where: { id: enrollmentId },
|
|
521
|
+
data: {
|
|
522
|
+
progress_percent: progressPercent,
|
|
523
|
+
...(progressPercent === 100 ? { status: 'completed', completed_at: new Date() } : {}),
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async getLessonStudentAnswers(userId: number, lessonId: number) {
|
|
529
|
+
const rows = await (this.prisma as any).course_lesson_answer.findMany({
|
|
530
|
+
where: { user_id: userId, course_lesson_id: lessonId },
|
|
531
|
+
select: {
|
|
532
|
+
question_id: true,
|
|
533
|
+
exam_option_id: true,
|
|
534
|
+
answer_text: true,
|
|
535
|
+
is_correct: true,
|
|
536
|
+
},
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
return rows as Array<{
|
|
540
|
+
question_id: number;
|
|
541
|
+
exam_option_id: number | null;
|
|
542
|
+
answer_text: string | null;
|
|
543
|
+
is_correct: boolean | null;
|
|
544
|
+
}>;
|
|
545
|
+
}
|
|
546
|
+
|
|
358
547
|
async getLessonDetail(lessonId: number) {
|
|
359
548
|
const lesson = await this.prisma.course_lesson.findFirst({
|
|
360
549
|
where: { id: lessonId, published: true },
|
|
@@ -383,6 +572,35 @@ export class TrainingStudentService {
|
|
|
383
572
|
(f) => f.file?.id && f.is_public && !f.type?.startsWith('video'),
|
|
384
573
|
);
|
|
385
574
|
|
|
575
|
+
const lessonQuestions = lesson.type === 'quiz'
|
|
576
|
+
? await this.prisma.course_lesson_question.findMany({
|
|
577
|
+
where: { course_lesson_id: lessonId },
|
|
578
|
+
orderBy: { order: 'asc' },
|
|
579
|
+
include: {
|
|
580
|
+
question: {
|
|
581
|
+
include: {
|
|
582
|
+
exam_option: { orderBy: { position: 'asc' } },
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
},
|
|
586
|
+
})
|
|
587
|
+
: [];
|
|
588
|
+
|
|
589
|
+
const questions = lessonQuestions.map((lq) => ({
|
|
590
|
+
id: lq.question.id,
|
|
591
|
+
questionType: lq.question.question_type,
|
|
592
|
+
statement: lq.question.statement,
|
|
593
|
+
explanation: lq.question.explanation,
|
|
594
|
+
points: lq.question.points,
|
|
595
|
+
options: lq.question.exam_option.map((o) => ({
|
|
596
|
+
id: o.id,
|
|
597
|
+
optionText: o.option_text,
|
|
598
|
+
isCorrect: o.is_correct,
|
|
599
|
+
feedback: o.feedback,
|
|
600
|
+
position: o.position,
|
|
601
|
+
})),
|
|
602
|
+
}));
|
|
603
|
+
|
|
386
604
|
// HLS takes priority: if a video_hls file exists, use it exclusively
|
|
387
605
|
const hlsFile = lesson.course_lesson_file.find((f) => f.file?.id && f.type === 'video_hls');
|
|
388
606
|
if (hlsFile) {
|
|
@@ -399,6 +617,7 @@ export class TrainingStudentService {
|
|
|
399
617
|
title: r.title,
|
|
400
618
|
url: r.file?.id ? `/file/open/${r.file.id}` : null,
|
|
401
619
|
})),
|
|
620
|
+
questions,
|
|
402
621
|
};
|
|
403
622
|
}
|
|
404
623
|
|
|
@@ -430,6 +649,7 @@ export class TrainingStudentService {
|
|
|
430
649
|
title: r.title,
|
|
431
650
|
url: r.file?.id ? `/file/open/${r.file.id}` : null,
|
|
432
651
|
})),
|
|
652
|
+
questions,
|
|
433
653
|
};
|
|
434
654
|
}
|
|
435
655
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
OnModuleInit,
|
|
11
11
|
} from '@nestjs/common';
|
|
12
12
|
import axios from 'axios';
|
|
13
|
+
import { CourseAiUsageService } from '../course/course-ai-usage.service';
|
|
13
14
|
|
|
14
15
|
type XpSegmentAiResult = {
|
|
15
16
|
startSeconds: number;
|
|
@@ -44,6 +45,8 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
|
|
|
44
45
|
private readonly registry: QueueHandlerRegistry,
|
|
45
46
|
@Inject(forwardRef(() => DatabaseQueueProvider))
|
|
46
47
|
private readonly dbQueue: DatabaseQueueProvider,
|
|
48
|
+
@Inject(forwardRef(() => CourseAiUsageService))
|
|
49
|
+
private readonly aiUsageService: CourseAiUsageService,
|
|
47
50
|
) {}
|
|
48
51
|
|
|
49
52
|
onModuleInit() {
|
|
@@ -295,6 +298,17 @@ export class LessonXpAiCalculationService implements OnModuleInit, IJobHandler {
|
|
|
295
298
|
return;
|
|
296
299
|
}
|
|
297
300
|
|
|
301
|
+
// Record AI cost (gpt-4o, billed per token).
|
|
302
|
+
const xpUsage = response.data?.usage ?? {};
|
|
303
|
+
await this.aiUsageService.recordChatUsage({
|
|
304
|
+
lessonId,
|
|
305
|
+
jobType: 'xp_calculation',
|
|
306
|
+
provider: 'openai',
|
|
307
|
+
model: 'gpt-4o',
|
|
308
|
+
inputTokens: Number(xpUsage.prompt_tokens ?? 0),
|
|
309
|
+
outputTokens: Number(xpUsage.completion_tokens ?? 0),
|
|
310
|
+
});
|
|
311
|
+
|
|
298
312
|
const rawSegments: any[] = Array.isArray(parsed.segments) ? parsed.segments : [];
|
|
299
313
|
const areaIds = new Set(areas.map((a) => a.id));
|
|
300
314
|
const skillIds = new Set(skills.map((s) => s.id));
|
|
@@ -2,6 +2,7 @@ import { PrismaModule } from '@hed-hog/api-prisma';
|
|
|
2
2
|
import { CoreModule } from '@hed-hog/core';
|
|
3
3
|
import { QueueModule } from '@hed-hog/queue';
|
|
4
4
|
import { forwardRef, Module } from '@nestjs/common';
|
|
5
|
+
import { CourseAiUsageService } from '../course/course-ai-usage.service';
|
|
5
6
|
import { LessonXpAiCalculationService } from './lesson-xp-ai-calculation.service';
|
|
6
7
|
import { LessonXpMapController } from './lesson-xp-map.controller';
|
|
7
8
|
import { LessonXpMapService } from './lesson-xp-map.service';
|
|
@@ -15,7 +16,7 @@ import { LessonXpSegmentService } from './lesson-xp-segment.service';
|
|
|
15
16
|
forwardRef(() => QueueModule),
|
|
16
17
|
],
|
|
17
18
|
controllers: [LessonXpMapController, LessonXpSegmentController],
|
|
18
|
-
providers: [LessonXpMapService, LessonXpSegmentService, LessonXpAiCalculationService],
|
|
19
|
+
providers: [LessonXpMapService, LessonXpSegmentService, LessonXpAiCalculationService, CourseAiUsageService],
|
|
19
20
|
exports: [LessonXpMapService, LessonXpSegmentService, LessonXpAiCalculationService],
|
|
20
21
|
})
|
|
21
22
|
export class LessonXpMapModule {}
|
package/src/lms.module.ts
CHANGED
|
@@ -13,7 +13,9 @@ import { ClassGroupModule } from './class-group/class-group.module';
|
|
|
13
13
|
import { CourseLessonDiscussionModule } from './course-lesson-discussion/course-lesson-discussion.module';
|
|
14
14
|
import { CourseLessonNoteModule } from './course-lesson-note/course-lesson-note.module';
|
|
15
15
|
import { LmsCommerceAccessSubscriber } from './lms-commerce-access.subscriber';
|
|
16
|
+
import { CourseAiUsageService } from './course/course-ai-usage.service';
|
|
16
17
|
import { CourseAudioTranscriptionService } from './course/course-audio-transcription.service';
|
|
18
|
+
import { CourseTranscriptionTranslationService } from './course/course-transcription-translation.service';
|
|
17
19
|
import { CourseModule } from './course/course.module';
|
|
18
20
|
import { LmsDashboardModule } from './dashboard/dashboard.module';
|
|
19
21
|
import { EnterpriseModule } from './enterprise/enterprise.module';
|
|
@@ -67,7 +69,9 @@ import { TrainingModule } from './training/training.module';
|
|
|
67
69
|
],
|
|
68
70
|
controllers: [PlataformaController],
|
|
69
71
|
providers: [
|
|
72
|
+
CourseAiUsageService,
|
|
70
73
|
CourseAudioTranscriptionService,
|
|
74
|
+
CourseTranscriptionTranslationService,
|
|
71
75
|
PlatformaService,
|
|
72
76
|
PlatformaVideoService,
|
|
73
77
|
PlatformaHeartbeatService,
|
|
@@ -1,30 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,117 +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
|
-
}
|
|
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
|
+
}
|