apps/recallassess/recallassess-api/src/api/client/learning/learning.service.ts
Methods |
|
constructor(prisma: BNestPrismaService, mediaUtilService: BNestMediaUtilService, eventEmitter: EventEmitter2, systemLogService: SystemLogService, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
|
||||||||||||||||||
|
Parameters :
|
| Private Async checkTrialPackage | ||||||||
checkTrialPackage(participantId: number)
|
||||||||
|
Helper: Check if participant's company has a trial package
Parameters :
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 :
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 :
Returns :
number
|
| Private findFirstIncompletePage | ||||||
findFirstIncompletePage(allPages: CourseModulePageDto[])
|
||||||
|
Helper: Find first incomplete page and return its sequence info
Parameters :
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 :
Returns :
Promise<number>
Number of pages accessed |
| Async getAllCoursePages |
getAllCoursePages(learningGroupParticipantId: number, participantId: number)
|
|
Get all course pages across all modules (continuous flow)
Returns :
Promise<CourseModulePageDto[]>
|
| Async getCourseModulePages | ||||||||||||
getCourseModulePages(learningGroupParticipantId: number, courseModuleId: number, participantId: number)
|
||||||||||||
|
Get e-Learning content
Parameters :
Returns :
Promise<CourseModulePageDto[]>
|
| Async getCourseModules |
getCourseModules(learningGroupParticipantId: number, participantId: number)
|
|
Get course modules with progress
Returns :
Promise<CourseModuleDto[]>
|
| 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 :
Returns :
Promise<number[]>
Array of filtered course module IDs |
| Async getFirstIncompletePage |
getFirstIncompletePage(learningGroupParticipantId: number, participantId: number)
|
|
Get first incomplete page URL information
Returns :
Promise<FirstIncompletePageDto>
|
| 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 :
Returns :
Promise<unknown>
|
| Private getSequenceInfoForPage | ||||||||||||
getSequenceInfoForPage(allPages: CourseModulePageDto[], pageIndex: number, uniqueModules: Array
|
||||||||||||
|
Helper: Get sequence info for a page at a specific index
Parameters :
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 :
Returns :
Promise<literal type>
|
| Private mapEnrollmentMediaItemForClient | ||||||
mapEnrollmentMediaItemForClient(m: Media)
|
||||||
|
Enrollment page responses: only fields matching CourseModulePageDto media item shape
(full Prisma
Parameters :
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 :
Returns :
Promise<literal type>
|
| Private shuffleArray | ||||||
shuffleArray(array: T[])
|
||||||
Type parameters :
|
||||||
|
Parameters :
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
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 :
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 :
Returns :
Promise<literal type>
|
| Private Async updateCourseProgress | ||||||||||||
updateCourseProgress(courseId: number, participantId: number, learningGroupId: number)
|
||||||||||||
|
Update course-level progress
Parameters :
Returns :
Promise<void>
|
| Private Async updateModuleProgress | |||||||||||||||
updateModuleProgress(courseId: number, courseModuleId: number, participantId: number, learningGroupId: number)
|
|||||||||||||||
|
Update module-level progress
Parameters :
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 :
Returns :
Promise<PageAccessValidationDto>
|
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: [],
};
}
}