@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
@@ -0,0 +1,343 @@
1
+ import { PrismaService } from '@hed-hog/api-prisma';
2
+ import { IJobHandler, QueueHandlerRegistry, QueueJobService } from '@hed-hog/queue';
3
+ import { Inject, Injectable, Logger, OnModuleInit, forwardRef } from '@nestjs/common';
4
+ import { LmsRealtimeService } from '../../realtime/lms-realtime.service';
5
+
6
+ export const LESSON_HEARTBEAT_JOB = 'lms.lesson_heartbeat';
7
+
8
+ @Injectable()
9
+ export class LessonHeartbeatHandler implements OnModuleInit, IJobHandler {
10
+ private readonly logger = new Logger(LessonHeartbeatHandler.name);
11
+
12
+ constructor(
13
+ @Inject(forwardRef(() => PrismaService))
14
+ private readonly prisma: PrismaService,
15
+ @Inject(forwardRef(() => QueueHandlerRegistry))
16
+ private readonly registry: QueueHandlerRegistry,
17
+ @Inject(forwardRef(() => QueueJobService))
18
+ private readonly queueJob: QueueJobService,
19
+ @Inject(forwardRef(() => LmsRealtimeService))
20
+ private readonly realtime: LmsRealtimeService,
21
+ ) {}
22
+
23
+ onModuleInit() {
24
+ this.registry.register(LESSON_HEARTBEAT_JOB, this);
25
+ this.logger.log(`Registered handler for "${LESSON_HEARTBEAT_JOB}"`);
26
+ }
27
+
28
+ async handle(job: { payload: Record<string, any> }) {
29
+ const {
30
+ userId,
31
+ ip,
32
+ userAgent,
33
+ lessonId,
34
+ positionSeconds,
35
+ sessionId,
36
+ screenWidth,
37
+ screenHeight,
38
+ isTouch,
39
+ } = job.payload;
40
+
41
+ const { browser, os, deviceType } = this.parseUA(String(userAgent ?? ''));
42
+ const geo = await this.resolveGeo(ip);
43
+
44
+ const lesson = await (this.prisma as any).course_lesson.findUnique({
45
+ where: { id: lessonId },
46
+ select: {
47
+ id: true,
48
+ duration_seconds: true,
49
+ published: true,
50
+ course_module: { select: { course_id: true } },
51
+ },
52
+ });
53
+
54
+ if (!lesson?.published) {
55
+ return { skipped: true, reason: 'lesson not published' };
56
+ }
57
+
58
+ const courseId: number = lesson.course_module?.course_id;
59
+
60
+ let enrollmentId: number | null = null;
61
+ let personId: number | null = null;
62
+
63
+ if (userId && courseId) {
64
+ const personUser = await this.prisma.person_user.findFirst({
65
+ where: { user_id: userId },
66
+ select: { person_id: true },
67
+ });
68
+
69
+ if (personUser) {
70
+ personId = personUser.person_id;
71
+ const enrollment = await (this.prisma as any).course_enrollment.findFirst({
72
+ where: {
73
+ person_id: personId,
74
+ course_id: courseId,
75
+ status: { in: ['active', 'completed'] },
76
+ },
77
+ select: { id: true },
78
+ });
79
+ enrollmentId = enrollment?.id ?? null;
80
+ }
81
+ }
82
+
83
+ // Record raw view event
84
+ await (this.prisma as any).lesson_view_event.create({
85
+ data: {
86
+ user_id: userId ?? null,
87
+ course_lesson_id: lessonId,
88
+ course_enrollment_id: enrollmentId,
89
+ session_id: sessionId ?? null,
90
+ video_position_seconds: positionSeconds ?? 0,
91
+ ip: ip ?? null,
92
+ country: geo?.country ?? null,
93
+ city: geo?.city ?? null,
94
+ browser,
95
+ os,
96
+ device_type: deviceType,
97
+ screen_width: screenWidth ?? null,
98
+ screen_height: screenHeight ?? null,
99
+ is_touch: isTouch ?? false,
100
+ },
101
+ });
102
+
103
+ // Update lesson progress if enrolled
104
+ if (enrollmentId) {
105
+ const wasLessonCompleted = await this.updateLessonProgress(
106
+ enrollmentId,
107
+ lessonId,
108
+ positionSeconds ?? 0,
109
+ lesson.duration_seconds ?? 0,
110
+ );
111
+
112
+ if (wasLessonCompleted) {
113
+ await this.syncEnrollmentProgress(enrollmentId, courseId, personId);
114
+ }
115
+ }
116
+
117
+ // Publish to admin real-time monitor
118
+ this.realtime.publish({
119
+ domain: 'lesson_view',
120
+ type: 'created',
121
+ entityId: lessonId,
122
+ actorId: userId ?? null,
123
+ payload: {
124
+ ip,
125
+ country: geo?.country ?? null,
126
+ city: geo?.city ?? null,
127
+ browser,
128
+ os,
129
+ deviceType,
130
+ positionSeconds,
131
+ enrollmentId,
132
+ },
133
+ });
134
+
135
+ return { ok: true };
136
+ }
137
+
138
+ private async updateLessonProgress(
139
+ enrollmentId: number,
140
+ lessonId: number,
141
+ positionSeconds: number,
142
+ durationSeconds: number,
143
+ ): Promise<boolean> {
144
+ const progressPercent =
145
+ durationSeconds > 0
146
+ ? Math.min(100, Math.round((positionSeconds / durationSeconds) * 100))
147
+ : positionSeconds > 0
148
+ ? 100
149
+ : 0;
150
+
151
+ const isCompleted = progressPercent >= 95;
152
+
153
+ const existing = await (this.prisma as any).course_lesson_progress.findUnique({
154
+ where: {
155
+ course_enrollment_id_course_lesson_id: {
156
+ course_enrollment_id: enrollmentId,
157
+ course_lesson_id: lessonId,
158
+ },
159
+ },
160
+ select: { status: true },
161
+ });
162
+
163
+ const wasAlreadyCompleted = existing?.status === 'completed';
164
+
165
+ const newStatus = isCompleted
166
+ ? 'completed'
167
+ : positionSeconds > 0
168
+ ? 'in_progress'
169
+ : 'not_started';
170
+
171
+ await (this.prisma as any).course_lesson_progress.upsert({
172
+ where: {
173
+ course_enrollment_id_course_lesson_id: {
174
+ course_enrollment_id: enrollmentId,
175
+ course_lesson_id: lessonId,
176
+ },
177
+ },
178
+ create: {
179
+ course_enrollment_id: enrollmentId,
180
+ course_lesson_id: lessonId,
181
+ status: newStatus,
182
+ progress_percent: progressPercent,
183
+ video_progress_seconds: positionSeconds,
184
+ started_at: new Date(),
185
+ completed_at: isCompleted ? new Date() : null,
186
+ time_spent_seconds: 0,
187
+ },
188
+ update: {
189
+ video_progress_seconds: positionSeconds,
190
+ progress_percent: wasAlreadyCompleted ? 100 : progressPercent,
191
+ // Never regress status from completed
192
+ status: wasAlreadyCompleted ? 'completed' : newStatus,
193
+ ...(!wasAlreadyCompleted && isCompleted ? { completed_at: new Date() } : {}),
194
+ },
195
+ });
196
+
197
+ // Returns true when the lesson just became completed for the first time
198
+ return isCompleted && !wasAlreadyCompleted;
199
+ }
200
+
201
+ private async syncEnrollmentProgress(
202
+ enrollmentId: number,
203
+ courseId: number,
204
+ personId: number | null,
205
+ ) {
206
+ const [totalLessons, completedLessons] = await Promise.all([
207
+ (this.prisma as any).course_lesson.count({
208
+ where: { published: true, course_module: { course_id: courseId } },
209
+ }),
210
+ (this.prisma as any).course_lesson_progress.count({
211
+ where: {
212
+ course_enrollment_id: enrollmentId,
213
+ status: 'completed',
214
+ course_lesson: { published: true, course_module: { course_id: courseId } },
215
+ },
216
+ }),
217
+ ]);
218
+
219
+ const progressPercent =
220
+ totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
221
+
222
+ const isCourseComplete = progressPercent === 100;
223
+
224
+ await (this.prisma as any).course_enrollment.update({
225
+ where: { id: enrollmentId },
226
+ data: {
227
+ progress_percent: progressPercent,
228
+ ...(isCourseComplete
229
+ ? { status: 'completed', completed_at: new Date() }
230
+ : {}),
231
+ },
232
+ });
233
+
234
+ if (isCourseComplete && personId) {
235
+ await this.maybeTriggerCertificate(enrollmentId, courseId, personId);
236
+ }
237
+ }
238
+
239
+ private async maybeTriggerCertificate(
240
+ enrollmentId: number,
241
+ courseId: number,
242
+ personId: number,
243
+ ) {
244
+ const enrollment = await (this.prisma as any).course_enrollment.findUnique({
245
+ where: { id: enrollmentId },
246
+ select: { certificate_issued_at: true },
247
+ });
248
+
249
+ if (enrollment?.certificate_issued_at) return;
250
+
251
+ const course = await (this.prisma as any).course.findUnique({
252
+ where: { id: courseId },
253
+ select: { has_certificate: true, certificate_template_id: true },
254
+ });
255
+
256
+ if (!course?.has_certificate || !course.certificate_template_id) return;
257
+
258
+ await this.queueJob.enqueue({
259
+ type: 'lms.emit_certificate',
260
+ queueName: 'lms.certificate',
261
+ payload: { enrollmentId, courseId, personId },
262
+ maxAttempts: 3,
263
+ sourceModule: 'lms',
264
+ });
265
+ }
266
+
267
+ private parseUA(ua: string): { browser: string; os: string; deviceType: string } {
268
+ const browser =
269
+ /Chrome\//.test(ua) && !/Edg\/|OPR\//.test(ua)
270
+ ? 'Chrome'
271
+ : /Firefox\//.test(ua)
272
+ ? 'Firefox'
273
+ : /Safari\//.test(ua) && !/Chrome\//.test(ua)
274
+ ? 'Safari'
275
+ : /Edg\//.test(ua)
276
+ ? 'Edge'
277
+ : /OPR\//.test(ua)
278
+ ? 'Opera'
279
+ : 'Other';
280
+
281
+ const os =
282
+ /Android/.test(ua)
283
+ ? 'Android'
284
+ : /iPhone|iPad/.test(ua)
285
+ ? 'iOS'
286
+ : /Windows/.test(ua)
287
+ ? 'Windows'
288
+ : /Mac OS X/.test(ua)
289
+ ? 'macOS'
290
+ : /Linux/.test(ua)
291
+ ? 'Linux'
292
+ : 'Other';
293
+
294
+ const deviceType =
295
+ /Mobile|Android|iPhone/.test(ua)
296
+ ? 'mobile'
297
+ : /iPad|Tablet/.test(ua)
298
+ ? 'tablet'
299
+ : 'desktop';
300
+
301
+ return { browser, os, deviceType };
302
+ }
303
+
304
+ private async resolveGeo(ip?: string): Promise<{ country: string; city: string } | null> {
305
+ if (
306
+ !ip ||
307
+ ip === '127.0.0.1' ||
308
+ ip === '::1' ||
309
+ ip.startsWith('192.168.') ||
310
+ ip.startsWith('10.') ||
311
+ ip.startsWith('172.')
312
+ ) {
313
+ return null;
314
+ }
315
+
316
+ try {
317
+ const controller = new AbortController();
318
+ const timeout = setTimeout(() => controller.abort(), 2000);
319
+ const res = await fetch(
320
+ `http://ip-api.com/json/${ip}?fields=status,countryCode,city`,
321
+ { signal: controller.signal },
322
+ );
323
+ clearTimeout(timeout);
324
+
325
+ if (!res.ok) return null;
326
+
327
+ const data = (await res.json()) as {
328
+ status: string;
329
+ countryCode?: string;
330
+ city?: string;
331
+ };
332
+
333
+ if (data.status !== 'success') return null;
334
+
335
+ return {
336
+ country: (data.countryCode ?? '').slice(0, 2),
337
+ city: (data.city ?? '').slice(0, 100),
338
+ };
339
+ } catch {
340
+ return null;
341
+ }
342
+ }
343
+ }
@@ -0,0 +1,33 @@
1
+ import { QueueJobService } from '@hed-hog/queue';
2
+ import { Inject, Injectable, forwardRef } from '@nestjs/common';
3
+ import { HeartbeatDto } from './dto/heartbeat.dto';
4
+
5
+ @Injectable()
6
+ export class PlatformaHeartbeatService {
7
+ constructor(
8
+ @Inject(forwardRef(() => QueueJobService))
9
+ private readonly queueJob: QueueJobService,
10
+ ) {}
11
+
12
+ async enqueue(userId: number, ip: string, userAgent: string, dto: HeartbeatDto) {
13
+ await this.queueJob.enqueue({
14
+ type: 'lms.lesson_heartbeat',
15
+ queueName: 'lms.heartbeat',
16
+ payload: {
17
+ userId,
18
+ ip,
19
+ userAgent,
20
+ lessonId: dto.lessonId,
21
+ positionSeconds: dto.positionSeconds,
22
+ sessionId: dto.sessionId ?? null,
23
+ screenWidth: dto.screenWidth ?? null,
24
+ screenHeight: dto.screenHeight ?? null,
25
+ isTouch: dto.isTouch ?? false,
26
+ },
27
+ maxAttempts: 2,
28
+ sourceModule: 'lms',
29
+ });
30
+
31
+ return { ok: true };
32
+ }
33
+ }