File

apps/recallassess/recallassess-api/src/api/client/my-course/my-course.service.ts

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, mediaUtilService: BNestMediaUtilService, integrationService: IntegrationService, assessmentService: CLAssessmentService, httpService: HttpService, configService: ConfigService, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
mediaUtilService BNestMediaUtilService No
integrationService IntegrationService No
assessmentService CLAssessmentService No
httpService HttpService No
configService ConfigService No
subscriptionCourseAccess ParticipantSubscriptionCourseAccessService No

Methods

Private Async enrichMyCourseData
enrichMyCourseData(lgp: LearningGroupParticipantWithCourse, participantId: number, companyActiveOverride?: boolean)

Enrich learning group participant data with course and progress info for frontend

Parameters :
Name Type Optional
lgp LearningGroupParticipantWithCourse No
participantId number No
companyActiveOverride boolean Yes
Private Async enrichMyCourseDataSequential
enrichMyCourseDataSequential(lgp: LearningGroupParticipantWithCourse, participantId: number, sequentialPosition: number, companyActive?: boolean)

Enrich learning group participant data with sequential position

Parameters :
Name Type Optional
lgp LearningGroupParticipantWithCourse No
participantId number No
sequentialPosition number No
companyActive boolean Yes
Async generatePostBatHtml
generatePostBatHtml(participantId: number, learningGroupParticipantId: number)

Generate post-BAT HTML with learning recommendations

Parameters :
Name Type Optional Description
participantId number No

Current participant ID

learningGroupParticipantId number No

Learning group participant ID

Returns : Promise<string>

HTML string

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

Generate pre-BAT HTML for a course

Parameters :
Name Type Optional Description
participantId number No

Current logged-in participant ID

learningGroupParticipantId number No

LearningGroupParticipant ID

Returns : Promise<string>

HTML string

Async getMyCourseByCode
getMyCourseByCode(participantId: number, courseCode: string)

Get a single enrolled course by course_code with sequential validation Returns redirect info if course is not accessible (instead of throwing error)

Parameters :
Name Type Optional Description
participantId number No

Current logged-in participant ID

courseCode string No

Course code (e.g., "communication-skills-101")

Returns : Promise<literal type>

Course with redirect info if not accessible

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

Get a single enrolled course by LearningGroupParticipant ID

Parameters :
Name Type Optional Description
participantId number No

Current logged-in participant ID

learningGroupParticipantId number No

LearningGroupParticipant ID

Enrolled course with progress or null

Async getMyCourses
getMyCourses(participantId: number)

Get all courses the current participant is enrolled in via learning groups

Parameters :
Name Type Optional Description
participantId number No

Current logged-in participant ID

Returns : Promise<literal type>

Array of enrolled courses with progress

Async getMyCoursesSequential
getMyCoursesSequential(participantId: number)

Get all courses ordered sequentially with accessibility validation Courses are ordered by enrollment date (created_at ASC)

Parameters :
Name Type Optional Description
participantId number No

Current logged-in participant ID

Array of enrolled courses with sequential position and accessibility

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

Get post-BAT PDF download URL (presigned S3 URL) Reads the latest CR-generated PDF from S3 (no on-click regeneration)

Parameters :
Name Type Optional Description
participantId number No

Current participant ID

learningGroupParticipantId number No

Learning group participant ID

Returns : Promise<string>

Presigned S3 URL for PDF download

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

Get pre-BAT PDF download URL (presigned S3 URL) Reads the latest CR-generated PDF from S3 (no on-click regeneration)

Parameters :
Name Type Optional Description
participantId number No

Current participant ID

learningGroupParticipantId number No

Learning group participant ID

Returns : Promise<string>

Presigned S3 URL for PDF download

Async getResumeCourseInfo
getResumeCourseInfo(participantId: number)

Get resume course information (last accessed course) Returns the most recent incomplete course with last accessed page

Parameters :
Name Type Optional
participantId number No
Returns : Promise<literal type | null>
Private mapLearningGroupParticipantStatusToCourseStatus
mapLearningGroupParticipantStatusToCourseStatus(status: string | null | undefined)

Map LearningGroupParticipant status to frontend course status format LearningGroupParticipant status: INVITED, PRE_BAT, E_LEARNING, POST_BAT, COMPLETED Frontend status: NOT_STARTED, IN_PROGRESS, COMPLETED

Parameters :
Name Type Optional
status string | null | undefined No
Returns : "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED"
Async regeneratePostBatAnalysis
regeneratePostBatAnalysis(participantId: number, learningGroupParticipantId: number)

Regenerate POST-BAT analysis by resending data to CR API

Parameters :
Name Type Optional Description
participantId number No

Current logged-in participant ID

learningGroupParticipantId number No

Learning group participant ID

Returns : Promise<literal type>

Success status and message

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

Regenerate PRE-BAT analysis by resending data to CR API

Parameters :
Name Type Optional Description
participantId number No

Current logged-in participant ID

learningGroupParticipantId number No

Learning group participant ID

Returns : Promise<literal type>

Success status and message

Properties

