File

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

Index

Methods

Constructor

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

Methods

Private Async checkTrialPackage
checkTrialPackage(participantId: number)

Helper: Check if participant's company has a trial package

Parameters :
Name Type Optional Description
participantId number No
  • Participant ID
Returns : Promise<literal type | null>

Object with isTrialPackage flag and package info, or null if not found

Private Async checkTrialPageLimit
checkTrialPageLimit(participantId: number, courseId: number, totalPagesInCourse: number)

Helper: Check if participant has reached trial package page limit

Parameters :
Name Type Optional Description
participantId number No
  • Participant ID
courseId number No
  • Course ID
totalPagesInCourse number No
  • Total number of pages in the course
Returns : Promise<literal type | null>

Object with limit info or null if not a trial package

Private compareGradeLevels
compareGradeLevels(level1: BatAnswerLevel | null | undefined, level2: BatAnswerLevel | null | undefined)

Compare two grade levels Returns: -1 if level1 < level2, 0 if equal, 1 if level1 > level2 Priority: FOUNDATION < INTERMEDIATE < ADVANCED < EXPERT

Parameters :
Name Type Optional
level1 BatAnswerLevel | null | undefined No
level2 BatAnswerLevel | null | undefined No
Returns : number
Private findFirstIncompletePage
findFirstIncompletePage(allPages: CourseModulePageDto[])

Helper: Find first incomplete page and return its sequence info

Parameters :
Name Type Optional
allPages CourseModulePageDto[] No
Returns : literal type | null
Private Async getAccessedPagesCount
getAccessedPagesCount(participantId: number, courseId: number)

Helper: Get number of pages accessed (viewed) by participant in a course

Parameters :
Name Type Optional Description
participantId number No
  • Participant ID
courseId number No
  • Course ID
Returns : Promise<number>

Number of pages accessed

Async getAllCoursePages
getAllCoursePages(learningGroupParticipantId: number, participantId: number)

Get all course pages across all modules (continuous flow)

Parameters :
Name Type Optional
learningGroupParticipantId number No
participantId number No
Async getCourseModulePages
getCourseModulePages(learningGroupParticipantId: number, courseModuleId: number, participantId: number)

Get e-Learning content

Parameters :
Name Type Optional
learningGroupParticipantId number No
courseModuleId number No
participantId number No
Async getCourseModules
getCourseModules(learningGroupParticipantId: number, participantId: number)

Get course modules with progress

Parameters :
Name Type Optional
learningGroupParticipantId number No
participantId number No
Private Async getFilteredModuleIds
getFilteredModuleIds(courseId: number, participantId: number, moduleAssignmentRule: string, assessmentId: number | null)

Get filtered modules based on module assignment rule and PRE BAT assessment results

Parameters :
Name Type Optional Description
courseId number No
  • Course ID
participantId number No
  • Participant ID
moduleAssignmentRule string No
  • Module assignment rule (ALL_MODULES or RED_AMBER_ONLY)
assessmentId number | null No
  • PRE BAT assessment ID (optional)
Returns : Promise<number[]>

Array of filtered course module IDs

Async getFirstIncompletePage
getFirstIncompletePage(learningGroupParticipantId: number, participantId: number)

Get first incomplete page URL information

Parameters :
Name Type Optional
learningGroupParticipantId number No
participantId number No
Async getPageBySequence
getPageBySequence(learningGroupParticipantId: number, moduleSequence: number, pageSequence: number, participantId: number)

Get page by module and page sequence numbers Returns page data with sequence information

Parameters :
Name Type Optional
learningGroupParticipantId number No
moduleSequence number No
pageSequence number No
participantId number No
Returns : Promise<unknown>
Private getSequenceInfoForPage
getSequenceInfoForPage(allPages: CourseModulePageDto[], pageIndex: number, uniqueModules: Array)

Helper: Get sequence info for a page at a specific index

Parameters :
Name Type Optional
allPages CourseModulePageDto[] No
pageIndex number No
uniqueModules Array<literal type> No
Returns : literal type
Private getTrialPackagePageLimitPercentage
getTrialPackagePageLimitPercentage()

Helper: Get trial package page limit percentage from config

Returns : number

Percentage as number (e.g., 50 for 50%)

Async getVideoWatchStatus
getVideoWatchStatus(learningGroupParticipantId: number, _pageId: number, participantId: number)

Get video watch status for all videos on a page (local storage only)

Parameters :
Name Type Optional
learningGroupParticipantId number No
_pageId number No
participantId number No
Returns : Promise<literal type>
Private mapEnrollmentMediaItemForClient
mapEnrollmentMediaItemForClient(m: Media)

Enrollment page responses: only fields matching CourseModulePageDto media item shape (full Prisma Media rows include created_at, FKs, etc.).

Parameters :
Name Type Optional
m Media No
Returns : literal type

New shuffled array

Async markPageComplete
markPageComplete(learningGroupParticipantId: number, courseModuleId: number, pageId: number, participantId: number)

Mark page as completed (updated to work with all pages)

Parameters :
Name Type Optional
learningGroupParticipantId number No
courseModuleId number No
pageId number No
participantId number No
Returns : Promise<literal type>
Private shuffleArray
shuffleArray(array: T[])
Type parameters :
  • T
Parameters :
Name Type Optional
array T[] No
Returns : T[]
Async startCourse
startCourse(learningGroupParticipantId: number, participantId: number)

Start e-learning course - Initialize ELearningParticipant if not exists Validates PRE BAT completion before allowing start

Parameters :
Name Type Optional
learningGroupParticipantId number No
participantId number No
Returns : Promise<literal type>
Async trackPageView
trackPageView(learningGroupParticipantId: number, _courseModuleId: number, pageId: number, participantId: number)

Track page view (set start_date when page is first viewed)

Parameters :
Name Type Optional
learningGroupParticipantId number No
_courseModuleId number No
pageId number No
participantId number No
Returns : Promise<literal type>
Async trackVideoProgress
trackVideoProgress(learningGroupParticipantId: number, _pageId: number, _mediaId: number, _watchedSeconds: number, participantId: number)

Track video watch progress (local storage only) Video clicks are now stored locally on the frontend

Parameters :
Name Type Optional
learningGroupParticipantId number No
_pageId number No
_mediaId number No
_watchedSeconds number No
participantId number No
Returns : Promise<literal type>
Private Async updateCourseProgress
updateCourseProgress(courseId: number, participantId: number, learningGroupId: number)

Update course-level progress

Parameters :
Name Type Optional
courseId number No
participantId number No
learningGroupId number No
Returns : Promise<void>
Private Async updateModuleProgress
updateModuleProgress(courseId: number, courseModuleId: number, participantId: number, learningGroupId: number)

Update module-level progress

Parameters :
Name Type Optional
courseId number No
courseModuleId number No
participantId number No
learningGroupId number No
Returns : Promise<void>
Async validatePageAccess
validatePageAccess(learningGroupParticipantId: number, moduleSequence: number, pageSequence: number, participantId: number)

Validate if a page can be accessed based on completion of previous pages

Parameters :
Name Type Optional
learningGroupParticipantId number No
moduleSequence number No
pageSequence number No
participantId number No
import { ParticipantSubscriptionCourseAccessService } from "@api/client/shared/participant-subscription-course-access.service";
import { SystemLogService } from "@api/shared/services/system-log.service";
import { bnestPlainToDtoArray } from "@bish-nest/core";
import { BNestMediaUtilService, BNestPrismaService } from "@bish-nest/core/services";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import {
  AssessmentType,
  BatAnswerLevel,
  ContentType,
  ELearningProgressStatus,
  Media,
  MediaName,
  MediaType,
  Prisma,
  SystemLogEntityType,
} from "@prisma/client";
import { plainToInstance } from "class-transformer";
import { COURSE_PROGRESS_CONFIG } from "../../../config/course-progress.config";
import { TRIAL_PACKAGE_CONFIG } from "../../../config/trial-package.config";
import { COURSE_PROGRESS_EVENTS, ELearningCompletedEvent } from "../learning-group/events/course-progress.events";
import {
  CourseModuleDto,
  CourseModulePageDto,
  FirstIncompletePageDto,
  PageAccessValidationDto,
  PageSequenceInfoDto,
} from "./dto";

