File

apps/recallassess/recallassess-api/src/api/client/dashboard/dashboard.service.ts

Index

Methods

Constructor

constructor(prisma: BNestPrismaService, mediaUtilService: BNestMediaUtilService, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
mediaUtilService BNestMediaUtilService No
subscriptionCourseAccess ParticipantSubscriptionCourseAccessService No

Methods

Async getAdminDashboardOverview
getAdminDashboardOverview(companyId: number)

Get admin dashboard overview with completion percentage

Parameters :
Name Type Optional
companyId number No
Async getAdminDashboardProgress
getAdminDashboardProgress(companyId: number)

Get admin dashboard progress statistics

Parameters :
Name Type Optional
companyId number No
Async getParticipantDashboard
getParticipantDashboard(participantId: number)

Get participant dashboard data

Parameters :
Name Type Optional
participantId number No
Private parseTimeToHours
parseTimeToHours(timeStr: string)

Helper to parse time string to hours for sorting

Parameters :
Name Type Optional
timeStr string No
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
  }
}

results matching ""

    No results matching ""