@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
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { PrismaService } from '@hed-hog/api-prisma';
|
|
2
|
+
import { Injectable } from '@nestjs/common';
|
|
3
|
+
|
|
4
|
+
const CATEGORY_COLORS = [
|
|
5
|
+
'#8B5CF6',
|
|
6
|
+
'#22C55E',
|
|
7
|
+
'#3B82F6',
|
|
8
|
+
'#F59E0B',
|
|
9
|
+
'#EC4899',
|
|
10
|
+
'#14B8A6',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const TRAIL_COLORS = [
|
|
14
|
+
'#8B5CF6',
|
|
15
|
+
'#34D399',
|
|
16
|
+
'#F59E0B',
|
|
17
|
+
'#60A5FA',
|
|
18
|
+
'#FB7185',
|
|
19
|
+
'#A78BFA',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const PT_MONTHS: Record<string, string> = {
|
|
23
|
+
Jan: 'Jan',
|
|
24
|
+
Feb: 'Fev',
|
|
25
|
+
Mar: 'Mar',
|
|
26
|
+
Apr: 'Abr',
|
|
27
|
+
May: 'Mai',
|
|
28
|
+
Jun: 'Jun',
|
|
29
|
+
Jul: 'Jul',
|
|
30
|
+
Aug: 'Ago',
|
|
31
|
+
Sep: 'Set',
|
|
32
|
+
Oct: 'Out',
|
|
33
|
+
Nov: 'Nov',
|
|
34
|
+
Dec: 'Dez',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function slugToTitle(slug: string): string {
|
|
38
|
+
return slug
|
|
39
|
+
.split('-')
|
|
40
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
41
|
+
.join(' ');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toPtMonth(enAbbr: string): string {
|
|
45
|
+
return PT_MONTHS[enAbbr] ?? enAbbr;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parsePeriod(period: string): {
|
|
49
|
+
start: Date;
|
|
50
|
+
previousStart: Date;
|
|
51
|
+
previousEnd: Date;
|
|
52
|
+
} {
|
|
53
|
+
const now = new Date();
|
|
54
|
+
const start = new Date(now);
|
|
55
|
+
|
|
56
|
+
if (period === '3m') {
|
|
57
|
+
start.setMonth(start.getMonth() - 3);
|
|
58
|
+
} else if (period === 'year') {
|
|
59
|
+
start.setMonth(0, 1);
|
|
60
|
+
start.setHours(0, 0, 0, 0);
|
|
61
|
+
} else {
|
|
62
|
+
// default: 30d
|
|
63
|
+
start.setDate(start.getDate() - 30);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const periodMs = now.getTime() - start.getTime();
|
|
67
|
+
const previousEnd = new Date(start);
|
|
68
|
+
const previousStart = new Date(start.getTime() - periodMs);
|
|
69
|
+
|
|
70
|
+
return { start, previousStart, previousEnd };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatHms(totalSeconds: number): string {
|
|
74
|
+
const h = Math.floor(totalSeconds / 3600);
|
|
75
|
+
const m = Math.floor((totalSeconds % 3600) / 60);
|
|
76
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
77
|
+
return `${m}m`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function calcChange(current: number, previous: number): number {
|
|
81
|
+
if (previous === 0) return current > 0 ? 100 : 0;
|
|
82
|
+
return Math.round(((current - previous) / previous) * 100);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Injectable()
|
|
86
|
+
export class PlatformaPerformanceService {
|
|
87
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
88
|
+
|
|
89
|
+
async getSummary(userId: number, period: string) {
|
|
90
|
+
const { start, previousStart, previousEnd } = parsePeriod(period);
|
|
91
|
+
|
|
92
|
+
const personId = await this.getPersonId(userId);
|
|
93
|
+
|
|
94
|
+
const [kpis, studyHours, areaPerformance, accuracy, lessonsByCategory, avgLessonTime, trailProgress, ranking] =
|
|
95
|
+
await Promise.all([
|
|
96
|
+
this.getKpis(userId, personId, start, previousStart, previousEnd),
|
|
97
|
+
this.getStudyHoursChart(personId),
|
|
98
|
+
this.getAreaPerformance(personId),
|
|
99
|
+
this.getAccuracyDistribution(personId, start),
|
|
100
|
+
this.getLessonsByCategory(personId, start),
|
|
101
|
+
this.getAvgLessonTime(personId),
|
|
102
|
+
this.getTrailProgress(personId),
|
|
103
|
+
this.getRanking(),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
kpis,
|
|
108
|
+
studyHours,
|
|
109
|
+
areaPerformance,
|
|
110
|
+
accuracyDistribution: accuracy,
|
|
111
|
+
lessonsByCategory,
|
|
112
|
+
avgLessonTime,
|
|
113
|
+
trailProgress,
|
|
114
|
+
ranking,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// KPI cards
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
private async getKpis(
|
|
123
|
+
userId: number,
|
|
124
|
+
personId: number | null,
|
|
125
|
+
start: Date,
|
|
126
|
+
previousStart: Date,
|
|
127
|
+
previousEnd: Date,
|
|
128
|
+
) {
|
|
129
|
+
const [
|
|
130
|
+
studyTimeCurrent,
|
|
131
|
+
studyTimePrev,
|
|
132
|
+
lessonsCurrent,
|
|
133
|
+
lessonsPrev,
|
|
134
|
+
exercisesCurrent,
|
|
135
|
+
exercisesPrev,
|
|
136
|
+
accuracyCurrent,
|
|
137
|
+
accuracyPrev,
|
|
138
|
+
bitcodes,
|
|
139
|
+
bitcodesEarnedCurrent,
|
|
140
|
+
bitcodesEarnedPrev,
|
|
141
|
+
studySparkline,
|
|
142
|
+
lessonsSparkline,
|
|
143
|
+
exercisesSparkline,
|
|
144
|
+
bitcodesSparkline,
|
|
145
|
+
] = await Promise.all([
|
|
146
|
+
this.countStudySeconds(userId, start, new Date()),
|
|
147
|
+
this.countStudySeconds(userId, previousStart, previousEnd),
|
|
148
|
+
this.countCompletedLessons(personId, start, new Date()),
|
|
149
|
+
this.countCompletedLessons(personId, previousStart, previousEnd),
|
|
150
|
+
this.countResolvedExercises(personId, start, new Date()),
|
|
151
|
+
this.countResolvedExercises(personId, previousStart, previousEnd),
|
|
152
|
+
this.getAccuracyRate(personId, start, new Date()),
|
|
153
|
+
this.getAccuracyRate(personId, previousStart, previousEnd),
|
|
154
|
+
this.getBitcodesBalance(personId),
|
|
155
|
+
this.getBitcodesEarned(personId, start, new Date()),
|
|
156
|
+
this.getBitcodesEarned(personId, previousStart, previousEnd),
|
|
157
|
+
this.getWeeklySparkline((w) => this.countStudySeconds(userId, w.start, w.end), 10),
|
|
158
|
+
this.getWeeklySparkline((w) => this.countCompletedLessons(personId, w.start, w.end), 10),
|
|
159
|
+
this.getWeeklySparkline((w) => this.countResolvedExercises(personId, w.start, w.end), 10),
|
|
160
|
+
this.getWeeklySparkline((w) => this.getBitcodesEarned(personId, w.start, w.end), 10),
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
const accuracyChange = calcChange(accuracyCurrent, accuracyPrev);
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
id: 'study-time',
|
|
168
|
+
title: 'Tempo de estudo',
|
|
169
|
+
value: formatHms(studyTimeCurrent),
|
|
170
|
+
subtitle: 'Horas totais',
|
|
171
|
+
change: this.fmtChange(calcChange(studyTimeCurrent, studyTimePrev)),
|
|
172
|
+
icon: 'clock' as const,
|
|
173
|
+
color: '#8B5CF6',
|
|
174
|
+
sparkline: studySparkline,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'completed-lessons',
|
|
178
|
+
title: 'Aulas concluídas',
|
|
179
|
+
value: String(lessonsCurrent),
|
|
180
|
+
subtitle: 'Total de aulas',
|
|
181
|
+
change: this.fmtChange(calcChange(lessonsCurrent, lessonsPrev)),
|
|
182
|
+
icon: 'bookOpen' as const,
|
|
183
|
+
color: '#22C55E',
|
|
184
|
+
sparkline: lessonsSparkline,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'resolved-exercises',
|
|
188
|
+
title: 'Exercícios resolvidos',
|
|
189
|
+
value: String(exercisesCurrent),
|
|
190
|
+
subtitle: 'Total de exercícios',
|
|
191
|
+
change: this.fmtChange(calcChange(exercisesCurrent, exercisesPrev)),
|
|
192
|
+
icon: 'pencil' as const,
|
|
193
|
+
color: '#FB923C',
|
|
194
|
+
sparkline: exercisesSparkline,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: 'accuracy-rate',
|
|
198
|
+
title: 'Taxa de acerto',
|
|
199
|
+
value: `${accuracyCurrent}%`,
|
|
200
|
+
subtitle: 'Média de acertos',
|
|
201
|
+
change: this.fmtChange(accuracyChange),
|
|
202
|
+
icon: 'target' as const,
|
|
203
|
+
color: '#3B82F6',
|
|
204
|
+
sparkline: await this.getWeeklySparkline(
|
|
205
|
+
(w) => this.getAccuracyRate(personId, w.start, w.end),
|
|
206
|
+
10,
|
|
207
|
+
),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'bitcodes',
|
|
211
|
+
title: 'Bitcodes',
|
|
212
|
+
value: bitcodes.toLocaleString('pt-BR'),
|
|
213
|
+
subtitle: 'Saldo atual',
|
|
214
|
+
change: this.fmtBitcodesChange(bitcodesEarnedCurrent, bitcodesEarnedPrev),
|
|
215
|
+
icon: 'coins' as const,
|
|
216
|
+
color: '#F59E0B',
|
|
217
|
+
sparkline: bitcodesSparkline,
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private fmtChange(pct: number): string {
|
|
223
|
+
const sign = pct >= 0 ? '+' : '';
|
|
224
|
+
return `${sign}${pct}% vs. mês passado`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private fmtBitcodesChange(current: number, previous: number): string {
|
|
228
|
+
const diff = current - previous;
|
|
229
|
+
const sign = diff >= 0 ? '+' : '';
|
|
230
|
+
return `${sign}${diff.toLocaleString('pt-BR')} vs. mês passado`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async countStudySeconds(userId: number, start: Date, end: Date): Promise<number> {
|
|
234
|
+
const rows = await (this.prisma as any).$queryRaw<[{ total: bigint }]>`
|
|
235
|
+
SELECT COUNT(*) * 15 AS total
|
|
236
|
+
FROM lesson_view_event
|
|
237
|
+
WHERE user_id = ${userId}
|
|
238
|
+
AND created_at >= ${start}
|
|
239
|
+
AND created_at < ${end}
|
|
240
|
+
`;
|
|
241
|
+
return Number(rows[0]?.total ?? 0);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async countCompletedLessons(
|
|
245
|
+
personId: number | null,
|
|
246
|
+
start: Date,
|
|
247
|
+
end: Date,
|
|
248
|
+
): Promise<number> {
|
|
249
|
+
if (!personId) return 0;
|
|
250
|
+
const rows = await (this.prisma as any).$queryRaw<[{ total: bigint }]>`
|
|
251
|
+
SELECT COUNT(*) AS total
|
|
252
|
+
FROM course_lesson_progress clp
|
|
253
|
+
JOIN course_enrollment ce ON ce.id = clp.course_enrollment_id
|
|
254
|
+
WHERE ce.person_id = ${personId}
|
|
255
|
+
AND clp.status = 'completed'
|
|
256
|
+
AND clp.completed_at >= ${start}
|
|
257
|
+
AND clp.completed_at < ${end}
|
|
258
|
+
`;
|
|
259
|
+
return Number(rows[0]?.total ?? 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async countResolvedExercises(
|
|
263
|
+
personId: number | null,
|
|
264
|
+
start: Date,
|
|
265
|
+
end: Date,
|
|
266
|
+
): Promise<number> {
|
|
267
|
+
if (!personId) return 0;
|
|
268
|
+
const rows = await (this.prisma as any).$queryRaw<[{ total: bigint }]>`
|
|
269
|
+
SELECT COUNT(ea.id) AS total
|
|
270
|
+
FROM exam_answer ea
|
|
271
|
+
JOIN exam_attempt att ON att.id = ea.exam_attempt_id
|
|
272
|
+
WHERE att.student_id = ${personId}
|
|
273
|
+
AND att.started_at >= ${start}
|
|
274
|
+
AND att.started_at < ${end}
|
|
275
|
+
`;
|
|
276
|
+
return Number(rows[0]?.total ?? 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async getAccuracyRate(
|
|
280
|
+
personId: number | null,
|
|
281
|
+
start: Date,
|
|
282
|
+
end: Date,
|
|
283
|
+
): Promise<number> {
|
|
284
|
+
if (!personId) return 0;
|
|
285
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
286
|
+
[{ correct: bigint; total: bigint }]
|
|
287
|
+
>`
|
|
288
|
+
SELECT
|
|
289
|
+
COUNT(CASE WHEN ea.is_correct = true THEN 1 END) AS correct,
|
|
290
|
+
COUNT(*) AS total
|
|
291
|
+
FROM exam_answer ea
|
|
292
|
+
JOIN exam_attempt att ON att.id = ea.exam_attempt_id
|
|
293
|
+
WHERE att.student_id = ${personId}
|
|
294
|
+
AND ea.is_correct IS NOT NULL
|
|
295
|
+
AND att.started_at >= ${start}
|
|
296
|
+
AND att.started_at < ${end}
|
|
297
|
+
`;
|
|
298
|
+
const { correct, total } = rows[0] ?? { correct: BigInt(0), total: BigInt(0) };
|
|
299
|
+
const t = Number(total);
|
|
300
|
+
return t === 0 ? 0 : Math.round((Number(correct) / t) * 100);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async getBitcodesBalance(personId: number | null): Promise<number> {
|
|
304
|
+
if (!personId) return 0;
|
|
305
|
+
const row = await (this.prisma as any).bitcode_wallet.findFirst({
|
|
306
|
+
where: { person_id: personId },
|
|
307
|
+
select: { current_balance: true },
|
|
308
|
+
});
|
|
309
|
+
return Number(row?.current_balance ?? 0);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private async getBitcodesEarned(
|
|
313
|
+
personId: number | null,
|
|
314
|
+
start: Date,
|
|
315
|
+
end: Date,
|
|
316
|
+
): Promise<number> {
|
|
317
|
+
if (!personId) return 0;
|
|
318
|
+
const rows = await (this.prisma as any).$queryRaw<[{ total: bigint }]>`
|
|
319
|
+
SELECT COALESCE(SUM(bwt.amount), 0) AS total
|
|
320
|
+
FROM bitcode_wallet_transaction bwt
|
|
321
|
+
JOIN bitcode_wallet bw ON bw.id = bwt.wallet_id
|
|
322
|
+
WHERE bw.person_id = ${personId}
|
|
323
|
+
AND bwt.type = 'credit'
|
|
324
|
+
AND bwt.created_at >= ${start}
|
|
325
|
+
AND bwt.created_at < ${end}
|
|
326
|
+
`;
|
|
327
|
+
return Number(rows[0]?.total ?? 0);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private async getWeeklySparkline(
|
|
331
|
+
fetcher: (w: { start: Date; end: Date }) => Promise<number>,
|
|
332
|
+
weeks: number,
|
|
333
|
+
): Promise<Array<{ index: number; value: number }>> {
|
|
334
|
+
const now = new Date();
|
|
335
|
+
const results: Array<{ index: number; value: number }> = [];
|
|
336
|
+
|
|
337
|
+
for (let i = weeks - 1; i >= 0; i--) {
|
|
338
|
+
const end = new Date(now.getTime() - i * 7 * 24 * 3600 * 1000);
|
|
339
|
+
const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000);
|
|
340
|
+
const value = await fetcher({ start, end });
|
|
341
|
+
results.push({ index: weeks - i, value });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return results;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
// Study hours chart
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
private async getStudyHoursChart(
|
|
352
|
+
personId: number | null,
|
|
353
|
+
): Promise<Array<{ month: string; hours: number }>> {
|
|
354
|
+
if (!personId) return [];
|
|
355
|
+
|
|
356
|
+
const tenMonthsAgo = new Date();
|
|
357
|
+
tenMonthsAgo.setMonth(tenMonthsAgo.getMonth() - 10);
|
|
358
|
+
|
|
359
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
360
|
+
Array<{ month_label: string; hours: number }>
|
|
361
|
+
>`
|
|
362
|
+
SELECT
|
|
363
|
+
TO_CHAR(DATE_TRUNC('month', clp.completed_at), 'Mon') AS month_label,
|
|
364
|
+
ROUND(SUM(cl.duration_seconds) / 3600.0, 1) AS hours
|
|
365
|
+
FROM course_lesson_progress clp
|
|
366
|
+
JOIN course_enrollment ce ON ce.id = clp.course_enrollment_id
|
|
367
|
+
JOIN course_lesson cl ON cl.id = clp.course_lesson_id
|
|
368
|
+
WHERE ce.person_id = ${personId}
|
|
369
|
+
AND clp.status = 'completed'
|
|
370
|
+
AND clp.completed_at IS NOT NULL
|
|
371
|
+
AND clp.completed_at >= ${tenMonthsAgo}
|
|
372
|
+
GROUP BY DATE_TRUNC('month', clp.completed_at)
|
|
373
|
+
ORDER BY DATE_TRUNC('month', clp.completed_at)
|
|
374
|
+
`;
|
|
375
|
+
|
|
376
|
+
return rows.map((r) => ({
|
|
377
|
+
month: toPtMonth(r.month_label),
|
|
378
|
+
hours: Number(r.hours),
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Area radar chart
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
private async getAreaPerformance(
|
|
387
|
+
personId: number | null,
|
|
388
|
+
): Promise<Array<{ area: string; score: number }>> {
|
|
389
|
+
if (!personId) return [];
|
|
390
|
+
|
|
391
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
392
|
+
Array<{ slug: string; score: number }>
|
|
393
|
+
>`
|
|
394
|
+
SELECT
|
|
395
|
+
cat.slug AS slug,
|
|
396
|
+
ROUND(AVG(ce.progress_percent)) AS score
|
|
397
|
+
FROM course_enrollment ce
|
|
398
|
+
JOIN course_category cc ON cc.course_id = ce.course_id
|
|
399
|
+
JOIN category cat ON cat.id = cc.category_id
|
|
400
|
+
WHERE ce.person_id = ${personId}
|
|
401
|
+
GROUP BY cat.id, cat.slug
|
|
402
|
+
ORDER BY score DESC
|
|
403
|
+
LIMIT 6
|
|
404
|
+
`;
|
|
405
|
+
|
|
406
|
+
return rows.map((r) => ({
|
|
407
|
+
area: slugToTitle(r.slug),
|
|
408
|
+
score: Number(r.score),
|
|
409
|
+
}));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Accuracy donut chart
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
private async getAccuracyDistribution(
|
|
417
|
+
personId: number | null,
|
|
418
|
+
start: Date,
|
|
419
|
+
): Promise<Array<{ label: string; value: number; fill: string }>> {
|
|
420
|
+
if (!personId) {
|
|
421
|
+
return [
|
|
422
|
+
{ label: 'Acertos', value: 0, fill: '#4ADE80' },
|
|
423
|
+
{ label: 'Erros', value: 0, fill: '#FB7185' },
|
|
424
|
+
{ label: 'Não respondidas', value: 0, fill: '#D1D5DB' },
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
429
|
+
[{ correct: bigint; incorrect: bigint; unanswered: bigint }]
|
|
430
|
+
>`
|
|
431
|
+
SELECT
|
|
432
|
+
COUNT(CASE WHEN ea.is_correct = true THEN 1 END) AS correct,
|
|
433
|
+
COUNT(CASE WHEN ea.is_correct = false THEN 1 END) AS incorrect,
|
|
434
|
+
COUNT(CASE WHEN ea.is_correct IS NULL THEN 1 END) AS unanswered
|
|
435
|
+
FROM exam_answer ea
|
|
436
|
+
JOIN exam_attempt att ON att.id = ea.exam_attempt_id
|
|
437
|
+
WHERE att.student_id = ${personId}
|
|
438
|
+
AND att.started_at >= ${start}
|
|
439
|
+
`;
|
|
440
|
+
|
|
441
|
+
const { correct, incorrect, unanswered } = rows[0] ?? {
|
|
442
|
+
correct: BigInt(0),
|
|
443
|
+
incorrect: BigInt(0),
|
|
444
|
+
unanswered: BigInt(0),
|
|
445
|
+
};
|
|
446
|
+
const total = Number(correct) + Number(incorrect) + Number(unanswered);
|
|
447
|
+
const pct = (n: bigint) =>
|
|
448
|
+
total === 0 ? 0 : Math.round((Number(n) / total) * 100);
|
|
449
|
+
|
|
450
|
+
return [
|
|
451
|
+
{ label: 'Acertos', value: pct(correct), fill: '#4ADE80' },
|
|
452
|
+
{ label: 'Erros', value: pct(incorrect), fill: '#FB7185' },
|
|
453
|
+
{ label: 'Não respondidas', value: pct(unanswered), fill: '#D1D5DB' },
|
|
454
|
+
];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Lessons by category chart
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
|
|
461
|
+
private async getLessonsByCategory(
|
|
462
|
+
personId: number | null,
|
|
463
|
+
start: Date,
|
|
464
|
+
): Promise<Array<{ label: string; value: number; fill: string }>> {
|
|
465
|
+
if (!personId) return [];
|
|
466
|
+
|
|
467
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
468
|
+
Array<{ slug: string; value: bigint }>
|
|
469
|
+
>`
|
|
470
|
+
SELECT
|
|
471
|
+
cat.slug AS slug,
|
|
472
|
+
COUNT(clp.id) AS value
|
|
473
|
+
FROM course_lesson_progress clp
|
|
474
|
+
JOIN course_enrollment ce ON ce.id = clp.course_enrollment_id
|
|
475
|
+
JOIN course_lesson cl ON cl.id = clp.course_lesson_id
|
|
476
|
+
JOIN course_module cm ON cm.id = cl.course_module_id
|
|
477
|
+
JOIN course_category cc ON cc.course_id = cm.course_id
|
|
478
|
+
JOIN category cat ON cat.id = cc.category_id
|
|
479
|
+
WHERE ce.person_id = ${personId}
|
|
480
|
+
AND clp.status = 'completed'
|
|
481
|
+
AND clp.completed_at >= ${start}
|
|
482
|
+
GROUP BY cat.id, cat.slug
|
|
483
|
+
ORDER BY value DESC
|
|
484
|
+
LIMIT 6
|
|
485
|
+
`;
|
|
486
|
+
|
|
487
|
+
return rows.map((r, i) => ({
|
|
488
|
+
label: slugToTitle(r.slug),
|
|
489
|
+
value: Number(r.value),
|
|
490
|
+
fill: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
|
491
|
+
}));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Average lesson time chart
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
private async getAvgLessonTime(
|
|
499
|
+
personId: number | null,
|
|
500
|
+
): Promise<Array<{ month: string; minutes: number }>> {
|
|
501
|
+
if (!personId) return [];
|
|
502
|
+
|
|
503
|
+
const tenMonthsAgo = new Date();
|
|
504
|
+
tenMonthsAgo.setMonth(tenMonthsAgo.getMonth() - 10);
|
|
505
|
+
|
|
506
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
507
|
+
Array<{ month_label: string; minutes: number }>
|
|
508
|
+
>`
|
|
509
|
+
SELECT
|
|
510
|
+
TO_CHAR(DATE_TRUNC('month', clp.completed_at), 'Mon') AS month_label,
|
|
511
|
+
ROUND(AVG(cl.duration_seconds) / 60.0) AS minutes
|
|
512
|
+
FROM course_lesson_progress clp
|
|
513
|
+
JOIN course_enrollment ce ON ce.id = clp.course_enrollment_id
|
|
514
|
+
JOIN course_lesson cl ON cl.id = clp.course_lesson_id
|
|
515
|
+
WHERE ce.person_id = ${personId}
|
|
516
|
+
AND clp.status = 'completed'
|
|
517
|
+
AND clp.completed_at IS NOT NULL
|
|
518
|
+
AND clp.completed_at >= ${tenMonthsAgo}
|
|
519
|
+
GROUP BY DATE_TRUNC('month', clp.completed_at)
|
|
520
|
+
ORDER BY DATE_TRUNC('month', clp.completed_at)
|
|
521
|
+
`;
|
|
522
|
+
|
|
523
|
+
return rows.map((r) => ({
|
|
524
|
+
month: toPtMonth(r.month_label),
|
|
525
|
+
minutes: Number(r.minutes),
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// Trail progress card
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
private async getTrailProgress(
|
|
534
|
+
personId: number | null,
|
|
535
|
+
): Promise<Array<{ trail: string; progress: number; color: string }>> {
|
|
536
|
+
if (!personId) return [];
|
|
537
|
+
|
|
538
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
539
|
+
Array<{ title: string; progress: number }>
|
|
540
|
+
>`
|
|
541
|
+
SELECT
|
|
542
|
+
c.title AS title,
|
|
543
|
+
ce.progress_percent AS progress
|
|
544
|
+
FROM course_enrollment ce
|
|
545
|
+
JOIN course c ON c.id = ce.course_id
|
|
546
|
+
WHERE ce.person_id = ${personId}
|
|
547
|
+
AND ce.status IN ('active', 'completed')
|
|
548
|
+
AND ce.progress_percent > 0
|
|
549
|
+
ORDER BY ce.progress_percent DESC
|
|
550
|
+
LIMIT 5
|
|
551
|
+
`;
|
|
552
|
+
|
|
553
|
+
return rows.map((r, i) => ({
|
|
554
|
+
trail: r.title,
|
|
555
|
+
progress: Number(r.progress),
|
|
556
|
+
color: TRAIL_COLORS[i % TRAIL_COLORS.length],
|
|
557
|
+
}));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---------------------------------------------------------------------------
|
|
561
|
+
// Weekly ranking
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
private async getRanking(): Promise<
|
|
565
|
+
Array<{
|
|
566
|
+
id: string;
|
|
567
|
+
position: number;
|
|
568
|
+
name: string;
|
|
569
|
+
score: string;
|
|
570
|
+
avatar: string | null;
|
|
571
|
+
}>
|
|
572
|
+
> {
|
|
573
|
+
const rows = await (this.prisma as any).$queryRaw<
|
|
574
|
+
Array<{ person_id: number; name: string; current_balance: number }>
|
|
575
|
+
>`
|
|
576
|
+
SELECT
|
|
577
|
+
bw.person_id,
|
|
578
|
+
p.name,
|
|
579
|
+
bw.current_balance
|
|
580
|
+
FROM bitcode_wallet bw
|
|
581
|
+
JOIN person p ON p.id = bw.person_id
|
|
582
|
+
ORDER BY bw.current_balance DESC
|
|
583
|
+
LIMIT 5
|
|
584
|
+
`;
|
|
585
|
+
|
|
586
|
+
return rows.map((r, i) => ({
|
|
587
|
+
id: String(r.person_id),
|
|
588
|
+
position: i + 1,
|
|
589
|
+
name: r.name ?? 'Aluno',
|
|
590
|
+
score: `${Number(r.current_balance).toLocaleString('pt-BR')} BITCODES`,
|
|
591
|
+
avatar: null,
|
|
592
|
+
}));
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// Helpers
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
private async getPersonId(userId: number): Promise<number | null> {
|
|
600
|
+
const row = await this.prisma.person_user.findFirst({
|
|
601
|
+
where: { user_id: userId },
|
|
602
|
+
select: { person_id: true },
|
|
603
|
+
});
|
|
604
|
+
return row?.person_id ?? null;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ClassGroupService } from '../class-group/class-group.service';
|
|
3
|
+
import { TrainingStudentService } from '../enterprise/training/training-student.service';
|
|
4
|
+
import { ExamService } from '../exam/exam.service';
|
|
5
|
+
import { InstructorService } from '../instructor/instructor.service';
|
|
6
|
+
import { TrainingService } from '../training/training.service';
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class PlatformaSearchService {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly trainingStudentService: TrainingStudentService,
|
|
12
|
+
private readonly trainingService: TrainingService,
|
|
13
|
+
private readonly examService: ExamService,
|
|
14
|
+
private readonly classGroupService: ClassGroupService,
|
|
15
|
+
private readonly instructorService: InstructorService,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
async search(q: string, types: string[], pageSize = 5) {
|
|
19
|
+
const all = types.length === 0;
|
|
20
|
+
|
|
21
|
+
const [courses, tracks, exams, classrooms, instructors] =
|
|
22
|
+
await Promise.allSettled([
|
|
23
|
+
all || types.includes('course')
|
|
24
|
+
? this.trainingStudentService.getPublishedCourses({ search: q, pageSize })
|
|
25
|
+
: Promise.resolve(null),
|
|
26
|
+
all || types.includes('track')
|
|
27
|
+
? this.trainingService.list({ search: q, status: 'ativa', pageSize })
|
|
28
|
+
: Promise.resolve(null),
|
|
29
|
+
all || types.includes('exam')
|
|
30
|
+
? this.examService.list({ search: q, status: 'published', pageSize })
|
|
31
|
+
: Promise.resolve(null),
|
|
32
|
+
all || types.includes('classroom')
|
|
33
|
+
? this.classGroupService.list({ search: q, pageSize })
|
|
34
|
+
: Promise.resolve(null),
|
|
35
|
+
all || types.includes('instructor')
|
|
36
|
+
? this.instructorService.list({ search: q, status: 'active', pageSize })
|
|
37
|
+
: Promise.resolve(null),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
courses: courses.status === 'fulfilled' ? courses.value : null,
|
|
42
|
+
tracks: tracks.status === 'fulfilled' ? tracks.value : null,
|
|
43
|
+
exams: exams.status === 'fulfilled' ? exams.value : null,
|
|
44
|
+
classrooms: classrooms.status === 'fulfilled' ? classrooms.value : null,
|
|
45
|
+
instructors: instructors.status === 'fulfilled' ? instructors.value : null,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|