apps/recallassess/recallassess-api/src/api/client/learning-group/listeners/course-progress-status.listener.ts
Event Listener for Course Progress Status Updates Subscribes to course progress events and updates LearningGroupParticipant status accordingly
Status progression:
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService, httpService: HttpService, configService: ConfigService, assessmentService: CLAssessmentService, eventEmitter: EventEmitter2, dynamicMailFieldsService: DynamicMailFieldsService, learningGroupService: CLLearningGroupService)
|
|||||||||||||||||||||||||||
|
Parameters :
|
| Private Async checkAndUpdateLearningGroupCompletionStatus | ||||||||
checkAndUpdateLearningGroupCompletionStatus(learningGroupId: number)
|
||||||||
|
Update learning group progress and check if all participants have completed Updates participants_completed and completion_percentage incrementally Changes status to COMPLETED when all participants complete Note: A participant is considered "completed" when status is E_LEARNING or COMPLETED
Parameters :
Returns :
Promise<void>
|
| Private Async createKnowledgeReviewInviteEmailLog | ||||||
createKnowledgeReviewInviteEmailLog(data: literal type)
|
||||||
|
Create a scheduled email log for the Knowledge Review invite This is used when 100DJ is enabled – the invite is scheduled at knowledge_review_email_date
Parameters :
Returns :
Promise<void>
|
| Private Async createPostBatInviteEmailLog | ||||||
createPostBatInviteEmailLog(data: literal type)
|
||||||
|
Create a scheduled email log for the Post-Training (POST BAT) invite This is created when Knowledge Review is completed, scheduled at post_bat_email_date
Parameters :
Returns :
Promise<void>
|
| Async handleCourseCompleted | ||||||
handleCourseCompleted(event: CourseCompletedEvent)
|
||||||
Decorators :
@OnEvent(COURSE_PROGRESS_EVENTS.COURSE_COMPLETED)
|
||||||
|
Handle Course completion event (all stages completed) Updates status to COMPLETED Sends course completion email
Parameters :
Returns :
any
|
| Async handleELearningCompleted | ||||||
handleELearningCompleted(event: ELearningCompletedEvent)
|
||||||
Decorators :
@OnEvent(COURSE_PROGRESS_EVENTS.E_LEARNING_COMPLETED)
|
||||||
|
Handle E-Learning completion event Updates status to E_LEARNING and sets completion_percentage using COURSE_PROGRESS_PERCENTAGES Sets e_learning_completed_at and calculates 100DJ email dates Sends email notification to participant
Parameters :
Returns :
any
|
| Async handleKnowledgeReviewCompleted | ||||||
handleKnowledgeReviewCompleted(event: KnowledgeReviewCompletedEvent)
|
||||||
Decorators :
@OnEvent(COURSE_PROGRESS_EVENTS.KNOWLEDGE_REVIEW_COMPLETED)
|
||||||
|
Handle Knowledge Review completion event Updates status to E_LEARNING and sets completion_percentage using COURSE_PROGRESS_PERCENTAGES Sends email notification to participant
Parameters :
Returns :
any
|
| Async handlePostBatCompleted | ||||||
handlePostBatCompleted(event: PostBatCompletedEvent)
|
||||||
Decorators :
@OnEvent(COURSE_PROGRESS_EVENTS.POST_BAT_COMPLETED)
|
||||||
|
Handle POST BAT completion event Updates status to COMPLETED and sets completion_percentage using COURSE_PROGRESS_PERCENTAGES Calls external CRAI API to generate feedback (same as PRE-BAT)
Parameters :
Returns :
any
|
| Async handlePreBatCompleted | ||||||
handlePreBatCompleted(event: PreBatCompletedEvent)
|
||||||
Decorators :
@OnEvent(COURSE_PROGRESS_EVENTS.PRE_BAT_COMPLETED)
|
||||||
|
Handle PRE BAT completion event Updates status to PRE_BAT and sets completion_percentage If e-learning has started, preserves e-learning progress and adds PRE_BAT percentage Sets pre_bat_completed_at timestamp
Parameters :
Returns :
any
|
| Private Async sendCourseCompletionEmail | ||||||
sendCourseCompletionEmail(event: CourseCompletedEvent | PostBatCompletedEvent)
|
||||||
|
Send course completion email to participant Handles both CourseCompletedEvent and PostBatCompletedEvent
Parameters :
Returns :
Promise<void>
|
| Private Async sendELearningCompletionEmail | ||||||
sendELearningCompletionEmail(event: ELearningCompletedEvent)
|
||||||
|
Send E-Learning completion email to participant
Parameters :
Returns :
Promise<void>
|
| Private Async sendELearningCompletionEmailWithHundredDj | |||||||||
sendELearningCompletionEmailWithHundredDj(event: ELearningCompletedEvent, emailDates: literal type)
|
|||||||||
|
Send E-Learning completion email mentioning 100DJ emails and dates
Parameters :
Returns :
Promise<void>
|
| Private Async sendKnowledgeReviewCompletionEmail | ||||||
sendKnowledgeReviewCompletionEmail(event: KnowledgeReviewCompletedEvent)
|
||||||
|
Send Knowledge Review completion email to participant Informs participant that POST BAT button is now available
Parameters :
Returns :
Promise<void>
|
| Private Async sendKnowledgeReviewInviteEmail | ||||||
sendKnowledgeReviewInviteEmail(event: ELearningCompletedEvent)
|
||||||
|
Send Knowledge Review invite email directly (when 100DJ is skipped)
Parameters :
Returns :
Promise<void>
|
| Private Async sendPreBatDataToCraiApi | ||||||||||||||||
sendPreBatDataToCraiApi(learningGroupId: number, participantId: number, triggerEvent?: "PRE_BAT_COMPLETED" | "POST_BAT_COMPLETED")
|
||||||||||||||||
|
Send PRE BAT data to external CRAI API for AI feedback generation This is called asynchronously after PRE BAT completion
Parameters :
Returns :
Promise<void>
|
| Private Readonly craiApiToken |
Type : string
|
| Private Readonly craiApiUrl |
Type : string
|
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(CourseProgressStatusListener.name)
|
import { DynamicMailFieldsService } from "@api/shared/dynamic-mail-fields/dynamic-mail-fields.service";
import { renderEmailTemplate } from "@api/shared/email/render-email-template.util";
import { stripSubscriptionExpiredBannerFromHtml } from "@api/shared/email/subscription-expired-banner-html.util";
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
import {
EmailStatus,
EmailTemplateType,
LearningGroupStatus,
ParticipantLearningProgressStatus,
Prisma,
SysmtemLogOperationType,
SystemLogEntityType,
} from "@prisma/client";
import { firstValueFrom } from "rxjs";
import { COURSE_PROGRESS_CONFIG, COURSE_PROGRESS_PERCENTAGES } from "../../../../config/course-progress.config";
import { EMAIL_REMINDER_CONFIG } from "../../../../config/email-reminder.config";
import { HUNDRED_DJ_EMAIL_CONFIG } from "../../../../config/hundred-dj.config";
import { getEmailSkeleton } from "../../../../templates/email/email-skeleton.template";
import { CLAssessmentService } from "../../assessment/assessment.service";
import {
COURSE_PROGRESS_EVENTS,
CourseCompletedEvent,
ELearningCompletedEvent,
KnowledgeReviewCompletedEvent,
PostBatCompletedEvent,
PreBatCompletedEvent,
} from "../events/course-progress.events";
import { LICENSE_ALLOCATION_EVENTS, LicenseReleasedEvent } from "../events/license-allocation.events";
import { CLLearningGroupService } from "../learning-group.service";
/**
* Event Listener for Course Progress Status Updates
* Subscribes to course progress events and updates LearningGroupParticipant status accordingly
*
* Status progression:
* - PRE_BAT_COMPLETED → status = PRE_BAT
* - E_LEARNING_COMPLETED → status = E_LEARNING
* - KNOWLEDGE_REVIEW_COMPLETED → status = E_LEARNING (75% completion)
* - POST_BAT_COMPLETED → status = COMPLETED
* - COURSE_COMPLETED → status = COMPLETED
*/
@Injectable()
export class CourseProgressStatusListener {
private readonly logger = new Logger(CourseProgressStatusListener.name);
private readonly craiApiUrl: string;
private readonly craiApiToken: string;
constructor(
private readonly prisma: BNestPrismaService,
private readonly emailSender: BNestEmailSenderService,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
private readonly assessmentService: CLAssessmentService,
private readonly eventEmitter: EventEmitter2,
private readonly dynamicMailFieldsService: DynamicMailFieldsService,
private readonly learningGroupService: CLLearningGroupService,
) {
this.craiApiUrl =
this.configService.get<string>("CRAI_API_URL") || "https://recallassess-craiapi.pilottest.site";
this.craiApiToken = this.configService.get<string>("CRAI_API_TOKEN") || "";
}
/**
* Handle PRE BAT completion event
* Updates status to PRE_BAT and sets completion_percentage
* If e-learning has started, preserves e-learning progress and adds PRE_BAT percentage
* Sets pre_bat_completed_at timestamp
*/
@OnEvent(COURSE_PROGRESS_EVENTS.PRE_BAT_COMPLETED)
async handlePreBatCompleted(event: PreBatCompletedEvent) {
try {
this.logger.debug(
`[PRE_BAT_COMPLETED] Event received: Participant ${event.participantId}, Course ${event.courseId}, Enrollment ${event.learningGroupParticipantId}`,
);
const preBatCompletedAt = new Date();
this.logger.debug(`[PRE_BAT_COMPLETED] Timestamp set: ${preBatCompletedAt.toISOString()}`);
// Get current enrollment to check if e-learning has started
this.logger.debug(
`[PRE_BAT_COMPLETED] Fetching enrollment ${event.learningGroupParticipantId} from database...`,
);
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: {
id: event.learningGroupParticipantId,
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
},
},
},
},
},
});
if (!enrollment) {
this.logger.warn(
`[PRE_BAT_COMPLETED] Enrollment ${event.learningGroupParticipantId} not found. Skipping status update and feedback generation.`,
);
return;
}
this.logger.debug(
`[PRE_BAT_COMPLETED] Enrollment found: Learning Group ${enrollment.learning_group_id}, Course ${enrollment.course_id}, Status: ${enrollment.status}`,
);
let completionPercentage = Number(COURSE_PROGRESS_PERCENTAGES.AFTER_PRE_BAT); // Default: 25%
this.logger.debug(`[PRE_BAT_COMPLETED] Initial completion percentage: ${completionPercentage}%`);
// Always check if e-learning has started to include module progress
// This ensures progress is accurate even if PRE BAT completes after modules are started
this.logger.debug(
`[PRE_BAT_COMPLETED] Checking e-learning progress for participant ${event.participantId}, course ${event.courseId}, learning group ${enrollment.learning_group_id}...`,
);
const eLearningParticipant = await this.prisma.client.eLearningParticipant.findFirst({
where: {
participant_id: event.participantId,
course_id: event.courseId,
learning_group_id: enrollment.learning_group_id,
},
});
if (eLearningParticipant && eLearningParticipant.total_course_modules > 0) {
// E-learning has started, calculate: PRE_BAT (25%) + E-LEARNING progress
const eLearningProgress =
(eLearningParticipant.course_modules_completed / eLearningParticipant.total_course_modules) *
COURSE_PROGRESS_CONFIG.E_LEARNING;
completionPercentage = Math.round(COURSE_PROGRESS_CONFIG.PRE_BAT + eLearningProgress);
this.logger.debug(
`[PRE_BAT_COMPLETED] E-learning progress found: ${eLearningParticipant.course_modules_completed}/${eLearningParticipant.total_course_modules} modules = ${eLearningProgress.toFixed(2)}% e-learning, total: ${completionPercentage}%`,
);
} else {
this.logger.debug(
`[PRE_BAT_COMPLETED] No e-learning progress yet (found: ${!!eLearningParticipant}). Setting to ${completionPercentage}%`,
);
}
this.logger.debug(
`[PRE_BAT_COMPLETED] Updating enrollment ${event.learningGroupParticipantId} with status PRE_BAT, completion ${completionPercentage}%...`,
);
await this.prisma.client.learningGroupParticipant.update({
where: {
id: event.learningGroupParticipantId,
},
data: {
status: ParticipantLearningProgressStatus.PRE_BAT,
completion_percentage: completionPercentage,
pre_bat_completed_at: preBatCompletedAt,
},
});
this.logger.debug(
`[PRE_BAT_COMPLETED] ✅ Enrollment ${event.learningGroupParticipantId} updated successfully: Status=PRE_BAT, Completion=${completionPercentage}%, Timestamp=${preBatCompletedAt.toISOString()}`,
);
// Call external CRAI API to generate feedback (async, don't wait)
this.logger.debug(
`[PRE_BAT_COMPLETED] 🚀 Triggering feedback generation for Learning Group ${enrollment.learning_group_id}, Participant ${event.participantId}...`,
);
this.sendPreBatDataToCraiApi(enrollment.learning_group_id, event.participantId, "PRE_BAT_COMPLETED").catch(
(err) => {
this.logger.error(
`[PRE_BAT_COMPLETED] ❌ Failed to send PRE BAT data to CRAI API: ${err.message || err}`,
err.stack,
);
},
);
// Recalculate subscription license counts (participant now in PRE_BAT = consumed)
if (enrollment.learningGroup?.company_id) {
await this.learningGroupService.recalculateSubscriptionLicenseCounts(enrollment.learningGroup.company_id, {
setLastLicenseRelease: false,
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(
`[PRE_BAT_COMPLETED] ❌ Failed to update status after PRE BAT completion: ${errorMessage}`,
errorStack,
);
// Don't throw - status update should not fail the main operation
}
}
/**
* Send PRE BAT data to external CRAI API for AI feedback generation
* This is called asynchronously after PRE BAT completion
* @param triggerEvent Optional event name for system log (PRE_BAT_COMPLETED / POST_BAT_COMPLETED)
*/
private async sendPreBatDataToCraiApi(
learningGroupId: number,
participantId: number,
triggerEvent?: "PRE_BAT_COMPLETED" | "POST_BAT_COMPLETED",
): Promise<void> {
let systemLogId: number | null = null;
let requestStartTime = 0;
try {
this.logger.debug(
`[GENERATE_FEEDBACK] 🚀 Starting feedback generation: Learning Group ${learningGroupId}, Participant ${participantId}`,
);
const apiUrl = `${this.craiApiUrl}/generate-feedback`;
this.logger.debug(`[GENERATE_FEEDBACK] CRAI API URL: ${apiUrl}`);
// Get formatted PRE/POST BAT data for the participant
this.logger.debug(
`[GENERATE_FEEDBACK] Formatting PRE/POST BAT data for Learning Group ${learningGroupId}, Participant ${participantId}...`,
);
const batData = await this.assessmentService.formatPrePostBatDataForIntegration(
learningGroupId,
participantId,
);
this.logger.debug(
`[GENERATE_FEEDBACK] ✅ Data formatted successfully. Payload keys: ${Object.keys(batData).join(", ")}`,
);
this.logger.debug(`[GENERATE_FEEDBACK] Payload size: ${JSON.stringify(batData).length} characters`);
// Print full JSON payload for debugging
const jsonPayload = JSON.stringify(batData, null, 2);
this.logger.debug(`[GENERATE_FEEDBACK] 📋 Full JSON payload being sent to CRAI API:\n${jsonPayload}`);
// Create system log entry directly via Prisma (listener runs async after request ends,
// so request-scoped SystemLogService is not available; set endpoint/method for audit)
try {
const requestBodyJson = JSON.parse(JSON.stringify(batData)) as Prisma.InputJsonValue;
// Store the actual API endpoint we call for audit purposes.
const requestEndpoint = apiUrl;
this.logger.debug(
`[GENERATE_FEEDBACK] Creating system log with request_endpoint=${requestEndpoint} (triggerEvent=${triggerEvent ?? "CR_export"})`,
);
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: requestBodyJson,
request_endpoint: requestEndpoint,
request_method: "EVENT",
timestamp: new Date(),
},
});
systemLogId = createdLog.id;
this.logger.debug(
`[GENERATE_FEEDBACK] System log created for PRE/POST BAT completion: learning_group_id=${learningGroupId}, participant_id=${participantId}`,
);
} catch (logErr) {
const msg = logErr instanceof Error ? logErr.message : String(logErr);
this.logger.warn(`[GENERATE_FEEDBACK] Failed to create system log (non-fatal): ${msg}`);
}
// POST to CRAI API
this.logger.debug(`[GENERATE_FEEDBACK] 📤 Sending POST request to ${apiUrl}...`);
// Prepare headers with Bearer token
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.craiApiToken) {
headers["Authorization"] = `Bearer ${this.craiApiToken}`;
this.logger.debug(
`[GENERATE_FEEDBACK] Request config: timeout=30000ms, Content-Type=application/json, Authorization=Bearer *** (token present)`,
);
} else {
this.logger.warn(
`[GENERATE_FEEDBACK] ⚠️ CRAI_API_TOKEN not configured! Request may fail with 401 Unauthorized.`,
);
this.logger.debug(
`[GENERATE_FEEDBACK] Request config: timeout=30000ms, Content-Type=application/json, Authorization=missing`,
);
}
requestStartTime = Date.now();
const response = await firstValueFrom(
this.httpService.post(apiUrl, batData, {
headers,
timeout: 30000, // 30 second timeout
}),
);
const requestDuration = Date.now() - requestStartTime;
this.logger.log(
`[GENERATE_FEEDBACK] ✅ Successfully sent PRE BAT data to CRAI API for participant ${participantId}. Response status: ${response.status}, Duration: ${requestDuration}ms`,
);
// Persist audit fields back to the same system log row.
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(`[GENERATE_FEEDBACK] Failed to update system log status_code (non-fatal): ${msg}`);
}
}
this.logger.debug(
`[GENERATE_FEEDBACK] Response data: ${JSON.stringify(response.data).substring(0, 200)}...`,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(
`[GENERATE_FEEDBACK] ❌ Failed to send PRE BAT data to CRAI API for Learning Group ${learningGroupId}, Participant ${participantId}: ${errorMessage}`,
errorStack,
);
const requestDuration = requestStartTime ? Date.now() - requestStartTime : null;
let statusCode: number | null = null;
// Log additional error details if available
if (error && typeof error === "object" && "response" in error) {
const httpError = error as { response?: { status?: number; data?: unknown } };
statusCode = httpError.response?.status ?? null;
this.logger.error(
`[GENERATE_FEEDBACK] HTTP Error details: Status=${httpError.response?.status}, Data=${JSON.stringify(httpError.response?.data)}`,
);
}
// Persist audit fields back to the same system log row.
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(`[GENERATE_FEEDBACK] Failed to update system log error fields (non-fatal): ${msg}`);
}
}
// Don't throw - CRAI API failure should not affect the main flow
}
}
/**
* Handle E-Learning completion event
* Updates status to E_LEARNING and sets completion_percentage using COURSE_PROGRESS_PERCENTAGES
* Sets e_learning_completed_at and calculates 100DJ email dates
* Sends email notification to participant
*/
@OnEvent(COURSE_PROGRESS_EVENTS.E_LEARNING_COMPLETED)
async handleELearningCompleted(event: ELearningCompletedEvent) {
try {
this.logger.debug(
`E-Learning completed: Participant ${event.participantId}, Course ${event.courseId}, Enrollment ${event.learningGroupParticipantId}`,
);
// Get course to check if 100DJ should be skipped
const course = await this.prisma.client.course.findUnique({
where: { id: event.courseId },
select: { skip_hundred_dj: true },
});
this.logger.debug(
`Course ${event.courseId} skip_hundred_dj value: ${course?.skip_hundred_dj} (type: ${typeof course?.skip_hundred_dj})`,
);
const eLearningCompletedAt = new Date();
// Calculate 100DJ email dates based on cumulative days from e-learning completion
// Email 1: day 2 (2 days after E-Learning completion)
// Email 2: after 7 days (day 2 + 7 = day 9)
// Email 3: after 14 days (day 9 + 14 = day 23)
// Email 4: after 30 days (day 23 + 30 = day 53)
const calculateDate = (days: number): Date => {
const date = new Date(eLearningCompletedAt);
date.setDate(date.getDate() + days);
return date;
};
// Calculate cumulative dates
const email1Days = 2; // Email 1: day 2
const email2Days = email1Days + 7; // Email 2: day 2 + 7 = day 9
const email3Days = email2Days + 14; // Email 3: day 9 + 14 = day 23
const email4Days = email3Days + 30; // Email 4: day 23 + 30 = day 53
const updateData: {
status: ParticipantLearningProgressStatus;
completion_percentage: number;
e_learning_completed_at: Date;
hundred_dj_email1_date?: Date;
hundred_dj_email2_date?: Date;
hundred_dj_email3_date?: Date;
hundred_dj_email4_date?: Date;
knowledge_review_email_date?: Date;
} = {
status: ParticipantLearningProgressStatus.E_LEARNING,
completion_percentage: COURSE_PROGRESS_PERCENTAGES.AFTER_E_LEARNING,
e_learning_completed_at: eLearningCompletedAt,
};
// Get participant and enrollment details for email creation
const participant = await this.prisma.client.participant.findUnique({
where: { id: event.participantId },
include: { company: true },
});
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
include: { learningGroup: true },
});
if (!participant || !enrollment) {
this.logger.warn(
`Cannot process e-learning completion: Participant ${event.participantId} or Enrollment ${event.learningGroupParticipantId} not found`,
);
return;
}
// skip_hundred_dj === true means 100DJ is SKIPPED (send knowledge review immediately)
// skip_hundred_dj === false means 100DJ is ENABLED (show 100DJ emails)
const skipHundredDj = course?.skip_hundred_dj ?? false;
this.logger.debug(
`Processing e-learning completion: skip_hundred_dj=${skipHundredDj} (100DJ ${skipHundredDj ? "SKIPPED" : "ENABLED"})`,
);
if (skipHundredDj === true) {
// 100DJ is SKIPPED - Set knowledge review email date to same date as e-learning completion
// and send knowledge review invite email immediately
this.logger.debug("100DJ is skipped, setting knowledge review date to e-learning completion date");
updateData.knowledge_review_email_date = eLearningCompletedAt;
this.logger.debug(
`Set knowledge review date to e-learning completion date: ${updateData.knowledge_review_email_date.toISOString()}`,
);
} else {
// 100DJ is ENABLED - Set 100DJ email dates and create email logs
this.logger.debug("Setting 100DJ email dates...");
updateData.hundred_dj_email1_date = calculateDate(email1Days);
updateData.hundred_dj_email2_date = calculateDate(email2Days);
updateData.hundred_dj_email3_date = calculateDate(email3Days);
updateData.hundred_dj_email4_date = calculateDate(email4Days);
updateData.knowledge_review_email_date = calculateDate(
HUNDRED_DJ_EMAIL_CONFIG.KNOWLEDGE_REVIEW_EMAIL_DAYS,
);
this.logger.debug(
`Calculated 100DJ dates (cumulative): Email1=${updateData.hundred_dj_email1_date.toISOString()} (${email1Days} days), Email2=${updateData.hundred_dj_email2_date.toISOString()} (${email2Days} days), Email3=${updateData.hundred_dj_email3_date.toISOString()} (${email3Days} days), Email4=${updateData.hundred_dj_email4_date.toISOString()} (${email4Days} days), KnowledgeReview=${updateData.knowledge_review_email_date.toISOString()}`,
);
// Get course details for variable replacement
const course = await this.prisma.client.course.findUnique({
where: { id: event.courseId },
select: { id: true, title: true },
});
// Create email logs for 100DJ emails
await this.createHundredDjEmailLogs({
participant,
enrollment,
course: course || { id: event.courseId, title: "" },
emailDates: {
email1: updateData.hundred_dj_email1_date,
email2: updateData.hundred_dj_email2_date,
email3: updateData.hundred_dj_email3_date,
email4: updateData.hundred_dj_email4_date,
},
});
this.logger.debug("100DJ email logs created successfully");
// Also create a scheduled Knowledge Review invite email at knowledge_review_email_date
if (updateData.knowledge_review_email_date) {
await this.createKnowledgeReviewInviteEmailLog({
participant,
enrollment,
course: course || { id: event.courseId, title: "" },
knowledgeReviewDate: updateData.knowledge_review_email_date,
});
this.logger.debug(
`Knowledge Review invite email log created for enrollment ${event.learningGroupParticipantId} at ${updateData.knowledge_review_email_date.toISOString()}`,
);
}
}
this.logger.debug(
`Updating enrollment ${event.learningGroupParticipantId} with data: ${JSON.stringify({
status: updateData.status,
completion_percentage: updateData.completion_percentage,
e_learning_completed_at: updateData.e_learning_completed_at.toISOString(),
hundred_dj_email1_date: updateData.hundred_dj_email1_date?.toISOString() || null,
hundred_dj_email2_date: updateData.hundred_dj_email2_date?.toISOString() || null,
hundred_dj_email3_date: updateData.hundred_dj_email3_date?.toISOString() || null,
hundred_dj_email4_date: updateData.hundred_dj_email4_date?.toISOString() || null,
knowledge_review_email_date: updateData.knowledge_review_email_date?.toISOString() || null,
})}`,
);
const updatedEnrollment = await this.prisma.client.learningGroupParticipant.update({
where: {
id: event.learningGroupParticipantId,
},
data: updateData,
});
this.logger.debug(
`Updated enrollment ${event.learningGroupParticipantId} status to E_LEARNING with ${COURSE_PROGRESS_PERCENTAGES.AFTER_E_LEARNING}% completion`,
);
// Verify the dates were actually saved
this.logger.debug(
`Verification - Enrollment ${event.learningGroupParticipantId} dates after update: hundred_dj_email1_date=${updatedEnrollment.hundred_dj_email1_date?.toISOString() || "null"}, hundred_dj_email2_date=${updatedEnrollment.hundred_dj_email2_date?.toISOString() || "null"}, hundred_dj_email3_date=${updatedEnrollment.hundred_dj_email3_date?.toISOString() || "null"}, hundred_dj_email4_date=${updatedEnrollment.hundred_dj_email4_date?.toISOString() || "null"}, knowledge_review_email_date=${updatedEnrollment.knowledge_review_email_date?.toISOString() || "null"}`,
);
if (course?.skip_hundred_dj === false) {
this.logger.debug(
`Set 100DJ email dates for enrollment ${event.learningGroupParticipantId}: Email 1 (${updateData.hundred_dj_email1_date?.toISOString()}), Email 2 (${updateData.hundred_dj_email2_date?.toISOString()}), Email 3 (${updateData.hundred_dj_email3_date?.toISOString()}), Email 4 (${updateData.hundred_dj_email4_date?.toISOString()})`,
);
}
this.logger.debug(
`Set knowledge review email date for enrollment ${event.learningGroupParticipantId}: ${updateData.knowledge_review_email_date?.toISOString()}`,
);
// Send appropriate email based on skip_hundred_dj flag
if (
course?.skip_hundred_dj === false &&
updateData.hundred_dj_email1_date &&
updateData.hundred_dj_email2_date &&
updateData.hundred_dj_email3_date &&
updateData.hundred_dj_email4_date
) {
// 100DJ is ENABLED - Send e-learning completion email mentioning 100DJ emails and dates
await this.sendELearningCompletionEmailWithHundredDj(event, {
email1Date: updateData.hundred_dj_email1_date,
email2Date: updateData.hundred_dj_email2_date,
email3Date: updateData.hundred_dj_email3_date,
email4Date: updateData.hundred_dj_email4_date,
});
} else {
// 100DJ is SKIPPED - send knowledge review invite email directly
await this.sendKnowledgeReviewInviteEmail(event);
}
// Recalculate subscription license counts (participant now E_LEARNING = no longer in consumed)
if (enrollment?.learningGroup?.company_id) {
await this.learningGroupService.recalculateSubscriptionLicenseCounts(enrollment.learningGroup.company_id, {
setLastLicenseRelease: false,
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to update status after E-Learning completion: ${errorMessage}`, errorStack);
// Don't throw - status update should not fail the main operation
}
}
/**
* Handle Knowledge Review completion event
* Updates status to E_LEARNING and sets completion_percentage using COURSE_PROGRESS_PERCENTAGES
* Sends email notification to participant
*/
@OnEvent(COURSE_PROGRESS_EVENTS.KNOWLEDGE_REVIEW_COMPLETED)
async handleKnowledgeReviewCompleted(event: KnowledgeReviewCompletedEvent) {
try {
this.logger.debug(
`Knowledge Review completed: Participant ${event.participantId}, Course ${event.courseId}, Enrollment ${event.learningGroupParticipantId}`,
);
const knowledgeReviewCompletedAt = new Date();
// Calculate post_bat_email_date: 48-72 hours (2-3 days) after KR completion
// Using 2.5 days (60 hours) as default, configurable via EMAIL_REMINDER_CONFIG
const postBatEmailDays = EMAIL_REMINDER_CONFIG.assessments.postBat.available.daysAfter || 2.5;
const postBatEmailDate = new Date(knowledgeReviewCompletedAt);
postBatEmailDate.setHours(postBatEmailDate.getHours() + postBatEmailDays * 24);
const enrollment = await this.prisma.client.learningGroupParticipant.update({
where: {
id: event.learningGroupParticipantId,
},
data: {
status: ParticipantLearningProgressStatus.E_LEARNING,
completion_percentage: COURSE_PROGRESS_PERCENTAGES.AFTER_KNOWLEDGE_REVIEW,
knowledge_review_completed_at: knowledgeReviewCompletedAt,
post_bat_email_date: postBatEmailDate,
},
});
this.logger.debug(
`Updated enrollment ${event.learningGroupParticipantId} status to E_LEARNING with ${COURSE_PROGRESS_PERCENTAGES.AFTER_KNOWLEDGE_REVIEW}% completion at ${knowledgeReviewCompletedAt.toISOString()}, post_bat_email_date set to ${postBatEmailDate.toISOString()}`,
);
// Persist a system log for knowledge review completion.
// This ensures `system-log/list` shows an entry for the KR milestone.
try {
const requestEndpoint = `event:course_progress:${COURSE_PROGRESS_EVENTS.KNOWLEDGE_REVIEW_COMPLETED}`;
await this.prisma.client.systemLog.create({
data: {
entity_type: SystemLogEntityType.KNOWLEDGE_REVIEW,
operation_type: SysmtemLogOperationType.UPDATE,
course_id: event.courseId,
participant_id: event.participantId,
learning_group_participant_id: event.learningGroupParticipantId,
knowledge_review_participant_id: event.knowledgeReviewParticipantId,
request_endpoint: requestEndpoint,
request_method: "EVENT",
status_code: 200,
timestamp: new Date(),
},
});
} catch (logErr) {
const msg = logErr instanceof Error ? logErr.message : String(logErr);
this.logger.warn(`[KNOWLEDGE_REVIEW_COMPLETED] Failed to create system log (non-fatal): ${msg}`);
}
// Create scheduled email log for Post-Training (POST BAT) invite at post_bat_email_date
await this.createPostBatInviteEmailLog({
participantId: event.participantId,
learningGroupParticipantId: event.learningGroupParticipantId,
courseId: event.courseId,
postBatEmailDate,
learningGroupId: enrollment.learning_group_id,
});
// Send Knowledge Review completion email
await this.sendKnowledgeReviewCompletionEmail(event);
// Recalculate subscription license counts (participant now E_LEARNING = no longer in consumed)
const enrollmentForCompany = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
select: { learningGroup: { select: { company_id: true } } },
});
if (enrollmentForCompany?.learningGroup?.company_id) {
await this.learningGroupService.recalculateSubscriptionLicenseCounts(
enrollmentForCompany.learningGroup.company_id,
{ setLastLicenseRelease: false },
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to update status after Knowledge Review completion: ${errorMessage}`, errorStack);
// Don't throw - status update should not fail the main operation
}
}
/**
* Handle POST BAT completion event
* Updates status to COMPLETED and sets completion_percentage using COURSE_PROGRESS_PERCENTAGES
* Calls external CRAI API to generate feedback (same as PRE-BAT)
*/
@OnEvent(COURSE_PROGRESS_EVENTS.POST_BAT_COMPLETED)
async handlePostBatCompleted(event: PostBatCompletedEvent) {
try {
this.logger.debug(
`POST BAT completed: Participant ${event.participantId}, Course ${event.courseId}, Enrollment ${event.learningGroupParticipantId}`,
);
// Get enrollment to access learning_group_id for CR AI API call
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: {
id: event.learningGroupParticipantId,
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
},
},
},
},
},
});
if (!enrollment) {
this.logger.warn(
`[POST_BAT_COMPLETED] Enrollment ${event.learningGroupParticipantId} not found. Skipping status update and feedback generation.`,
);
return;
}
// Update status to COMPLETED and set completion_percentage using config
// POST BAT is the final stage, so completing it means the course is complete
const completedAt = new Date();
const updatedEnrollment = await this.prisma.client.learningGroupParticipant.update({
where: {
id: event.learningGroupParticipantId,
},
data: {
status: ParticipantLearningProgressStatus.COMPLETED,
completion_percentage: COURSE_PROGRESS_PERCENTAGES.AFTER_POST_BAT,
completed_at: completedAt,
},
});
this.logger.debug(
`Updated enrollment ${event.learningGroupParticipantId} status to COMPLETED with ${COURSE_PROGRESS_PERCENTAGES.AFTER_POST_BAT}% completion at ${completedAt.toISOString()}`,
);
// Call external CRAI API to generate feedback (async, don't wait) - same as PRE-BAT
this.logger.debug(
`[POST_BAT_COMPLETED] 🚀 Triggering feedback generation for Learning Group ${enrollment.learning_group_id}, Participant ${event.participantId}...`,
);
this.sendPreBatDataToCraiApi(enrollment.learning_group_id, event.participantId, "POST_BAT_COMPLETED").catch(
(err) => {
this.logger.error(
`[POST_BAT_COMPLETED] ❌ Failed to send POST BAT data to CRAI API: ${err.message || err}`,
err.stack,
);
},
);
// Check if all participants have completed and update learning group status
await this.checkAndUpdateLearningGroupCompletionStatus(updatedEnrollment.learning_group_id);
// Recalculate subscription license counts (participant now COMPLETED = no longer in consumed)
if (enrollment.learningGroup?.company_id) {
await this.learningGroupService.recalculateSubscriptionLicenseCounts(enrollment.learningGroup.company_id, {
setLastLicenseRelease: false,
});
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to handle POST BAT completion: ${errorMessage}`, errorStack);
// Don't throw - status update should not fail the main operation
}
}
/**
* Handle Course completion event (all stages completed)
* Updates status to COMPLETED
* Sends course completion email
*/
@OnEvent(COURSE_PROGRESS_EVENTS.COURSE_COMPLETED)
async handleCourseCompleted(event: CourseCompletedEvent) {
try {
this.logger.debug(
`Course completed: Participant ${event.participantId}, Course ${event.courseId}, Enrollment ${event.learningGroupParticipantId}`,
);
const updatedEnrollment = await this.prisma.client.learningGroupParticipant.update({
where: {
id: event.learningGroupParticipantId,
},
data: {
status: ParticipantLearningProgressStatus.COMPLETED,
completion_percentage: 100,
},
});
this.logger.debug(`Updated enrollment ${event.learningGroupParticipantId} status to COMPLETED`);
// Send course completion email
await this.sendCourseCompletionEmail(event);
// Emit license release event (course completion releases the license)
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
include: {
learningGroup: {
include: {
company: true,
},
},
},
});
if (enrollment?.learningGroup?.company_id) {
const subscription = await this.prisma.client.subscription.findFirst({
where: {
company_id: enrollment.learningGroup.company_id,
is_current: true,
status: "ACTIVE",
},
});
if (subscription) {
this.eventEmitter.emit(
LICENSE_ALLOCATION_EVENTS.LICENSE_RELEASED,
new LicenseReleasedEvent(
enrollment.learningGroup.company_id,
1, // 1 license released
subscription.license_count,
event.learningGroupParticipantId,
),
);
this.logger.debug(
`License release event emitted for company ${enrollment.learningGroup.company_id}: course completion released 1 license`,
);
}
// Recalculate subscription license counts (participant now COMPLETED = no longer in consumed)
await this.learningGroupService.recalculateSubscriptionLicenseCounts(enrollment.learningGroup.company_id, {
setLastLicenseRelease: false,
});
}
// Check if all participants have completed and update learning group status
await this.checkAndUpdateLearningGroupCompletionStatus(updatedEnrollment.learning_group_id);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`Failed to update status after course completion: ${errorMessage}`, errorStack);
// Don't throw - status update should not fail the main operation
}
}
/**
* Update learning group progress and check if all participants have completed
* Updates participants_completed and completion_percentage incrementally
* Changes status to COMPLETED when all participants complete
* Note: A participant is considered "completed" when status is E_LEARNING or COMPLETED
* @param learningGroupId - Learning group ID to check
*/
private async checkAndUpdateLearningGroupCompletionStatus(learningGroupId: number): Promise<void> {
try {
// Get the learning group
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: {
id: learningGroupId,
},
});
if (!learningGroup) {
this.logger.warn(`Learning group ${learningGroupId} not found`);
return;
}
// Only update if status is ACTIVE or PENDING (not already COMPLETED or CANCELLED)
if (
learningGroup.status !== LearningGroupStatus.ACTIVE &&
learningGroup.status !== LearningGroupStatus.PENDING
) {
return;
}
// Get all participants with their completion percentages
const participants = await this.prisma.client.learningGroupParticipant.findMany({
where: {
learning_group_id: learningGroupId,
},
select: {
completion_percentage: true,
status: true,
},
});
const totalParticipants = participants.length;
if (totalParticipants === 0) {
return;
}
// Count participants who have completed the course (for participants_completed count)
// Both E_LEARNING and COMPLETED statuses indicate the participant has completed training
const completedParticipants = participants.filter(
(p) =>
p.status === ParticipantLearningProgressStatus.E_LEARNING ||
p.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
// Calculate average completion percentage from all participants' progress
// This gives a more accurate representation of overall progress
const totalProgress = participants.reduce((sum, p) => {
const progress = p.completion_percentage ? Number(p.completion_percentage) : 0;
return sum + progress;
}, 0);
const completionPercentage = Math.round(totalProgress / totalParticipants);
// Determine if all participants have completed
const allCompleted = completedParticipants === totalParticipants;
// Prepare update data
const updateData: {
participants_completed: number;
completion_percentage: number;
status?: LearningGroupStatus;
completion_date?: Date;
is_completed?: boolean;
} = {
participants_completed: completedParticipants,
completion_percentage: completionPercentage,
};
// If all participants have completed, update status to COMPLETED
if (allCompleted) {
updateData.status = LearningGroupStatus.COMPLETED;
updateData.completion_date = new Date();
updateData.is_completed = true;
}
// Update learning group with progress and status
await this.prisma.client.learningGroup.update({
where: {
id: learningGroupId,
},
data: updateData,
});
if (allCompleted) {
this.logger.log(
`Learning group ${learningGroupId} status changed to COMPLETED (all ${totalParticipants} participants completed)`,
);
} else {
this.logger.debug(
`Learning group ${learningGroupId} progress updated: ${completedParticipants}/${totalParticipants} (${completionPercentage}%)`,
);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(
`Failed to check and update learning group completion status: ${errorMessage}`,
errorStack,
);
// Don't throw - status update should not fail the main operation
}
}
/**
* Create email logs for 100DJ emails with scheduled dates
* Variables are replaced at creation time, not at send time
*/
private async createHundredDjEmailLogs(data: {
participant: {
id: number;
first_name: string;
last_name: string;
email: string;
company_id: number;
company?: { name: string } | null;
};
enrollment: { id: number; learning_group_id: number };
course: { id: number; title: string };
emailDates: {
email1: Date;
email2: Date;
email3: Date;
email4: Date;
};
}): Promise<void> {
try {
// Get all 4 100DJ email templates by their specific template keys
const [template1, template2, template3, template4] = await Promise.all([
this.prisma.client.emailTemplate.findFirst({
where: {
template_key: "course.hundred.day.journey.email1",
is_active: true,
},
}),
this.prisma.client.emailTemplate.findFirst({
where: {
template_key: "course.hundred.day.journey.email2",
is_active: true,
},
}),
this.prisma.client.emailTemplate.findFirst({
where: {
template_key: "course.hundred.day.journey.email3",
is_active: true,
},
}),
this.prisma.client.emailTemplate.findFirst({
where: {
template_key: "course.hundred.day.journey.email4",
is_active: true,
},
}),
]);
if (!template1 || !template2 || !template3 || !template4) {
this.logger.warn(
`100DJ email templates not found (email1: ${!!template1}, email2: ${!!template2}, email3: ${!!template3}, email4: ${!!template4}), skipping email log creation`,
);
return;
}
// Note: Attachment metadata will be fetched at sending time, not creation time
// This ensures we always get the latest files and any files added/modified after email scheduling
// Prepare variables for replacement (includes dynamic mail fields from admin settings)
const dynamicMailVars = await this.dynamicMailFieldsService.getDynamicMailVariables({
companyNameFallback: data.participant.company?.name,
});
const frontendUrl = requireEnv("FRONTEND_URL");
const variables: Record<string, string> = {
"user.name": data.participant.first_name,
"user.email": data.participant.email,
"course.name": data.course.title,
"course.id": data.course.id.toString(),
// Use enrollment id (learning_group_participant_id) for my-courses route
"system.knowledgeReviewUrl": `${frontendUrl}/portal/my-courses/${data.enrollment.id}/knowledge-review`,
...dynamicMailVars,
};
// Helper function to replace variables in text. Uses the shared
// renderEmailTemplate util so {{#if}}{{else}}{{/if}} and {{#unless}}{{/unless}}
// blocks are correctly resolved (admin templates rely on these).
const replaceVariables = (text: string): string => renderEmailTemplate(text, variables);
const applySkeletonIfNeeded = (html: string, customStyles?: string | null): string => {
const trimmed = html.trim().toLowerCase();
let result =
trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
? html
: getEmailSkeleton(html, customStyles || undefined);
// Skeleton header/footer can contain {{mail.*}} placeholders; replace again after wrapping.
result = replaceVariables(result);
return result;
};
// Create email logs for each 100DJ email with their respective templates
// Variables are replaced at creation time
// Attachment metadata will be fetched at sending time to ensure latest files
const emailLogs = [
{
email_template_id: template1.id,
recipient_email: data.participant.email,
subject: replaceVariables(template1.subject),
content: applySkeletonIfNeeded(replaceVariables(template1.message_body), template1.custom_styles),
status: "PENDING" as const,
scheduled_date: data.emailDates.email1,
participant_id: data.participant.id,
company_id: data.participant.company_id,
course_id: data.course.id,
learning_group_id: data.enrollment.learning_group_id,
learning_group_participant_id: data.enrollment.id,
metadata: {
hundred_dj_email_number: 1,
// Attachments will be fetched dynamically at send time
},
},
{
email_template_id: template2.id,
recipient_email: data.participant.email,
subject: replaceVariables(template2.subject),
content: applySkeletonIfNeeded(replaceVariables(template2.message_body), template2.custom_styles),
status: "PENDING" as const,
scheduled_date: data.emailDates.email2,
participant_id: data.participant.id,
company_id: data.participant.company_id,
course_id: data.course.id,
learning_group_id: data.enrollment.learning_group_id,
learning_group_participant_id: data.enrollment.id,
metadata: {
hundred_dj_email_number: 2,
// Attachments will be fetched dynamically at send time
},
},
{
email_template_id: template3.id,
recipient_email: data.participant.email,
subject: replaceVariables(template3.subject),
content: applySkeletonIfNeeded(replaceVariables(template3.message_body), template3.custom_styles),
status: "PENDING" as const,
scheduled_date: data.emailDates.email3,
participant_id: data.participant.id,
company_id: data.participant.company_id,
course_id: data.course.id,
learning_group_id: data.enrollment.learning_group_id,
learning_group_participant_id: data.enrollment.id,
metadata: {
hundred_dj_email_number: 3,
// Attachments will be fetched dynamically at send time
},
},
{
email_template_id: template4.id,
recipient_email: data.participant.email,
subject: replaceVariables(template4.subject),
content: applySkeletonIfNeeded(replaceVariables(template4.message_body), template4.custom_styles),
status: "PENDING" as const,
scheduled_date: data.emailDates.email4,
participant_id: data.participant.id,
company_id: data.participant.company_id,
course_id: data.course.id,
learning_group_id: data.enrollment.learning_group_id,
learning_group_participant_id: data.enrollment.id,
metadata: {
hundred_dj_email_number: 4,
// Attachments will be fetched dynamically at send time
},
},
];
// Create email logs
await this.prisma.client.emailLog.createMany({
data: emailLogs,
});
this.logger.log(
`Created 4 100DJ email logs for participant ${data.participant.id} with scheduled dates (variables replaced)`,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to create 100DJ email logs: ${errorMessage}`, error);
// Don't throw - email log creation failure shouldn't break the main flow
}
}
/**
* Create a scheduled email log for the Knowledge Review invite
* This is used when 100DJ is enabled – the invite is scheduled at knowledge_review_email_date
*/
private async createKnowledgeReviewInviteEmailLog(data: {
participant: {
id: number;
first_name: string;
last_name: string;
email: string;
company_id: number;
company?: { name: string } | null;
};
enrollment: { id: number; learning_group_id: number };
course: { id: number; title: string };
knowledgeReviewDate: Date;
}): Promise<void> {
try {
const template = await this.prisma.client.emailTemplate.findFirst({
where: {
template_key: "course.knowledge.review",
is_active: true,
},
});
if (!template) {
this.logger.warn(
"Knowledge Review invite template (course.knowledge.review) not found, skipping email log creation",
);
return;
}
const dynamicMailVars = await this.dynamicMailFieldsService.getDynamicMailVariables({
companyNameFallback: data.participant.company?.name,
});
const frontendUrl = requireEnv("FRONTEND_URL");
const assetsUrl = process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl;
const variables: Record<string, string> = {
"user.name": data.participant.first_name,
"user.email": data.participant.email,
"course.name": data.course.title,
"course.id": data.course.id.toString(),
// For KR invite, we send them to the course detail; the portal decides the next step
"system.myCourseUrl": `${frontendUrl}/portal/my-courses/${data.enrollment.id}`,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
"system.preBatUrl": `${frontendUrl}/portal/my-courses/${data.enrollment.id}/pre-bat`,
"system.postBatUrl": `${frontendUrl}/portal/my-courses/${data.enrollment.id}/post-bat`,
"system.knowledgeReviewUrl": `${frontendUrl}/portal/my-courses/${data.enrollment.id}/knowledge-review`,
"system.iconsUrl": `${assetsUrl}/assets/images/email-icons`,
...dynamicMailVars,
};
const replaceVariables = (text: string): string => renderEmailTemplate(text, variables);
const applySkeletonIfNeeded = (html: string, customStyles?: string | null): string => {
const trimmed = html.trim().toLowerCase();
let result =
trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
? html
: getEmailSkeleton(html, customStyles || undefined);
result = replaceVariables(result);
return result;
};
await this.prisma.client.emailLog.create({
data: {
email_template_id: template.id,
recipient_email: data.participant.email,
subject: replaceVariables(template.subject),
content: applySkeletonIfNeeded(replaceVariables(template.message_body), template.custom_styles),
status: "PENDING",
scheduled_date: data.knowledgeReviewDate,
participant_id: data.participant.id,
company_id: data.participant.company_id,
course_id: data.course.id,
learning_group_id: data.enrollment.learning_group_id,
learning_group_participant_id: data.enrollment.id,
metadata: {
reminder_type: "knowledge_review_invite_after_e_learning",
scheduled_from: "e_learning_completion",
} as any,
} as any,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to create Knowledge Review invite email log: ${errorMessage}`, error);
// Don't throw - email log creation failure shouldn't break the main flow
}
}
/**
* Create a scheduled email log for the Post-Training (POST BAT) invite
* This is created when Knowledge Review is completed, scheduled at post_bat_email_date
*/
private async createPostBatInviteEmailLog(data: {
participantId: number;
learningGroupParticipantId: number;
courseId: number;
learningGroupId: number;
postBatEmailDate: Date;
}): Promise<void> {
try {
const participant = await this.prisma.client.participant.findUnique({
where: { id: data.participantId },
include: { company: true },
});
const course = await this.prisma.client.course.findUnique({
where: { id: data.courseId },
});
if (!participant || !course) {
this.logger.warn(
`Cannot create POST BAT invite email log: Participant ${data.participantId} or Course ${data.courseId} not found`,
);
return;
}
const template = await this.prisma.client.emailTemplate.findFirst({
where: {
template_key: EMAIL_REMINDER_CONFIG.assessments.postBat.available.templateKey,
is_active: true,
},
});
if (!template) {
this.logger.warn(
`POST BAT available template (${EMAIL_REMINDER_CONFIG.assessments.postBat.available.templateKey}) not found, skipping email log creation`,
);
return;
}
// Pre-merge template variables so the email log (PENDING) already contains user- and course-specific content.
// Remaining system variables (e.g. unsubscribe URL) can still be filled by the scheduled-email processor as a fallback.
const variables: Record<string, string> = {};
// Participant variables
variables["user.name"] = participant.first_name;
variables["user.email"] = participant.email;
// Course variables
variables["course.name"] = course.title;
variables["course.id"] = course.id.toString();
// Dynamic mail fields (company name, current year, etc.)
const dynamicMailVars = await this.dynamicMailFieldsService.getDynamicMailVariables({
companyNameFallback: participant.company?.name,
});
Object.assign(variables, dynamicMailVars);
// System URLs used by the template
const frontendUrl = requireEnv("FRONTEND_URL");
// Enrollment-specific URLs — frontend routes are /portal/my-courses/:id/{pre-bat,post-bat}.
// The legacy hardcoded /portal/assessment/post-bat doesn't exist in the router and would
// 404-fallback to the dashboard, losing the user's course context.
const enrollmentId = data.learningGroupParticipantId;
variables["system.myCourseUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}`;
variables["system.myCoursesUrl"] = `${frontendUrl}/portal/my-courses`;
variables["system.preBatUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/pre-bat`;
variables["system.postBatUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/post-bat`;
variables["system.assessmentUrl"] = `${frontendUrl}/portal/my-courses/${enrollmentId}/post-bat`;
variables["system.managePreferencesUrl"] = `${frontendUrl}/portal/settings/notifications`;
// Hosted email icon PNGs.
const assetsUrl = process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl;
variables["system.iconsUrl"] = `${assetsUrl}/assets/images/email-icons`;
// Apply variable replacement to subject and content
let mergedSubject = template.subject;
let mergedContent = template.message_body;
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{\\{${key.replace(/\./g, "\\.")}\\}\\}`, "g");
mergedSubject = mergedSubject.replace(regex, value);
mergedContent = mergedContent.replace(regex, value);
});
const trimmed = mergedContent.trim().toLowerCase();
let finalContent =
trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
? mergedContent
: getEmailSkeleton(mergedContent, template.custom_styles || undefined);
// Replace again so skeleton header/footer can contain {{mail.*}} placeholders
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{\\{${key.replace(/\./g, "\\.")}\\}\\}`, "g");
finalContent = finalContent.replace(regex, value);
});
await this.prisma.client.emailLog.create({
data: {
email_template_id: template.id,
recipient_email: participant.email,
subject: mergedSubject,
content: finalContent,
status: "PENDING",
scheduled_date: data.postBatEmailDate,
participant_id: participant.id,
company_id: participant.company_id,
course_id: course.id,
learning_group_id: data.learningGroupId,
learning_group_participant_id: data.learningGroupParticipantId,
metadata: {
reminder_type: "post_bat_invite",
scheduled_from: "knowledge_review_completion",
} as any,
} as any,
});
this.logger.debug(
`Created scheduled POST BAT invite email log for participant ${participant.id} at ${data.postBatEmailDate.toISOString()}`,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to create POST BAT invite email log: ${errorMessage}`, error);
// Don't throw - email log creation failure shouldn't break the main flow
}
}
/**
* Send E-Learning completion email mentioning 100DJ emails and dates
*/
private async sendELearningCompletionEmailWithHundredDj(
event: ELearningCompletedEvent,
emailDates: {
email1Date: Date;
email2Date: Date;
email3Date: Date;
email4Date: Date;
},
): Promise<void> {
try {
const templateForDedup = await this.prisma.client.emailTemplate.findFirst({
where: { template_key: "course.elearning.completion" },
});
if (templateForDedup) {
const existing = await this.prisma.client.emailLog.findFirst({
where: {
participant_id: event.participantId,
learning_group_participant_id: event.learningGroupParticipantId,
email_template_id: templateForDedup.id,
status: { in: [EmailStatus.SENT, EmailStatus.PENDING] },
},
orderBy: { sent_date: "desc" },
});
if (existing) {
this.logger.debug(
`Skipping duplicate course.elearning.completion email for participant ${event.participantId}, enrollment ${event.learningGroupParticipantId}`,
);
return;
}
}
// Get participant and course details
const participant = await this.prisma.client.participant.findUnique({
where: { id: event.participantId },
include: { company: true },
});
const course = await this.prisma.client.course.findUnique({
where: { id: event.courseId },
});
if (!participant || !course) {
this.logger.warn(
`Cannot send E-Learning completion email: Participant ${event.participantId} or Course ${event.courseId} not found`,
);
return;
}
const frontendUrl = requireEnv("FRONTEND_URL");
// Get learning group participant to find the enrollment ID
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
include: { learningGroup: true },
});
const courseUrl = `${frontendUrl}/portal/my-courses/${enrollment?.id ?? event.learningGroupParticipantId}`;
// Format dates for display
const formatDate = (date: Date): string => {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
// Fetch template and override subject for 100DJ enabled case
const template = await this.prisma.client.emailTemplate.findFirst({
where: {
template_key: "course.elearning.completion",
is_active: true,
},
});
if (!template) {
throw new Error("Email template course.elearning.completion not found");
}
// Override subject: "E-Learning Completed - Ready for 100DJ"
const subject = "E-Learning Completed - Ready for 100DJ";
// Include dynamic mail fields (mail.company_name, mail.footer_text, etc.) so {{mail.company_name}} is replaced
const dynamicMailVars = await this.dynamicMailFieldsService.getDynamicMailVariables({
companyNameFallback: participant.company?.name,
});
// Replace variables in body
const variables: Record<string, string> = {
"user.name": participant.first_name,
"user.email": participant.email,
"course.name": course.title,
"course.id": course.id.toString(),
"system.myCourseUrl": courseUrl,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
"system.iconsUrl": `${process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl}/assets/images/email-icons`,
"hundred_dj.email1_date": formatDate(emailDates.email1Date),
"hundred_dj.email2_date": formatDate(emailDates.email2Date),
"hundred_dj.email3_date": formatDate(emailDates.email3Date),
"hundred_dj.email4_date": formatDate(emailDates.email4Date),
...dynamicMailVars,
};
// Render the body through the shared template renderer. This handles
// {{key}} substitution AND {{#if}}{{else}}{{/if}} / {{#unless}}{{/unless}}
// blocks generically — the previous hand-rolled regex was hardcoded to
// `hundred_dj.email1_date` and a buggy variant of it (in email-test.service)
// was leaving the literal `{{else}}` token plus both branches in test
// emails. This code path is the "100DJ ENABLED" branch, so
// hundred_dj.email1_date is set in `variables` and the renderer keeps the
// if-branch as expected.
let content = renderEmailTemplate(template.message_body, variables);
// Wrap with the email skeleton so the recipient sees the standard purple header,
// logo, tagline and footer (and so the .success-box / .hundred-dj-box / .info-box
// classes referenced in the body have their CSS available). Without this wrap,
// the email ships as a bare body fragment with no header, no footer, no styles.
const trimmed = content.trim().toLowerCase();
content =
trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
? content
: getEmailSkeleton(content, template.custom_styles || undefined);
// Re-run the renderer so skeleton placeholders ({{mail.*}}, {{system.*}}) resolve.
content = renderEmailTemplate(content, variables);
await this.emailSender.sendEmail({
to: participant.email,
subject,
content,
templateId: template.id,
metadata: {
participant_id: event.participantId,
company_id: participant.company_id,
course_id: event.courseId,
learning_group_id: enrollment?.learning_group_id,
learning_group_participant_id: event.learningGroupParticipantId,
companyName: participant.company?.name || "",
courseName: course.title,
triggeredBy: "e_learning_completion_with_hundred_dj",
templateKey: "course.elearning.completion",
variables,
},
});
this.logger.log(
`E-Learning completion email with 100DJ dates sent to ${participant.email} for course ${course.title}`,
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to send E-Learning completion email: ${errorMessage}`, error);
// Don't throw - email failure should not break the main flow
}
}
/**
* Send Knowledge Review invite email directly (when 100DJ is skipped)
*/
private async sendKnowledgeReviewInviteEmail(event: ELearningCompletedEvent): Promise<void> {
try {
// Get participant and course details
const participant = await this.prisma.client.participant.findUnique({
where: { id: event.participantId },
include: { company: true },
});
const course = await this.prisma.client.course.findUnique({
where: { id: event.courseId },
});
if (!participant || !course) {
this.logger.warn(
`Cannot send Knowledge Review invite email: Participant ${event.participantId} or Course ${event.courseId} not found`,
);
return;
}
const frontendUrl = requireEnv("FRONTEND_URL");
// Get learning group participant to find the enrollment ID
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
include: { learningGroup: true },
});
const courseUrl = `${frontendUrl}/portal/my-courses/${enrollment?.id ?? event.learningGroupParticipantId}`;
// Fetch template and override subject for knowledge review (100DJ skipped case)
const template = await this.prisma.client.emailTemplate.findFirst({
where: {
template_key: EmailTemplateType.KNOWLEDGE_REVIEW,
is_active: true,
},
});
if (!template) {
throw new Error(`Email template ${EmailTemplateType.KNOWLEDGE_REVIEW} not found`);
}
// Override subject: "E-Learning Completed - Ready for Knowledge Review! 🎓"
const subject = "E-Learning Completed - Ready for Knowledge Review! 🎓";
// Include dynamic mail fields (mail.company_name, etc.) so {{mail.company_name}} is replaced
const dynamicMailVars = await this.dynamicMailFieldsService.getDynamicMailVariables({
companyNameFallback: participant.company?.name,
});
// Replace variables in body
const variables: Record<string, string> = {
"user.name": participant.first_name,
"user.email": participant.email,
"course.name": course.title,
"course.id": course.id.toString(),
"system.myCourseUrl": courseUrl,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
"system.iconsUrl": `${process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl}/assets/images/email-icons`,
...dynamicMailVars,
};
let content = template.message_body;
// Replace variables manually (simple replacement)
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{\\{${key.replace(/\./g, "\\.")}\\}\\}`, "g");
content = content.replace(regex, value);
});
// Wrap with skeleton so the recipient gets the standard header/footer/styles.
const trimmedKr = content.trim().toLowerCase();
content =
trimmedKr.startsWith("<!doctype") || trimmedKr.startsWith("<html")
? content
: getEmailSkeleton(content, template.custom_styles || undefined);
// Re-run variable replacement so skeleton placeholders resolve.
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{\\{${key.replace(/\./g, "\\.")}\\}\\}`, "g");
content = content.replace(regex, value);
});
await this.emailSender.sendEmail({
to: participant.email,
subject,
content,
templateId: template.id,
metadata: {
participant_id: event.participantId,
company_id: participant.company_id,
course_id: event.courseId,
learning_group_id: enrollment?.learning_group_id,
learning_group_participant_id: event.learningGroupParticipantId,
companyName: participant.company?.name || "",
courseName: course.title,
triggeredBy: "knowledge_review_invite_after_e_learning",
templateKey: EmailTemplateType.KNOWLEDGE_REVIEW,
variables,
},
});
this.logger.log(`Knowledge Review invite email sent to ${participant.email} for course ${course.title}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to send Knowledge Review invite email: ${errorMessage}`, error);
// Don't throw - email failure should not break the main flow
}
}
/**
* Send E-Learning completion email to participant
*/
private async sendELearningCompletionEmail(event: ELearningCompletedEvent): Promise<void> {
try {
// Get participant and course details
const participant = await this.prisma.client.participant.findUnique({
where: { id: event.participantId },
include: { company: true },
});
const course = await this.prisma.client.course.findUnique({
where: { id: event.courseId },
});
if (!participant || !course) {
this.logger.warn(
`Cannot send E-Learning completion email: Participant ${event.participantId} or Course ${event.courseId} not found`,
);
return;
}
const frontendUrl = requireEnv("FRONTEND_URL");
// Get learning group participant to find the enrollment ID
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
include: { learningGroup: true },
});
const courseUrl = `${frontendUrl}/portal/my-courses/${enrollment?.id ?? event.learningGroupParticipantId}`;
await this.emailSender.sendTemplatedEmail({
to: participant.email,
templateKey: "course.elearning.completion",
variables: {
"user.name": participant.first_name,
"user.email": participant.email,
"course.name": course.title,
"course.id": course.id.toString(),
"system.myCourseUrl": courseUrl,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
"system.iconsUrl": `${process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl}/assets/images/email-icons`,
},
metadata: {
participant_id: event.participantId,
company_id: participant.company_id,
course_id: event.courseId,
learning_group_id: enrollment?.learning_group_id,
learning_group_participant_id: event.learningGroupParticipantId,
companyName: participant.company?.name || "",
courseName: course.title,
triggeredBy: "e_learning_completion",
},
});
this.logger.log(`E-Learning completion email sent to ${participant.email} for course ${course.title}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to send E-Learning completion email: ${errorMessage}`, error);
// Don't throw - email failure should not break the main flow
}
}
/**
* Send Knowledge Review completion email to participant
* Informs participant that POST BAT button is now available
*/
private async sendKnowledgeReviewCompletionEmail(event: KnowledgeReviewCompletedEvent): Promise<void> {
try {
// Get participant and course details
const participant = await this.prisma.client.participant.findUnique({
where: { id: event.participantId },
include: { company: true },
});
const course = await this.prisma.client.course.findUnique({
where: { id: event.courseId },
});
if (!participant || !course) {
this.logger.warn(
`Cannot send Knowledge Review completion email: Participant ${event.participantId} or Course ${event.courseId} not found`,
);
return;
}
const frontendUrl = requireEnv("FRONTEND_URL");
// Get learning group participant to find the enrollment ID
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
include: { learningGroup: true },
});
const courseUrl = `${frontendUrl}/portal/my-courses/${enrollment?.id ?? event.learningGroupParticipantId}`;
await this.emailSender.sendTemplatedEmail({
to: participant.email,
templateKey: "course.knowledge.review.completion",
variables: {
"user.name": participant.first_name,
"user.email": participant.email,
"course.name": course.title,
"course.id": course.id.toString(),
"system.myCourseUrl": courseUrl,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
"system.iconsUrl": `${process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl}/assets/images/email-icons`,
},
metadata: {
participant_id: event.participantId,
company_id: participant.company_id,
course_id: event.courseId,
learning_group_id: enrollment?.learning_group_id,
learning_group_participant_id: event.learningGroupParticipantId,
companyName: participant.company?.name || "",
courseName: course.title,
triggeredBy: "knowledge_review_completion",
},
postProcessContent: (html) => stripSubscriptionExpiredBannerFromHtml(html),
});
this.logger.log(`Knowledge Review completion email sent to ${participant.email} for course ${course.title}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to send Knowledge Review completion email: ${errorMessage}`, error);
// Don't throw - email failure should not break the main flow
}
}
/**
* Send course completion email to participant
* Handles both CourseCompletedEvent and PostBatCompletedEvent
*/
private async sendCourseCompletionEmail(event: CourseCompletedEvent | PostBatCompletedEvent): Promise<void> {
try {
const template = await this.prisma.client.emailTemplate.findFirst({
where: { template_key: "course.completion" },
});
if (template) {
const existing = await this.prisma.client.emailLog.findFirst({
where: {
participant_id: event.participantId,
learning_group_participant_id: event.learningGroupParticipantId,
email_template_id: template.id,
status: { in: [EmailStatus.SENT, EmailStatus.PENDING] },
},
orderBy: { sent_date: "desc" },
});
if (existing) {
this.logger.debug(
`Skipping duplicate course.completion email for participant ${event.participantId}, enrollment ${event.learningGroupParticipantId}`,
);
return;
}
}
// Get participant and course details
const participant = await this.prisma.client.participant.findUnique({
where: { id: event.participantId },
include: { company: true },
});
const course = await this.prisma.client.course.findUnique({
where: { id: event.courseId },
});
if (!participant || !course) {
this.logger.warn(
`Cannot send course completion email: Participant ${event.participantId} or Course ${event.courseId} not found`,
);
return;
}
const frontendUrl = requireEnv("FRONTEND_URL");
// Get learning group participant to find the enrollment ID
const enrollment = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: event.learningGroupParticipantId },
include: { learningGroup: true },
});
const courseUrl = `${frontendUrl}/portal/my-courses/${enrollment?.id ?? event.learningGroupParticipantId}`;
await this.emailSender.sendTemplatedEmail({
to: participant.email,
templateKey: "course.completion",
variables: {
"user.name": participant.first_name,
"user.email": participant.email,
"course.name": course.title,
"course.id": course.id.toString(),
"system.myCourseUrl": courseUrl,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
"system.iconsUrl": `${process.env["FRONTEND_ASSETS_URL_FOR_LOCAL_EMAIL"] || frontendUrl}/assets/images/email-icons`,
},
metadata: {
participant_id: event.participantId,
company_id: participant.company_id,
course_id: event.courseId,
learning_group_id: enrollment?.learning_group_id,
learning_group_participant_id: event.learningGroupParticipantId,
companyName: participant.company?.name || "",
courseName: course.title,
triggeredBy: "course_completion",
},
});
this.logger.log(`Course completion email sent to ${participant.email} for course ${course.title}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to send course completion email: ${errorMessage}`, error);
// Don't throw - email failure should not break the main flow
}
}
}