Private Readonly craiApiToken
Type : string
Private Readonly craiApiUrl
Type : string
Private Readonly logger
Type : unknown
Default value : new Logger(CLMyCourseService.name)
import { IntegrationService } from "@api/integration/integration.service";
import { BNestMediaUtilService, BNestPrismaService } from "@bish-nest/core/services";
import { HttpService } from "@nestjs/axios";
import {
  ForbiddenException,
  Injectable,
  InternalServerErrorException,
  Logger,
  NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import {
  Course,
  CourseModule,
  LearningGroup,
  LearningGroupParticipant,
  Media,
  ParticipantLearningProgressStatus,
  Prisma,
  SysmtemLogOperationType,
  SystemLogEntityType,
} from "@prisma/client";
import { Decimal } from "@prisma/client/runtime/library";
import { firstValueFrom } from "rxjs";
import { CLAssessmentService } from "../assessment/assessment.service";
import { ParticipantSubscriptionCourseAccessService } from "../shared/participant-subscription-course-access.service";
import { CLMyCourseDto } from "./dto";

type LearningGroupParticipantWithCourse = LearningGroupParticipant & {
  learningGroup: LearningGroup & {
    course: Course & {
      assessment_id?: number | null;
      mediaImages: Media[];
      courseModulePages: Array<{
        courseModule: Pick<CourseModule, "id" | "title" | "sort_order" | "course_module_code"> | null;
      }>;
    };
    eLearningParticipants: Array<{
      participant_id: number;
      progress_percentage: Decimal | null;
      status: string;
      course_modules_completed: number;
      total_course_modules: number;
      start_date: Date | null;
      complete_date: Date | null;
      last_activity: Date | null;
    }>;
  };
};

@Injectable()
export class CLMyCourseService {
  private readonly logger = new Logger(CLMyCourseService.name);
  private readonly craiApiUrl: string;
  private readonly craiApiToken: string;

  constructor(
    private prisma: BNestPrismaService,
    private mediaUtilService: BNestMediaUtilService,
    private integrationService: IntegrationService,
    private assessmentService: CLAssessmentService,
    private httpService: HttpService,
    private configService: ConfigService,
    private readonly subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService,
  ) {
    this.craiApiUrl =
      this.configService.get<string>("CRAI_API_URL") || "https://recallassess-craiapi.pilottest.site";
    this.craiApiToken = this.configService.get<string>("CRAI_API_TOKEN") || "";
  }

  /**
   * Get all courses the current participant is enrolled in via learning groups
   * @param participantId Current logged-in participant ID
   * @returns Array of enrolled courses with progress
   */
  async getMyCourses(participantId: number): Promise<{ courses: CLMyCourseDto[]; company_active: boolean }> {
    // Allow listing courses even when subscription is expired (courses remain visible)
    const companyActive = await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId);

    // Fetch all learning group assignments for this participant
    const learningGroupParticipants = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: participantId,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              include: {
                mediaImages: {
                  where: {
                    media_name: "COURSE__IMAGE",
                  },
                },
                courseModulePages: {
                  where: {
                    courseModule: {
                      is_published: true,
                    },
                  },
                  select: {
                    courseModule: {
                      select: {
                        id: true,
                        title: true,
                        description: true,
                        short_description: true,
                        is_published: true,
                        sort_order: true,
                        flag: true,
                        created_at: true,
                        updated_at: true,
                        user_id_created_by: true,
                        user_id_updated_by: true,
                        course_module_code: true,
                        exclude_from_bat: true,
                      },
                    },
                  },
                },
              },
            },
            // Get progress from eLearningParticipant for this participant and course
            eLearningParticipants: {
              where: {
                participant_id: participantId,
              },
            },
          },
        },
      },
      orderBy: [
        { updated_at: "desc" }, // Recently updated first
        { created_at: "desc" }, // Then by enrollment date
      ],
    });

    // Process S3 URLs for media
    await Promise.all(
      learningGroupParticipants.map(async (lgp) => {
        if (lgp.learningGroup?.course?.mediaImages) {
          await this.mediaUtilService.addS3UrlPrefixToMediaArray(lgp.learningGroup.course.mediaImages);
        }
      }),
    );

    // Transform to DTOs
    const courses = await Promise.all(
      learningGroupParticipants.map((lgp) =>
        this.enrichMyCourseData(lgp as LearningGroupParticipantWithCourse, participantId, companyActive),
      ),
    );
    return { courses, company_active: companyActive };
  }

  /**
   * Get a single enrolled course by LearningGroupParticipant ID
   * @param participantId Current logged-in participant ID
   * @param learningGroupParticipantId LearningGroupParticipant ID
   * @returns Enrolled course with progress or null
   */
  async getMyCourseById(participantId: number, learningGroupParticipantId: number): Promise<CLMyCourseDto | null> {
    const learningGroupParticipant = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              include: {
                mediaImages: {
                  where: {
                    media_name: "COURSE__IMAGE",
                  },
                },
                courseModulePages: {
                  where: {
                    courseModule: {
                      is_published: true,
                    },
                  },
                  select: {
                    courseModule: {
                      select: {
                        id: true,
                        title: true,
                        description: true,
                        short_description: true,
                        is_published: true,
                        sort_order: true,
                        flag: true,
                        created_at: true,
                        updated_at: true,
                        user_id_created_by: true,
                        user_id_updated_by: true,
                        course_module_code: true,
                        exclude_from_bat: true,
                      },
                    },
                  },
                },
              },
            },
            eLearningParticipants: {
              where: {
                participant_id: participantId,
              },
            },
          },
        },
      },
    });

    if (!learningGroupParticipant) {
      return null;
    }

    // Allow opening course detail even when company subscription is inactive.
    // Blocking is enforced only when participant tries to continue/start gated course actions.

    // Process S3 URLs for media
    if (learningGroupParticipant.learningGroup?.course?.mediaImages) {
      await this.mediaUtilService.addS3UrlPrefixToMediaArray(learningGroupParticipant.learningGroup.course.mediaImages);
    }

    return this.enrichMyCourseData(learningGroupParticipant as LearningGroupParticipantWithCourse, participantId);
  }

  /**
   * Enrich learning group participant data with course and progress info for frontend
   */
  private async enrichMyCourseData(
    lgp: LearningGroupParticipantWithCourse,
    participantId: number,
    companyActiveOverride?: boolean,
  ): Promise<CLMyCourseDto> {
    const course = lgp.learningGroup?.course;
    if (!course) {
      throw new Error("Course not found in learning group participant");
    }
    // Extract unique modules from courseModulePages
    const courseModules =
      course.courseModulePages
        ?.map((page) => page.courseModule)
        .filter((module, idx, self) => module && self.findIndex((m) => m?.id === module.id) === idx)
        .filter((module): module is NonNullable<typeof module> => module !== null) || [];
    const moduleCount = courseModules.length;

    // For courses with Pre-BAT assessments, count modules that have assessment questions
    let assessmentModuleCount = moduleCount;
    if (course.assessment_id) {
      try {
        const assessmentQuestions = await this.prisma.client.assessmentQuestion.findMany({
          where: {
            assessment_id: course.assessment_id,
          },
          select: {
            course_module_id: true,
          },
        });
        // Count unique modules that have assessment questions
        const uniqueModuleIds = [
          ...new Set(assessmentQuestions.map((q) => q.course_module_id).filter((id) => id !== null)),
        ];
        assessmentModuleCount = uniqueModuleIds.length;
      } catch {
        // Fallback to regular module count if assessment query fails
        assessmentModuleCount = moduleCount;
      }
    }

    // Get progress data from eLearningParticipant (if exists)
    const progressData = lgp.learningGroup?.eLearningParticipants?.[0] || null;

    // Use category from DB, with fallback
    const categories = ["Communication", "Psychology", "Sales", "Negotiation", "Leadership"];
    const category = course.category || categories[0];

    // Level color mapping
    const level_color_map = {
      FOUNDATION: "orange",
      INTERMEDIATE: "blue",
      ADVANCED: "green",
    };

    const level = course.level || "FOUNDATION";
    const level_color = level_color_map[level as keyof typeof level_color_map] || "orange";

    // Calculate duration
    const calculatedDuration = (() => {
      const weeksMin = Math.max(3, Math.floor(moduleCount * 0.4));
      const weeksMax = Math.max(4, Math.ceil(moduleCount * 0.7));
      return `${weeksMin}-${weeksMax} weeks`;
    })();
    const duration = course.duration || calculatedDuration;

    // Get course image
    // If no media exists, frontend will show a gradient placeholder
    const image = course.mediaImages && course.mediaImages.length > 0 ? course.mediaImages[0].media_path : "";

    // Check if PRE BAT assessment is completed using assessment_id from course
    // Type assertion to access assessment_id (field exists but TypeScript types not regenerated yet)
    const courseWithAssessmentId = course as typeof course & { assessment_id: number | null };
    const courseWithKnowledgeReviewId = course as typeof course & { knowledge_review_id: number | null };

    let preBatCompleted = false;
    let postBatCompleted = false;
    let preBatAnalysisAvailable = false;
    let postBatAnalysisAvailable = false;
    let knowledgeReviewCompleted = false;

    if (courseWithAssessmentId.assessment_id) {
      const [preBatParticipant, postBatParticipant] = await Promise.all([
        this.prisma.client.assessmentParticipant.findFirst({
          where: {
            assessment_id: courseWithAssessmentId.assessment_id,
            participant_id: participantId,
            assessment_type: "PRE_BAT",
          },
        }),
        this.prisma.client.assessmentParticipant.findFirst({
          where: {
            assessment_id: courseWithAssessmentId.assessment_id,
            participant_id: participantId,
            assessment_type: "POST_BAT",
          },
        }),
      ]);
      preBatCompleted = !!preBatParticipant;
      postBatCompleted = !!postBatParticipant;
      // Check if ai_analysis is available (not null and not empty) for PRE BAT
      preBatAnalysisAvailable = !!(
        preBatParticipant?.ai_analysis && preBatParticipant.ai_analysis.trim().length > 0
      );
      // Check if POST BAT analysis is available: either per-topic learning_path (legacy) or post_ai_analysis with overall_summary (new)
      if (postBatParticipant) {
        const hasLearningPathInResults = await this.prisma.client.assessmentResult.findFirst({
          where: {
            assessment_participant_id: postBatParticipant.id,
            learning_path: {
              not: null,
            },
          },
        });
        const postAnalysisRaw = (postBatParticipant as { post_ai_analysis?: string | null }).post_ai_analysis;
        const hasOverallSummaryInPostAiAnalysis =
          postAnalysisRaw &&
          postAnalysisRaw.trim().length > 0 &&
          (() => {
            try {
              const parsed = JSON.parse(postAnalysisRaw) as { overall_summary?: string };
              return typeof parsed.overall_summary === "string" && parsed.overall_summary.trim().length > 0;
            } catch {
              return false;
            }
          })();
        postBatAnalysisAvailable = !!(
          (hasLearningPathInResults?.learning_path && hasLearningPathInResults.learning_path.trim().length > 0) ||
          hasOverallSummaryInPostAiAnalysis
        );
      }
    }

    // Check if standalone knowledge review is completed
    // Only check for standalone KR (course_module_page_id is NULL), not embedded quizzes
    if (courseWithKnowledgeReviewId.knowledge_review_id) {
      const knowledgeReviewParticipant = await this.prisma.client.knowledgeReviewParticipant.findFirst({
        where: {
          knowledge_review_id: courseWithKnowledgeReviewId.knowledge_review_id,
          participant_id: participantId,
          course_module_page_id: null, // Only check standalone KR, not embedded quizzes
        },
      });
      knowledgeReviewCompleted = !!knowledgeReviewParticipant;
    }

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

    // Get skip_hundred_dj from course
    const courseWithSkipHundredDj = course as typeof course & { skip_hundred_dj?: boolean };
    const skipHundredDj = courseWithSkipHundredDj.skip_hundred_dj ?? false;

    // Get 100DJ email dates and knowledge_review_email_date from LearningGroupParticipant
    const lgpWithDates = lgp as typeof lgp & {
      knowledge_review_email_date?: Date | null;
      post_bat_email_date?: Date | null;
      hundred_dj_email1_date?: Date | null;
      hundred_dj_email2_date?: Date | null;
      hundred_dj_email3_date?: Date | null;
      hundred_dj_email4_date?: Date | null;
    };
    const knowledgeReviewEmailDate = lgpWithDates.knowledge_review_email_date || null;
    const postBatEmailDate = lgpWithDates.post_bat_email_date || null;
    const hundredDjEmail1Date = lgpWithDates.hundred_dj_email1_date || null;
    const hundredDjEmail2Date = lgpWithDates.hundred_dj_email2_date || null;
    const hundredDjEmail3Date = lgpWithDates.hundred_dj_email3_date || null;
    const hundredDjEmail4Date = lgpWithDates.hundred_dj_email4_date || null;

    // Derive frontend status with progress-aware fallback
    let status = this.mapLearningGroupParticipantStatusToCourseStatus(lgp.status);
    const completionPercentage = lgp.completion_percentage ? Number(lgp.completion_percentage) : 0;
    const eLearningProgress = progressData ? Number(progressData.progress_percentage) || 0 : 0;
    const hasProgress = completionPercentage > 0 || eLearningProgress > 0;
    const isInvitedNotAccepted = lgp.status === "INVITED" && !lgp.accepted_at;

    if (status === "NOT_STARTED" && hasProgress && !isInvitedNotAccepted) {
      status = "IN_PROGRESS";
    }

    const company_active =
      companyActiveOverride !== undefined
        ? companyActiveOverride
        : await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId);

    return {
      id: lgp.id,
      learning_group_participant_id: lgp.id, // Explicit alias for clarity
      course_id: course.id,
      title: course.title,
      category,
      level,
      level_color,
      description:
        course.description || course.short_description || "Learn essential skills to advance your career.",
      modules: moduleCount,
      duration,
      image,
      course_code: course.course_code,

      // Progress tracking from eLearningParticipant (if available) - for e-learning module progress only
      progress_percentage: eLearningProgress,
      // Overall course completion percentage from LearningGroupParticipant (25%, 50%, 75%, 100%)
      // This is the authoritative source for overall progress, set by event listeners
      completion_percentage: completionPercentage,
      // Map LearningGroupParticipant status to frontend status format
      // Use LearningGroupParticipant.status (overall course status) instead of eLearningParticipant.status (e-learning only)
      status,
      // Include raw status and accepted_at for invitation handling
      raw_status: lgp.status,
      accepted_at: lgp.accepted_at,
      course_modules_completed: progressData?.course_modules_completed || 0,
      total_course_modules: progressData?.total_course_modules || assessmentModuleCount,
      start_date: progressData?.start_date || null,
      complete_date: progressData?.complete_date || null,
      last_activity: progressData?.last_activity || null,
      learning_group_id: lgp.learning_group_id,
      pre_bat_completed: preBatCompleted,
      post_bat_completed: postBatCompleted,
      pre_bat_analysis_available: preBatAnalysisAvailable,
      post_bat_analysis_available: postBatAnalysisAvailable,
      knowledge_review_completed: knowledgeReviewCompleted,
      module_assignment_rule: moduleAssignmentRule, // For testing
      skip_hundred_dj: skipHundredDj,
      knowledge_review_email_date: knowledgeReviewEmailDate,
      post_bat_email_date: postBatEmailDate,
      hundred_dj_email1_date: hundredDjEmail1Date,
      hundred_dj_email2_date: hundredDjEmail2Date,
      hundred_dj_email3_date: hundredDjEmail3Date,
      hundred_dj_email4_date: hundredDjEmail4Date,
      cancelled: lgp.cancelled || false,
      company_active,
    };
  }

  /**
   * Map LearningGroupParticipant status to frontend course status format
   * LearningGroupParticipant status: INVITED, PRE_BAT, E_LEARNING, POST_BAT, COMPLETED
   * Frontend status: NOT_STARTED, IN_PROGRESS, COMPLETED
   */
  private mapLearningGroupParticipantStatusToCourseStatus(
    status: string | null | undefined,
  ): "NOT_STARTED" | "IN_PROGRESS" | "COMPLETED" {
    if (!status) {
      return "NOT_STARTED";
    }

    // Map to frontend status format
    switch (status) {
      case "COMPLETED":
        return "COMPLETED"; // Only when all stages (PRE BAT, E-Learning, Knowledge Review, POST BAT) are done
      // Note: cancelled field is checked separately, not via status enum
      case "INVITED":
        return "NOT_STARTED";
      case "ACCEPTED":
        return "NOT_STARTED"; // Accepted but not yet started PRE_BAT
      case "PRE_BAT":
      case "E_LEARNING":
      case "POST_BAT":
        return "IN_PROGRESS"; // Course is in progress (e-learning may be done, but course is not fully complete)
      default:
        return "NOT_STARTED";
    }
  }

  /**
   * Generate pre-BAT HTML for a course
   * @param participantId Current logged-in participant ID
   * @param learningGroupParticipantId LearningGroupParticipant ID
   * @returns HTML string
   */
  async generatePreBatHtml(participantId: number, learningGroupParticipantId: number): Promise<string> {
    // Get the learning group participant to access learning_group_id and course
    const learningGroupParticipant = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: {
          include: {
            course: true,
          },
        },
      },
    });

    if (!learningGroupParticipant) {
      throw new NotFoundException(
        `Course enrollment with ID ${learningGroupParticipantId} not found or not enrolled`,
      );
    }

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

    await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
      participantId,
      learningGroupParticipantId,
      !!learningGroupParticipant.pre_bat_completed_at,
      learningGroupParticipant.status,
    );

    const learningGroupId = learningGroupParticipant.learning_group_id;

    // Call the integration service to generate the HTML
    return this.integrationService.generatePreBatHtml(learningGroupId, participantId);
  }

  /**
   * Get pre-BAT PDF download URL (presigned S3 URL)
   * Reads the latest CR-generated PDF from S3 (no on-click regeneration)
   * @param participantId Current participant ID
   * @param learningGroupParticipantId Learning group participant ID
   * @returns Presigned S3 URL for PDF download
   */
  async getPreBatPdfDownloadUrl(participantId: number, learningGroupParticipantId: number): Promise<string> {
    // Get the learning group participant to access learning_group_id
    const learningGroupParticipant = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: {
          include: {
            course: true,
          },
        },
      },
    });

    if (!learningGroupParticipant) {
      throw new NotFoundException(
        `Learning group participant with ID ${learningGroupParticipantId} not found or not enrolled`,
      );
    }

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

    await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
      participantId,
      learningGroupParticipantId,
      !!learningGroupParticipant.pre_bat_completed_at,
      learningGroupParticipant.status,
    );

    const learningGroupId = learningGroupParticipant.learning_group_id;
    const course = learningGroupParticipant.learningGroup.course;

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

    if (!course.assessment_id) {
      throw new NotFoundException("Course does not have an assessment assigned");
    }

    try {
      // Use the latest pre-generated report created when CR data arrived.
      const s3Path = await this.integrationService.checkPreBatPdfExists(learningGroupId, participantId);

      // Generate presigned URL for download
      return await this.mediaUtilService.getS3Url(s3Path, false);
    } catch (error) {
      const err = error instanceof Error ? error : new Error(String(error));
      const causeMsg = err.cause instanceof Error ? err.cause.message : err.cause != null ? String(err.cause) : "";
      this.logger.error(
        `Failed to generate fresh Pre-BAT PDF: ${err.message}${causeMsg ? ` (cause: ${causeMsg})` : ""}`,
        err.stack,
      );
      throw new InternalServerErrorException("Failed to generate PDF. Please try again.");
    }
  }

  /**
   * Generate post-BAT HTML with learning recommendations
   * @param participantId Current participant ID
   * @param learningGroupParticipantId Learning group participant ID
   * @returns HTML string
   */
  async generatePostBatHtml(participantId: number, learningGroupParticipantId: number): Promise<string> {
    // Get the learning group participant to access learning_group_id and course
    const learningGroupParticipant = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: {
          include: {
            course: true,
          },
        },
      },
    });

    if (!learningGroupParticipant) {
      throw new NotFoundException(
        `Course enrollment with ID ${learningGroupParticipantId} not found or not enrolled`,
      );
    }

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

    const assessmentId = learningGroupParticipant.learningGroup.course.assessment_id;
    const postBatCompleted =
      assessmentId != null &&
      !!(await this.prisma.client.assessmentParticipant.findFirst({
        where: {
          participant_id: participantId,
          assessment_id: assessmentId,
          assessment_type: "POST_BAT",
        },
        select: { id: true },
      }));

    await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
      participantId,
      learningGroupParticipantId,
      postBatCompleted,
      learningGroupParticipant.status,
    );

    const learningGroupId = learningGroupParticipant.learning_group_id;
    const courseCode = learningGroupParticipant.learningGroup.course.course_code;

    // Call the integration service to generate the HTML
    return this.integrationService.generatePostBatHtml(learningGroupId, participantId, courseCode);
  }

  /**
   * Get post-BAT PDF download URL (presigned S3 URL)
   * Reads the latest CR-generated PDF from S3 (no on-click regeneration)
   * @param participantId Current participant ID
   * @param learningGroupParticipantId Learning group participant ID
   * @returns Presigned S3 URL for PDF download
   */
  async getPostBatPdfDownloadUrl(participantId: number, learningGroupParticipantId: number): Promise<string> {
    // Get the learning group participant to access learning_group_id
    const learningGroupParticipant = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId, // Verify it belongs to the current participant
      },
      include: {
        learningGroup: {
          include: {
            course: true,
          },
        },
      },
    });

    if (!learningGroupParticipant) {
      throw new NotFoundException(
        `Learning group participant with ID ${learningGroupParticipantId} not found or not enrolled`,
      );
    }

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

    const assessmentId = learningGroupParticipant.learningGroup.course.assessment_id;
    const postBatCompleted =
      assessmentId != null &&
      !!(await this.prisma.client.assessmentParticipant.findFirst({
        where: {
          participant_id: participantId,
          assessment_id: assessmentId,
          assessment_type: "POST_BAT",
        },
        select: { id: true },
      }));

    await this.subscriptionCourseAccess.assertAllowsCompletedStageRead(
      participantId,
      learningGroupParticipantId,
      postBatCompleted,
      learningGroupParticipant.status,
    );

    const learningGroupId = learningGroupParticipant.learning_group_id;
    const course = learningGroupParticipant.learningGroup.course;

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

    if (!course.assessment_id) {
      throw new NotFoundException("Course does not have an assessment assigned");
    }

    try {
      // Use the latest pre-generated report created when CR data arrived.
      const s3Path = await this.integrationService.checkPostBatPdfExists(learningGroupId, participantId);

      // Generate presigned URL for download
      return await this.mediaUtilService.getS3Url(s3Path, false);
    } catch (error) {
      const err = error instanceof Error ? error : new Error(String(error));
      const causeMsg = err.cause instanceof Error ? err.cause.message : err.cause != null ? String(err.cause) : "";
      this.logger.error(
        `Failed to generate POST-BAT PDF: ${err.message}${causeMsg ? ` (cause: ${causeMsg})` : ""}`,
        err.stack,
      );
      throw new NotFoundException(
        "POST BAT PDF report could not be generated. Please contact the administrator for assistance.",
      );
    }
  }

  /**
   * Get all courses ordered sequentially with accessibility validation
   * Courses are ordered by enrollment date (created_at ASC)
   * @param participantId Current logged-in participant ID
   * @returns Array of enrolled courses with sequential position and accessibility
   */
  async getMyCoursesSequential(participantId: number): Promise<CLMyCourseDto[]> {
    // Allow listing courses even when subscription is expired (courses remain visible)
    const companyActive = await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId);

    // Fetch all learning group assignments ordered by enrollment date (sequential)
    // Include cancelled enrollments so participants can see cancelled courses with cancelled tag
    const learningGroupParticipants = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: participantId,
        // Include cancelled courses - they will be marked with cancelled tag
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
                category: true,
                level: true,
                description: true,
                duration: true,
                course_code: true,
                assessment_id: true,
              },
              include: {
                mediaImages: {
                  where: {
                    media_name: "COURSE__IMAGE",
                  },
                },
                courseModulePages: {
                  where: {
                    courseModule: {
                      is_published: true,
                    },
                  },
                  select: {
                    courseModule: {
                      select: {
                        id: true,
                        course_module_code: true,
                        title: true,
                        sort_order: true,
                      },
                    },
                  },
                },
              },
            },
            eLearningParticipants: {
              where: {
                participant_id: participantId,
              },
            },
          },
        },
      },
      orderBy: [
        { created_at: "asc" }, // Sequential order - oldest enrollment first
      ],
    });

    // Process S3 URLs for media
    await Promise.all(
      learningGroupParticipants.map(async (lgp) => {
        if (lgp.learningGroup?.course?.mediaImages) {
          await this.mediaUtilService.addS3UrlPrefixToMediaArray(lgp.learningGroup.course.mediaImages);
        }
      }),
    );

    // Transform to DTOs with sequential position
    const courses = await Promise.all(
      learningGroupParticipants.map((lgp, index) =>
        this.enrichMyCourseDataSequential(
          lgp as LearningGroupParticipantWithCourse,
          participantId,
          index + 1, // Position starts at 1
          companyActive,
        ),
      ),
    );

    // Check accessibility: a course is accessible if all previous courses are completed
    return courses.map((course, index) => {
      const previousCourses = courses.slice(0, index);
      const allPreviousCompleted = previousCourses.every((prevCourse) => prevCourse.status === "COMPLETED");

      return {
        ...course,
        is_accessible: index === 0 || allPreviousCompleted, // First course is always accessible
      };
    });
  }

  /**
   * Get a single enrolled course by course_code with sequential validation
   * Returns redirect info if course is not accessible (instead of throwing error)
   * @param participantId Current logged-in participant ID
   * @param courseCode Course code (e.g., "communication-skills-101")
   * @returns Course with redirect info if not accessible
   */
  async getMyCourseByCode(
    participantId: number,
    courseCode: string,
  ): Promise<{
    course: CLMyCourseDto;
    is_accessible: boolean;
    requires_redirect?: boolean;
    redirect_to_course?: CLMyCourseDto;
    message?: string;
  }> {
    // Get all courses sequentially ordered
    const allCourses = await this.getMyCoursesSequential(participantId);

    // Find the requested course
    const course = allCourses.find((c) => c.course_code === courseCode);

    if (!course) {
      throw new NotFoundException(`Course with code "${courseCode}" not found or not enrolled`);
    }

    // Check if accessible
    if (!course.is_accessible) {
      // Find the first incomplete course (the one they should complete)
      const courseIndex = allCourses.findIndex((c) => c.course_code === courseCode);
      const firstIncompleteCourse = allCourses.slice(0, courseIndex).find((c) => c.status !== "COMPLETED");

      return {
        course,
        is_accessible: false,
        requires_redirect: true,
        redirect_to_course: firstIncompleteCourse,
        message: `Complete "${firstIncompleteCourse?.title || "the previous course"}" first to unlock this course. We've taken you there! 🎯`,
      };
    }

    return {
      course,
      is_accessible: true,
      requires_redirect: false,
    };
  }

  /**
   * Enrich learning group participant data with sequential position
   */
  private async enrichMyCourseDataSequential(
    lgp: LearningGroupParticipantWithCourse,
    participantId: number,
    sequentialPosition: number,
    companyActive?: boolean,
  ): Promise<CLMyCourseDto> {
    const enrichedData = await this.enrichMyCourseData(lgp, participantId, companyActive);
    return {
      ...enrichedData,
      sequential_position: sequentialPosition,
    };
  }

  /**
   * Get resume course information (last accessed course)
   * Returns the most recent incomplete course with last accessed page
   */
  async getResumeCourseInfo(participantId: number): Promise<{
    course: CLMyCourseDto;
    lastPageId: number;
    lastPageTitle: string;
    progress: number;
    daysSinceLastActivity: number;
  } | null> {
    if (!(await this.subscriptionCourseAccess.isCompanyActiveForParticipant(participantId))) {
      return null;
    }
    // Find most recent incomplete course with activity
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        participant_id: participantId,
        cancelled: false,
        status: { not: ParticipantLearningProgressStatus.COMPLETED },
      },
      orderBy: { updated_at: "desc" },
      include: {
        learningGroup: {
          include: {
            course: {
              include: {
                mediaImages: {
                  where: {
                    media_name: "COURSE__IMAGE",
                  },
                },
                courseModulePages: {
                  where: {
                    courseModule: {
                      is_published: true,
                    },
                  },
                  select: {
                    courseModule: {
                      select: {
                        id: true,
                        course_module_code: true,
                        title: true,
                        sort_order: true,
                      },
                    },
                  },
                },
              },
            },
            eLearningParticipants: {
              where: {
                participant_id: participantId,
              },
            },
          },
        },
      },
    });

    if (!enrollment) {
      return null;
    }

    // Process S3 URLs for media
    if (enrollment.learningGroup?.course?.mediaImages) {
      await this.mediaUtilService.addS3UrlPrefixToMediaArray(enrollment.learningGroup.course.mediaImages);
    }

    // Get last accessed page - query by course_id and participant_id only
    const lastPage = await this.prisma.client.eLearningCourseModulePageProgress.findFirst({
      where: {
        participant_id: participantId,
        course_id: enrollment.learningGroup?.course?.id || 0,
      },
      orderBy: { updated_at: "desc" },
      include: {
        courseModulePage: {
          select: {
            id: true,
            title: true,
          },
        },
      },
    });

    const enrichedCourse = await this.enrichMyCourseData(
      enrollment as LearningGroupParticipantWithCourse,
      participantId,
      true,
    );
    const progress = Number(enrollment.completion_percentage) || 0;
    // Use same source as course detail page (elearning_participant.last_activity) so dashboard and course detail show same "last activity"
    const eLearningParticipant = (
      enrollment.learningGroup as { eLearningParticipants?: Array<{ last_activity: Date | null }> }
    )?.eLearningParticipants?.[0];
    const lastActivityDate = eLearningParticipant?.last_activity ?? lastPage?.updated_at ?? null;
    const daysSinceLastActivity = lastActivityDate
      ? Math.floor((Date.now() - lastActivityDate.getTime()) / (1000 * 60 * 60 * 24))
      : 0;

    return {
      course: enrichedCourse,
      lastPageId: lastPage?.courseModulePage?.id || 0,
      lastPageTitle: lastPage?.courseModulePage?.title || "Start Course",
      progress,
      daysSinceLastActivity,
    };
  }

  /**
   * Regenerate PRE-BAT analysis by resending data to CR API
   * @param participantId Current logged-in participant ID
   * @param learningGroupParticipantId Learning group participant ID
   * @returns Success status and message
   */
  async regeneratePreBatAnalysis(
    participantId: number,
    learningGroupParticipantId: number,
  ): Promise<{ success: boolean; message: string }> {
    // Verify enrollment exists and belongs to participant
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId,
      },
      include: {
        learningGroup: {
          include: {
            course: true,
          },
        },
      },
    });

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

    await this.subscriptionCourseAccess.assertCompanySubscriptionActive(participantId);

    if (!enrollment.pre_bat_completed_at) {
      throw new ForbiddenException("PRE-BAT assessment must be completed before regenerating analysis");
    }

    let systemLogId: number | null = null;
    let requestStartTime = 0;
    try {
      // Get learning group ID and participant ID for the assessment service
      const learningGroupId = enrollment.learning_group_id;

      // Format PRE/POST BAT data then strip it down to PRE-BAT only
      const fullBatData = await this.assessmentService.formatPrePostBatDataForIntegration(
        learningGroupId,
        participantId,
      );

      // Ensure only PRE-BAT data is sent (even if POST-BAT exists)
      const batData = {
        ...fullBatData,
        metadata: {
          ...fullBatData.metadata,
          plj_position: "PRE_BAT" as const,
        },
        data: fullBatData.data.map((participant) => {
          const modules: Record<
            string,
            {
              PRE_BAT: (typeof participant.modules)[string]["PRE_BAT"];
              POST_BAT: (typeof participant.modules)[string]["POST_BAT"];
            }
          > = {};

          Object.entries(participant.modules).forEach(([code, moduleData]) => {
            modules[code] = {
              ...moduleData,
              // Keep PRE_BAT answers, clear POST_BAT so CR only sees PRE data
              PRE_BAT: moduleData.PRE_BAT,
              POST_BAT: [],
            };
          });

          return {
            ...participant,
            // Keep PRE quotient, clear POST/change to avoid confusion
            metadata: {
              ...participant.metadata,
              individual_quotient_post: 0,
              individual_quotient_change: 0,
            },
            modules,
          };
        }),
      };

      // POST to CRAI API (same as in the listener)
      const apiUrl = `${this.craiApiUrl}/generate-feedback`;
      const headers: { "Content-Type": string; Authorization?: string } = {
        "Content-Type": "application/json",
      };

      // Create system log row for outbound audit, then update with status_code after the HTTP call.
      try {
        const createdLog = await this.prisma.client.systemLog.create({
          data: {
            entity_type: SystemLogEntityType.LEARNING_GROUP,
            operation_type: SysmtemLogOperationType.EXPORT,
            learning_group_id: learningGroupId,
            participant_id: participantId,
            request_body: batData as Prisma.InputJsonValue,
            request_endpoint: apiUrl,
            request_method: "POST",
            timestamp: new Date(),
          },
        });
        systemLogId = createdLog.id;
      } catch (logErr) {
        const msg = logErr instanceof Error ? logErr.message : String(logErr);
        this.logger.warn(`Failed to create outbound system log (non-fatal): ${msg}`);
      }

      if (this.craiApiToken) {
        headers.Authorization = `Bearer ${this.craiApiToken}`;
      }

      requestStartTime = Date.now();
      const response = await firstValueFrom(
        this.httpService.post(apiUrl, batData, {
          headers,
          timeout: 30000, // 30 second timeout
        }),
      );

      const requestDuration = Date.now() - requestStartTime;
      if (systemLogId !== null) {
        try {
          await this.prisma.client.systemLog.update({
            where: { id: systemLogId },
            data: {
              status_code: response.status,
              response_time_ms: requestDuration,
              error_message: null,
            },
          });
        } catch (updateErr) {
          const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
          this.logger.warn(`Failed to update outbound system log status_code (non-fatal): ${msg}`);
        }
      }

      // Return the actual CR API success message
      const message =
        response.data?.message || response.data?.status || "PRE-BAT analysis regeneration triggered successfully";
      return {
        success: true,
        message,
      };
    } catch (error) {
      // Log error but don't throw - allow frontend to handle gracefully
      console.error("Failed to regenerate PRE-BAT analysis:", error);

      // Try to extract CR API error message if available
      let errorMessage = "Failed to regenerate PRE-BAT analysis. Please try again later.";
      let statusCode: number | null = null;
      const requestDuration = requestStartTime ? Date.now() - requestStartTime : null;

      if (error && typeof error === "object" && "response" in error) {
        const httpError = error as { response?: { status?: number; data?: { message?: string; error?: string } } };
        statusCode = httpError.response?.status ?? null;
        if (httpError.response?.data?.message) {
          errorMessage = httpError.response.data.message;
        } else if (httpError.response?.data?.error) {
          errorMessage = httpError.response.data.error;
        }
      }

      if (systemLogId !== null) {
        try {
          await this.prisma.client.systemLog.update({
            where: { id: systemLogId },
            data: {
              status_code: statusCode,
              response_time_ms: requestDuration,
              error_message: errorMessage,
            },
          });
        } catch (updateErr) {
          const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
          this.logger.warn(`Failed to update outbound system log error fields (non-fatal): ${msg}`);
        }
      }

      return {
        success: false,
        message: errorMessage,
      };
    }
  }

  /**
   * Regenerate POST-BAT analysis by resending data to CR API
   * @param participantId Current logged-in participant ID
   * @param learningGroupParticipantId Learning group participant ID
   * @returns Success status and message
   */
  async regeneratePostBatAnalysis(
    participantId: number,
    learningGroupParticipantId: number,
  ): Promise<{ success: boolean; message: string }> {
    // Verify enrollment exists and belongs to participant
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                assessment_id: true,
              },
            },
          },
        },
      },
    });

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

    await this.subscriptionCourseAccess.assertCompanySubscriptionActive(participantId);

    // Check if POST-BAT assessment was completed by looking for assessment participant
    const course = enrollment.learningGroup?.course;
    if (!course?.assessment_id) {
      throw new ForbiddenException("Course does not have an assessment configured");
    }

    const postBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
      where: {
        assessment_id: course.assessment_id,
        participant_id: participantId,
        assessment_type: "POST_BAT",
      },
    });

    if (!postBatParticipant) {
      throw new ForbiddenException("POST-BAT assessment must be completed before regenerating analysis");
    }

    let systemLogId: number | null = null;
    let requestStartTime = 0;
    try {
      // Get learning group ID and participant ID for the assessment service
      const learningGroupId = enrollment.learning_group_id;

      // Send same payload as POST BAT completion: full pre+post data with plj_position POST_BAT
      const batData = await this.assessmentService
        .formatPrePostBatDataForIntegration(learningGroupId, participantId)
        .then((fullBatData) => ({
          ...fullBatData,
          metadata: {
            ...fullBatData.metadata,
            plj_position: "POST_BAT" as const,
          },
        }));

      // POST to CR AI API (same as in the listener)
      const apiUrl = `${this.craiApiUrl}/generate-feedback`;
      const headers: { "Content-Type": string; Authorization?: string } = {
        "Content-Type": "application/json",
      };

      // Create system log row for outbound audit, then update with status_code after the HTTP call.
      try {
        const createdLog = await this.prisma.client.systemLog.create({
          data: {
            entity_type: SystemLogEntityType.LEARNING_GROUP,
            operation_type: SysmtemLogOperationType.EXPORT,
            learning_group_id: learningGroupId,
            participant_id: participantId,
            request_body: batData as Prisma.InputJsonValue,
            request_endpoint: apiUrl,
            request_method: "POST",
            timestamp: new Date(),
          },
        });
        systemLogId = createdLog.id;
      } catch (logErr) {
        const msg = logErr instanceof Error ? logErr.message : String(logErr);
        this.logger.warn(`Failed to create outbound system log (non-fatal): ${msg}`);
      }

      if (this.craiApiToken) {
        headers.Authorization = `Bearer ${this.craiApiToken}`;
      }

      requestStartTime = Date.now();
      const response = await firstValueFrom(
        this.httpService.post(apiUrl, batData, {
          headers,
          timeout: 30000, // 30 second timeout
        }),
      );

      const requestDuration = Date.now() - requestStartTime;
      if (systemLogId !== null) {
        try {
          await this.prisma.client.systemLog.update({
            where: { id: systemLogId },
            data: {
              status_code: response.status,
              response_time_ms: requestDuration,
              error_message: null,
            },
          });
        } catch (updateErr) {
          const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
          this.logger.warn(`Failed to update outbound system log status_code (non-fatal): ${msg}`);
        }
      }

      // Return the actual CR API success message
      const message =
        response.data?.message || response.data?.status || "POST-BAT analysis regeneration triggered successfully";
      return {
        success: true,
        message,
      };
    } catch (error) {
      // Log error but don't throw - allow frontend to handle gracefully
      console.error("Failed to regenerate POST-BAT analysis:", error);

      // Try to extract CR API error message if available
      let errorMessage = "Failed to regenerate POST-BAT analysis. Please try again later.";
      let statusCode: number | null = null;
      const requestDuration = requestStartTime ? Date.now() - requestStartTime : null;

      if (error && typeof error === "object" && "response" in error) {
        const httpError = error as { response?: { status?: number; data?: { message?: string; error?: string } } };
        statusCode = httpError.response?.status ?? null;
        if (httpError.response?.data?.message) {
          errorMessage = httpError.response.data.message;
        } else if (httpError.response?.data?.error) {
          errorMessage = httpError.response.data.error;
        }
      }

      if (systemLogId !== null) {
        try {
          await this.prisma.client.systemLog.update({
            where: { id: systemLogId },
            data: {
              status_code: statusCode,
              response_time_ms: requestDuration,
              error_message: errorMessage,
            },
          });
        } catch (updateErr) {
          const msg = updateErr instanceof Error ? updateErr.message : String(updateErr);
          this.logger.warn(`Failed to update outbound system log error fields (non-fatal): ${msg}`);
        }
      }

      return {
        success: false,
        message: errorMessage,
      };
    }
  }
}

results matching ""

    No results matching ""