@Injectable()
export class CLLearningService {
  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly mediaUtilService: BNestMediaUtilService,
    private readonly eventEmitter: EventEmitter2,
    private readonly systemLogService: SystemLogService,
    private readonly subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService,
  ) {}

  /**
   * Shuffle array using Fisher-Yates algorithm
   * @param array Array to shuffle
   * @returns New shuffled array
   */
  /**
   * Enrollment page responses: only fields matching {@link CourseModulePageDto} media item shape
   * (full Prisma `Media` rows include `created_at`, FKs, etc.).
   */
  private mapEnrollmentMediaItemForClient(m: Media): {
    id: number;
    media_path: string;
    media_name: string;
    caption: string | null;
    description: string | null;
    original_filename: string | null;
    file_extension: string | null;
    mime_type: string | null;
    media_size: number | null;
  } {
    return {
      id: m.id,
      media_path: m.media_path,
      media_name: m.media_name,
      caption: m.caption,
      description: m.description,
      original_filename: m.original_filename,
      file_extension: m.file_extension,
      mime_type: m.mime_type,
      media_size: m.media_size != null ? Number(m.media_size) : null,
    };
  }

  private shuffleArray<T>(array: T[]): T[] {
    const shuffled = [...array];
    for (let i = shuffled.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }
    return shuffled;
  }

  /**
   * Start e-learning course - Initialize ELearningParticipant if not exists
   * Validates PRE BAT completion before allowing start
   */
  async startCourse(
    learningGroupParticipantId: number,
    participantId: number,
  ): Promise<{ success: boolean; elearningParticipantId: number }> {
    // Get enrollment using LearningGroupParticipant ID
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: true,
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      throw new ForbiddenException(
        "This course license has been cancelled. You can no longer start or continue this course.",
      );
    }

    await this.subscriptionCourseAccess.assertAllowsElearningRead(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Type assertion to access course_id (field exists in schema but Prisma client not regenerated yet)
    const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id: number };
    const courseId = enrollmentWithCourseId.course_id;

    // Verify PRE BAT assessment is completed
    const course = await this.prisma.client.course.findUnique({
      where: {
        id: courseId,
      },
    });

    if (!course) {
      throw new NotFoundException("Course not found");
    }

    // Type assertion to access assessment_id
    const courseWithAssessmentId = course as typeof course & { assessment_id: number | null };

    if (courseWithAssessmentId.assessment_id) {
      const preBatCompleted = await this.prisma.client.assessmentParticipant.findFirst({
        where: {
          assessment_id: courseWithAssessmentId.assessment_id,
          participant_id: participantId,
          assessment_type: AssessmentType.PRE_BAT,
          learning_group_id: enrollment.learning_group_id, // Include learning_group_id for more accurate lookup
        },
      });

      if (!preBatCompleted) {
        throw new NotFoundException("Please complete PRE BAT assessment before starting e-learning");
      }
    }

    // Get or create ELearningParticipant
    const existingProgress = await this.prisma.client.eLearningParticipant.findUnique({
      where: {
        participant_id_course_id_learning_group_id: {
          participant_id: participantId,
          course_id: courseId,
          learning_group_id: enrollment.learning_group_id,
        },
      },
    });

    if (existingProgress) {
      // Update last activity if already exists
      await this.prisma.client.eLearningParticipant.update({
        where: { id: existingProgress.id },
        data: { last_activity: new Date() },
      });

      return {
        success: true,
        elearningParticipantId: existingProgress.id,
      };
    }

    // Get module assignment rule from learning group
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: enrollment.learning_group_id },
    });

    const learningGroupWithRule = learningGroup as typeof learningGroup & {
      module_assignment_rule?: "ALL_MODULES" | "RED_AMBER_ONLY" | "RECOMMENDED";
    };
    const moduleAssignmentRule = learningGroupWithRule.module_assignment_rule || "ALL_MODULES";

    // Get filtered module IDs based on module assignment rule and PRE BAT results
    // Since PRE BAT is required before starting e-learning, we can safely filter modules
    const filteredModuleIds = await this.getFilteredModuleIds(
      courseId,
      participantId,
      moduleAssignmentRule,
      courseWithAssessmentId.assessment_id || null,
    );

    // Use filtered module count instead of all modules
    const totalModules = filteredModuleIds.length;

    // Create new ELearningParticipant
    const elearningParticipant = await this.prisma.client.eLearningParticipant.create({
      data: {
        course_id: courseId,
        participant_id: participantId,
        learning_group_id: enrollment.learning_group_id,
        status: ELearningProgressStatus.NOT_STARTED,
        progress_percentage: 0,
        course_modules_completed: 0,
        total_course_modules: totalModules,
        start_date: new Date(),
        last_activity: new Date(),
      },
    });

    return {
      success: true,
      elearningParticipantId: elearningParticipant.id,
    };
  }

  /**
   * Get course modules with progress
   */
  async getCourseModules(learningGroupParticipantId: number, participantId: number): Promise<CourseModuleDto[]> {
    // Get enrollment using LearningGroupParticipant ID
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: {
          include: {
            course: true,
          },
        },
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      throw new ForbiddenException(
        "This course license has been cancelled. You no longer have access to this course.",
      );
    }

    await this.subscriptionCourseAccess.assertAllowsElearningRead(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Use course_id from enrollment or fallback to learningGroup.course_id
    const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id?: number };
    const courseId = enrollmentWithCourseId.course_id ?? enrollment.learningGroup.course_id;

    // Debug: Check if course exists
    const course = await this.prisma.client.course.findUnique({
      where: { id: courseId },
    });

    if (!course) {
      throw new NotFoundException(`Course with ID ${courseId} not found`);
    }

    // Get module assignment rule from learning group
    const learningGroupWithRule = enrollment.learningGroup as typeof enrollment.learningGroup & {
      module_assignment_rule?: "ALL_MODULES" | "RED_AMBER_ONLY" | "RECOMMENDED";
    };
    const moduleAssignmentRule = learningGroupWithRule.module_assignment_rule || "ALL_MODULES";

    // Get module assignment rule and filtered module IDs first
    const courseWithAssessmentId = course as typeof course & { assessment_id: number | null };
    const filteredModuleIds = await this.getFilteredModuleIds(
      courseId,
      participantId,
      moduleAssignmentRule,
      courseWithAssessmentId.assessment_id || null,
    );

    // Get all published modules for e-learning
    // Get all course module pages to calculate earliest sort_order per module
    const allCourseModulePages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: courseId,
        courseModule: {
          is_published: true,
          id: {
            in: filteredModuleIds, // Filter by module assignment rule
          },
        },
        is_published: true,
      },
      select: {
        course_module_id: true,
        sort_order: true,
      },
    });

    // Calculate earliest page sort_order for each module
    const moduleEarliestPageSortOrder = new Map<number, number>();
    allCourseModulePages.forEach((page) => {
      const current = moduleEarliestPageSortOrder.get(page.course_module_id);
      if (current === undefined || page.sort_order < current) {
        moduleEarliestPageSortOrder.set(page.course_module_id, page.sort_order);
      }
    });

    // exclude_from_bat is only used for BAT assessments, not for e-learning
    // Filter by filteredModuleIds to respect module assignment rule
    // CourseModule no longer has course_id - find modules through CourseModulePage
    const courseModulePages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: courseId,
        courseModule: {
          is_published: true,
          id: {
            in: filteredModuleIds, // Filter by module assignment rule
          },
        },
      },
      select: {
        courseModule: {
          include: {
            courseModulePages: {
              where: {
                is_published: true,
                course_id: courseId,
              },
              orderBy: {
                sort_order: "asc",
              },
            },
            eLearningParticipantCourseModules: {
              where: {
                participant_id: participantId,
                course_id: courseId,
              },
            },
          },
        },
      },
    });

    // Extract unique modules and sort by earliest page sort_order
    const modules = courseModulePages
      .map((page) => page.courseModule)
      .filter((module, index, self) => self.findIndex((m) => m.id === module.id) === index)
      .map((module) => {
        // Get the earliest page sort_order for this module
        const earliestPageSortOrder = moduleEarliestPageSortOrder.get(module.id) || 0;
        return {
          ...module,
          earliest_page_sort_order: earliestPageSortOrder,
        };
      })
      .sort((a, b) => (a.earliest_page_sort_order || 0) - (b.earliest_page_sort_order || 0));

    // Get PRE BAT grade levels for each module (for testing display)
    // Get all PRE BAT results and check both result.course_module_id and question.course_module_id
    const moduleGradeLevels = new Map<number, BatAnswerLevel | null>();
    if (courseWithAssessmentId.assessment_id) {
      const preBatResults = await this.prisma.client.assessmentResult.findMany({
        where: {
          participant_id: participantId,
          course_id: courseId,
          assessment_id: courseWithAssessmentId.assessment_id,
        },
        select: {
          course_module_id: true,
          grade_level: true,
          assessmentQuestion: {
            select: {
              course_module_id: true,
            },
          },
        },
      });

      // Group results by module and get the highest grade level for each module
      // Use course_module_id from result if available, otherwise from question
      preBatResults.forEach((result) => {
        const moduleId = result.course_module_id || result.assessmentQuestion?.course_module_id;
        if (moduleId) {
          const currentLevel = moduleGradeLevels.get(moduleId);
          if (
            !currentLevel ||
            (result.grade_level && this.compareGradeLevels(result.grade_level, currentLevel) > 0)
          ) {
            moduleGradeLevels.set(moduleId, result.grade_level);
          }
        }
      });
    }

    // Map modules with progress
    const modulesWithProgress = await Promise.all(
      modules.map(async (module) => {
        const moduleProgress = module.eLearningParticipantCourseModules[0];
        const totalPages = module.courseModulePages.length;

        let pagesCompleted = 0;
        if (moduleProgress) {
          // Get completed pages count
          const completedPages = await this.prisma.client.eLearningCourseModulePageProgress.count({
            where: {
              participant_id: participantId,
              course_id: courseId,
              course_module_page_id: {
                in: module.courseModulePages.map((p) => p.id),
              },
              complete_date: {
                not: null,
              },
            },
          });
          pagesCompleted = completedPages;
        }

        // Safely convert Decimal to number
        let progressPercentage = 0;
        if (moduleProgress?.progress_percentage) {
          try {
            // Use toNumber() method for Prisma Decimal
            const decimalValue = moduleProgress.progress_percentage;
            if (decimalValue instanceof Prisma.Decimal) {
              progressPercentage = decimalValue.toNumber();
            } else if (decimalValue != null) {
              progressPercentage = Number(decimalValue) || 0;
            }
          } catch {
            progressPercentage = 0;
          }
        } else if (totalPages > 0) {
          progressPercentage = (pagesCompleted / totalPages) * 100;
        }

        // Get PRE BAT grade level for this module (for testing)
        const preBatGradeLevel = moduleGradeLevels.get(module.id);

        return {
          id: module.id,
          course_id: courseId, // Use courseId from function parameter
          course_module_code: module.course_module_code,
          title: module.title,
          description: module.description,
          short_description: module.short_description,
          sort_order: module.sort_order,
          progress_percentage: progressPercentage,
          status: moduleProgress?.status || ELearningProgressStatus.NOT_STARTED,
          pages_completed: pagesCompleted,
          total_pages: totalPages,
          pre_bat_grade_level: preBatGradeLevel || null, // For testing
        };
      }),
    );

    return bnestPlainToDtoArray(modulesWithProgress, CourseModuleDto);
  }

  /**
   * Get e-Learning content
   */
  async getCourseModulePages(
    learningGroupParticipantId: number,
    courseModuleId: number,
    participantId: number,
  ): Promise<CourseModulePageDto[]> {
    // Get enrollment using LearningGroupParticipant ID
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      throw new ForbiddenException(
        "This course license has been cancelled. You no longer have access to this course.",
      );
    }

    await this.subscriptionCourseAccess.assertAllowsElearningRead(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Type assertion to access course_id (field exists in schema but Prisma client not regenerated yet)
    const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id: number };
    const courseId = enrollmentWithCourseId.course_id;

    // Check if the module exists and is published
    // CourseModule no longer has course_id - verify through CourseModulePage
    const courseModulePage = await this.prisma.client.courseModulePage.findFirst({
      where: {
        course_module_id: courseModuleId,
        course_id: courseId,
        courseModule: {
          is_published: true,
        },
      },
      select: {
        courseModule: true,
      },
    });

    const module = courseModulePage?.courseModule;

    if (!module) {
      throw new NotFoundException("Course module not found");
    }

    // Note: exclude_from_bat is only used for BAT assessments (PRE and POST BAT)
    // All modules and their pages are accessible in e-learning regardless of exclude_from_bat value

    // Get pages
    const pages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: courseId,
        course_module_id: courseModuleId,
        is_published: true,
      },
      orderBy: {
        sort_order: "asc",
      },
      include: {
        courseModulePageProgress: {
          where: {
            participant_id: participantId,
            course_id: courseId,
          },
        },
        knowledgeReview: {
          include: {
            knowledgeReviewQuestions: {
              include: {
                knowledgeReviewAnswers: {
                  orderBy: {
                    sort_order: "asc",
                  },
                },
              },
              orderBy: {
                sort_order: "asc",
              },
            },
          },
        },
      },
    });

    // Get all media for these pages and filter by type
    const pageIds = pages.map((p) => p.id);
    const allMedia = await this.prisma.client.media.findMany({
      where: {
        course_module_page_id: {
          in: pageIds,
        },
      },
    });

    // Check trial package limit
    const totalPagesInCourse = pages.length;
    const trialLimitInfo = await this.checkTrialPageLimit(participantId, courseId, totalPagesInCourse);

    // For trial packages, determine which pages to lock
    // Pages that have been accessed should always be accessible
    // For unaccessed pages, we allow up to the limit
    let unaccessedPagesAllowed = 0;
    if (trialLimitInfo?.isTrialPackage) {
      // Calculate how many unaccessed pages we can still allow
      const accessedPages = trialLimitInfo.accessedPages;
      unaccessedPagesAllowed = Math.max(0, trialLimitInfo.maxAllowedPages - accessedPages);
    }

    // Map pages with completion status and filter media by type
    const pagesWithProgress = await Promise.all(
      pages.map(async (page) => {
        const progress = page.courseModulePageProgress[0];

        // For trial packages, check if this page exceeds the limit
        let isLocked = false;
        if (trialLimitInfo?.isTrialPackage) {
          // Check if this page has been accessed (viewed) before
          const hasBeenAccessed = !!progress?.start_date;
          // If page has been accessed, always allow it
          if (!hasBeenAccessed) {
            // If not accessed, check if we've reached the limit for unaccessed pages
            if (unaccessedPagesAllowed <= 0) {
              isLocked = true;
            } else {
              // This page can be accessed, decrement counter
              unaccessedPagesAllowed--;
            }
          }
        }

        // Filter media by type and name for this page
        const pageMedia = allMedia.filter((m) => m.course_module_page_id === page.id);

        const mediaPhotos = pageMedia.filter(
          (m) => m.media_type === MediaType.IMAGE && m.media_name === MediaName.COURSE_MODULE_PAGE__PHOTO,
        );
        const mediaVideos = pageMedia.filter(
          (m) => m.media_type === MediaType.VIDEO && m.media_name === MediaName.COURSE_MODULE_PAGE__VIDEO,
        );
        const mediaDocuments = pageMedia.filter(
          (m) => m.media_type === MediaType.DOCUMENT && m.media_name === MediaName.COURSE_MODULE_PAGE__DOCUMENT,
        );

        // Process media arrays with S3 URLs
        if (mediaPhotos.length > 0) {
          await this.mediaUtilService.addS3UrlPrefixToMediaArray(mediaPhotos);
        }
        if (mediaVideos.length > 0) {
          await this.mediaUtilService.addS3UrlPrefixToMediaArray(mediaVideos);
          this.mediaUtilService.addBunnyStreamEmbedUrlToVideoMediaArray(mediaVideos);
        }
        if (mediaDocuments.length > 0) {
          await this.mediaUtilService.addS3UrlPrefixToMediaArray(mediaDocuments);
        }

        // Build knowledge review data if content_type is QUIZ or ASSIGNMENT
        let knowledgeReview = null;
        const isQuizOrAssignment =
          page.content_type === ContentType.QUIZ || page.content_type === ("ASSIGNMENT" as ContentType);
        if (isQuizOrAssignment && page.knowledgeReview) {
          knowledgeReview = {
            id: page.knowledgeReview.id,
            title: page.knowledgeReview.title,
            description: page.knowledgeReview.description,
            questions: page.knowledgeReview.knowledgeReviewQuestions.map((q) => {
              // Randomize answers for each question
              const randomizedAnswers = this.shuffleArray(q.knowledgeReviewAnswers);
              return {
                id: q.id,
                question_text: q.question_text,
                question_type: q.question_type,
                sort_order: q.sort_order,
                knowledgeReviewAnswers: randomizedAnswers.map((a) => ({
                  id: a.id,
                  answer_text: a.answer_text,
                  is_correct: a.is_correct,
                  sort_order: a.sort_order,
                })),
              };
            }),
          };
        }

        return {
          id: page.id,
          course_module_id: page.course_module_id,
          course_id: page.course_id,
          title: page.title,
          outline: page.outline,
          topic_objectives: page.topic_objectives,
          top_tips: page.top_tips,
          content_type: page.content_type,
          knowledge_review_id: page.knowledge_review_id,
          sort_order: page.sort_order,
          time_frame: page.time_frame,
          quiz_difficulty_level: page.quiz_difficulty_level ?? null,
          difficulty_level: page.difficulty_level ?? null,
          is_completed: !!progress?.complete_date,
          is_locked: isLocked,
          module_title: module.title,
          module_sort_order: module.sort_order,
          mediaPhotos: mediaPhotos.map((item) => this.mapEnrollmentMediaItemForClient(item)),
          mediaVideos: mediaVideos.map((item) => this.mapEnrollmentMediaItemForClient(item)),
          mediaDocuments: mediaDocuments.map((item) => this.mapEnrollmentMediaItemForClient(item)),
          knowledgeReview,
        };
      }),
    );

    // For trial packages, show all pages but mark locked ones
    // Don't filter out locked pages - show them all so users can see what's locked
    // Frontend will handle displaying locked state and preventing access
    // Do not use excludeExtraneousValues here: nested `mediaPhotos`/`mediaVideos` are plain objects
    // without @Expose metadata and would be stripped to empty arrays/objects.
    return plainToInstance(CourseModulePageDto, pagesWithProgress, { enableImplicitConversion: true });
  }

  /**
   * Get all course pages across all modules (continuous flow)
   */
  async getAllCoursePages(
    learningGroupParticipantId: number,
    participantId: number,
  ): Promise<CourseModulePageDto[]> {
    // Get enrollment using LearningGroupParticipant ID
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: {
          include: {
            course: true,
          },
        },
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      throw new ForbiddenException(
        "This course license has been cancelled. You no longer have access to this course.",
      );
    }

    await this.subscriptionCourseAccess.assertAllowsElearningRead(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Use course_id from enrollment or fallback to learningGroup.course_id
    const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id?: number };
    const courseId = enrollmentWithCourseId.course_id ?? enrollment.learningGroup.course_id;

    // Get module assignment rule from learning group
    const learningGroupWithRule = enrollment.learningGroup as typeof enrollment.learningGroup & {
      module_assignment_rule?: "ALL_MODULES" | "RED_AMBER_ONLY" | "RECOMMENDED";
    };
    const moduleAssignmentRule = learningGroupWithRule.module_assignment_rule || "ALL_MODULES";

    // Get filtered module IDs based on module assignment rule
    const courseWithAssessmentId = enrollment.learningGroup.course as typeof enrollment.learningGroup.course & {
      assessment_id: number | null;
    };
    const filteredModuleIds = await this.getFilteredModuleIds(
      courseId,
      participantId,
      moduleAssignmentRule,
      courseWithAssessmentId.assessment_id || null,
    );

    // Get all course module pages to calculate earliest sort_order per module
    const allCourseModulePages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: courseId,
        courseModule: {
          is_published: true,
          id: {
            in: filteredModuleIds, // Filter by module assignment rule
          },
        },
        is_published: true,
      },
      select: {
        course_module_id: true,
        sort_order: true,
      },
    });

    // Calculate earliest page sort_order for each module
    const moduleEarliestPageSortOrder = new Map<number, number>();
    allCourseModulePages.forEach((page) => {
      const current = moduleEarliestPageSortOrder.get(page.course_module_id);
      if (current === undefined || page.sort_order < current) {
        moduleEarliestPageSortOrder.set(page.course_module_id, page.sort_order);
      }
    });

    // Get all published modules for e-learning
    // exclude_from_bat is only used for BAT assessments, all modules are included in e-learning
    // CourseModule no longer has course_id - find modules through CourseModulePage
    const courseModulePages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: courseId,
        courseModule: {
          is_published: true,
          id: {
            in: filteredModuleIds, // Filter by module assignment rule
          },
        },
      },
      select: {
        courseModule: true,
      },
    });

    // Extract unique modules and sort by earliest page sort_order
    const modules = courseModulePages
      .map((page) => page.courseModule)
      .filter((module, index, self) => self.findIndex((m) => m.id === module.id) === index)
      .map((module) => {
        // Get the earliest page sort_order for this module
        const earliestPageSortOrder = moduleEarliestPageSortOrder.get(module.id) || 0;
        return {
          ...module,
          earliest_page_sort_order: earliestPageSortOrder,
        };
      })
      .sort((a, b) => (a.earliest_page_sort_order || 0) - (b.earliest_page_sort_order || 0));

    // All modules are included in e-learning, no need to fetch excluded modules separately
    const allModuleIdsForPages = modules.map((m) => m.id);

    // Get PRE BAT grade levels for each module (for testing display)
    // Get all PRE BAT results and check both result.course_module_id and question.course_module_id
    const moduleGradeLevels = new Map<number, BatAnswerLevel | null>();
    if (courseWithAssessmentId.assessment_id) {
      const preBatResults = await this.prisma.client.assessmentResult.findMany({
        where: {
          participant_id: participantId,
          course_id: courseId,
          assessment_id: courseWithAssessmentId.assessment_id,
        },
        select: {
          course_module_id: true,
          grade_level: true,
          assessmentQuestion: {
            select: {
              course_module_id: true,
            },
          },
        },
      });

      // Group results by module and get the highest grade level for each module
      // Use course_module_id from result if available, otherwise from question
      preBatResults.forEach((result) => {
        const moduleId = result.course_module_id || result.assessmentQuestion?.course_module_id;
        if (moduleId) {
          const currentLevel = moduleGradeLevels.get(moduleId);
          if (
            !currentLevel ||
            (result.grade_level && this.compareGradeLevels(result.grade_level, currentLevel) > 0)
          ) {
            moduleGradeLevels.set(moduleId, result.grade_level);
          }
        }
      });
    }

    // Get all pages across all modules (include pages from both counted and excluded modules)
    // Pages from exclude_from_bat = true modules should be shown but not counted in progress
    if (allModuleIdsForPages.length === 0) {
      return [];
    }

    const pages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: courseId,
        course_module_id: {
          in: allModuleIdsForPages, // Include pages from all modules (counted + excluded)
        },
        is_published: true,
      },
      orderBy: [
        {
          courseModule: {
            sort_order: "asc",
          },
        },
        {
          sort_order: "asc",
        },
      ],
      include: {
        courseModule: true,
        courseModulePageProgress: {
          where: {
            participant_id: participantId,
            course_id: courseId,
          },
        },
        knowledgeReview: {
          include: {
            knowledgeReviewQuestions: {
              include: {
                knowledgeReviewAnswers: {
                  orderBy: {
                    sort_order: "asc",
                  },
                },
              },
              orderBy: {
                sort_order: "asc",
              },
            },
          },
        },
      },
    });

    // Get all media for these pages and filter by type
    const pageIds = pages.map((p) => p.id);
    const allMedia = await this.prisma.client.media.findMany({
      where: {
        course_module_page_id: {
          in: pageIds,
        },
      },
    });

    // Check trial package limit
    const totalPagesInCourse = pages.length;
    const trialLimitInfo = await this.checkTrialPageLimit(participantId, courseId, totalPagesInCourse);

    // For trial packages, determine which pages to lock
    // Pages that have been accessed should always be accessible
    // For unaccessed pages, we allow up to the limit
    let unaccessedPagesAllowed = 0;
    if (trialLimitInfo?.isTrialPackage) {
      // Calculate how many unaccessed pages we can still allow
      const accessedPages = trialLimitInfo.accessedPages;
      unaccessedPagesAllowed = Math.max(0, trialLimitInfo.maxAllowedPages - accessedPages);
    }

    // Map pages with completion status and module info, then sort
    const pagesWithProgress = (
      await Promise.all(
        pages.map(async (page) => {
          const progress = page.courseModulePageProgress[0];

          // For trial packages, check if this page exceeds the limit
          let isLocked = false;
          if (trialLimitInfo?.isTrialPackage) {
            // Check if this page has been accessed (viewed) before
            const hasBeenAccessed = !!progress?.start_date;
            // If page has been accessed, always allow it
            if (!hasBeenAccessed) {
              // If not accessed, check if we've reached the limit for unaccessed pages
              if (unaccessedPagesAllowed <= 0) {
                isLocked = true;
              } else {
                // This page can be accessed, decrement counter
                unaccessedPagesAllowed--;
              }
            }
          }

          // Filter media by type and name for this page
          const pageMedia = allMedia.filter((m) => m.course_module_page_id === page.id);

          const mediaPhotos = pageMedia.filter(
            (m) => m.media_type === MediaType.IMAGE && m.media_name === MediaName.COURSE_MODULE_PAGE__PHOTO,
          );
          const mediaVideos = pageMedia.filter(
            (m) => m.media_type === MediaType.VIDEO && m.media_name === MediaName.COURSE_MODULE_PAGE__VIDEO,
          );
          const mediaDocuments = pageMedia.filter(
            (m) => m.media_type === MediaType.DOCUMENT && m.media_name === MediaName.COURSE_MODULE_PAGE__DOCUMENT,
          );

          // Process media arrays with S3 URLs
          if (mediaPhotos.length > 0) {
            await this.mediaUtilService.addS3UrlPrefixToMediaArray(mediaPhotos);
          }
          if (mediaVideos.length > 0) {
            await this.mediaUtilService.addS3UrlPrefixToMediaArray(mediaVideos);
            this.mediaUtilService.addBunnyStreamEmbedUrlToVideoMediaArray(mediaVideos);
          }
          if (mediaDocuments.length > 0) {
            await this.mediaUtilService.addS3UrlPrefixToMediaArray(mediaDocuments);
          }

          // Get PRE BAT grade level for this page's module (for testing)
          const preBatGradeLevel = moduleGradeLevels.get(page.course_module_id);

          // Build knowledge review data if content_type is QUIZ or ASSIGNMENT
          let knowledgeReview = null;
          const isQuizOrAssignment =
            page.content_type === ContentType.QUIZ || page.content_type === ("ASSIGNMENT" as ContentType);
          if (isQuizOrAssignment && page.knowledgeReview) {
            knowledgeReview = {
              id: page.knowledgeReview.id,
              title: page.knowledgeReview.title,
              description: page.knowledgeReview.description,
              questions: page.knowledgeReview.knowledgeReviewQuestions.map((q) => {
                // Randomize answers for each question
                const randomizedAnswers = this.shuffleArray(q.knowledgeReviewAnswers);
                return {
                  id: q.id,
                  question_text: q.question_text,
                  question_type: q.question_type,
                  sort_order: q.sort_order,
                  knowledgeReviewAnswers: randomizedAnswers.map((a) => ({
                    id: a.id,
                    answer_text: a.answer_text,
                    is_correct: a.is_correct,
                    sort_order: a.sort_order,
                  })),
                };
              }),
            };
          }

          // Find the module's position in the sorted module list
          const moduleIndex = modules.findIndex((m) => m.id === page.course_module_id);
          const moduleSequenceNumber = moduleIndex + 1; // 1-based indexing

          return {
            id: page.id,
            course_module_id: page.course_module_id,
            course_id: page.course_id,
            title: page.title,
            outline: page.outline,
            topic_objectives: page.topic_objectives,
            top_tips: page.top_tips,
            content_type: page.content_type,
            knowledge_review_id: page.knowledge_review_id,
            sort_order: page.sort_order,
            time_frame: page.time_frame,
            quiz_difficulty_level: page.quiz_difficulty_level ?? null,
            difficulty_level: page.difficulty_level ?? null,
            is_completed: !!progress?.complete_date,
            is_locked: isLocked,
            module_title: page.courseModule.title,
            module_sort_order: moduleSequenceNumber,
            pre_bat_grade_level: preBatGradeLevel || null,
            mediaPhotos: mediaPhotos.map((item) => this.mapEnrollmentMediaItemForClient(item)),
            mediaVideos: mediaVideos.map((item) => this.mapEnrollmentMediaItemForClient(item)),
            mediaDocuments: mediaDocuments.map((item) => this.mapEnrollmentMediaItemForClient(item)),
            knowledgeReview,
          };
        }),
      )
    ).sort((a, b) => {
      // First sort by module sort_order, then by page sort_order
      if (a.module_sort_order !== b.module_sort_order) {
        return a.module_sort_order - b.module_sort_order;
      }
      return a.sort_order - b.sort_order;
    });

    // For trial packages, show all pages but mark locked ones
    // Don't filter out locked pages - show them all so users can see what's locked
    // Frontend will handle displaying locked state and preventing access
    // See getCourseModulePages: excludeExtraneousValues breaks nested media item objects.
    return plainToInstance(CourseModulePageDto, pagesWithProgress, { enableImplicitConversion: true });
  }

  /**
   * Track page view (set start_date when page is first viewed)
   */
  async trackPageView(
    learningGroupParticipantId: number,
    _courseModuleId: number,
    pageId: number,
    participantId: number,
  ): Promise<{ success: boolean }> {
    // Get enrollment using LearningGroupParticipant ID
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      throw new ForbiddenException(
        "This course license has been cancelled. You no longer have access to this course.",
      );
    }

    await this.subscriptionCourseAccess.assertAllowsElearningRead(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Type assertion to access course_id
    const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id: number };
    const courseId = enrollmentWithCourseId.course_id;

    // Check trial package limit before allowing page view
    const totalPagesInCourse = await this.prisma.client.courseModulePage.count({
      where: {
        course_id: courseId,
        is_published: true,
      },
    });

    const trialLimitInfo = await this.checkTrialPageLimit(participantId, courseId, totalPagesInCourse);

    // If trial package and limit reached, check if this page has been accessed before
    if (trialLimitInfo?.isTrialPackage && trialLimitInfo.hasReachedLimit) {
      const existingProgress = await this.prisma.client.eLearningCourseModulePageProgress.findUnique({
        where: {
          participant_id_course_id_course_module_page_id: {
            participant_id: participantId,
            course_id: courseId,
            course_module_page_id: pageId,
          },
        },
      });

      // If page hasn't been accessed before and limit is reached, throw error
      if (!existingProgress || !existingProgress.start_date) {
        throw new BadRequestException("trial_limit_reached");
      }
    }

    // Upsert page progress - set start_date if not already set
    const existingProgress = await this.prisma.client.eLearningCourseModulePageProgress.findUnique({
      where: {
        participant_id_course_id_course_module_page_id: {
          participant_id: participantId,
          course_id: courseId,
          course_module_page_id: pageId,
        },
      },
    });

    if (!existingProgress || !existingProgress.start_date) {
      await this.prisma.client.eLearningCourseModulePageProgress.upsert({
        where: {
          participant_id_course_id_course_module_page_id: {
            participant_id: participantId,
            course_id: courseId,
            course_module_page_id: pageId,
          },
        },
        create: {
          participant_id: participantId,
          course_id: courseId,
          course_module_page_id: pageId,
          start_date: new Date(),
        },
        update: {
          start_date: existingProgress?.start_date || new Date(),
        },
      });
    }

    return { success: true };
  }

  /**
   * Mark page as completed (updated to work with all pages)
   */
  async markPageComplete(
    learningGroupParticipantId: number,
    courseModuleId: number,
    pageId: number,
    participantId: number,
  ): Promise<{ success: boolean }> {
    // Get enrollment using LearningGroupParticipant ID
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: true,
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      throw new ForbiddenException(
        "This course license has been cancelled. You can no longer submit progress for this course.",
      );
    }

    await this.subscriptionCourseAccess.assertAllowsContinuingCourseAccess(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Type assertion to access course_id (field exists in schema but Prisma client not regenerated yet)
    const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id: number };
    const courseId = enrollmentWithCourseId.course_id;

    // Check if page has an embedded quiz/assignment - if so, validate it's been answered
    const page = await this.prisma.client.courseModulePage.findUnique({
      where: { id: pageId },
      include: {
        knowledgeReview: true,
      },
    });

    // Video watch validation removed - no minimum watch time requirement

    // Only validate quiz/assignment completion for QUIZ or ASSIGNMENT content types
    const isQuizOrAssignment =
      page?.content_type === ContentType.QUIZ || page?.content_type === ("ASSIGNMENT" as ContentType);
    if (page?.knowledge_review_id && page.knowledgeReview && isQuizOrAssignment) {
      // Page has an embedded quiz/assignment - check if it's been answered
      // Check for submission with the specific page ID (embedded quiz/assignment)
      const quizSubmission = await this.prisma.client.knowledgeReviewParticipant.findFirst({
        where: {
          knowledge_review_id: page.knowledge_review_id,
          participant_id: participantId,
          course_module_page_id: pageId, // Must be for this specific page (embedded quiz/assignment)
        },
      });

      if (!quizSubmission) {
        // Debug: Check what submissions exist for this knowledge review and participant
        const allSubmissions = await this.prisma.client.knowledgeReviewParticipant.findMany({
          where: {
            knowledge_review_id: page.knowledge_review_id,
            participant_id: participantId,
          },
          select: {
            id: true,
            course_module_page_id: true,
            review_completion_date: true,
          },
        });

        // Check if there's any embedded submission (not standalone)
        const embeddedSubmission = allSubmissions.find((s) => s.course_module_page_id !== null);

        if (!embeddedSubmission) {
          const pageType = page.content_type === ("ASSIGNMENT" as ContentType) ? "assignment" : "quiz";
          throw new BadRequestException(
            `Cannot mark page as complete. Please answer the embedded ${pageType} on this page before completing it.`,
          );
        }

        // If there's a submission but not for this specific page, log details for debugging
        console.warn(
          `Warning: Found quiz submission for knowledge review ${page.knowledge_review_id} but not for page ${pageId}.`,
          `Existing submissions:`,
          allSubmissions.map((s) => ({
            id: s.id,
            pageId: s.course_module_page_id,
            date: s.review_completion_date,
          })),
        );

        // For now, allow it to proceed if there's any embedded submission
        // This handles edge cases where the page ID might not match exactly
      }
    }

    // Get existing progress to check if start_date needs to be set
    const existingProgress = await this.prisma.client.eLearningCourseModulePageProgress.findUnique({
      where: {
        participant_id_course_id_course_module_page_id: {
          participant_id: participantId,
          course_id: courseId,
          course_module_page_id: pageId,
        },
      },
    });

    // Upsert page progress - mark as complete (no minimum viewing time required)
    await this.prisma.client.eLearningCourseModulePageProgress.upsert({
      where: {
        participant_id_course_id_course_module_page_id: {
          participant_id: participantId,
          course_id: courseId,
          course_module_page_id: pageId,
        },
      },
      create: {
        participant_id: participantId,
        course_id: courseId,
        course_module_page_id: pageId,
        start_date: new Date(),
        complete_date: new Date(),
      },
      update: {
        complete_date: new Date(),
        // Preserve start_date if it exists, otherwise set it
        start_date: existingProgress?.start_date || new Date(),
      },
    });

    // Update module progress
    await this.updateModuleProgress(courseId, courseModuleId, participantId, enrollment.learning_group_id);

    // Update course progress
    await this.updateCourseProgress(courseId, participantId, enrollment.learning_group_id);

    return { success: true };
  }

  /**
   * Update module-level progress
   */
  private async updateModuleProgress(
    courseId: number,
    courseModuleId: number,
    participantId: number,
    learningGroupId: number,
  ): Promise<void> {
    const module = await this.prisma.client.courseModule.findUnique({
      where: { id: courseModuleId },
      include: {
        courseModulePages: {
          where: { is_published: true },
        },
      },
    });

    if (!module) return;

    const totalPages = module.courseModulePages.length;
    const completedPages = await this.prisma.client.eLearningCourseModulePageProgress.count({
      where: {
        participant_id: participantId,
        course_id: courseId,
        course_module_page_id: {
          in: module.courseModulePages.map((p) => p.id),
        },
        complete_date: {
          not: null,
        },
      },
    });

    const progressPercentage = totalPages > 0 ? (completedPages / totalPages) * 100 : 0;
    const status =
      completedPages === totalPages && totalPages > 0
        ? ELearningProgressStatus.COMPLETED
        : completedPages > 0
          ? ELearningProgressStatus.IN_PROGRESS
          : ELearningProgressStatus.NOT_STARTED;

    await this.prisma.client.eLearningParticipantCourseModule.upsert({
      where: {
        participant_id_course_id_course_module_id: {
          participant_id: participantId,
          course_id: courseId,
          course_module_id: courseModuleId,
        },
      },
      create: {
        participant_id: participantId,
        course_id: courseId,
        course_module_id: courseModuleId,
        learning_group_id: learningGroupId,
        progress_percentage: progressPercentage,
        status: status,
        course_module_pages_completed: completedPages,
        total_course_module_pages: totalPages,
        start_date: completedPages > 0 ? new Date() : null,
        complete_date: completedPages === totalPages && totalPages > 0 ? new Date() : null,
      },
      update: {
        progress_percentage: progressPercentage,
        status: status,
        course_module_pages_completed: completedPages,
        total_course_module_pages: totalPages,
        complete_date: completedPages === totalPages && totalPages > 0 ? new Date() : null,
      },
    });
  }

  /**
   * Update course-level progress
   */
  private async updateCourseProgress(
    courseId: number,
    participantId: number,
    learningGroupId: number,
  ): Promise<void> {
    // Get learning group to access module assignment rule
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
      include: {
        course: true,
      },
    });

    if (!learningGroup) {
      return;
    }

    const learningGroupWithRule = learningGroup as typeof learningGroup & {
      module_assignment_rule?: "ALL_MODULES" | "RED_AMBER_ONLY" | "RECOMMENDED";
    };
    const moduleAssignmentRule = learningGroupWithRule.module_assignment_rule || "ALL_MODULES";

    // Get filtered module IDs based on module assignment rule
    const courseWithAssessmentId = learningGroup.course as typeof learningGroup.course & {
      assessment_id: number | null;
    };
    const filteredModuleIds = await this.getFilteredModuleIds(
      courseId,
      participantId,
      moduleAssignmentRule,
      courseWithAssessmentId.assessment_id || null,
    );

    const totalModules = filteredModuleIds.length;
    // Count only completed modules that are in the filtered list (based on module assignment rule)
    const completedModules = await this.prisma.client.eLearningParticipantCourseModule.count({
      where: {
        participant_id: participantId,
        course_id: courseId,
        course_module_id: {
          in: filteredModuleIds, // Only count modules in the filtered list
        },
        status: ELearningProgressStatus.COMPLETED,
      },
    });

    // Calculate page-level progress for smoother percentage updates
    const totalPages = await this.prisma.client.courseModulePage.count({
      where: {
        course_id: courseId,
        is_published: true,
        courseModule: {
          is_published: true,
          id: {
            in: filteredModuleIds,
          },
        },
      },
    });
    const completedPages = await this.prisma.client.eLearningCourseModulePageProgress.count({
      where: {
        participant_id: participantId,
        course_id: courseId,
        complete_date: {
          not: null,
        },
        courseModulePage: {
          course_id: courseId,
          is_published: true,
          courseModule: {
            id: {
              in: filteredModuleIds,
            },
          },
        },
      },
    });

    const progressPercentage = totalPages > 0 ? (completedPages / totalPages) * 100 : 0;
    const status =
      completedModules === totalModules && totalModules > 0
        ? ELearningProgressStatus.COMPLETED
        : completedModules > 0
          ? ELearningProgressStatus.IN_PROGRESS
          : ELearningProgressStatus.NOT_STARTED;

    // Check if this is the first time starting e-learning (status changing from NOT_STARTED to IN_PROGRESS)
    const existingELearning = await this.prisma.client.eLearningParticipant.findUnique({
      where: {
        participant_id_course_id_learning_group_id: {
          participant_id: participantId,
          course_id: courseId,
          learning_group_id: learningGroupId,
        },
      },
    });

    const isFirstStart = !existingELearning || existingELearning.status === ELearningProgressStatus.NOT_STARTED;
    const shouldSetStartDate =
      isFirstStart && (completedModules > 0 || status === ELearningProgressStatus.IN_PROGRESS);

    const eLearningParticipant = await this.prisma.client.eLearningParticipant.upsert({
      where: {
        participant_id_course_id_learning_group_id: {
          participant_id: participantId,
          course_id: courseId,
          learning_group_id: learningGroupId,
        },
      },
      create: {
        participant_id: participantId,
        course_id: courseId,
        learning_group_id: learningGroupId,
        progress_percentage: progressPercentage,
        status: status,
        course_modules_completed: completedModules,
        total_course_modules: totalModules,
        start_date: completedModules > 0 ? new Date() : null,
        complete_date: status === ELearningProgressStatus.COMPLETED ? new Date() : null,
        last_activity: new Date(),
      },
      update: {
        progress_percentage: progressPercentage,
        status: status,
        course_modules_completed: completedModules,
        total_course_modules: totalModules,
        complete_date: status === ELearningProgressStatus.COMPLETED ? new Date() : null,
        last_activity: new Date(),
      },
    });

    // Log e-learning progress update
    if (existingELearning) {
      // Update operation - log with old and new data
      const changedFields = SystemLogService.calculateChangedFields(
        existingELearning as Record<string, unknown>,
        eLearningParticipant as Record<string, unknown>,
      );

      await this.systemLogService.logUpdate(
        SystemLogEntityType.PARTICIPANT, // Using PARTICIPANT as closest entity type since it's participant progress
        participantId,
        existingELearning as Record<string, unknown>,
        eLearningParticipant as Record<string, unknown>,
        changedFields,
        { company_id: learningGroup.company_id },
      );
    } else {
      // Create operation - log insertion
      await this.systemLogService.logInsert(
        SystemLogEntityType.PARTICIPANT, // Using PARTICIPANT as closest entity type
        participantId,
        eLearningParticipant as Record<string, unknown>,
        { company_id: learningGroup.company_id },
      );
    }

    // Update LearningGroupParticipant completion_percentage based on module progress
    // Calculate: PRE_BAT (25%) + (completedModules/totalModules) * E_LEARNING (40%)
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        participant_id: participantId,
        learning_group_id: learningGroupId,
        course_id: courseId,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                assessment_id: true,
              },
            },
          },
        },
      },
    });

    if (enrollment) {
      // Check if PRE BAT is completed
      let preBatCompleted = false;
      const courseWithAssessmentId = enrollment.learningGroup.course as typeof enrollment.learningGroup.course & {
        assessment_id: number | null;
      };
      if (courseWithAssessmentId.assessment_id) {
        const preBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
          where: {
            assessment_id: courseWithAssessmentId.assessment_id,
            participant_id: participantId,
            assessment_type: "PRE_BAT",
          },
        });
        preBatCompleted = !!preBatParticipant;
      }

      // Calculate completion_percentage incrementally based on module completion
      // PRE_BAT: 25% (if completed) - from COURSE_PROGRESS_CONFIG
      // E-LEARNING: (completedModules / totalModules) * 40% - from COURSE_PROGRESS_CONFIG
      // Example: 4/10 modules completed = (4/10) * 40% = 16% E-LEARNING progress
      // If PRE_BAT completed: 25% + 16% = 41% total
      const preBatProgress = preBatCompleted ? COURSE_PROGRESS_CONFIG.PRE_BAT : 0;
      const eLearningProgress =
        totalPages > 0 ? (completedPages / totalPages) * COURSE_PROGRESS_CONFIG.E_LEARNING : 0;
      const calculatedCompletionPercentage = Math.round(preBatProgress + eLearningProgress);

      // Debug logging
      console.log(
        `[updateCourseProgress] Participant ${participantId}, Course ${courseId}: PRE_BAT=${preBatCompleted} (${preBatProgress}%), Pages=${completedPages}/${totalPages} (${eLearningProgress.toFixed(2)}%), Total=${calculatedCompletionPercentage}%`,
      );

      // Update completion_percentage in LearningGroupParticipant
      // Always update to ensure progress is current (handles cases where PRE BAT completes after modules)
      const currentCompletionPercentage = enrollment.completion_percentage
        ? Number(enrollment.completion_percentage)
        : null;

      if (currentCompletionPercentage === null || currentCompletionPercentage !== calculatedCompletionPercentage) {
        await this.prisma.client.learningGroupParticipant.update({
          where: { id: enrollment.id },
          data: {
            completion_percentage: calculatedCompletionPercentage,
            // Set e_learning_started_at when participant first starts e-learning
            ...(shouldSetStartDate && !enrollment.e_learning_started_at
              ? { e_learning_started_at: new Date() }
              : {}),
          },
        });
      } else if (shouldSetStartDate && !enrollment.e_learning_started_at) {
        // Only update e_learning_started_at if completion_percentage doesn't need updating
        await this.prisma.client.learningGroupParticipant.update({
          where: { id: enrollment.id },
          data: { e_learning_started_at: new Date() },
        });
      }

      // Emit E_LEARNING_COMPLETED event if all modules are completed
      if (status === ELearningProgressStatus.COMPLETED) {
        this.eventEmitter.emit(
          COURSE_PROGRESS_EVENTS.E_LEARNING_COMPLETED,
          new ELearningCompletedEvent(enrollment.id, participantId, courseId),
        );
      }
    }
  }

  /**
   * Get filtered modules based on module assignment rule and PRE BAT assessment results
   * @param courseId - Course ID
   * @param participantId - Participant ID
   * @param moduleAssignmentRule - Module assignment rule (ALL_MODULES or RED_AMBER_ONLY)
   * @param assessmentId - PRE BAT assessment ID (optional)
   * @returns Array of filtered course module IDs
   */
  private async getFilteredModuleIds(
    courseId: number,
    participantId: number,
    moduleAssignmentRule: string,
    assessmentId: number | null,
  ): Promise<number[]> {
    // Get all published modules for e-learning that have at least one course_module_page
    // exclude_from_bat is only used for BAT assessments, not for e-learning filtering
    // Only count modules that have pages (modules without pages are not included in e-learning)
    // Since CourseModule no longer has course_id, filter through CourseModulePage
    const pages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: courseId,
        is_published: true,
      },
      select: {
        course_module_id: true,
      },
      distinct: ["course_module_id"],
    });

    const moduleIds = pages.map((page) => page.course_module_id);

    if (moduleIds.length === 0) {
      return [];
    }

    const allModules = await this.prisma.client.courseModule.findMany({
      where: {
        id: {
          in: moduleIds,
        },
        is_published: true,
        courseModulePages: {
          some: {}, // At least one course_module_page exists
        },
      },
      select: {
        id: true,
      },
    });

    // If rule is ALL_MODULES, return all module IDs
    if (moduleAssignmentRule !== "RED_AMBER_ONLY" || !assessmentId) {
      return allModules.map((m) => m.id);
    }

    // Get PRE BAT assessment results for this participant and course
    // Get all results and check both result.course_module_id and question.course_module_id
    const preBatResults = await this.prisma.client.assessmentResult.findMany({
      where: {
        participant_id: participantId,
        course_id: courseId,
        assessment_id: assessmentId,
      },
      select: {
        course_module_id: true,
        grade_level: true,
        assessmentQuestion: {
          select: {
            course_module_id: true,
          },
        },
      },
    });

    // Group results by module and get the highest grade level for each module
    // Use course_module_id from result if available, otherwise from question
    const moduleGradeLevels = new Map<number, BatAnswerLevel>();
    preBatResults.forEach((result) => {
      const moduleId = result.course_module_id || result.assessmentQuestion?.course_module_id;
      if (moduleId && result.grade_level) {
        const currentLevel = moduleGradeLevels.get(moduleId);
        if (!currentLevel || this.compareGradeLevels(result.grade_level, currentLevel) > 0) {
          moduleGradeLevels.set(moduleId, result.grade_level);
        }
      }
    });

    // Filter modules: only include modules with FOUNDATION (Red) or INTERMEDIATE (Amber)
    // Exclude modules with ADVANCED (Light Green) or EXPERT (Green)
    return allModules
      .filter((module) => {
        const gradeLevel = moduleGradeLevels.get(module.id);
        // If no PRE BAT result for this module, include it (might be a new module)
        if (!gradeLevel) {
          return true;
        }
        // Only include FOUNDATION (Red) or INTERMEDIATE (Amber)
        return gradeLevel === BatAnswerLevel.FOUNDATION || gradeLevel === BatAnswerLevel.INTERMEDIATE;
      })
      .map((m) => m.id);
  }

  /**
   * Compare two grade levels
   * Returns: -1 if level1 < level2, 0 if equal, 1 if level1 > level2
   * Priority: FOUNDATION < INTERMEDIATE < ADVANCED < EXPERT
   */
  private compareGradeLevels(
    level1: BatAnswerLevel | null | undefined,
    level2: BatAnswerLevel | null | undefined,
  ): number {
    if (!level1 && !level2) return 0;
    if (!level1) return -1;
    if (!level2) return 1;

    const order: Record<BatAnswerLevel, number> = {
      [BatAnswerLevel.FOUNDATION]: 1,
      [BatAnswerLevel.INTERMEDIATE]: 2,
      [BatAnswerLevel.ADVANCED]: 3,
      [BatAnswerLevel.EXPERT]: 4,
    };

    const order1 = order[level1] || 0;
    const order2 = order[level2] || 0;

    if (order1 < order2) return -1;
    if (order1 > order2) return 1;
    return 0;
  }

  /**
   * Get page by module and page sequence numbers
   * Returns page data with sequence information
   */
  async getPageBySequence(
    learningGroupParticipantId: number,
    moduleSequence: number,
    pageSequence: number,
    participantId: number,
  ): Promise<CourseModulePageDto & { sequenceInfo: PageSequenceInfoDto }> {
    // Get all pages first
    const allPages = await this.getAllCoursePages(learningGroupParticipantId, participantId);

    if (allPages.length === 0) {
      throw new NotFoundException("No learning content available for this course");
    }

    // Get unique modules sorted by sort_order
    const uniqueModules = Array.from(
      new Map(
        allPages
          .filter((p) => p.module_sort_order !== undefined)
          .map((p) => [p.module_sort_order, { sortOrder: p.module_sort_order, title: p.module_title }]),
      ).values(),
    ).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));

    if (moduleSequence < 1 || moduleSequence > uniqueModules.length) {
      throw new BadRequestException(`Invalid module sequence. Valid range: 1-${uniqueModules.length}`);
    }

    const targetModule = uniqueModules[moduleSequence - 1];
    const targetModuleSortOrder = targetModule.sortOrder;

    // Get pages for target module, sorted by sort_order
    const pagesInModule = allPages
      .filter((p) => p.module_sort_order === targetModuleSortOrder)
      .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));

    if (pageSequence < 1 || pageSequence > pagesInModule.length) {
      throw new BadRequestException(
        `Invalid page sequence for module ${moduleSequence}. Valid range: 1-${pagesInModule.length}`,
      );
    }

    const targetPage = pagesInModule[pageSequence - 1];

    // Build sequence info
    const sequenceInfo: PageSequenceInfoDto = {
      moduleSequence,
      pageSequence,
      moduleTitle: targetModule.title || "",
      totalModules: uniqueModules.length,
      totalPagesInModule: pagesInModule.length,
    };

    return {
      ...targetPage,
      sequenceInfo,
    } as CourseModulePageDto & { sequenceInfo: PageSequenceInfoDto };
  }

  /**
   * Validate if a page can be accessed based on completion of previous pages
   */
  async validatePageAccess(
    learningGroupParticipantId: number,
    moduleSequence: number,
    pageSequence: number,
    participantId: number,
  ): Promise<PageAccessValidationDto> {
    // Get enrollment to get course_id
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId,
      },
    });

    if (!enrollment) {
      return {
        isValid: 0,
        message: "Course enrollment not found or access denied",
      };
    }

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      return {
        isValid: 0,
        message: "This course license has been cancelled. You no longer have access to this course.",
      };
    }

    try {
      await this.subscriptionCourseAccess.assertAllowsElearningRead(
        participantId,
        learningGroupParticipantId,
        enrollment.status,
      );
    } catch (err) {
      if (err instanceof ForbiddenException) {
        return { isValid: 0, message: err.message };
      }
      throw err;
    }

    const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id: number };
    const courseId = enrollmentWithCourseId.course_id;

    // Get all pages (this will already be filtered by trial limit)
    const allPages = await this.getAllCoursePages(learningGroupParticipantId, participantId);

    if (allPages.length === 0) {
      return {
        isValid: 0,
        message: "No learning content available for this course",
      };
    }

    // Check trial package limit before validating sequential access
    const totalPagesInCourse = await this.prisma.client.courseModulePage.count({
      where: {
        course_id: courseId,
        is_published: true,
      },
    });

    const trialLimitInfo = await this.checkTrialPageLimit(participantId, courseId, totalPagesInCourse);

    // If trial package, check if the requested page is locked
    if (trialLimitInfo?.isTrialPackage) {
      // Find the requested page in all pages (including locked ones)
      const requestedPage = allPages.find(
        (p) => p.module_sort_order === moduleSequence && p.sort_order === pageSequence,
      );

      if (!requestedPage) {
        return {
          isValid: 0,
          message: `Page not found. You have reached the limited modules available in your trial. Please upgrade your subscription to access all course content.`,
        };
      }

      // Check if this specific page is locked
      if (requestedPage.is_locked) {
        return {
          isValid: 0,
          message: `You have not allowed to continue. You have reached the limited modules available in your trial. Please upgrade your subscription to access all course content.`,
        };
      }

      // Also check if they've reached the limit and trying to access a new page
      if (trialLimitInfo.hasReachedLimit) {
        // Check if this page has been accessed before
        const pageProgress = await this.prisma.client.eLearningCourseModulePageProgress.findUnique({
          where: {
            participant_id_course_id_course_module_page_id: {
              participant_id: participantId,
              course_id: courseId,
              course_module_page_id: requestedPage.id,
            },
          },
        });

        // If page hasn't been accessed and limit is reached, block access
        if (!pageProgress || !pageProgress.start_date) {
          return {
            isValid: 0,
            message: `You have not allowed to continue. You have reached the limited modules available in your trial. Please upgrade your subscription to access all course content.`,
          };
        }
      }
    }

    // Get unique modules sorted by sort_order
    const uniqueModules = Array.from(
      new Map(
        allPages
          .filter((p) => p.module_sort_order !== undefined)
          .map((p) => [p.module_sort_order, { sortOrder: p.module_sort_order, title: p.module_title }]),
      ).values(),
    ).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));

    if (moduleSequence < 1 || moduleSequence > uniqueModules.length) {
      const firstIncomplete = this.findFirstIncompletePage(allPages);
      return {
        isValid: 0,
        message: `Invalid module sequence. Valid range: 1-${uniqueModules.length}`,
        firstIncompleteModuleSequence: firstIncomplete?.moduleSequence,
        firstIncompletePageSequence: firstIncomplete?.pageSequence,
      };
    }

    const targetModule = uniqueModules[moduleSequence - 1];
    const targetModuleSortOrder = targetModule.sortOrder;

    // Get pages for target module
    const pagesInModule = allPages
      .filter((p) => p.module_sort_order === targetModuleSortOrder)
      .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));

    if (pageSequence < 1 || pageSequence > pagesInModule.length) {
      const firstIncomplete = this.findFirstIncompletePage(allPages);
      return {
        isValid: 0,
        message: `Invalid page sequence for module ${moduleSequence}. Valid range: 1-${pagesInModule.length}`,
        firstIncompleteModuleSequence: firstIncomplete?.moduleSequence,
        firstIncompletePageSequence: firstIncomplete?.pageSequence,
      };
    }

    const targetPage = pagesInModule[pageSequence - 1];
    const targetIndex = allPages.findIndex((p) => p.id === targetPage.id);

    if (targetIndex === -1) {
      const firstIncomplete = this.findFirstIncompletePage(allPages);
      return {
        isValid: 0,
        message: "The requested page could not be found",
        firstIncompleteModuleSequence: firstIncomplete?.moduleSequence,
        firstIncompletePageSequence: firstIncomplete?.pageSequence,
      };
    }

    // Check if all accessible pages are completed - if yes, allow access to any accessible page for review
    // For trial packages, exclude locked pages from "all completed" check
    const accessiblePages = trialLimitInfo?.isTrialPackage ? allPages.filter((p) => !p.is_locked) : allPages;
    const allAccessiblePagesCompleted = accessiblePages.length > 0 && accessiblePages.every((p) => p.is_completed);

    // If all accessible pages are completed, allow access to any accessible page for review
    if (allAccessiblePagesCompleted) {
      return {
        isValid: 1,
      };
    }

    // Check all pages before target index (only enforce sequential access if not all accessible pages completed)
    // For trial packages, allow skipping locked pages in sequential check
    for (let i = 0; i < targetIndex; i++) {
      const page = allPages[i];

      // Skip locked pages in sequential check (they can't be completed)
      if (trialLimitInfo?.isTrialPackage && page.is_locked) {
        continue;
      }

      if (!page.is_completed) {
        const blockingInfo = this.getSequenceInfoForPage(allPages, i, uniqueModules);
        const firstIncomplete = this.findFirstIncompletePage(accessiblePages);

        let message = "You need to complete all previous modules and pages before accessing this content.";
        if (blockingInfo.moduleSequence !== moduleSequence) {
          message += ` You're trying to access Module ${moduleSequence}, but you haven't completed Module ${blockingInfo.moduleSequence} yet.`;
        } else {
          message += ` You're trying to access Module ${moduleSequence}, Page ${pageSequence}, but you haven't completed Page ${blockingInfo.pageSequence} yet.`;
        }

        return {
          isValid: 0,
          message,
          firstIncompleteModuleSequence: firstIncomplete?.moduleSequence,
          firstIncompletePageSequence: firstIncomplete?.pageSequence,
          blockingModuleTitle: blockingInfo.moduleTitle,
          blockingModuleSequence: blockingInfo.moduleSequence,
          blockingPageSequence: blockingInfo.pageSequence,
        };
      }
    }

    return {
      isValid: 1,
    };
  }

  /**
   * Get first incomplete page URL information
   */
  async getFirstIncompletePage(
    learningGroupParticipantId: number,
    participantId: number,
  ): Promise<FirstIncompletePageDto> {
    const allPages = await this.getAllCoursePages(learningGroupParticipantId, participantId);

    if (allPages.length === 0) {
      throw new NotFoundException("No learning content available for this course");
    }

    const firstIncomplete = this.findFirstIncompletePage(allPages);

    if (!firstIncomplete) {
      // All pages completed - return first page for review (better UX than last page)
      const uniqueModules = Array.from(
        new Map(
          allPages
            .filter((p) => p.module_sort_order !== undefined)
            .map((p) => [p.module_sort_order, { sortOrder: p.module_sort_order, title: p.module_title }]),
        ).values(),
      ).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));

      const firstModule = uniqueModules[0];
      const pagesInFirstModule = allPages
        .filter((p) => p.module_sort_order === firstModule.sortOrder)
        .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));

      const firstPage = pagesInFirstModule[0];
      const firstPageInfo = this.getSequenceInfoForPage(
        allPages,
        allPages.findIndex((p) => p.id === firstPage.id),
        uniqueModules,
      );

      return {
        moduleSequence: firstPageInfo.moduleSequence,
        pageSequence: firstPageInfo.pageSequence,
        pageId: firstPage.id,
        url: `/portal/my-courses/${learningGroupParticipantId}/learning/${firstPage.id}`,
      };
    }

    // Find the page object for the first incomplete page
    const firstIncompletePage = allPages.find((p) => !p.is_completed);

    return {
      moduleSequence: firstIncomplete.moduleSequence,
      pageSequence: firstIncomplete.pageSequence,
      pageId: firstIncompletePage?.id || allPages[0]?.id || 0,
      url: `/portal/my-courses/${learningGroupParticipantId}/learning/${firstIncompletePage?.id || allPages[0]?.id || 0}`,
    };
  }

  /**
   * Helper: Find first incomplete page and return its sequence info
   */
  private findFirstIncompletePage(
    allPages: CourseModulePageDto[],
  ): { moduleSequence: number; pageSequence: number } | null {
    const firstIncompleteIndex = allPages.findIndex((p) => !p.is_completed);
    if (firstIncompleteIndex === -1) {
      return null;
    }

    const uniqueModules = Array.from(
      new Map(
        allPages
          .filter((p) => p.module_sort_order !== undefined)
          .map((p) => [p.module_sort_order, { sortOrder: p.module_sort_order, title: p.module_title }]),
      ).values(),
    ).sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));

    return this.getSequenceInfoForPage(allPages, firstIncompleteIndex, uniqueModules);
  }

  /**
   * Helper: Get sequence info for a page at a specific index
   */
  private getSequenceInfoForPage(
    allPages: CourseModulePageDto[],
    pageIndex: number,
    uniqueModules: Array<{ sortOrder?: number; title?: string }>,
  ): { moduleSequence: number; pageSequence: number; moduleTitle: string } {
    const page = allPages[pageIndex];
    if (!page) {
      return { moduleSequence: 0, pageSequence: 0, moduleTitle: "" };
    }

    const currentModuleSortOrder = page.module_sort_order;
    const moduleNumber = uniqueModules.findIndex((m) => m.sortOrder === currentModuleSortOrder) + 1;

    const pagesInCurrentModule = allPages
      .filter((p) => p.module_sort_order === currentModuleSortOrder)
      .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));

    const pageInModule = pagesInCurrentModule.findIndex((p) => p.id === page.id) + 1;
    const moduleTitle = uniqueModules[moduleNumber - 1]?.title || "";

    return {
      moduleSequence: moduleNumber,
      pageSequence: pageInModule,
      moduleTitle,
    };
  }

  /**
   * Helper: Get trial package page limit percentage from config
   * @returns Percentage as number (e.g., 50 for 50%)
   */
  private getTrialPackagePageLimitPercentage(): number {
    return TRIAL_PACKAGE_CONFIG.PAGE_LIMIT_PERCENTAGE;
  }

  /**
   * Helper: Check if participant's company has a trial package
   * @param participantId - Participant ID
   * @returns Object with isTrialPackage flag and package info, or null if not found
   */
  private async checkTrialPackage(participantId: number): Promise<{
    isTrialPackage: boolean;
    packageId: number | null;
  } | null> {
    // Get participant with company
    const participant = await this.prisma.client.participant.findUnique({
      where: { id: participantId },
      select: { company_id: true },
    });

    if (!participant) {
      return null;
    }

    // Get current active subscription for the company
    const subscription = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: participant.company_id,
        is_current: true,
        status: "ACTIVE",
      },
      include: {
        package: true,
      },
    });

    if (!subscription || !subscription.package) {
      return null;
    }

    return {
      isTrialPackage: subscription.package.is_trial_package || false,
      packageId: subscription.package_id,
    };
  }

  /**
   * Helper: Get number of pages accessed (viewed) by participant in a course
   * @param participantId - Participant ID
   * @param courseId - Course ID
   * @returns Number of pages accessed
   */
  private async getAccessedPagesCount(participantId: number, courseId: number): Promise<number> {
    return await this.prisma.client.eLearningCourseModulePageProgress.count({
      where: {
        participant_id: participantId,
        course_id: courseId,
        start_date: {
          not: null, // Page has been viewed (start_date is set)
        },
      },
    });
  }

  /**
   * Helper: Check if participant has reached trial package page limit
   * @param participantId - Participant ID
   * @param courseId - Course ID
   * @param totalPagesInCourse - Total number of pages in the course
   * @returns Object with limit info or null if not a trial package
   */
  private async checkTrialPageLimit(
    participantId: number,
    courseId: number,
    totalPagesInCourse: number,
  ): Promise<{
    isTrialPackage: boolean;
    limitPercentage: number;
    maxAllowedPages: number;
    accessedPages: number;
    hasReachedLimit: boolean;
  } | null> {
    const trialInfo = await this.checkTrialPackage(participantId);
    if (!trialInfo || !trialInfo.isTrialPackage) {
      return null;
    }

    const limitPercentage = this.getTrialPackagePageLimitPercentage();
    const maxAllowedPages = Math.floor((totalPagesInCourse * limitPercentage) / 100);
    const accessedPages = await this.getAccessedPagesCount(participantId, courseId);

    return {
      isTrialPackage: true,
      limitPercentage,
      maxAllowedPages,
      accessedPages,
      hasReachedLimit: accessedPages >= maxAllowedPages,
    };
  }

  /**
   * Track video watch progress (local storage only)
   * Video clicks are now stored locally on the frontend
   */
  async trackVideoProgress(
    learningGroupParticipantId: number,
    _pageId: number,
    _mediaId: number,
    _watchedSeconds: number,
    participantId: number,
  ): Promise<{ success: boolean; requirementMet: boolean }> {
    // Basic validation to ensure the enrollment exists
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId,
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    if (enrollment.cancelled) {
      throw new ForbiddenException("This course license has been cancelled.");
    }

    await this.subscriptionCourseAccess.assertAllowsElearningRead(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Since video progress is now stored locally, we just return success
    // The frontend handles video watch tracking and validation
    return {
      success: true,
      requirementMet: true, // Always consider requirement met since local storage handles validation
    };
  }

  /**
   * Get video watch status for all videos on a page (local storage only)
   */
  async getVideoWatchStatus(
    learningGroupParticipantId: number,
    _pageId: number,
    participantId: number,
  ): Promise<{
    allRequirementsMet: boolean;
    videos: Array<{
      mediaId: number;
      watchedSeconds: number;
      requiredSeconds: number;
      requirementMet: boolean;
      remainingSeconds: number;
    }>;
  }> {
    // Basic validation to ensure the enrollment exists
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId,
      },
    });

    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    if (enrollment.cancelled) {
      throw new ForbiddenException("This course license has been cancelled.");
    }

    await this.subscriptionCourseAccess.assertAllowsElearningRead(
      participantId,
      learningGroupParticipantId,
      enrollment.status,
    );

    // Since video progress is now stored locally, return empty status
    // The frontend handles video watch tracking and status
    return {
      allRequirementsMet: true, // Consider all requirements met since local storage handles validation
      videos: [],
    };
  }

}

results matching ""

    No results matching ""