apps/recallassess/recallassess-api/src/api/client/dashboard/dashboard.service.ts
Methods |
|
constructor(prisma: BNestPrismaService, mediaUtilService: BNestMediaUtilService, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
|
||||||||||||
|
Parameters :
|
| Async getAdminDashboardOverview | ||||||
getAdminDashboardOverview(companyId: number)
|
||||||
|
Get admin dashboard overview with completion percentage
Parameters :
Returns :
Promise<AdminDashboardOverviewDto>
|
| Async getAdminDashboardProgress | ||||||
getAdminDashboardProgress(companyId: number)
|
||||||
|
Get admin dashboard progress statistics
Parameters :
Returns :
Promise<AdminDashboardProgressDto>
|
| Async getParticipantDashboard | ||||||
getParticipantDashboard(participantId: number)
|
||||||
|
Get participant dashboard data
Parameters :
Returns :
Promise<ParticipantDashboardDto>
|
| Private parseTimeToHours | ||||||
parseTimeToHours(timeStr: string)
|
||||||
|
Helper to parse time string to hours for sorting
Parameters :
Returns :
number
|
import { ParticipantSubscriptionCourseAccessService } from "@api/client/shared/participant-subscription-course-access.service";
import { bnestPlainToDto } from "@bish-nest/core";
import { BNestMediaUtilService, BNestPrismaService } from "@bish-nest/core/services";
import { Injectable } from "@nestjs/common";
import { ParticipantLearningProgressStatus } from "@prisma/client";
import {
AdminDashboardOverviewDto,
AdminDashboardProgressDto,
AdminRecentActivityDto,
ParticipantDashboardDto,
ParticipantDashboardStatsDto,
PendingAssessmentDto,
ProgressStatisticsDto,
RecentCourseActivityDto,
} from "./dto";
@Injectable()
export class CLDashboardService {
constructor(
private prisma: BNestPrismaService,
private mediaUtilService: BNestMediaUtilService,
private readonly subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService,
) {}
/**
* Get participant dashboard data
*/
async getParticipantDashboard(participantId: number): Promise<ParticipantDashboardDto> {
// Get all enrolled courses for this participant
const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: participantId,
},
include: {
learningGroup: {
include: {
course: {
include: {
mediaImages: {
where: {
media_name: "COURSE__IMAGE",
},
take: 1,
},
courseModulePages: {
where: {
courseModule: {
is_published: true,
},
},
select: {
courseModule: {
select: {
id: true,
title: true,
},
},
},
},
},
},
eLearningParticipants: {
where: {
participant_id: participantId,
},
take: 1,
},
},
},
},
orderBy: {
updated_at: "desc",
},
});
// Process media URLs
await Promise.all(
enrollments.map(async (enrollment) => {
if (enrollment.learningGroup?.course?.mediaImages) {
await this.mediaUtilService.addS3UrlPrefixToMediaArray(enrollment.learningGroup.course.mediaImages);
}
}),
);
// Calculate statistics (exclude cancelled enrollments)
const activeEnrollments = enrollments.filter((e) => !e.cancelled);
const totalCourses = activeEnrollments.length;
const inProgress = activeEnrollments.filter((e) => {
const progressData = e.learningGroup?.eLearningParticipants?.[0];
const completionProgress =
e.completion_percentage !== null && e.completion_percentage !== undefined
? Number(e.completion_percentage) || 0
: 0;
const progress = progressData ? Number(progressData.progress_percentage) || 0 : 0;
const effectiveProgress = completionProgress > 0 ? completionProgress : progress;
return (
e.status === ParticipantLearningProgressStatus.PRE_BAT ||
e.status === ParticipantLearningProgressStatus.E_LEARNING ||
e.status === ParticipantLearningProgressStatus.POST_BAT ||
effectiveProgress > 0
);
}).length;
const completed = activeEnrollments.filter(
(e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
// Calculate average progress
let totalProgress = 0;
let progressCount = 0;
activeEnrollments.forEach((enrollment) => {
const progressData = enrollment.learningGroup?.eLearningParticipants?.[0];
const completionProgress =
enrollment.completion_percentage !== null && enrollment.completion_percentage !== undefined
? Number(enrollment.completion_percentage) || 0
: 0;
const rawProgress = progressData?.progress_percentage ? Number(progressData.progress_percentage) : 0;
const effectiveProgress = completionProgress > 0 ? completionProgress : rawProgress;
if (effectiveProgress > 0) {
totalProgress += effectiveProgress;
progressCount++;
}
});
const averageProgress = progressCount > 0 ? Math.round(totalProgress / progressCount) : 0;
// Get recent courses (limit to 5) with BAT assessment info
const recentCourses = await Promise.all(
enrollments.slice(0, 5).map(async (enrollment) => {
const progressData = enrollment.learningGroup?.eLearningParticipants?.[0];
const progressFromEnrollment =
enrollment.completion_percentage !== null && enrollment.completion_percentage !== undefined
? Number(enrollment.completion_percentage) || 0
: 0;
const progressFromELearning = progressData ? Number(progressData.progress_percentage) || 0 : 0;
const progress = progressFromEnrollment > 0 ? progressFromEnrollment : progressFromELearning;
// Extract unique modules from courseModulePages
const courseModules =
enrollment.learningGroup?.course?.courseModulePages
?.map((page) => page.courseModule)
.filter((module, idx, self) => module && self.findIndex((m) => m?.id === module.id) === idx)
.filter((module): module is NonNullable<typeof module> => module !== null) || [];
const totalModules = progressData?.total_course_modules || courseModules.length;
const completedModules = progressData?.course_modules_completed || 0;
let status = "Not Started";
if (enrollment.cancelled) {
status = "Cancelled";
} else if (enrollment.status === ParticipantLearningProgressStatus.COMPLETED) {
status = "Completed";
} else if (
enrollment.status === ParticipantLearningProgressStatus.PRE_BAT ||
enrollment.status === ParticipantLearningProgressStatus.E_LEARNING ||
enrollment.status === ParticipantLearningProgressStatus.POST_BAT
) {
status = "In Progress";
} else if (progress > 0) {
status = "In Progress";
}
// Media URLs are already processed by addS3UrlPrefixToMediaArray above (writes to media_path)
const firstImage = enrollment.learningGroup?.course?.mediaImages?.[0];
const image = firstImage?.media_path ? String(firstImage.media_path) : "";
// Check BAT assessment availability
const course = enrollment.learningGroup?.course;
const courseWithAssessmentId = course as typeof course & { assessment_id: number | null };
let preBatCompleted = false;
let postBatCompleted = false;
let preBatAnalysisAvailable = false;
let postBatAnalysisAvailable = false;
if (courseWithAssessmentId?.assessment_id) {
const [preBatParticipant, postBatParticipant] = await Promise.all([
this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: courseWithAssessmentId.assessment_id,
participant_id: participantId,
assessment_type: "PRE_BAT",
},
}),
this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: courseWithAssessmentId.assessment_id,
participant_id: participantId,
assessment_type: "POST_BAT",
},
}),
]);
preBatCompleted = !!preBatParticipant;
postBatCompleted = !!postBatParticipant;
// Check if ai_analysis is available (not null and not empty) for PRE BAT
preBatAnalysisAvailable = !!(
preBatParticipant?.ai_analysis && preBatParticipant.ai_analysis.trim().length > 0
);
// Check if POST BAT analysis is available: either per-topic learning_path (legacy) or post_ai_analysis with overall_summary (new)
if (postBatParticipant) {
const hasLearningPathInResults = await this.prisma.client.assessmentResult.findFirst({
where: {
assessment_participant_id: postBatParticipant.id,
learning_path: {
not: null,
},
},
});
const postAnalysisRaw = (postBatParticipant as { post_ai_analysis?: string | null }).post_ai_analysis;
const hasOverallSummaryInPostAiAnalysis =
postAnalysisRaw &&
postAnalysisRaw.trim().length > 0 &&
(() => {
try {
const parsed = JSON.parse(postAnalysisRaw) as { overall_summary?: string };
return typeof parsed.overall_summary === "string" && parsed.overall_summary.trim().length > 0;
} catch {
return false;
}
})();
postBatAnalysisAvailable = !!(
(hasLearningPathInResults?.learning_path &&
hasLearningPathInResults.learning_path.trim().length > 0) ||
hasOverallSummaryInPostAiAnalysis
);
}
}
return {
id: enrollment.id,
title: enrollment.learningGroup?.course?.title || "",
progress,
totalModules,
completedModules,
status,
dueDate: null, // TODO: Add due date if available
image,
raw_status: enrollment.status,
accepted_at: enrollment.accepted_at,
cancelled: enrollment.cancelled,
pre_bat_analysis_available: preBatAnalysisAvailable,
pre_bat_completed: preBatCompleted,
post_bat_analysis_available: postBatAnalysisAvailable,
post_bat_completed: postBatCompleted,
};
}),
);
// Generate recent activities from course progress
const recentActivities: RecentCourseActivityDto[] = [];
enrollments.slice(0, 5).forEach((enrollment) => {
if (enrollment.cancelled) {
return;
}
const progressData = enrollment.learningGroup?.eLearningParticipants?.[0];
if (progressData?.last_activity) {
const hoursAgo = Math.floor(
(Date.now() - new Date(progressData.last_activity).getTime()) / (1000 * 60 * 60),
);
let timeAgo = "";
if (hoursAgo < 1) {
timeAgo = "Just now";
} else if (hoursAgo === 1) {
timeAgo = "1 hour ago";
} else if (hoursAgo < 24) {
timeAgo = `${hoursAgo} hours ago`;
} else {
const daysAgo = Math.floor(hoursAgo / 24);
timeAgo = daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`;
}
let action = "";
let icon = "pi pi-play-circle";
let iconColor = "text-blue-600";
if (enrollment.status === ParticipantLearningProgressStatus.COMPLETED) {
action = `Completed ${enrollment.learningGroup?.course?.title || "course"}`;
icon = "pi pi-check-circle";
iconColor = "text-green-600";
} else if (progressData.course_modules_completed > 0) {
action = `Completed Module ${progressData.course_modules_completed} - ${enrollment.learningGroup?.course?.title || "course"}`;
icon = "pi pi-check-circle";
iconColor = "text-green-600";
} else {
action = `Started ${enrollment.learningGroup?.course?.title || "course"}`;
icon = "pi pi-play-circle";
iconColor = "text-blue-600";
}
recentActivities.push({
id: enrollment.id,
action,
course: enrollment.learningGroup?.course?.title || "",
time: timeAgo,
icon,
iconColor,
});
}
});
// Sort activities by time (most recent first)
recentActivities.sort((a, b) => {
const timeA = this.parseTimeToHours(a.time);
const timeB = this.parseTimeToHours(b.time);
return timeA - timeB;
});
const stats: ParticipantDashboardStatsDto = {
totalCourses,
inProgress,
completed,
averageProgress,
};
const company_active = await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId);
return bnestPlainToDto(
{
stats,
myCourses: recentCourses,
recentActivity: recentActivities.slice(0, 5),
company_active,
},
ParticipantDashboardDto,
);
}
/**
* Get admin dashboard overview with completion percentage
*/
async getAdminDashboardOverview(companyId: number): Promise<AdminDashboardOverviewDto> {
// Get recent enrollments and activities
const recentEnrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
learningGroup: {
company_id: companyId,
},
},
include: {
participant: {
select: {
first_name: true,
last_name: true,
},
},
learningGroup: {
include: {
course: {
select: {
title: true,
},
},
eLearningParticipants: {
orderBy: {
last_activity: "desc",
},
},
},
},
},
orderBy: {
updated_at: "desc",
},
take: 10,
});
// Build recent activities
const recentActivities: AdminRecentActivityDto[] = [];
recentEnrollments.forEach((enrollment) => {
// Type guard to ensure learningGroup is included
if (!enrollment.learningGroup || !enrollment.learningGroup.course) {
return;
}
const userName = `${enrollment.participant.first_name} ${enrollment.participant.last_name}`;
// Filter eLearningParticipants for this specific enrollment's participant
const progressData = enrollment.learningGroup.eLearningParticipants?.find(
(ep) => ep.participant_id === enrollment.participant_id,
);
const courseTitle = enrollment.learningGroup.course.title;
if (progressData?.last_activity) {
const hoursAgo = Math.floor(
(Date.now() - new Date(progressData.last_activity).getTime()) / (1000 * 60 * 60),
);
let timeAgo = "";
if (hoursAgo < 1) {
timeAgo = "Just now";
} else if (hoursAgo === 1) {
timeAgo = "1 hour ago";
} else if (hoursAgo < 24) {
timeAgo = `${hoursAgo} hours ago`;
} else {
const daysAgo = Math.floor(hoursAgo / 24);
timeAgo = daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`;
}
let action = "";
let icon = "pi pi-play-circle";
let iconColor = "text-blue-600";
if (enrollment.status === ParticipantLearningProgressStatus.COMPLETED) {
action = `completed ${courseTitle}`;
icon = "pi pi-check-circle";
iconColor = "text-green-600 fill-green-100";
} else if (progressData.course_modules_completed > 0) {
action = `completed ${courseTitle} Module ${progressData.course_modules_completed}`;
icon = "pi pi-check-circle";
iconColor = "text-green-600 fill-green-100";
} else {
action = `started ${courseTitle}`;
icon = "pi pi-play-circle";
iconColor = "text-blue-600";
}
recentActivities.push({
id: enrollment.id,
userName,
action,
time: timeAgo,
icon,
iconColor,
});
}
});
// Get pending assessments (invited but not started)
const pendingAssessments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
learningGroup: {
company_id: companyId,
},
status: {
in: [
ParticipantLearningProgressStatus.INVITED,
ParticipantLearningProgressStatus.ACCEPTED,
ParticipantLearningProgressStatus.PRE_BAT,
],
},
},
include: {
participant: {
select: {
first_name: true,
last_name: true,
},
},
learningGroup: {
include: {
course: {
select: {
title: true,
},
},
},
},
},
orderBy: {
created_at: "asc",
},
take: 10,
});
const pendingAssessmentsDto: PendingAssessmentDto[] = pendingAssessments.map((enrollment) => {
const userName = `${enrollment.participant.first_name} ${enrollment.participant.last_name}`;
const assignedDate = new Date(enrollment.created_at);
const daysSinceAssigned = Math.floor((Date.now() - assignedDate.getTime()) / (1000 * 60 * 60 * 24));
let status = "Pending";
if (daysSinceAssigned > 5) {
status = "Overdue";
}
return {
id: enrollment.id,
userName,
courseName: enrollment.learningGroup.course.title,
assignedDate: assignedDate.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
}),
waitingDays: daysSinceAssigned,
status,
};
});
// Calculate overall completion percentage for overview
const allEnrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
learningGroup: {
company_id: companyId,
},
},
});
const totalEnrollments = allEnrollments.length;
const completedEnrollments = allEnrollments.filter(
(e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
const completionPercentage =
totalEnrollments > 0 ? Math.round((completedEnrollments / totalEnrollments) * 100) : 0;
return bnestPlainToDto(
{
recentActivities: recentActivities.slice(0, 5),
pendingAssessments: pendingAssessmentsDto,
completionPercentage,
},
AdminDashboardOverviewDto,
);
}
/**
* Get admin dashboard progress statistics
*/
async getAdminDashboardProgress(companyId: number): Promise<AdminDashboardProgressDto> {
// Get all enrollments for the company
const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
learningGroup: {
company_id: companyId,
},
},
include: {
learningGroup: {
include: {
course: true,
},
},
},
});
const completedCourses = enrollments.filter(
(e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
const inProgress = enrollments.filter(
(e) =>
e.status === ParticipantLearningProgressStatus.PRE_BAT ||
e.status === ParticipantLearningProgressStatus.E_LEARNING ||
e.status === ParticipantLearningProgressStatus.POST_BAT,
).length;
const pendingStart = enrollments.filter(
(e) =>
e.status === ParticipantLearningProgressStatus.INVITED ||
e.status === ParticipantLearningProgressStatus.ACCEPTED,
).length;
// Calculate overall completion percentage
const totalEnrollments = enrollments.length;
const completionPercentage =
totalEnrollments > 0 ? Math.round((completedCourses / totalEnrollments) * 100) : 0;
// Calculate completion rate for current month
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const enrollmentsThisMonth = enrollments.filter((e) => new Date(e.created_at) >= startOfMonth);
const completedThisMonth = enrollmentsThisMonth.filter(
(e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
const overallCompletionRateThisMonth =
enrollmentsThisMonth.length > 0
? Math.round((completedThisMonth / enrollmentsThisMonth.length) * 100)
: completionPercentage;
// Calculate active participants (participants with active enrollments)
const activeParticipantIds = new Set(
enrollments
.filter(
(e) =>
e.status !== ParticipantLearningProgressStatus.INVITED &&
e.status !== ParticipantLearningProgressStatus.ACCEPTED,
)
.map((e) => e.participant_id),
);
const activeParticipants = activeParticipantIds.size;
// Calculate average assessment score from AssessmentParticipant
const assessmentParticipants = await this.prisma.client.assessmentParticipant.findMany({
where: {
course: {
learningGroups: {
some: {
company_id: companyId,
},
},
},
individual_quotient: {
not: null,
},
},
select: {
individual_quotient: true,
},
});
let totalQuotient = 0;
let quotientCount = 0;
assessmentParticipants.forEach((ap) => {
if (ap.individual_quotient) {
const quotient = Number(ap.individual_quotient);
// Convert quotient (1-4 scale) to percentage (0-100)
// FOUNDATION=1 (25%), INTERMEDIATE=2 (50%), ADVANCED=3 (75%), EXPERT=4 (100%)
const percentage = ((quotient - 1) / 3) * 100;
totalQuotient += percentage;
quotientCount++;
}
});
const averageAssessmentScore = quotientCount > 0 ? Math.round(totalQuotient / quotientCount) : 0;
// Calculate average completion time (days from created_at to completion)
const completedEnrollments = enrollments.filter(
(e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
);
let totalCompletionDays = 0;
let completionCount = 0;
completedEnrollments.forEach((enrollment) => {
const createdDate = new Date(enrollment.created_at);
const completedDate = enrollment.updated_at ? new Date(enrollment.updated_at) : new Date();
const daysDiff = Math.floor((completedDate.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > 0) {
totalCompletionDays += daysDiff;
completionCount++;
}
});
const averageCompletionTimeDays = completionCount > 0 ? Math.round(totalCompletionDays / completionCount) : 0;
// Calculate monthly completion trends (last 6 months)
const months: string[] = [];
const completionRates: number[] = [];
for (let i = 5; i >= 0; i--) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthName = date.toLocaleDateString("en-US", { month: "short" });
months.push(monthName);
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59);
const enrollmentsInMonth = enrollments.filter((e) => {
const enrollDate = new Date(e.created_at);
return enrollDate >= monthStart && enrollDate <= monthEnd;
});
const completedInMonth = enrollmentsInMonth.filter(
(e) =>
e.status === ParticipantLearningProgressStatus.COMPLETED &&
e.updated_at &&
new Date(e.updated_at) <= monthEnd,
).length;
const rate =
enrollmentsInMonth.length > 0 ? Math.round((completedInMonth / enrollmentsInMonth.length) * 100) : 0;
completionRates.push(rate);
}
const completionTrends = months.map((month, index) => ({
month,
rate: completionRates[index],
}));
const summaryCards: ProgressStatisticsDto = {
completedCourses,
inProgress,
pendingStart,
completionPercentage,
overallCompletionRateThisMonth: overallCompletionRateThisMonth,
averageAssessmentScore,
activeParticipants,
averageCompletionTimeDays,
};
// Skill gaps - empty for now (can be extended later with actual skill analysis)
const skillGaps: Array<{ name: string; percentage: number; color: string }> = [];
return bnestPlainToDto(
{
summaryCards,
completionTrends,
skillGaps,
},
AdminDashboardProgressDto,
);
}
/**
* Helper to parse time string to hours for sorting
*/
private parseTimeToHours(timeStr: string): number {
if (timeStr === "Just now") return 0;
if (timeStr.includes("hour")) {
const match = timeStr.match(/(\d+)/);
return match ? parseInt(match[1]) : 0;
}
if (timeStr.includes("day")) {
const match = timeStr.match(/(\d+)/);
return match ? parseInt(match[1]) * 24 : 0;
}
return 999; // Default for unknown formats
}
}