File

apps/recallassess/recallassess-api/src/api/client/learning-group/listeners/course-progress-status.listener.ts

Description

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

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, emailSender: BNestEmailSenderService, httpService: HttpService, configService: ConfigService, assessmentService: CLAssessmentService, eventEmitter: EventEmitter2, dynamicMailFieldsService: DynamicMailFieldsService, learningGroupService: CLLearningGroupService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
emailSender BNestEmailSenderService No
httpService HttpService No
configService ConfigService No
assessmentService CLAssessmentService No
eventEmitter EventEmitter2 No
dynamicMailFieldsService DynamicMailFieldsService No
learningGroupService CLLearningGroupService No

Methods

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 :
Name Type Optional Description
learningGroupId number No
  • Learning group ID to check
Returns : Promise<void>
Private Async createHundredDjEmailLogs
createHundredDjEmailLogs(data: literal type)

Create email logs for 100DJ emails with scheduled dates Variables are replaced at creation time, not at send time

Parameters :
Name Type Optional
data literal type No
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 :
Name Type Optional
data literal type No
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 :
Name Type Optional
data literal type No
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 :
Name Type Optional
event CourseCompletedEvent No
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 :
Name Type Optional
event ELearningCompletedEvent No
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 :
Name Type Optional
event KnowledgeReviewCompletedEvent No
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 :
Name Type Optional
event PostBatCompletedEvent No
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 :
Name Type Optional
event PreBatCompletedEvent No
Returns : any
Private Async sendCourseCompletionEmail
sendCourseCompletionEmail(event: CourseCompletedEvent | PostBatCompletedEvent)

Send course completion email to participant Handles both CourseCompletedEvent and PostBatCompletedEvent

Parameters :
Name Type Optional
event CourseCompletedEvent | PostBatCompletedEvent No
Returns : Promise<void>
Private Async sendELearningCompletionEmail
sendELearningCompletionEmail(event: ELearningCompletedEvent)

Send E-Learning completion email to participant

Parameters :
Name Type Optional
event ELearningCompletedEvent No
Returns : Promise<void>
Private Async sendELearningCompletionEmailWithHundredDj
sendELearningCompletionEmailWithHundredDj(event: ELearningCompletedEvent, emailDates: literal type)

Send E-Learning completion email mentioning 100DJ emails and dates

Parameters :
Name Type Optional
event ELearningCompletedEvent No
emailDates literal type No
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 :
Name Type Optional
event KnowledgeReviewCompletedEvent No
Returns : Promise<void>
Private Async sendKnowledgeReviewInviteEmail
sendKnowledgeReviewInviteEmail(event: ELearningCompletedEvent)

Send Knowledge Review invite email directly (when 100DJ is skipped)

Parameters :
Name Type Optional
event ELearningCompletedEvent No
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 :
Name Type Optional Description
learningGroupId number No
participantId number No
triggerEvent "PRE_BAT_COMPLETED" | "POST_BAT_COMPLETED" Yes

Optional event name for system log (PRE_BAT_COMPLETED / POST_BAT_COMPLETED)

Returns : Promise<void>

Properties

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
    }
  }
}

results matching ""

    No results matching ""