File

apps/recallassess/recallassess-api/src/api/integration/integration.service.ts

Description

IntegrationService

Integration API — Integration. Business Logic Service for RecallAssess.

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService, browserService: BNestBrowserService, config: ConfigService, reportService: BNestReportService, angularSSR: BNestAngularSSRService, preBatAnalysisService: PreBatAnalysisService, postBatAnalysisService: PostBatAnalysisService, systemLogService: SystemLogService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
browserService BNestBrowserService No
config ConfigService No
reportService BNestReportService No
angularSSR BNestAngularSSRService No
preBatAnalysisService PreBatAnalysisService No
postBatAnalysisService PostBatAnalysisService No
systemLogService SystemLogService No

Methods

Async checkPostBatPdfExists
checkPostBatPdfExists(learningGroupId: number, participantId: number, forceRegenerate: unknown)

Check if Post-BAT PDF exists in S3 Delegates to PostBatAnalysisService

Parameters :
Name Type Optional Default value
learningGroupId number No
participantId number No
forceRegenerate unknown No false
Returns : Promise<string>
Async checkPreBatPdfExists
checkPreBatPdfExists(learningGroupId: number, participantId: number, forceRegenerate: unknown)

Check if Pre-BAT PDF exists in S3 Delegates to PreBatAnalysisService

Parameters :
Name Type Optional Default value
learningGroupId number No
participantId number No
forceRegenerate unknown No false
Returns : Promise<string>
Private Async createReportLogPlaceholder
createReportLogPlaceholder(params: literal type)

Create a report log entry with a placeholder title, before the PDF is generated.

The integration endpoints respond as soon as the analysis data is stored; the PDF is generated in the background and finalizeReportLogTitle replaces the placeholder with the final title (including the S3 filename) afterwards.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<number | null>

the created report_log.id, or null if creation failed (non-fatal).

Private Async finalizeReportLogTitle
finalizeReportLogTitle(params: literal type)

Update a report log title once background PDF generation has settled. No-op when reportLogId is null (placeholder creation failed).

Parameters :
Name Type Optional
params literal type No
Returns : Promise<void>
Async generateAndUploadPostBatPdf
generateAndUploadPostBatPdf(learningGroupId: number, participantId: number, course: literal type, authorization?: string)

Generate POST-BAT PDF and upload to S3 Delegates to PostBatAnalysisService

Parameters :
Name Type Optional
learningGroupId number No
participantId number No
course literal type No
authorization string Yes
Returns : Promise<string>
Async generateAndUploadPreBatPdf
generateAndUploadPreBatPdf(learningGroupId: number, participantId: number, course: literal type, authorization?: string, freshAnalysisData?: string)

Generate PDF and upload to S3 Delegates to PreBatAnalysisService

Parameters :
Name Type Optional
learningGroupId number No
participantId number No
course literal type No
authorization string Yes
freshAnalysisData string Yes
Returns : Promise<string>
Async generatePostBatHtml
generatePostBatHtml(learningGroupId: number, participantId: number, courseCode: string)

Generate POST-BAT HTML preview Delegates to PostBatAnalysisService

Parameters :
Name Type Optional
learningGroupId number No
participantId number No
courseCode string No
Returns : Promise<string>
Private Async generatePostBatPdfInBackground
generatePostBatPdfInBackground(params: literal type)

Generate a Post-BAT PDF in the background and finalize its report log title. See generatePreBatPdfInBackground for the un-awaited contract.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<void>
Async generatePreBatHtml
generatePreBatHtml(learningGroupId: number, participantId: number)

Generate pre-BAT HTML for a participant (preview) Delegates to PreBatAnalysisService

Parameters :
Name Type Optional
learningGroupId number No
participantId number No
Returns : Promise<string>
Async generatePreBatPdfDirect
generatePreBatPdfDirect(learningGroupId: number, participantId: number, course: literal type)

Generate Pre-BAT PDF directly and return buffer (for download) Delegates to PreBatAnalysisService

Parameters :
Name Type Optional
learningGroupId number No
participantId number No
course literal type No
Returns : Promise<Buffer>
Private Async generatePreBatPdfInBackground
generatePreBatPdfInBackground(params: literal type)

Generate a Pre-BAT PDF in the background and finalize its report log title.

Intentionally un-awaited by callers. All errors are caught and logged so failures never surface as unhandled rejections or affect the already-sent HTTP response.

Parameters :
Name Type Optional
params literal type No
Returns : Promise<void>
Async getCourseForLearningGroup
getCourseForLearningGroup(learningGroupId: number)

Get course information for a learning group

Parameters :
Name Type Optional Description
learningGroupId number No

Learning group ID

Returns : Promise<literal type>
Async getCoursesForIntegration
getCoursesForIntegration()

Get list of published courses for CodeRythm integration. Returns course_code, course_title, and related modules (published course modules).

