apps/recallassess/recallassess-api/src/api/client/my-course/my-course.service.ts
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, mediaUtilService: BNestMediaUtilService, integrationService: IntegrationService, assessmentService: CLAssessmentService, httpService: HttpService, configService: ConfigService, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
|
||||||||||||||||||||||||
|
Parameters :
|
| Private Async enrichMyCourseData | ||||||||||||
enrichMyCourseData(lgp: LearningGroupParticipantWithCourse, participantId: number, companyActiveOverride?: boolean)
|
||||||||||||
|
Enrich learning group participant data with course and progress info for frontend
Parameters :
Returns :
Promise<CLMyCourseDto>
|
| Private Async enrichMyCourseDataSequential | |||||||||||||||
enrichMyCourseDataSequential(lgp: LearningGroupParticipantWithCourse, participantId: number, sequentialPosition: number, companyActive?: boolean)
|
|||||||||||||||
|
Enrich learning group participant data with sequential position
Parameters :
Returns :
Promise<CLMyCourseDto>
|
| Async generatePostBatHtml | ||||||||||||
generatePostBatHtml(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Generate post-BAT HTML with learning recommendations
Parameters :
Returns :
Promise<string>
HTML string |
| Async generatePreBatHtml | ||||||||||||
generatePreBatHtml(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Generate pre-BAT HTML for a course
Parameters :
Returns :
Promise<string>
HTML string |
| Async getMyCourseByCode | ||||||||||||
getMyCourseByCode(participantId: number, courseCode: string)
|
||||||||||||
|
Get a single enrolled course by course_code with sequential validation Returns redirect info if course is not accessible (instead of throwing error)
Parameters :
Returns :
Promise<literal type>
Course with redirect info if not accessible |
| Async getMyCourseById | ||||||||||||
getMyCourseById(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Get a single enrolled course by LearningGroupParticipant ID
Parameters :
Returns :
Promise<CLMyCourseDto | null>
Enrolled course with progress or null |
| Async getMyCourses | ||||||||
getMyCourses(participantId: number)
|
||||||||
|
Get all courses the current participant is enrolled in via learning groups
Parameters :
Returns :
Promise<literal type>
Array of enrolled courses with progress |
| Async getMyCoursesSequential | ||||||||
getMyCoursesSequential(participantId: number)
|
||||||||
|
Get all courses ordered sequentially with accessibility validation Courses are ordered by enrollment date (created_at ASC)
Parameters :
Returns :
Promise<CLMyCourseDto[]>
Array of enrolled courses with sequential position and accessibility |
| Async getPostBatPdfDownloadUrl | ||||||||||||
getPostBatPdfDownloadUrl(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Get post-BAT PDF download URL (presigned S3 URL) Reads the latest CR-generated PDF from S3 (no on-click regeneration)
Parameters :
Returns :
Promise<string>
Presigned S3 URL for PDF download |
| Async getPreBatPdfDownloadUrl | ||||||||||||
getPreBatPdfDownloadUrl(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Get pre-BAT PDF download URL (presigned S3 URL) Reads the latest CR-generated PDF from S3 (no on-click regeneration)
Parameters :
Returns :
Promise<string>
Presigned S3 URL for PDF download |
| Async getResumeCourseInfo | ||||||
getResumeCourseInfo(participantId: number)
|
||||||
|
Get resume course information (last accessed course) Returns the most recent incomplete course with last accessed page
Parameters :
Returns :
Promise<literal type | null>
|
| Async regeneratePostBatAnalysis | ||||||||||||
regeneratePostBatAnalysis(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Regenerate POST-BAT analysis by resending data to CR API
Parameters :
Returns :
Promise<literal type>
Success status and message |
| Async regeneratePreBatAnalysis | ||||||||||||
regeneratePreBatAnalysis(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Regenerate PRE-BAT analysis by resending data to CR API
Parameters :
Returns :
Promise<literal type>
Success status and message |
| Private Readonly craiApiToken |
Type : string
|
| Private Readonly craiApiUrl |
Type : string
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(CLMyCourseService.name)
|
import { IntegrationService } from "@api/integration/integration.service";
import { BNestMediaUtilService, BNestPrismaService } from "@bish-nest/core/services";
import { HttpService } from "@nestjs/axios";
import {
ForbiddenException,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
Course,
CourseModule,
LearningGroup,
LearningGroupParticipant,
Media,
ParticipantLearningProgressStatus,
Prisma,
SysmtemLogOperationType,
SystemLogEntityType,
} from "@prisma/client";
import { Decimal } from "@prisma/client/runtime/library";
import { firstValueFrom } from "rxjs";
import { CLAssessmentService } from "../assessment/assessment.service";
import { ParticipantSubscriptionCourseAccessService } from "../shared/participant-subscription-course-access.service";
import { CLMyCourseDto } from "./dto";
type LearningGroupParticipantWithCourse = LearningGroupParticipant & {
learningGroup: LearningGroup & {
course: Course & {
assessment_id?: number | null;
mediaImages: Media[];
courseModulePages: Array<{
courseModule: Pick<CourseModule, "id" | "title" | "sort_order" | "course_module_code"> | null;
}>;
};
eLearningParticipants: Array<{
participant_id: number;
progress_percentage: Decimal | null;
status: string;
course_modules_completed: number;
total_course_modules: number;
start_date: Date | null;
complete_date: Date | null;
last_activity: Date | null;
}>;
};
};
@Injectable()
export class CLMyCourseService {
private readonly logger = new Logger(CLMyCourseService.name);
private readonly craiApiUrl: string;
private readonly craiApiToken: string;
constructor(
private prisma: BNestPrismaService,
private mediaUtilService: BNestMediaUtilService,
private integrationService: IntegrationService,
private assessmentService: CLAssessmentService,
private httpService: HttpService,
private configService: ConfigService,
private readonly subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService,
) {
this.craiApiUrl =
this.configService.get<string>("CRAI_API_URL") || "https://recallassess-craiapi.pilottest.site";
this.craiApiToken = this.configService.get<string>("CRAI_API_TOKEN") || "";
}
/**
* Get all courses the current participant is enrolled in via learning groups
* @param participantId Current logged-in participant ID
* @returns Array of enrolled courses with progress
*/
async getMyCourses(participantId: number): Promise<{ courses: CLMyCourseDto[]; company_active: boolean }> {
// Allow listing courses even when subscription is expired (courses remain visible)
const companyActive = await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId);
// Fetch all learning group assignments for this participant
const learningGroupParticipants = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: participantId,
},
include: {
learningGroup: {
include: {
course: {
include: {
mediaImages: {
where: {
media_name: "COURSE__IMAGE",
},
},
courseModulePages: {
where: {
courseModule: {
is_published: true,
},
},
select: {
courseModule: {
select: {
id: true,
title: true,
description: true,
short_description: true,
is_published: true,
sort_order: true,
flag: true,
created_at: true,
updated_at: true,
user_id_created_by: true,
user_id_updated_by: true,
course_module_code: true,
exclude_from_bat: true,
},
},
},
},
},
},
// Get progress from eLearningParticipant for this participant and course
eLearningParticipants: {
where: {
participant_id: participantId,
},
},
},
},
},
orderBy: [
{ updated_at: "desc" }, // Recently updated first
{ created_at: "desc" }, // Then by enrollment date
],
});
// Process S3 URLs for media
await Promise.all(
learningGroupParticipants.map(async (lgp) => {
if (lgp.learningGroup?.course?.mediaImages) {
await this.mediaUtilService.addS3UrlPrefixToMediaArray(lgp.learningGroup.course.mediaImages);
}
}),
);
// Transform to DTOs
const courses = await Promise.all(
learningGroupParticipants.map((lgp) =>
this.enrichMyCourseData(lgp as LearningGroupParticipantWithCourse, participantId, companyActive),
),
);
return { courses, company_active: companyActive };
}
/**
* Get a single enrolled course by LearningGroupParticipant ID
* @param participantId Current logged-in participant ID
* @param learningGroupParticipantId LearningGroupParticipant ID
* @returns Enrolled course with progress or null
*/
async getMyCourseById(participantId: number, learningGroupParticipantId: number): Promise<CLMyCourseDto | null> {
const learningGroupParticipant = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
id: learningGroupParticipantId,
participant_id: participantId,
},
include: {
learningGroup: {
include: {
course: {
include: {
mediaImages: {
where: {
media_name: "COURSE__IMAGE",
},
},
courseModulePages: {
where: {
courseModule: {
is_published: true,
},
},
select: {
courseModule: {
select: {
id: true,
title: true,
description: true,
short_description: true,
is_published: true,
sort_order: true,
flag: true,
created_at: true,
updated_at: true,
user_id_created_by: true,
user_id_updated_by: true,
course_module_code: true,
exclude_from_bat: true,
},
},
},
},
},
},
eLearningParticipants: {
where: {
participant_id: participantId,
},
},
},
},
},
});
if (!learningGroupParticipant) {
return null;
}
// Allow opening course detail even when company subscription is inactive.
// Blocking is enforced only when participant tries to continue/start gated course actions.
// Process S3 URLs for media
if (learningGroupParticipant.learningGroup?.course?.mediaImages) {
await this.mediaUtilService.addS3UrlPrefixToMediaArray(learningGroupParticipant.learningGroup.course.mediaImages);
}
return this.enrichMyCourseData(learningGroupParticipant as LearningGroupParticipantWithCourse, participantId);
}
/**
* Enrich learning group participant data with course and progress info for frontend
*/
private async enrichMyCourseData(
lgp: LearningGroupParticipantWithCourse,
participantId: number,
companyActiveOverride?: boolean,
): Promise<CLMyCourseDto> {
const course = lgp.learningGroup?.course;
if (!course) {
throw new Error("Course not found in learning group participant");
}
// Extract unique modules from courseModulePages
const courseModules =
course.courseModulePages
?.map((page) => page.courseModule)
.filter((module, idx, self) => module && self.findIndex((m) => m?.id === module.id) === idx)
.filter((module): module is NonNullable<typeof module> => module !== null) || [];
const moduleCount = courseModules.length;
// For courses with Pre-BAT assessments, count modules that have assessment questions
let assessmentModuleCount = moduleCount;
if (course.assessment_id) {
try {
const assessmentQuestions = await this.prisma.client.assessmentQuestion.findMany({
where: {
assessment_id: course.assessment_id,
},
select: {
course_module_id: true,
},
});
// Count unique modules that have assessment questions
const uniqueModuleIds = [
...new Set(assessmentQuestions.map((q) => q.course_module_id).filter((id) => id !== null)),
];
assessmentModuleCount = uniqueModuleIds.length;
} catch {
// Fallback to regular module count if assessment query fails
assessmentModuleCount = moduleCount;
}
}
// Get progress data from eLearningParticipant (if exists)
const progressData = lgp.learningGroup?.eLearningParticipants?.[0] || null;
// Use category from DB, with fallback
const categories = ["Communication", "Psychology", "Sales", "Negotiation", "Leadership"];
const category = course.category || categories[0];
// Level color mapping
const level_color_map = {
FOUNDATION: "orange",
INTERMEDIATE: "blue",
ADVANCED: "green",
};
const level = course.level || "FOUNDATION";
const level_color = level_color_map[level as keyof typeof level_color_map] || "orange";
// Calculate duration
const calculatedDuration = (() => {
const weeksMin = Math.max(3, Math.floor(moduleCount * 0.4));
const weeksMax = Math.max(4, Math.ceil(moduleCount * 0.7));
return `${weeksMin}-${weeksMax} weeks`;
})();
const duration = course.duration || calculatedDuration;
// Get course image
// If no media exists, frontend will show a gradient placeholder
const image = course.mediaImages && course.mediaImages.length > 0 ? course.mediaImages[0].media_path : "";
// Check if PRE BAT assessment is completed using assessment_id from course
// Type assertion to access assessment_id (field exists but TypeScript types not regenerated yet)
const courseWithAssessmentId = course as typeof course & { assessment_id: number | null };
const courseWithKnowledgeReviewId = course as typeof course & { knowledge_review_id: number | null };
let preBatCompleted = false;
let postBatCompleted = false;
let preBatAnalysisAvailable = false;
let postBatAnalysisAvailable = false;
let knowledgeReviewCompleted = false;
if (courseWithAssessmentId.assessment_id) {
const [preBatParticipant, postBatParticipant] = await Promise.all([
this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: courseWithAssessmentId.assessment_id,
participant_id: participantId,
assessment_type: "PRE_BAT",
},
}),
this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: courseWithAssessmentId.assessment_id,
participant_id: participantId,
assessment_type: "POST_BAT",
},
}),
]);
preBatCompleted = !!preBatParticipant;
postBatCompleted = !!postBatParticipant;
// Check if ai_analysis is available (not null and not empty) for PRE BAT
preBatAnalysisAvailable = !!(
preBatParticipant?.ai_analysis && preBatParticipant.ai_analysis.trim().length > 0
);
// Check if POST BAT analysis is available: either per-topic learning_path (legacy) or post_ai_analysis with overall_summary (new)
if (postBatParticipant) {
const hasLearningPathInResults = await this.prisma.client.assessmentResult.findFirst({
where: {
assessment_participant_id: postBatParticipant.id,
learning_path: {
not: null,
},
},
});
const postAnalysisRaw = (postBatParticipant as { post_ai_analysis?: string | null }).post_ai_analysis;
const hasOverallSummaryInPostAiAnalysis =
postAnalysisRaw &&
postAnalysisRaw.trim().length > 0 &&
(() => {
try {
const parsed = JSON.parse(postAnalysisRaw) as { overall_summary?: string };
return typeof parsed.overall_summary === "string" && parsed.overall_summary.trim().length > 0;
} catch {
return false;
}
})();
postBatAnalysisAvailable = !!(
(hasLearningPathInResults?.learning_path && hasLearningPathInResults.learning_path.trim().length > 0) ||
hasOverallSummaryInPostAiAnalysis
);
}
}
// Check if standalone knowledge review is completed
// Only check for standalone KR (course_module_page_id is NULL), not embedded quizzes
if (courseWithKnowledgeReviewId.knowledge_review_id) {
const knowledgeReviewParticipant = await this.prisma.client.knowledgeReviewParticipant.findFirst({
where: {
knowledge_review_id: courseWithKnowledgeReviewId.knowledge_review_id,
participant_id: participantId,
course_module_page_id: null, // Only check standalone KR, not embedded quizzes
},
});
knowledgeReviewCompleted = !!knowledgeReviewParticipant;
}
// Get module assignment rule from learning group
const learningGroupWithRule = lgp.learningGroup as typeof lgp.learningGroup & {
module_assignment_rule?: string;
};
const moduleAssignmentRule = learningGroupWithRule?.module_assignment_rule || "ALL_MODULES";
// Get skip_hundred_dj from course
const courseWithSkipHundredDj = course as typeof course & { skip_hundred_dj?: boolean };
const skipHundredDj = courseWithSkipHundredDj.skip_hundred_dj ?? false;
// Get 100DJ email dates and knowledge_review_email_date from LearningGroupParticipant
const lgpWithDates = lgp as typeof lgp & {
knowledge_review_email_date?: Date | null;
post_bat_email_date?: Date | null;
hundred_dj_email1_date?: Date | null;
hundred_dj_email2_date?: Date | null;
hundred_dj_email3_date?: Date | null;
hundred_dj_email4_date?: Date | null;
};
const knowledgeReviewEmailDate = lgpWithDates.knowledge_review_email_date || null;
const postBatEmailDate = lgpWithDates.post_bat_email_date || null;
const hundredDjEmail1Date = lgpWithDates.hundred_dj_email1_date || null;
const hundredDjEmail2Date = lgpWithDates.hundred_dj_email2_date || null;
const hundredDjEmail3Date = lgpWithDates.hundred_dj_email3_date || null;
const hundredDjEmail4Date = lgpWithDates.hundred_dj_email4_date || null;
// Derive frontend status with progress-aware fallback
let status = this.mapLearningGroupParticipantStatusToCourseStatus(lgp.status);
const completionPercentage = lgp.completion_percentage ? Number(lgp.completion_percentage) : 0;
const eLearningProgress = progressData ? Number(progressData.progress_percentage) || 0 : 0;
const hasProgress = completionPercentage > 0 || eLearningProgress > 0;
const isInvitedNotAccepted = lgp.status === "INVITED" && !lgp.accepted_at;
if (status === "NOT_STARTED" && hasProgress && !isInvitedNotAccepted) {
status = "IN_PROGRESS";
}
const company_active =
companyActiveOverride !== undefined
? companyActiveOverride
: await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId);
return {
id: lgp.id,
learning_group_participant_id: lgp.id, // Explicit alias for clarity
course_id: course.id,
title: course.title,
category,
level,
level_color,
description:
course.description || course.short_description || "Learn essential skills to advance your career.",
modules: moduleCount,
duration,
image,
course_code: course.course_code,
// Progress tracking from eLearningParticipant (if available) - for e-learning module progress only
progress_percentage: eLearningProgress,
// Overall course completion percentage from LearningGroupParticipant (25%, 50%, 75%, 100%)
// This is the authoritative source for overall progress, set by event listeners
completion_percentage: completionPercentage,
// Map LearningGroupParticipant status to frontend status format
// Use LearningGroupParticipant.status (overall course status) instead of eLearningParticipant.status (e-learning only)
status,
// Include raw status and accepted_at for invitation handling
raw_status: lgp.status,
accepted_at: lgp.accepted_at,
course_modules_completed: progressData?.course_modules_completed || 0,
total_course_modules: progressData?.total_course_modules || assessmentModuleCount,
start_date: progressData?.start_date || null,
complete_date: progressData?.complete_date || null,
last_activity: progressData?.last_activity || null,
learning_group_id: lgp.learning_group_id,
pre_bat_completed: preBatCompleted,
post_bat_completed: postBatCompleted,
pre_bat_analysis_available: preBatAnalysisAvailable,
post_bat_analysis_available: postBatAnalysisAvailable,
knowledge_review_completed: knowledgeReviewCompleted,
module_assignment_rule: moduleAssignmentRule, // For testing
skip_hundred_dj: skipHundredDj,
knowledge_review_email_date: knowledgeReviewEmailDate,
post_bat_email_date: postBatEmailDate,
hundred_dj_email1_date: hundredDjEmail1Date,
hundred_dj_email2_date: hundredDjEmail2Date,
hundred_dj_email3_date: hundredDjEmail3Date,
hundred_dj_email4_date: hundredDjEmail4Date,
cancelled: lgp.cancelled || false,
company_active,
};
}
/**
* Map LearningGroupParticipant status to frontend course status format
* LearningGroupParticipant status: INVITED, PRE_BAT, E_LEARNING, POST_BAT, COMPLETED
* Frontend status: NOT_STARTED, IN_PROGRESS, COMPLETED
*/
private mapLearningGroupParticipantStatusToCourseStatus(
status: string | null | undefined,
): "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" {
if (!status) {
return "NOT_STARTED";
}
// Map to frontend status format
switch (status) {
case "COMPLETED":
return "COMPLETED"; // Only when all stages (PRE BAT, E-Learning, Knowledge Review, POST BAT) are done
// Note: cancelled field is checked separately, not via status enum
case "INVITED":
return "NOT_STARTED";
case "ACCEPTED":
return "NOT_STARTED"; // Accepted but not yet started PRE_BAT
case "PRE_BAT":
case "E_LEARNING":
case "POST_BAT":
return "IN_PROGRESS"; // Course is in progress (e-learning may be done, but course is not fully complete)
default:
return "NOT_STARTED";
}
}
/**
* Generate pre-BAT HTML for a course
* @param participantId Current logged-in participant ID
* @param learningGroupParticipantId LearningGroupParticipant ID
* @returns HTML string
*/
async generatePreBatHtml(participantId: number, learningGroupParticipantId: number): Promise<string> {
// Get the learning group participant to access learning_group_id and course
const learningGroupParticipant = 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 (!learningGroupParticipant) {
throw new NotFoundException(
`Course enrollment with ID ${learningGroupParticipantId} not found or not enrolled`,
);
}
// Check if enrollment is cancelled
if (learningGroupParticipant.cancelled) {
throw new ForbiddenException(
"This course license has been cancelled. You no longer have access to this course.",
);
}
await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
participantId,
learningGroupParticipantId,
!!learningGroupParticipant.pre_bat_completed_at,
learningGroupParticipant.status,
);
const learningGroupId = learningGroupParticipant.learning_group_id;
// Call the integration service to generate the HTML
return this.integrationService.generatePreBatHtml(learningGroupId, participantId);
}
/**
* Get pre-BAT PDF download URL (presigned S3 URL)
* Reads the latest CR-generated PDF from S3 (no on-click regeneration)
* @param participantId Current participant ID
* @param learningGroupParticipantId Learning group participant ID
* @returns Presigned S3 URL for PDF download
*/
async getPreBatPdfDownloadUrl(participantId: number, learningGroupParticipantId: number): Promise<string> {
// Get the learning group participant to access learning_group_id
const learningGroupParticipant = 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 (!learningGroupParticipant) {
throw new NotFoundException(
`Learning group participant with ID ${learningGroupParticipantId} not found or not enrolled`,
);
}
// Check if enrollment is cancelled
if (learningGroupParticipant.cancelled) {
throw new ForbiddenException(
"This course license has been cancelled. You no longer have access to this course.",
);
}
await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
participantId,
learningGroupParticipantId,
!!learningGroupParticipant.pre_bat_completed_at,
learningGroupParticipant.status,
);
const learningGroupId = learningGroupParticipant.learning_group_id;
const course = learningGroupParticipant.learningGroup.course;
if (!course) {
throw new NotFoundException("Course not found for learning group");
}
if (!course.assessment_id) {
throw new NotFoundException("Course does not have an assessment assigned");
}
try {
// Use the latest pre-generated report created when CR data arrived.
const s3Path = await this.integrationService.checkPreBatPdfExists(learningGroupId, participantId);
// Generate presigned URL for download
return await this.mediaUtilService.getS3Url(s3Path, false);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const causeMsg = err.cause instanceof Error ? err.cause.message : err.cause != null ? String(err.cause) : "";
this.logger.error(
`Failed to generate fresh Pre-BAT PDF: ${err.message}${causeMsg ? ` (cause: ${causeMsg})` : ""}`,
err.stack,
);
throw new InternalServerErrorException("Failed to generate PDF. Please try again.");
}
}
/**
* Generate post-BAT HTML with learning recommendations
* @param participantId Current participant ID
* @param learningGroupParticipantId Learning group participant ID
* @returns HTML string
*/
async generatePostBatHtml(participantId: number, learningGroupParticipantId: number): Promise<string> {
// Get the learning group participant to access learning_group_id and course
const learningGroupParticipant = 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 (!learningGroupParticipant) {
throw new NotFoundException(
`Course enrollment with ID ${learningGroupParticipantId} not found or not enrolled`,
);
}
// Check if enrollment is cancelled
if (learningGroupParticipant.cancelled) {
throw new ForbiddenException(
"This course license has been cancelled. You no longer have access to this course.",
);
}
const assessmentId = learningGroupParticipant.learningGroup.course.assessment_id;
const postBatCompleted =
assessmentId != null &&
!!(await this.prisma.client.assessmentParticipant.findFirst({
where: {
participant_id: participantId,
assessment_id: assessmentId,
assessment_type: "POST_BAT",
},
select: { id: true },
}));
await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
participantId,
learningGroupParticipantId,
postBatCompleted,
learningGroupParticipant.status,
);
const learningGroupId = learningGroupParticipant.learning_group_id;
const courseCode = learningGroupParticipant.learningGroup.course.course_code;
// Call the integration service to generate the HTML
return this.integrationService.generatePostBatHtml(learningGroupId, participantId, courseCode);
}
/**
* Get post-BAT PDF download URL (presigned S3 URL)
* Reads the latest CR-generated PDF from S3 (no on-click regeneration)
* @param participantId Current participant ID
* @param learningGroupParticipantId Learning group participant ID
* @returns Presigned S3 URL for PDF download
*/
async getPostBatPdfDownloadUrl(participantId: number, learningGroupParticipantId: number): Promise<string> {
// Get the learning group participant to access learning_group_id
const learningGroupParticipant = 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 (!learningGroupParticipant) {
throw new NotFoundException(
`Learning group participant with ID ${learningGroupParticipantId} not found or not enrolled`,
);
}
// Check if enrollment is cancelled
if (learningGroupParticipant.cancelled) {
throw new ForbiddenException(
"This course license has been cancelled. You no longer have access to this course.",
);
}
const assessmentId = learningGroupParticipant.learningGroup.course.assessment_id;
const postBatCompleted =
assessmentId != null &&
!!(await this.prisma.client.assessmentParticipant.findFirst({
where: {
participant_id: participantId,
assessment_id: assessmentId,
assessment_type: "POST_BAT",
},
select: { id: true },
}));
await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
participantId,
learningGroupParticipantId,
postBatCompleted,
learningGroupParticipant.status,
);
const learningGroupId = learningGroupParticipant.learning_group_id;
const course = learningGroupParticipant.learningGroup.course;
if (!course) {
throw new NotFoundException("Course not found for learning group");
}
if (!course.assessment_id) {
throw new NotFoundException("Course does not have an assessment assigned");
}
try {
// Use the latest pre-generated report created when CR data arrived.
const s3Path = await this.integrationService.checkPostBatPdfExists(learningGroupId, participantId);
// Generate presigned URL for download
return await this.mediaUtilService.getS3Url(s3Path, false);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
const causeMsg = err.cause instanceof Error ? err.cause.message : err.cause != null ? String(err.cause) : "";
this.logger.error(
`Failed to generate POST-BAT PDF: ${err.message}${causeMsg ? ` (cause: ${causeMsg})` : ""}`,
err.stack,
);
throw new NotFoundException(
"POST BAT PDF report could not be generated. Please contact the administrator for assistance.",
);
}
}
/**
* Get all courses ordered sequentially with accessibility validation
* Courses are ordered by enrollment date (created_at ASC)
* @param participantId Current logged-in participant ID
* @returns Array of enrolled courses with sequential position and accessibility
*/
async getMyCoursesSequential(participantId: number): Promise<CLMyCourseDto[]> {
// Allow listing courses even when subscription is expired (courses remain visible)
const companyActive = await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId);
// Fetch all learning group assignments ordered by enrollment date (sequential)
// Include cancelled enrollments so participants can see cancelled courses with cancelled tag
const learningGroupParticipants = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: participantId,
// Include cancelled courses - they will be marked with cancelled tag
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
title: true,
category: true,
level: true,
description: true,
duration: true,
course_code: true,
assessment_id: true,
},
include: {
mediaImages: {
where: {
media_name: "COURSE__IMAGE",
},
},
courseModulePages: {
where: {
courseModule: {
is_published: true,
},
},
select: {
courseModule: {
select: {
id: true,
course_module_code: true,
title: true,
sort_order: true,
},
},
},
},
},
},
eLearningParticipants: {
where: {
participant_id: participantId,
},
},
},
},
},
orderBy: [
{ created_at: "asc" }, // Sequential order - oldest enrollment first
],
});
// Process S3 URLs for media
await Promise.all(
learningGroupParticipants.map(async (lgp) => {
if (lgp.learningGroup?.course?.mediaImages) {
await this.mediaUtilService.addS3UrlPrefixToMediaArray(lgp.learningGroup.course.mediaImages);
}
}),
);
// Transform to DTOs with sequential position
const courses = await Promise.all(
learningGroupParticipants.map((lgp, index) =>
this.enrichMyCourseDataSequential(
lgp as LearningGroupParticipantWithCourse,
participantId,
index + 1, // Position starts at 1
companyActive,
),
),
);
// Check accessibility: a course is accessible if all previous courses are completed
return courses.map((course, index) => {
const previousCourses = courses.slice(0, index);
const allPreviousCompleted = previousCourses.every((prevCourse) => prevCourse.status === "COMPLETED");
return {
...course,
is_accessible: index === 0 || allPreviousCompleted, // First course is always accessible
};
});
}
/**
* Get a single enrolled course by course_code with sequential validation
* Returns redirect info if course is not accessible (instead of throwing error)
* @param participantId Current logged-in participant ID
* @param courseCode Course code (e.g., "communication-skills-101")
* @returns Course with redirect info if not accessible
*/
async getMyCourseByCode(
participantId: number,
courseCode: string,
): Promise<{
course: CLMyCourseDto;
is_accessible: boolean;
requires_redirect?: boolean;
redirect_to_course?: CLMyCourseDto;
message?: string;
}> {
// Get all courses sequentially ordered
const allCourses = await this.getMyCoursesSequential(participantId);
// Find the requested course
const course = allCourses.find((c) => c.course_code === courseCode);
if (!course) {
throw new NotFoundException(`Course with code "${courseCode}" not found or not enrolled`);
}
// Check if accessible
if (!course.is_accessible) {
// Find the first incomplete course (the one they should complete)
const courseIndex = allCourses.findIndex((c) => c.course_code === courseCode);
const firstIncompleteCourse = allCourses.slice(0, courseIndex).find((c) => c.status !== "COMPLETED");
return {
course,
is_accessible: false,
requires_redirect: true,
redirect_to_course: firstIncompleteCourse,
message: `Complete "${firstIncompleteCourse?.title || "the previous course"}" first to unlock this course. We've taken you there! 🎯`,
};
}
return {
course,
is_accessible: true,
requires_redirect: false,
};
}
/**
* Enrich learning group participant data with sequential position
*/
private async enrichMyCourseDataSequential(
lgp: LearningGroupParticipantWithCourse,
participantId: number,
sequentialPosition: number,
companyActive?: boolean,
): Promise<CLMyCourseDto> {
const enrichedData = await this.enrichMyCourseData(lgp, participantId, companyActive);
return {
...enrichedData,
sequential_position: sequentialPosition,
};
}
/**
* Get resume course information (last accessed course)
* Returns the most recent incomplete course with last accessed page
*/
async getResumeCourseInfo(participantId: number): Promise<{
course: CLMyCourseDto;
lastPageId: number;
lastPageTitle: string;
progress: number;
daysSinceLastActivity: number;
} | null> {
if (!(await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId))) {
return null;
}
// Find most recent incomplete course with activity
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
participant_id: participantId,
cancelled: false,
status: { not: ParticipantLearningProgressStatus.COMPLETED },
},
orderBy: { updated_at: "desc" },
include: {
learningGroup: {
include: {
course: {
include: {
mediaImages: {
where: {
media_name: "COURSE__IMAGE",
},
},
courseModulePages: {
where: {
courseModule: {
is_published: true,
},
},
select: {
courseModule: {
select: {
id: true,
course_module_code: true,
title: true,
sort_order: true,
},
},
},
},
},
},
eLearningParticipants: {
where: {
participant_id: participantId,
},
},
},
},
},
});
if (!enrollment) {
return null;
}
// Process S3 URLs for media
if (enrollment.learningGroup?.course?.mediaImages) {
await this.mediaUtilService.addS3UrlPrefixToMediaArray(enrollment.learningGroup.course.mediaImages);
}
// Get last accessed page - query by course_id and participant_id only
const lastPage = await this.prisma.client.eLearningCourseModulePageProgress.findFirst({
where: {
participant_id: participantId,
course_id: enrollment.learningGroup?.course?.id || 0,
},
orderBy: { updated_at: "desc" },
include: {
courseModulePage: {
select: {
id: true,
title: true,
},
},
},
});
const enrichedCourse = await this.enrichMyCourseData(
enrollment as LearningGroupParticipantWithCourse,
participantId,
true,
);
const progress = Number(enrollment.completion_percentage) || 0;
// Use same source as course detail page (elearning_participant.last_activity) so dashboard and course detail show same "last activity"
const eLearningParticipant = (
enrollment.learningGroup as { eLearningParticipants?: Array<{ last_activity: Date | null }> }
)?.eLearningParticipants?.[0];
const lastActivityDate = eLearningParticipant?.last_activity ?? lastPage?.updated_at ?? null;
const daysSinceLastActivity = lastActivityDate
? Math.floor((Date.now() - lastActivityDate.getTime()) / (1000 * 60 * 60 * 24))
: 0;
return {
course: enrichedCourse,
lastPageId: lastPage?.courseModulePage?.id || 0,
lastPageTitle: lastPage?.courseModulePage?.title || "Start Course",
progress,
daysSinceLastActivity,
};
}
/**
* Regenerate PRE-BAT analysis by resending data to CR API
* @param participantId Current logged-in participant ID
* @param learningGroupParticipantId Learning group participant ID
* @returns Success status and message
*/
async regeneratePreBatAnalysis(
participantId: number,
learningGroupParticipantId: number,
): Promise<{ success: boolean; message: string }> {
// Verify enrollment exists and belongs to participant
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
id: learningGroupParticipantId,
participant_id: participantId,
},
include: {
learningGroup: {
include: {
course: true,
},
},
},
});
if (!enrollment) {
throw new NotFoundException("Course enrollment not found");
}
await this.subscriptionCourseAccess.assertCompanySubscriptionActive(participantId);
if (!enrollment.pre_bat_completed_at) {
throw new ForbiddenException("PRE-BAT assessment must be completed before regenerating analysis");
}
let systemLogId: number | null = null;
let requestStartTime = 0;
try {
// Get learning group ID and participant ID for the assessment service
const learningGroupId = enrollment.learning_group_id;
// Format PRE/POST BAT data then strip it down to PRE-BAT only
const fullBatData = await this.assessmentService.formatPrePostBatDataForIntegration(
learningGroupId,
participantId,
);
// Ensure only PRE-BAT data is sent (even if POST-BAT exists)
const batData = {
...fullBatData,
metadata: {
...fullBatData.metadata,
plj_position: "PRE_BAT" as const,
},
data: fullBatData.data.map((participant) => {
const modules: Record<
string,
{
PRE_BAT: (typeof participant.modules)[string]["PRE_BAT"];
POST_BAT: (typeof participant.modules)[string]["POST_BAT"];
}
> = {};
Object.entries(participant.modules).forEach(([code, moduleData]) => {
modules[code] = {
...moduleData,
// Keep PRE_BAT answers, clear POST_BAT so CR only sees PRE data
PRE_BAT: moduleData.PRE_BAT,
POST_BAT: [],
};
});
return {
...participant,
// Keep PRE quotient, clear POST/change to avoid confusion
metadata: {
...participant.metadata,
individual_quotient_post: 0,
individual_quotient_change: 0,
},
modules,
};
}),
};
// POST to CRAI API (same as in the listener)
const apiUrl = `${this.craiApiUrl}/generate-feedback`;
const headers: { "Content-Type": string; Authorization?: string } = {
"Content-Type": "application/json",
};
// Create system log row for outbound audit, then update with status_code after the HTTP call.
try {
const createdLog = await this.prisma.client.systemLog.create({
data: {
entity_type: SystemLogEntityType.LEARNING_GROUP,
operation_type: SysmtemLogOperationType.EXPORT,
learning_group_id: learningGroupId,
participant_id: participantId,
request_body: batData as Prisma.InputJsonValue,
request_endpoint: apiUrl,
request_method: "POST",
timestamp: new Date(),
},
});
systemLogId = createdLog.id;
} catch (logErr) {
const msg = logErr instanceof Error ? logErr.message : String(logErr);
this.logger.warn(`Failed to create outbound system log (non-fatal): ${msg}`);
}
if (this.craiApiToken) {
headers.Authorization = `Bearer ${this.craiApiToken}`;
}
requestStartTime = Date.now();
const response = await firstValueFrom(
this.httpService.post(apiUrl, batData, {
headers,
timeout: 30000, // 30 second timeout
}),
);
const requestDuration = Date.now() - requestStartTime;
if (systemLogId !== null) {
try {
await this.prisma.client.systemLog.update({
where: { id: systemLogId },
data: {
status_code: response.status,
response_time_ms: requestDuration,
error_message: null,
},
});
} catch (updateErr) {
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
this.logger.warn(`Failed to update outbound system log status_code (non-fatal): ${msg}`);
}
}
// Return the actual CR API success message
const message =
response.data?.message || response.data?.status || "PRE-BAT analysis regeneration triggered successfully";
return {
success: true,
message,
};
} catch (error) {
// Log error but don't throw - allow frontend to handle gracefully
console.error("Failed to regenerate PRE-BAT analysis:", error);
// Try to extract CR API error message if available
let errorMessage = "Failed to regenerate PRE-BAT analysis. Please try again later.";
let statusCode: number | null = null;
const requestDuration = requestStartTime ? Date.now() - requestStartTime : null;
if (error && typeof error === "object" && "response" in error) {
const httpError = error as { response?: { status?: number; data?: { message?: string; error?: string } } };
statusCode = httpError.response?.status ?? null;
if (httpError.response?.data?.message) {
errorMessage = httpError.response.data.message;
} else if (httpError.response?.data?.error) {
errorMessage = httpError.response.data.error;
}
}
if (systemLogId !== null) {
try {
await this.prisma.client.systemLog.update({
where: { id: systemLogId },
data: {
status_code: statusCode,
response_time_ms: requestDuration,
error_message: errorMessage,
},
});
} catch (updateErr) {
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
this.logger.warn(`Failed to update outbound system log error fields (non-fatal): ${msg}`);
}
}
return {
success: false,
message: errorMessage,
};
}
}
/**
* Regenerate POST-BAT analysis by resending data to CR API
* @param participantId Current logged-in participant ID
* @param learningGroupParticipantId Learning group participant ID
* @returns Success status and message
*/
async regeneratePostBatAnalysis(
participantId: number,
learningGroupParticipantId: number,
): Promise<{ success: boolean; message: string }> {
// Verify enrollment exists and belongs to participant
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
id: learningGroupParticipantId,
participant_id: participantId,
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
assessment_id: true,
},
},
},
},
},
});
if (!enrollment) {
throw new NotFoundException("Course enrollment not found");
}
await this.subscriptionCourseAccess.assertCompanySubscriptionActive(participantId);
// Check if POST-BAT assessment was completed by looking for assessment participant
const course = enrollment.learningGroup?.course;
if (!course?.assessment_id) {
throw new ForbiddenException("Course does not have an assessment configured");
}
const postBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: course.assessment_id,
participant_id: participantId,
assessment_type: "POST_BAT",
},
});
if (!postBatParticipant) {
throw new ForbiddenException("POST-BAT assessment must be completed before regenerating analysis");
}
let systemLogId: number | null = null;
let requestStartTime = 0;
try {
// Get learning group ID and participant ID for the assessment service
const learningGroupId = enrollment.learning_group_id;
// Send same payload as POST BAT completion: full pre+post data with plj_position POST_BAT
const batData = await this.assessmentService
.formatPrePostBatDataForIntegration(learningGroupId, participantId)
.then((fullBatData) => ({
...fullBatData,
metadata: {
...fullBatData.metadata,
plj_position: "POST_BAT" as const,
},
}));
// POST to CR AI API (same as in the listener)
const apiUrl = `${this.craiApiUrl}/generate-feedback`;
const headers: { "Content-Type": string; Authorization?: string } = {
"Content-Type": "application/json",
};
// Create system log row for outbound audit, then update with status_code after the HTTP call.
try {
const createdLog = await this.prisma.client.systemLog.create({
data: {
entity_type: SystemLogEntityType.LEARNING_GROUP,
operation_type: SysmtemLogOperationType.EXPORT,
learning_group_id: learningGroupId,
participant_id: participantId,
request_body: batData as Prisma.InputJsonValue,
request_endpoint: apiUrl,
request_method: "POST",
timestamp: new Date(),
},
});
systemLogId = createdLog.id;
} catch (logErr) {
const msg = logErr instanceof Error ? logErr.message : String(logErr);
this.logger.warn(`Failed to create outbound system log (non-fatal): ${msg}`);
}
if (this.craiApiToken) {
headers.Authorization = `Bearer ${this.craiApiToken}`;
}
requestStartTime = Date.now();
const response = await firstValueFrom(
this.httpService.post(apiUrl, batData, {
headers,
timeout: 30000, // 30 second timeout
}),
);
const requestDuration = Date.now() - requestStartTime;
if (systemLogId !== null) {
try {
await this.prisma.client.systemLog.update({
where: { id: systemLogId },
data: {
status_code: response.status,
response_time_ms: requestDuration,
error_message: null,
},
});
} catch (updateErr) {
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
this.logger.warn(`Failed to update outbound system log status_code (non-fatal): ${msg}`);
}
}
// Return the actual CR API success message
const message =
response.data?.message || response.data?.status || "POST-BAT analysis regeneration triggered successfully";
return {
success: true,
message,
};
} catch (error) {
// Log error but don't throw - allow frontend to handle gracefully
console.error("Failed to regenerate POST-BAT analysis:", error);
// Try to extract CR API error message if available
let errorMessage = "Failed to regenerate POST-BAT analysis. Please try again later.";
let statusCode: number | null = null;
const requestDuration = requestStartTime ? Date.now() - requestStartTime : null;
if (error && typeof error === "object" && "response" in error) {
const httpError = error as { response?: { status?: number; data?: { message?: string; error?: string } } };
statusCode = httpError.response?.status ?? null;
if (httpError.response?.data?.message) {
errorMessage = httpError.response.data.message;
} else if (httpError.response?.data?.error) {
errorMessage = httpError.response.data.error;
}
}
if (systemLogId !== null) {
try {
await this.prisma.client.systemLog.update({
where: { id: systemLogId },
data: {
status_code: statusCode,
response_time_ms: requestDuration,
error_message: errorMessage,
},
});
} catch (updateErr) {
const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
this.logger.warn(`Failed to update outbound system log error fields (non-fatal): ${msg}`);
}
}
return {
success: false,
message: errorMessage,
};
}
}
}