Returns : Promise<Array<literal type>>
Async getLearningGroupIdForCourse
getLearningGroupIdForCourse(courseId: number)

Get learning group ID for a course

Parameters :
Name Type Optional Description
courseId number No

Course ID

Returns : Promise<number>
Async getPostBatAnalysisData
getPostBatAnalysisData(learningGroupId: number, participantId: number, course: literal type, options?: literal type)

Get Post-BAT analysis data for individual report rendering Delegates to PostBatAnalysisService

Parameters :
Name Type Optional
learningGroupId number No
participantId number No
course literal type No
options literal type Yes
Returns : Promise<literal type>
Async getPreBatAnalysisData
getPreBatAnalysisData(learningGroupId: number, participantId: number, course: literal type, includeChartImage: unknown, freshAnalysisData?: string)

Get Pre-BAT analysis data for individual report rendering Delegates to PreBatAnalysisService

Parameters :
Name Type Optional Default value
learningGroupId number No
participantId number No
course literal type No
includeChartImage unknown No false
freshAnalysisData string Yes
Returns : Promise<literal type>
Async handlePostBatAnalysis
handlePostBatAnalysis(dto: IntegrationPostBatAnalysisDto)

Handle post-bat-analysis request (comparative matrix with module-level data)

Parameters :
Name Type Optional Description
dto IntegrationPostBatAnalysisDto No

Request DTO containing result with metadata and trainees array

Returns : Promise<literal type>

Success response

Async handlePostBatGroupAnalysis
handlePostBatGroupAnalysis(dto: IntegrationPostBatGroupAnalysisDto)

Handle post-bat-group-analysis request (team analysis - POST BAT)

Parameters :
Name Type Optional Description
dto IntegrationPostBatGroupAnalysisDto No

Request DTO containing learning_group_id, course_code, and analysis

Returns : Promise<literal type>

Success response

Async handlePreBatAnalysis
handlePreBatAnalysis(dto: IntegrationPreBatAnalysisDto)

Handle pre-bat-analysis request

Parameters :
Name Type Optional Description
dto IntegrationPreBatAnalysisDto No

Request DTO containing result with metadata and trainees array

Returns : Promise<literal type>

Success response with sample data

Async handlePreBatGroupAnalysis
handlePreBatGroupAnalysis(dto: IntegrationPreBatGroupAnalysisDto)

Handle pre-bat-group-analysis request (team analysis - PRE BAT)

Parameters :
Name Type Optional Description
dto IntegrationPreBatGroupAnalysisDto No

Request DTO containing learning_group_id, course_code, and analysis

Returns : Promise<literal type>

Success response

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(IntegrationService.name)
import {
  BAT_COLOR_HEX,
  BAT_LEVEL_TO_QUOTIENT,
  BAT_LEVEL_TO_SCORE,
  getColorClassFromScore,
  SCORE_COLOR_THRESHOLDS,
  SCORE_TO_METRIC,
} from "@api/shared/constants/assessment-scores.constants";
import { SystemLogService } from "@api/shared/services";
import { BNestReportService } from "@bish-nest/admin/modules/report/report.service";
import { BNestBrowserService } from "@bish-nest/reporting";
import { BNestPrismaService } from "@bish-nest/prisma";
import { BNestAngularSSRService } from "@bish-nest/reporting";
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AssessmentType, SystemLogEntityType } from "@prisma/client";
import {
  IntegrationPostBatAnalysisDto,
  IntegrationPostBatGroupAnalysisDto,
  IntegrationPreBatAnalysisDto,
  IntegrationPreBatGroupAnalysisDto,
} from "./dto";
import { PostBatAnalysisService } from "./post-bat-analysis.service";
import { PreBatAnalysisService } from "./pre-bat-analysis.service";

/**
 * IntegrationService
 *
 * Integration API — Integration. Business Logic Service for RecallAssess.
 */


@Injectable()
export class IntegrationService {
  private readonly logger = new Logger(IntegrationService.name);

  constructor(
    private readonly prisma: BNestPrismaService,
    @Inject() private readonly browserService: BNestBrowserService,
    private readonly config: ConfigService,
    @Inject() private readonly reportService: BNestReportService,
    @Inject() private readonly angularSSR: BNestAngularSSRService,
    private readonly preBatAnalysisService: PreBatAnalysisService,
    private readonly postBatAnalysisService: PostBatAnalysisService,
    private readonly systemLogService: SystemLogService,
  ) {}

  /**
   * Handle pre-bat-analysis request
   * @param dto Request DTO containing result with metadata and trainees array
   * @returns Success response with sample data
   */
  async handlePreBatAnalysis(dto: IntegrationPreBatAnalysisDto): Promise<{ success: boolean; message: string }> {
    // Validate DTO structure
    if (!dto.result) {
      throw new BadRequestException('Request body must contain a "result" object');
    }
    if (!dto.result.metadata) {
      throw new BadRequestException('Request body must contain "result.metadata" object');
    }
    if (!dto.result.trainees) {
      throw new BadRequestException('Request body must contain "result.trainees" array');
    }

    const learningGroupId = dto.result.metadata.learning_group_id;
    const trainees = dto.result.trainees;

    this.logger.log(
      `Processing pre-bat-analysis for ${trainees.length} trainee(s) in learning group ${learningGroupId}`,
    );

    // Verify learning group exists
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
    });

    if (!learningGroup) {
      throw new NotFoundException(`Learning group with ID ${learningGroupId} not found`);
    }

    // Get course information
    const course = await this.prisma.client.course.findUnique({
      where: { id: learningGroup.course_id },
    });

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

    // Get assessment_id from course
    if (!course.assessment_id) {
      throw new NotFoundException(`Course ${course.id} does not have an assessment assigned`);
    }

    // Log full raw CR JSON payload in system_log (IMPORT) for debug/audit
    try {
      await this.systemLogService.logImport(SystemLogEntityType.ASSESSMENT, {
        entity_id: course.assessment_id,
        request_body: dto as unknown as Record<string, unknown>,
      });
    } catch {
      // System logging failure must not break main flow
    }

    // Process each trainee
    const results = [];
    for (const trainee of trainees) {
      const participantId = trainee.participant_id;

      // Verify participant exists
      const participant = await this.prisma.client.participant.findUnique({
        where: { id: participantId },
      });

      if (!participant) {
        this.logger.warn(`Participant with ID ${participantId} not found, skipping`);
        continue;
      }

      // Verify participant is enrolled in this learning group
      const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
        where: {
          learning_group_id: learningGroupId,
          participant_id: participantId,
        },
      });

      if (!enrollment) {
        this.logger.warn(
          `Participant ${participantId} is not enrolled in learning group ${learningGroupId}, skipping`,
        );
        continue;
      }

      // Check if PRE-BAT assessment is completed for this participant (required before processing analysis)
      const existingPreBat = await this.prisma.client.assessmentParticipant.findFirst({
        where: {
          assessment_id: course.assessment_id,
          participant_id: participantId,
          learning_group_id: learningGroupId,
          assessment_type: AssessmentType.PRE_BAT,
        },
      });

      if (!existingPreBat?.assessment_completion_date) {
        const errorMsg = `PRE-BAT assessment not completed for participant ${participantId}. Participant must complete the assessment before analysis can be processed.`;
        this.logger.error(errorMsg);
        throw new BadRequestException(errorMsg);
      }

      // Find or create AssessmentParticipant record and update ai_analysis
      await this.prisma.client.assessmentParticipant.upsert({
        where: {
          assessment_id_participant_id_assessment_type: {
            assessment_id: course.assessment_id,
            participant_id: participantId,
            assessment_type: AssessmentType.PRE_BAT,
          },
        },
        update: {
          ai_analysis: trainee.pre_report,
        },
        create: {
          course_id: course.id,
          assessment_id: course.assessment_id,
          participant_id: participantId,
          learning_group_id: learningGroupId,
          assessment_type: AssessmentType.PRE_BAT,
          ai_analysis: trainee.pre_report,
        },
      });

      this.logger.log(
        `Pre-BAT analysis saved for participant ${participantId} in assessment ${course.assessment_id}`,
      );

      // Create report log entry immediately (placeholder title) so the caller can
      // respond without waiting for PDF generation. The title is updated in the
      // background once the PDF has been generated (or failed).
      const reportLogId = await this.createReportLogPlaceholder({
        participantId,
        courseId: course.id,
        assessmentId: course.assessment_id,
        learningGroupId,
        reportLabel: "Pre-BAT",
        courseName: course.title || course.course_code || "Course",
      });

      // Kick off PDF generation in the background — do NOT await.
      // The analysis data is already persisted, so the integration request can
      // return immediately; the PDF is uploaded to S3 and the report log title
      // is finalized once generation completes.
      void this.generatePreBatPdfInBackground({
        learningGroupId,
        participantId,
        course,
        freshAnalysisData: trainee.pre_report,
        reportLogId,
      });

      results.push({ participantId, success: true });
    }

    // Check if any participants were processed
    if (results.length === 0) {
      throw new BadRequestException(
        "No participants were processed. Please verify that participants exist, are enrolled in the learning group, and trainees data is valid.",
      );
    }

    return {
      success: true,
      message: `Pre-BAT analysis processed successfully for ${results.length} participant(s)`,
    };
  }

  /**
   * Handle post-bat-analysis request (comparative matrix with module-level data)
   * @param dto Request DTO containing result with metadata and trainees array
   * @returns Success response
   */
  async handlePostBatAnalysis(dto: IntegrationPostBatAnalysisDto): Promise<{ success: boolean; message: string }> {
    const learningGroupId = dto.result.metadata.learning_group_id;
    const courseCode = dto.result.metadata.course_code;
    const trainees = dto.result.trainees;

    this.logger.log(
      `Processing post-bat-analysis for ${trainees.length} trainee(s) in learning group ${learningGroupId}`,
    );

    // Verify learning group exists
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
    });

    if (!learningGroup) {
      throw new NotFoundException(`Learning group with ID ${learningGroupId} not found`);
    }

    // Verify course code matches
    const course = await this.prisma.client.course.findUnique({
      where: { id: learningGroup.course_id },
    });

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

    // Verify course code matches the provided course_code
    if (course.course_code !== courseCode) {
      throw new NotFoundException(
        `Course code mismatch. Expected ${course.course_code}, but received ${courseCode}`,
      );
    }

    // Get assessment_id from course
    if (!course.assessment_id) {
      throw new NotFoundException(`Course ${course.id} does not have an assessment assigned`);
    }

    // Resolve module codes from payload to CourseModule ids (no course-scoped mapping)
    // Only needed when trainees use legacy modules format
    const allModuleCodes = new Set<string>();
    for (const trainee of trainees) {
      if (trainee.modules && typeof trainee.modules === "object") {
        for (const code of Object.keys(trainee.modules)) {
          allModuleCodes.add(code);
        }
      }
    }
    const moduleCodeToIdMap = new Map<string, number>();
    if (allModuleCodes.size > 0) {
      const modules = await this.prisma.client.courseModule.findMany({
        where: { course_module_code: { in: [...allModuleCodes] } },
        select: { id: true, course_module_code: true },
      });
      for (const m of modules) {
        if (m.course_module_code) {
          moduleCodeToIdMap.set(m.course_module_code, m.id);
        }
      }
    }

    // Log full raw CR JSON payload in system_log (IMPORT) for debug/audit
    try {
      await this.systemLogService.logImport(SystemLogEntityType.ASSESSMENT, {
        entity_id: course.assessment_id,
        request_body: dto as unknown as Record<string, unknown>,
      });
    } catch {
      // System logging failure must not break main flow
    }

    // Process each trainee
    const results = [];
    for (const trainee of trainees) {
      const participantId = trainee.participant_id;

      // Verify participant exists
      const participant = await this.prisma.client.participant.findUnique({
        where: { id: participantId },
      });

      if (!participant) {
        this.logger.warn(`Participant with ID ${participantId} not found, skipping`);
        continue;
      }

      // Verify participant is enrolled in this learning group
      const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
        where: {
          learning_group_id: learningGroupId,
          participant_id: participantId,
        },
      });

      if (!enrollment) {
        this.logger.warn(
          `Participant ${participantId} is not enrolled in learning group ${learningGroupId}, skipping`,
        );
        continue;
      }

      // Check if PRE-BAT assessment is completed (required before POST-BAT)
      const existingPreBat = await this.prisma.client.assessmentParticipant.findFirst({
        where: {
          assessment_id: course.assessment_id,
          participant_id: participantId,
          learning_group_id: learningGroupId,
          assessment_type: AssessmentType.PRE_BAT,
        },
      });

      if (!existingPreBat?.assessment_completion_date) {
        const errorMsg = `PRE-BAT assessment not completed for participant ${participantId}. Participant must complete PRE-BAT before POST-BAT analysis can be processed.`;
        this.logger.error(errorMsg);
        throw new BadRequestException(errorMsg);
      }

      // Check if POST-BAT assessment is completed for this participant (required before processing analysis)
      const existingPostBat = await this.prisma.client.assessmentParticipant.findFirst({
        where: {
          assessment_id: course.assessment_id,
          participant_id: participantId,
          learning_group_id: learningGroupId,
          assessment_type: AssessmentType.POST_BAT,
        },
      });

      if (!existingPostBat?.assessment_completion_date) {
        const errorMsg = `POST-BAT assessment not completed for participant ${participantId}. Participant must complete the assessment before analysis can be processed.`;
        this.logger.error(errorMsg);
        throw new BadRequestException(errorMsg);
      }

      // Store the complete analysis in AssessmentParticipant (post_ai_analysis column for POST_BAT)
      await this.prisma.client.assessmentParticipant.upsert({
        where: {
          assessment_id_participant_id_assessment_type: {
            assessment_id: course.assessment_id,
            participant_id: participantId,
            assessment_type: AssessmentType.POST_BAT,
          },
        },
        update: {
          post_ai_analysis: JSON.stringify(trainee),
        } as any,
        create: {
          course_id: course.id,
          assessment_id: course.assessment_id,
          participant_id: participantId,
          learning_group_id: learningGroupId,
          assessment_type: AssessmentType.POST_BAT,
          post_ai_analysis: JSON.stringify(trainee),
        } as any,
      });

      const hasOverallSummary =
        typeof (trainee as { overall_summary?: string }).overall_summary === "string" &&
        ((trainee as { overall_summary?: string }).overall_summary ?? "").trim().length > 0;
      const hasModules =
        trainee.modules && typeof trainee.modules === "object" && Object.keys(trainee.modules).length > 0;

      if (!hasOverallSummary && !hasModules) {
        throw new BadRequestException(
          `Each trainee must have either overall_summary or modules. Participant ${participantId} has neither.`,
        );
      }

      // New format: one summary in post_ai_analysis only. Skip per-topic AssessmentResult updates.
      if (hasOverallSummary) {
        this.logger.log(
          `Post-BAT analysis saved for participant ${participantId} (overall summary in post_ai_analysis)`,
        );

        const reportLogId = await this.createReportLogPlaceholder({
          participantId,
          courseId: course.id,
          assessmentId: course.assessment_id,
          learningGroupId,
          reportLabel: "Post-BAT",
          courseName: course.title || course.course_code || "Course",
        });

        // Generate PDF in the background so the integration request returns
        // as soon as the analysis data is stored.
        void this.generatePostBatPdfInBackground({
          learningGroupId,
          participantId,
          course,
          reportLogId,
        });

        results.push({ participantId, modulesProcessed: 0, success: true });
        continue;
      }

      // Find POST BAT participant record (we'll need its ID for FK)
      const postBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
        where: {
          assessment_id: course.assessment_id,
          participant_id: participantId,
          learning_group_id: learningGroupId,
          assessment_type: AssessmentType.POST_BAT,
        },
      });

      if (!postBatParticipant) {
        this.logger.warn(
          `POST BAT AssessmentParticipant not found for participant ${participantId}, skipping module updates`,
        );
        continue;
      }

      // Process each module in the trainee's data
      let modulesProcessed = 0;
      const traineeModules = trainee.modules ?? {};
      for (const [moduleCode, moduleData] of Object.entries(traineeModules)) {
        const moduleId = moduleCodeToIdMap.get(moduleCode);

        if (!moduleId) {
          this.logger.warn(`Module code ${moduleCode} not found, skipping`);
          continue;
        }

        // Update assessment results for this module with the analysis data
        // Use assessment_participant_id FK instead of timestamp filtering
        const updateResult = await this.prisma.client.assessmentResult.updateMany({
          where: {
            assessment_participant_id: postBatParticipant.id, // ✅ Use FK instead of timestamps
            course_id: course.id,
            assessment_id: course.assessment_id,
            participant_id: participantId,
            course_module_id: moduleId,
          },
          data: {
            learning_path: moduleData.learning_path,
            additional_learning_path: moduleData.additional_learning_path || null,
            references:
              moduleData.references && moduleData.references.length > 0
                ? JSON.parse(JSON.stringify(moduleData.references))
                : null,
          },
        });

        if (updateResult.count > 0) {
          this.logger.log(
            `Updated ${updateResult.count} assessment result(s) for module ${moduleCode} (${moduleId}) - participant ${participantId}`,
          );
          modulesProcessed++;
        } else {
          this.logger.warn(
            `No assessment results found for module ${moduleCode} (${moduleId}) - participant ${participantId} with assessment_participant_id ${postBatParticipant.id}`,
          );
        }
      }

      // Check if any modules were processed for this participant
      if (modulesProcessed === 0) {
        const errorMsg = `No modules were updated for participant ${participantId}. Please verify that module codes match the course modules and assessment results exist.`;
        this.logger.error(errorMsg);
        throw new BadRequestException(errorMsg);
      }

      this.logger.log(
        `Post-BAT analysis saved for participant ${participantId}: ${modulesProcessed} modules processed`,
      );

      // Create report log entry immediately (placeholder title); the title is
      // updated in the background once the PDF has been generated (or failed).
      const reportLogId = await this.createReportLogPlaceholder({
        participantId,
        courseId: course.id,
        assessmentId: course.assessment_id,
        learningGroupId,
        reportLabel: "Post-BAT",
        courseName: course.title || course.course_code || "Course",
      });

      // Generate PDF off the request path.
      void this.generatePostBatPdfInBackground({
        learningGroupId,
        participantId,
        course,
        reportLogId,
      });

      results.push({ participantId, modulesProcessed, success: true });
    }

    // Check if any participants were processed
    if (results.length === 0) {
      throw new BadRequestException(
        "No participants were processed. Please verify that participants exist, are enrolled in the learning group, and trainees data is valid.",
      );
    }

    return {
      success: true,
      message: `Post-BAT analysis processed successfully for ${results.length} participant(s)`,
    };
  }

  /**
   * Handle pre-bat-group-analysis request (team analysis - PRE BAT)
   * @param dto Request DTO containing learning_group_id, course_code, and analysis
   * @returns Success response
   */
  async handlePreBatGroupAnalysis(
    dto: IntegrationPreBatGroupAnalysisDto,
  ): Promise<{ success: boolean; message: string }> {
    this.logger.log(`Processing pre-bat-group-analysis for learning group ${dto.learning_group_id}`);

    // Verify learning group exists
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: dto.learning_group_id },
    });

    if (!learningGroup) {
      throw new NotFoundException(`Learning group with ID ${dto.learning_group_id} not found`);
    }

    // Verify course code matches
    const course = await this.prisma.client.course.findUnique({
      where: { id: learningGroup.course_id },
    });

    if (!course) {
      throw new NotFoundException(`Course not found for learning group ${dto.learning_group_id}`);
    }

    // TODO: Store the analysis data or process it as needed
    // This is a placeholder for the actual integration logic
    this.logger.log(
      `Pre-BAT group analysis (team analysis) received for learning group ${dto.learning_group_id}: ${dto.analysis.substring(0, 100)}...`,
    );

    return {
      success: true,
      message: "Pre-BAT group analysis (team analysis) processed successfully",
    };
  }

  /**
   * Handle post-bat-group-analysis request (team analysis - POST BAT)
   * @param dto Request DTO containing learning_group_id, course_code, and analysis
   * @returns Success response
   */
  async handlePostBatGroupAnalysis(
    dto: IntegrationPostBatGroupAnalysisDto,
  ): Promise<{ success: boolean; message: string }> {
    this.logger.log(`Processing post-bat-group-analysis for learning group ${dto.learning_group_id}`);

    // Verify learning group exists
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: dto.learning_group_id },
    });

    if (!learningGroup) {
      throw new NotFoundException(`Learning group with ID ${dto.learning_group_id} not found`);
    }

    // Verify course code matches
    const course = await this.prisma.client.course.findUnique({
      where: { id: learningGroup.course_id },
    });

    if (!course) {
      throw new NotFoundException(`Course not found for learning group ${dto.learning_group_id}`);
    }

    // TODO: Store the analysis data or process it as needed
    // This is a placeholder for the actual integration logic
    this.logger.log(
      `Post-BAT group analysis (team analysis) received for learning group ${dto.learning_group_id}: ${dto.analysis.substring(0, 100)}...`,
    );

    return {
      success: true,
      message: "Post-BAT group analysis (team analysis) processed successfully",
    };
  }

  /**
   * Create a report log entry with a placeholder title, before the PDF is generated.
   *
   * The integration endpoints respond as soon as the analysis data is stored; the
   * PDF is generated in the background and {@link finalizeReportLogTitle} replaces
   * the placeholder with the final title (including the S3 filename) afterwards.
   *
   * @returns the created `report_log.id`, or `null` if creation failed (non-fatal).
   */
  private async createReportLogPlaceholder(params: {
    participantId: number;
    courseId: number;
    assessmentId: number | null;
    learningGroupId: number;
    /** Human-readable report family, e.g. `Pre-BAT` / `Post-BAT`. */
    reportLabel: string;
    courseName: string;
  }): Promise<number | null> {
    const { participantId, courseId, assessmentId, learningGroupId, reportLabel, courseName } = params;
    try {
      const log = await this.prisma.client.reportLog.create({
        data: {
          participant_id: participantId,
          course_id: courseId,
          assessment_id: assessmentId,
          course_group_id: learningGroupId,
          title: `${reportLabel} Analysis Report - ${courseName} [generating...]`,
          sent_status: false,
        },
        select: { id: true },
      });
      this.logger.log(
        `Report log created for ${reportLabel} analysis - participant ${participantId} (log ${log.id}, PDF pending)`,
      );
      return log.id;
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      this.logger.warn(
        `Failed to create report log for ${reportLabel} analysis - participant ${participantId}: ${errorMessage}`,
      );
      return null;
    }
  }

  /**
   * Update a report log title once background PDF generation has settled.
   * No-op when `reportLogId` is `null` (placeholder creation failed).
   */
  private async finalizeReportLogTitle(params: {
    reportLogId: number | null;
    reportLabel: string;
    courseName: string;
    participantId: number;
    pdfS3Path: string | null;
  }): Promise<void> {
    const { reportLogId, reportLabel, courseName, participantId, pdfS3Path } = params;
    if (reportLogId == null) {
      return;
    }
    const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
    const s3FileName = pdfS3Path ? pdfS3Path.split("/").pop() : null;
    const title = s3FileName
      ? `${reportLabel} Analysis Report - ${courseName} [${s3FileName}]`
      : `${reportLabel} Analysis Report - ${courseName} [${timestamp}] [PDF Generation Failed]`;
    try {
      await this.prisma.client.reportLog.update({
        where: { id: reportLogId },
        data: { title },
      });
      this.logger.log(
        `Report log ${reportLogId} finalized for ${reportLabel} analysis - participant ${participantId}` +
          (pdfS3Path ? ` (S3: ${pdfS3Path})` : " (PDF generation failed)"),
      );
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      this.logger.warn(`Failed to finalize report log ${reportLogId}: ${errorMessage}`);
    }
  }

  /**
   * Generate a Pre-BAT PDF in the background and finalize its report log title.
   *
   * Intentionally un-awaited by callers. All errors are caught and logged so failures
   * never surface as unhandled rejections or affect the already-sent HTTP response.
   */
  private async generatePreBatPdfInBackground(params: {
    learningGroupId: number;
    participantId: number;
    course: { id: number; assessment_id: number | null; title: string | null; course_code?: string | null };
    freshAnalysisData?: string;
    reportLogId: number | null;
  }): Promise<void> {
    const { learningGroupId, participantId, course, freshAnalysisData, reportLogId } = params;
    const courseName = course.title || course.course_code || "Course";
    let pdfS3Path: string | null = null;
    try {
      pdfS3Path = await this.preBatAnalysisService.generateAndUploadPreBatPdf(
        learningGroupId,
        participantId,
        course,
        undefined,
        freshAnalysisData,
      );
      this.logger.log(`Pre-BAT PDF generated and uploaded to S3: ${pdfS3Path}`);
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      this.logger.error(
        `Failed to generate/upload Pre-BAT PDF for participant ${participantId}: ${errorMessage}`,
      );
    } finally {
      await this.finalizeReportLogTitle({
        reportLogId,
        reportLabel: "Pre-BAT",
        courseName,
        participantId,
        pdfS3Path,
      });
    }
  }

  /**
   * Generate a Post-BAT PDF in the background and finalize its report log title.
   * See {@link generatePreBatPdfInBackground} for the un-awaited contract.
   */
  private async generatePostBatPdfInBackground(params: {
    learningGroupId: number;
    participantId: number;
    course: { id: number; assessment_id: number | null; title: string | null; course_code?: string | null };
    reportLogId: number | null;
  }): Promise<void> {
    const { learningGroupId, participantId, course, reportLogId } = params;
    const courseName = course.title || course.course_code || "Course";
    let pdfS3Path: string | null = null;
    try {
      pdfS3Path = await this.postBatAnalysisService.generateAndUploadPostBatPdf(
        learningGroupId,
        participantId,
        course,
      );
      this.logger.log(`Post-BAT PDF generated and uploaded to S3: ${pdfS3Path}`);
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      this.logger.error(
        `Failed to generate/upload Post-BAT PDF for participant ${participantId}: ${errorMessage}`,
      );
    } finally {
      await this.finalizeReportLogTitle({
        reportLogId,
        reportLabel: "Post-BAT",
        courseName,
        participantId,
        pdfS3Path,
      });
    }
  }

  /**
   * Generate pre-BAT HTML for a participant (preview)
   * Delegates to PreBatAnalysisService
   */
  async generatePreBatHtml(learningGroupId: number, participantId: number): Promise<string> {
    return this.preBatAnalysisService.generatePreBatHtml(learningGroupId, participantId);
  }

  /**
   * Generate Pre-BAT PDF directly and return buffer (for download)
   * Delegates to PreBatAnalysisService
   */
  async generatePreBatPdfDirect(
    learningGroupId: number,
    participantId: number,
    course: { id: number; assessment_id: number | null; title: string | null },
  ): Promise<Buffer> {
    return this.preBatAnalysisService.generatePreBatPdfDirect(learningGroupId, participantId, course);
  }

  /**
   * Check if Pre-BAT PDF exists in S3
   * Delegates to PreBatAnalysisService
   */
  async checkPreBatPdfExists(
    learningGroupId: number,
    participantId: number,
    forceRegenerate = false,
  ): Promise<string> {
    return this.preBatAnalysisService.checkPreBatPdfExists(learningGroupId, participantId, forceRegenerate);
  }

  /**
   * Generate PDF and upload to S3
   * Delegates to PreBatAnalysisService
   */
  async generateAndUploadPreBatPdf(
    learningGroupId: number,
    participantId: number,
    course: { id: number; assessment_id: number | null; title: string | null },
    authorization?: string,
    freshAnalysisData?: string,
  ): Promise<string> {
    return this.preBatAnalysisService.generateAndUploadPreBatPdf(
      learningGroupId,
      participantId,
      course,
      authorization,
      freshAnalysisData,
    );
  }

  /**
   * Get Pre-BAT analysis data for individual report rendering
   * Delegates to PreBatAnalysisService
   */
  async getPreBatAnalysisData(
    learningGroupId: number,
    participantId: number,
    course: { id: number; assessment_id: number | null; title: string | null },
    includeChartImage = false,
    freshAnalysisData?: string,
  ): Promise<{
    courseTitle: string;
    participantName: string;
    skills: string;
    ipq: number;
    preTrainingResults: string;
    preReport: string;
    chartImage?: string | null;
  }> {
    return this.preBatAnalysisService.getPreBatAnalysisData(
      learningGroupId,
      participantId,
      course,
      includeChartImage,
      freshAnalysisData,
    );
  }

  // Removed: calculateSkillsFromResults - moved to PreBatAnalysisService
  // Removed: calculateIpqFromResults - moved to PreBatAnalysisService
  // Removed: calculatePreTrainingResults - moved to PreBatAnalysisService
  // Removed: renderSkillChart - moved to BatChartService
  // Removed: getColorHex - moved to BatChartService

  /**
   * Get Post-BAT analysis data for individual report rendering
   * Delegates to PostBatAnalysisService
   */
  async getPostBatAnalysisData(
    learningGroupId: number,
    participantId: number,
    course: { id: number; assessment_id: number | null; title: string | null },
    options?: { includeChartImages?: boolean },
  ): Promise<{
    courseTitle: string;
    participantName: string;
    preIpq: number;
    postIpq: number;
    moduleComparisons: string;
    overallSummary?: string | null;
    preChartImage?: string | null;
    postChartImage?: string | null;
  }> {
    return this.postBatAnalysisService.getPostBatAnalysisData(learningGroupId, participantId, course, options);
  }

  /**
   * Generate POST-BAT HTML preview
   * Delegates to PostBatAnalysisService
   */
  async generatePostBatHtml(learningGroupId: number, participantId: number, courseCode: string): Promise<string> {
    return this.postBatAnalysisService.generatePostBatHtml(learningGroupId, participantId, courseCode);
  }

  /**
   * Check if Post-BAT PDF exists in S3
   * Delegates to PostBatAnalysisService
   */
  async checkPostBatPdfExists(
    learningGroupId: number,
    participantId: number,
    forceRegenerate = false,
  ): Promise<string> {
    return this.postBatAnalysisService.checkPostBatPdfExists(learningGroupId, participantId, forceRegenerate);
  }

  /**
   * Generate POST-BAT PDF and upload to S3
   * Delegates to PostBatAnalysisService
   */
  async generateAndUploadPostBatPdf(
    learningGroupId: number,
    participantId: number,
    course: { id: number; assessment_id: number | null; title: string | null },
    authorization?: string,
  ): Promise<string> {
    return this.postBatAnalysisService.generateAndUploadPostBatPdf(
      learningGroupId,
      participantId,
      course,
      authorization,
    );
  }

  /**
   * Get learning group ID for a course
   * @param courseId Course ID
   */
  async getLearningGroupIdForCourse(courseId: number): Promise<number> {
    const learningGroup = await this.prisma.client.learningGroup.findFirst({
      where: { course_id: courseId },
      select: { id: true },
    });

    if (!learningGroup) {
      throw new NotFoundException(`No learning group found for course ${courseId}`);
    }

    return learningGroup.id;
  }

  /**
   * Get course information for a learning group
   * @param learningGroupId Learning group ID
   */
  async getCourseForLearningGroup(
    learningGroupId: number,
  ): Promise<{ id: number; assessment_id: number | null; title: string | null }> {
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
      select: {
        course_id: true,
        course: {
          select: {
            id: true,
            title: true,
            assessment_id: true,
          },
        },
      },
    });

    if (!learningGroup || !learningGroup.course) {
      throw new NotFoundException(`Course not found for learning group ${learningGroupId}`);
    }

    return {
      id: learningGroup.course.id,
      assessment_id: learningGroup.course.assessment_id,
      title: learningGroup.course.title,
    };
  }

  /**
   * Get list of published courses for CodeRythm integration.
   * Returns course_code, course_title, and related modules (published course modules).
   */
  async getCoursesForIntegration(): Promise<
    Array<{
      course_id: number;
      course_code: string;
      course_title: string;
      modules: Array<{ module_id: number; module_code: string; module_title: string }>;
    }>
  > {
    const courses = await this.prisma.client.course.findMany({
      where: {
        is_published: true,
      },
      orderBy: [{ sort_order: "asc" }, { title: "asc" }],
      include: {
        courseModulePages: {
          where: { is_published: true },
          orderBy: { sort_order: "asc" },
          select: { courseModule: true },
        },
      },
    });

    return courses.map((course) => {
      const seenModuleIds = new Set<number>();
      const modules: Array<{ module_id: number; module_code: string; module_title: string }> = [];
      for (const page of course.courseModulePages) {
        const mod = page.courseModule;
        if (mod?.is_published && !seenModuleIds.has(mod.id)) {
          seenModuleIds.add(mod.id);
          modules.push({
            module_id: mod.id,
            module_code: mod.course_module_code,
            module_title: mod.title,
          });
        }
      }
      return {
        course_id: course.id,
        course_code: course.course_code,
        course_title: course.title,
        modules,
      };
    });
  }
}

results matching ""

    No results matching ""