File

apps/recallassess/recallassess-api/src/api/integration/pre-bat-analysis.service.ts

Index

Properties
  • Private Readonly logger
  • Private Readonly s3
Methods

Constructor

constructor(prisma: BNestPrismaService, config: ConfigService, reportService: BNestReportService, angularSSR: BNestAngularSSRService, batChartService: BatChartService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
config ConfigService No
reportService BNestReportService No
angularSSR BNestAngularSSRService No
batChartService BatChartService No

Methods

Private calculateIpqFromResults
calculateIpqFromResults(assessmentResults: Array)

Calculate IPQ (Individual Performance Quotient) from assessment results

Parameters :
Name Type Optional
assessmentResults Array<literal type> No
Returns : number
Private calculatePreTrainingResults
calculatePreTrainingResults(skills: Array)

Calculate pre-training results distribution

Parameters :
Name Type Optional
skills Array<literal type> No
Returns : Array<literal type>
Private calculateSkillsFromResults
calculateSkillsFromResults(assessmentResults: Array, courseModules: Array, allModulesMap?: Map)

Calculate skills from actual assessment results

Parameters :
Name Type Optional
assessmentResults Array<literal type> No
courseModules Array<literal type> No
allModulesMap Map<number | literal type> Yes
Returns : Array<literal type>
Async checkPreBatPdfExists
checkPreBatPdfExists(learningGroupId: number, participantId: number)

Check if Pre-BAT PDF exists in S3

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

Generate PDF and upload to S3

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

Generate Pre-BAT HTML preview

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

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

Get Pre-BAT report data for the Angular report component Extracts and formats data needed by pre-bat-analysis.component

Parameters :
Name Type Optional Default value Description
learningGroupId number No

Learning group ID

participantId number No

Participant ID

course literal type No

Course object

includeChartImage unknown No false

Whether to include chart image

freshAnalysisData string Yes

Optional fresh analysis data

Returns : Promise<literal type>

Report data object for the Angular component

Private toNumber
toNumber(value: unknown, fallback: number)
Parameters :
Name Type Optional
value unknown No
fallback number No
Returns : number

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(PreBatAnalysisService.name)
Private Readonly s3
Type : S3Client | null
import {
  BAT_COLOR_HEX,
  BAT_LEVEL_TO_SCORE,
  getColorClassFromScore,
  SCORE_TO_METRIC,
} from "@api/shared/constants/assessment-scores.constants";
import {
  ListObjectsV2Command,
  type ListObjectsV2CommandOutput,
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { optionalEnv } from "@bish-nest/core";
import { BNestReportService } from "@bish-nest/core/admin/modules/report/report.service";
import { BNestPrismaService } from "@bish-nest/core/services/database/prisma/prisma.service";
import { BNestAngularSSRService } from "@bish-nest/core/services/pdf/angular-ssr.service";
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AssessmentType } from "@prisma/client";
import { BatChartService } from "./bat-chart.service";

@Injectable()
export class PreBatAnalysisService {
  private toNumber(value: unknown, fallback: number): number {
    const n = Number(value);
    return Number.isFinite(n) ? n : fallback;
  }
  private readonly logger = new Logger(PreBatAnalysisService.name);
  private readonly s3: S3Client | null;

  constructor(
    private readonly prisma: BNestPrismaService,
    private readonly config: ConfigService,
    @Inject() private readonly reportService: BNestReportService,
    @Inject() private readonly angularSSR: BNestAngularSSRService,
    private readonly batChartService: BatChartService,
  ) {
    // Initialize S3 client
    const awsRegion = this.config.get("AWS_REGION");
    const awsAccessKeyId = this.config.get("AWS_ACCESS_KEY_ID");
    const awsSecretAccessKey = this.config.get("AWS_SECRET_ACCESS_KEY");

    if (awsRegion) {
      this.s3 = new S3Client({
        region: awsRegion,
        ...(awsAccessKeyId && awsSecretAccessKey
          ? {
              credentials: {
                accessKeyId: awsAccessKeyId,
                secretAccessKey: awsSecretAccessKey,
              },
            }
          : {}),
      });
    } else {
      this.s3 = null;
      this.logger.warn("AWS_REGION not configured, S3 operations will be disabled");
    }
  }

  /**
   * Get Pre-BAT report data for the Angular report component
   * Extracts and formats data needed by pre-bat-analysis.component
   * @param learningGroupId Learning group ID
   * @param participantId Participant ID
   * @param course Course object
   * @param includeChartImage Whether to include chart image
   * @param freshAnalysisData Optional fresh analysis data
   * @returns Report data object for the Angular component
   */
  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; // JSON stringified array
    ipq: number;
    preTrainingResults: string; // JSON stringified array
    preReport: string;
    chartImage?: string | null;
  }> {
    // Verify learning group exists
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
      select: { id: true },
    });

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

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

    if (!participant) {
      throw new NotFoundException(`Participant with ID ${participantId} not found`);
    }

    // Verify course exists and has assessment
    const fullCourse = await this.prisma.client.course.findUnique({
      where: { id: course.id },
    });

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

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

    // Get the stored analysis data for this learning group
    const assessmentParticipant = await this.prisma.client.assessmentParticipant.findFirst({
      where: {
        assessment_id: fullCourse.assessment_id,
        participant_id: participantId,
        learning_group_id: learningGroupId,
        assessment_type: AssessmentType.PRE_BAT,
      },
    });

    if (!assessmentParticipant) {
      throw new NotFoundException(
        `Pre-BAT assessment not found for participant ${participantId} in learning group ${learningGroupId}`,
      );
    }

    // Get actual assessment results from database
    let assessmentResults = await this.prisma.client.assessmentResult.findMany({
      where: {
        course_id: course.id,
        assessment_id: fullCourse.assessment_id,
        participant_id: participantId,
      },
      select: {
        id: true,
        assessment_question_id: true,
        grade_level: true,
        course_module_id: true,
        created_at: true,
        assessmentQuestion: {
          select: {
            id: true,
            sort_order: true,
            question_text: true,
            courseModule: {
              select: {
                id: true,
                title: true,
                course_module_code: true,
              },
            },
          },
        },
        courseModule: {
          select: {
            id: true,
            title: true,
            course_module_code: true,
          },
        },
        assessmentAnswer: {
          select: {
            answer_level: true,
          },
        },
      },
    });

    // Filter to PRE_BAT results only
    const postBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
      where: {
        assessment_id: fullCourse.assessment_id,
        participant_id: participantId,
        learning_group_id: learningGroupId,
        assessment_type: AssessmentType.POST_BAT,
      },
    });

    const preBatStartTime = new Date(assessmentParticipant.created_at.getTime() - 5000);

    if (postBatParticipant?.created_at) {
      assessmentResults = assessmentResults.filter(
        (r) => r.created_at >= preBatStartTime && r.created_at < postBatParticipant.created_at,
      );
    } else {
      const upperBound = assessmentParticipant.assessment_completion_date
        ? new Date(assessmentParticipant.assessment_completion_date.getTime() + 5000)
        : new Date(assessmentParticipant.created_at.getTime() + 60000);

      assessmentResults = assessmentResults.filter(
        (r) => r.created_at >= preBatStartTime && r.created_at <= upperBound,
      );
    }

    // Sort assessment results by question sort_order to ensure questions appear in the correct order in the report
    assessmentResults = assessmentResults.sort((a, b) => {
      const sortOrderA = this.toNumber(a.assessmentQuestion?.sort_order, 999999);
      const sortOrderB = this.toNumber(b.assessmentQuestion?.sort_order, 999999);
      if (sortOrderA === sortOrderB) {
        return (a.assessmentQuestion?.id ?? 0) - (b.assessmentQuestion?.id ?? 0);
      }
      return sortOrderA - sortOrderB;
    });

    // Get course modules to map skills
    const courseModulePages = await this.prisma.client.courseModulePage.findMany({
      where: {
        course_id: course.id,
        courseModule: {
          is_published: true,
          exclude_from_bat: false,
        },
      },
      select: {
        courseModule: {
          select: {
            id: true,
            course_module_code: true,
            title: true,
            sort_order: true,
          },
        },
      },
    });

    // Extract modules from course pages and sort
    const coursePageModules = courseModulePages
      .map((page) => page.courseModule)
      .filter((module): module is NonNullable<typeof module> => module !== null)
      .filter((module, index, self) => self.findIndex((m) => m.id === module.id) === index)
      .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));

    // Get all modules that have assessment questions (for fallback)
    const allModuleIds = [
      ...new Set(assessmentResults.map((r) => r.course_module_id).filter((id) => id !== null)),
    ];
    const allModules = await this.prisma.client.courseModule.findMany({
      where: {
        id: { in: allModuleIds as number[] },
      },
      select: {
        id: true,
        title: true,
        course_module_code: true,
        sort_order: true,
      },
    });

    // Sort modules based on assessment question sort_order, not course module sort_order
    const moduleQuestionSortOrder = new Map<number, number>();

    assessmentResults.forEach((result) => {
      const moduleId =
        result.course_module_id || result.assessmentQuestion?.courseModule?.id || result.courseModule?.id;
      if (!moduleId) return;

      const questionSortOrder = this.toNumber(result.assessmentQuestion?.sort_order, 999999);
      const currentMin = moduleQuestionSortOrder.get(moduleId);

      if (currentMin === undefined || questionSortOrder < currentMin) {
        moduleQuestionSortOrder.set(moduleId, questionSortOrder);
      }
    });

    // Sort modules by the minimum question sort_order for each module
    const courseModules = allModules.sort((a, b) => {
      const sortOrderA = moduleQuestionSortOrder.get(a.id) ?? 999999;
      const sortOrderB = moduleQuestionSortOrder.get(b.id) ?? 999999;
      if (sortOrderA === sortOrderB) {
        const moduleSortA = a.sort_order ?? 999999;
        const moduleSortB = b.sort_order ?? 999999;
        if (moduleSortA === moduleSortB) {
          return a.id - b.id;
        }
        return moduleSortA - moduleSortB;
      }
      return sortOrderA - sortOrderB;
    });

    this.logger.log(`Pre-BAT PDF: Assessment results have modules: ${allModuleIds.join(", ")}`);
    this.logger.log(
      `Pre-BAT PDF: Found modules sorted by question sort_order: ${courseModules.map((m) => `${m.id}:${m.title} (min_q_sort: ${moduleQuestionSortOrder.get(m.id) ?? "N/A"})`).join(", ")}`,
    );

    // Parse the analysis data for pre_report text
    const analysisData = freshAnalysisData || assessmentParticipant.ai_analysis;
    let preReport = "";
    if (analysisData) {
      try {
        const parsedAnalysis = JSON.parse(analysisData) as {
          result?: { trainees?: Array<{ pre_report?: string }> };
          trainees?: Array<{ pre_report?: string }>;
          pre_report?: string;
        };
        preReport =
          parsedAnalysis?.result?.trainees?.[0]?.pre_report ||
          parsedAnalysis?.trainees?.[0]?.pre_report ||
          parsedAnalysis?.pre_report ||
          analysisData;
      } catch {
        preReport = analysisData;
      }
    }

    // Calculate skills from actual assessment results
    const skills = this.calculateSkillsFromResults(assessmentResults, courseModules);
    this.logger.log(`Pre-BAT PDF: Found ${courseModules.length} modules, generated ${skills.length} skills`);
    const ipq = assessmentParticipant.individual_quotient
      ? Number(assessmentParticipant.individual_quotient)
      : this.calculateIpqFromResults(assessmentResults);
    const preTrainingResults = this.calculatePreTrainingResults(skills);
    const chartImage = includeChartImage
      ? await this.batChartService.renderPreBatChart(skills, {
          centerColor: this.batChartService.getCenterColorHexFromIpq(ipq),
          centerLabel: ipq.toFixed(2),
        })
      : undefined;

    // Get participant name
    const participantName =
      participant.first_name && participant.last_name
        ? `${participant.first_name} ${participant.last_name}`
        : participant.email || `Participant ${participantId}`;

    return {
      courseTitle: fullCourse.title || fullCourse.course_code || `course-${course.id}`,
      participantName,
      skills: JSON.stringify(skills),
      ipq,
      preTrainingResults: JSON.stringify(preTrainingResults),
      preReport: preReport.replace(/\n/g, "<br>"),
      chartImage,
    };
  }

  /**
   * Calculate skills from actual assessment results
   */
  private calculateSkillsFromResults(
    assessmentResults: Array<{
      grade_level: string | null;
      course_module_id: number | null;
      assessmentQuestion?: {
        sort_order?: unknown | null;
        courseModule?: { id?: number | null; title: string | null; course_module_code: string | null } | null;
      } | null;
      courseModule?: { id?: number | null; title: string | null; course_module_code: string | null } | null;
      assessmentAnswer?: { answer_level?: string | null } | null;
    }>,
    courseModules: Array<{
      id: number;
      title: string;
      course_module_code: string | null;
      sort_order?: number | null;
    }>,
    allModulesMap?: Map<
      number,
      { id: number; title: string; course_module_code: string | null; sort_order?: number | null }
    >,
  ): Array<{ name: string; percentage: number; colorClass: string; score: number; sortOrder?: number | null }> {
    // Create a map of course modules for quick lookup by ID
    const courseModuleMap = new Map<
      number,
      { id: number; title: string; course_module_code: string | null; sort_order?: number | null }
    >();
    courseModules.forEach((module) => {
      courseModuleMap.set(module.id, module);
    });

    // Also include any additional modules from allModulesMap
    if (allModulesMap) {
      allModulesMap.forEach((module, id) => {
        if (!courseModuleMap.has(id)) {
          courseModuleMap.set(id, module);
        }
      });
    }

    // Calculate minimum question sort_order for each module
    const moduleQuestionSortOrder = new Map<number, number>();
    assessmentResults.forEach((result) => {
      const moduleId =
        result.course_module_id || result.assessmentQuestion?.courseModule?.id || result.courseModule?.id || null;
      if (!moduleId) return;

      const questionSortOrder = this.toNumber(result.assessmentQuestion?.sort_order, 999999);
      const currentMin = moduleQuestionSortOrder.get(moduleId);

      if (currentMin === undefined || questionSortOrder < currentMin) {
        moduleQuestionSortOrder.set(moduleId, questionSortOrder);
      }
    });

    // Initialize ALL course modules first
    const moduleScores = new Map<
      number,
      {
        name: string;
        scores: number[];
        totalScore: number;
        count: number;
        sortOrder?: number | null;
        moduleId: number;
      }
    >();

    courseModuleMap.forEach((moduleInfo, moduleId) => {
      const questionSortOrder = moduleQuestionSortOrder.get(moduleId);
      moduleScores.set(moduleId, {
        name: moduleInfo.title,
        scores: [],
        totalScore: 0,
        count: 0,
        sortOrder: questionSortOrder !== undefined ? questionSortOrder : moduleInfo.sort_order,
        moduleId: moduleId,
      });
    });

    // Process assessment results
    this.logger.log(`Processing ${assessmentResults.length} assessment results for pre-BAT report`);

    assessmentResults.forEach((result) => {
      const moduleId =
        result.course_module_id || result.assessmentQuestion?.courseModule?.id || result.courseModule?.id || null;

      if (!moduleId) {
        this.logger.warn(`No module ID found for assessment result. Result ID: ${(result as { id?: number }).id}`);
        return;
      }

      const moduleInfo = courseModuleMap.get(moduleId);
      if (!moduleInfo) {
        this.logger.warn(`Module ID ${moduleId} not found in course modules map`);
        return;
      }

      if (!moduleScores.has(moduleId)) {
        moduleScores.set(moduleId, {
          name: moduleInfo.title,
          scores: [],
          totalScore: 0,
          count: 0,
          sortOrder: moduleInfo.sort_order,
          moduleId: moduleId,
        });
      }

      const gradeLevel = (result.grade_level || result.assessmentAnswer?.answer_level || "INTERMEDIATE") as string;
      const score = BAT_LEVEL_TO_SCORE[gradeLevel as keyof typeof BAT_LEVEL_TO_SCORE] ?? 0;

      if (!result.grade_level && !result.assessmentAnswer?.answer_level) {
        this.logger.warn(
          `No grade_level found for assessment result ID ${(result as { id?: number }).id}, defaulting to INTERMEDIATE (score: ${score})`,
        );
      }

      const moduleData = moduleScores.get(moduleId);
      if (moduleData) {
        moduleData.scores.push(score);
        moduleData.totalScore += score;
        moduleData.count++;
      }
    });

    // Calculate average score per module and convert to skills format
    const skills: Array<{
      name: string;
      percentage: number;
      colorClass: string;
      score: number;
      sortOrder?: number | null;
    }> = [];

    courseModules.forEach((module) => {
      const moduleData = moduleScores.get(module.id);

      if (!moduleData) {
        this.logger.warn(`Module ${module.id} (${module.title}) not found in moduleScores`);
        return;
      }

      // For modules without assessment results, show 0% with gray color
      if (moduleData.count === 0) {
        const questionSortOrder = moduleQuestionSortOrder.get(module.id);
        skills.push({
          name: moduleData.name,
          percentage: 0,
          colorClass: "gray",
          score: 0,
          sortOrder:
            questionSortOrder !== undefined ? questionSortOrder : (module.sort_order ?? moduleData.sortOrder),
        });
        return;
      }

      const averageScore = moduleData.totalScore / moduleData.count;
      const mappedScore = Math.round(averageScore);
      const metric = SCORE_TO_METRIC[mappedScore];

      const percentage = metric?.percentage ?? Math.max(0, Math.min(100, ((averageScore + 2) / 4) * 100));

      const colorClass = metric?.colorClass ?? getColorClassFromScore(averageScore);

      const questionSortOrder = moduleQuestionSortOrder.get(module.id);

      skills.push({
        name: moduleData.name,
        percentage: Math.round(percentage),
        colorClass,
        score: Number(averageScore.toFixed(2)),
        sortOrder:
          questionSortOrder !== undefined ? questionSortOrder : (module.sort_order ?? moduleData.sortOrder),
      });
    });

    // Sort by sort_order (ascending)
    skills.sort((a, b) => {
      const aSort = a.sortOrder ?? 999999;
      const bSort = b.sortOrder ?? 999999;
      return aSort - bSort;
    });

    return skills;
  }

  /**
   * Calculate IPQ (Individual Performance Quotient) from assessment results
   */
  private calculateIpqFromResults(
    assessmentResults: Array<{
      grade_level: string | null;
      assessmentAnswer?: { answer_level?: string | null } | null;
    }>,
  ): number {
    if (assessmentResults.length === 0) return 0;

    let totalScore = 0;
    let count = 0;

    assessmentResults.forEach((result) => {
      const gradeLevel = (result.grade_level || result.assessmentAnswer?.answer_level || "INTERMEDIATE") as string;
      const score = BAT_LEVEL_TO_SCORE[gradeLevel as keyof typeof BAT_LEVEL_TO_SCORE] ?? -1;
      totalScore += score;
      count++;
    });

    return count > 0 ? Number((totalScore / count).toFixed(2)) : 0;
  }

  /**
   * Calculate pre-training results distribution
   */
  private calculatePreTrainingResults(skills: Array<{ score: number; colorClass: string }>): Array<{
    color: string;
    colorClass: string;
    count: number;
    total: number;
    percentage: number;
  }> {
    const total = skills.length;

    let foundation = 0;
    let belowAverage = 0;
    let average = 0;
    let excellent = 0;

    skills.forEach((skill) => {
      switch (skill.colorClass) {
        case "red":
          foundation++;
          break;
        case "orange":
          belowAverage++;
          break;
        case "light-green":
          average++;
          break;
        case "dark-green":
          excellent++;
          break;
      }
    });

    const createEntry = (color: string, colorClass: string, count: number) => ({
      color,
      colorClass,
      count,
      total,
      percentage: total > 0 ? Number.parseFloat(((count / total) * 100).toFixed(2)) : 0,
    });

    return [
      createEntry(BAT_COLOR_HEX.red, "red", foundation),
      createEntry(BAT_COLOR_HEX.orange, "orange", belowAverage),
      createEntry(BAT_COLOR_HEX["light-green"], "light-green", average),
      createEntry(BAT_COLOR_HEX["dark-green"], "dark-green", excellent),
    ];
  }

  /**
   * Generate Pre-BAT HTML preview
   */
  async generatePreBatHtml(learningGroupId: number, participantId: number): Promise<string> {
    this.logger.log(
      `Generating pre-BAT HTML preview for participant ${participantId} in learning group ${learningGroupId}`,
    );

    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
      select: { course: true },
    });

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

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

    const reportDataRaw = await this.getPreBatAnalysisData(
      learningGroupId,
      participantId,
      {
        id: course.id,
        assessment_id: course.assessment_id,
        title: course.title,
      },
      true,
    );

    const skillsParsed = JSON.parse(reportDataRaw.skills);
    const preTrainingResultsParsed = JSON.parse(reportDataRaw.preTrainingResults);

    const reportData = {
      courseTitle: reportDataRaw.courseTitle,
      participantName: reportDataRaw.participantName,
      skills: skillsParsed,
      ipq: reportDataRaw.ipq,
      preTrainingResults: preTrainingResultsParsed,
      preReport: reportDataRaw.preReport,
      chartImage: reportDataRaw.chartImage,
    };

    const serverDistPath = optionalEnv(
      "ANGULAR_SERVER_DIST_PATH",
      "dist/apps/recallassess/recallassess-admin-pwa/server",
    );
    const reportRoute = `/report/preview/paged/pre-bat-analysis`;
    const html = await this.angularSSR.renderReport(serverDistPath, reportRoute, reportData);

    return html;
  }

  /**
   * Generate Pre-BAT PDF directly and return buffer
   */
  async generatePreBatPdfDirect(
    learningGroupId: number,
    participantId: number,
    course: { id: number; assessment_id: number | null; title: string | null },
  ): Promise<Buffer> {
    const reportDataRaw = await this.getPreBatAnalysisData(learningGroupId, participantId, course, true);

    const skillsParsed = JSON.parse(reportDataRaw.skills);
    const preTrainingResultsParsed = JSON.parse(reportDataRaw.preTrainingResults);

    const reportData = {
      courseTitle: reportDataRaw.courseTitle,
      participantName: reportDataRaw.participantName,
      skills: skillsParsed,
      ipq: reportDataRaw.ipq,
      preTrainingResults: preTrainingResultsParsed,
      preReport: reportDataRaw.preReport,
      chartImage: reportDataRaw.chartImage,
    };

    const pdfBuffer = await this.reportService.generateReportPdf("pre-bat-analysis", "paged", reportData);

    return pdfBuffer;
  }

  /**
   * Check if Pre-BAT PDF exists in S3
   */
  async checkPreBatPdfExists(learningGroupId: number, participantId: number): Promise<string> {
    const prefix = `private/report-log/${learningGroupId}/document/${participantId}-pre-bat`;
    const bucketName = this.config.get("AWS_S3_MEDIA_BUCKET");

    if (!bucketName) {
      throw new Error("AWS_S3_MEDIA_BUCKET environment variable is required");
    }

    if (!this.s3) {
      throw new NotFoundException(
        `Pre-BAT PDF not found for participant ${participantId} in learning group ${learningGroupId}. Report generation failed. Please contact the administrator.`,
      );
    }

    let result: ListObjectsV2CommandOutput;
    try {
      result = await this.s3.send(
        new ListObjectsV2Command({
          Bucket: bucketName,
          Prefix: prefix,
        }),
      );
    } catch (error: unknown) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      throw new NotFoundException(
        `Pre-BAT PDF not found for participant ${participantId} in learning group ${learningGroupId}. Error: ${errorMessage}`,
      );
    }

    if (!result.Contents || result.Contents.length === 0) {
      this.logger.log(
        `Pre-BAT PDF not found in S3 for participant ${participantId} in learning group ${learningGroupId}. Generating report on demand.`,
      );

      const learningGroup = await this.prisma.client.learningGroup.findUnique({
        where: { id: learningGroupId },
        select: {
          id: true,
          course: {
            select: {
              id: true,
              assessment_id: true,
              title: true,
            },
          },
        },
      });

      if (!learningGroup || !learningGroup.course) {
        throw new NotFoundException(
          `Learning group ${learningGroupId} or its course could not be found while generating Pre-BAT PDF.`,
        );
      }

      const course = learningGroup.course;

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

      return this.generateAndUploadPreBatPdf(learningGroupId, participantId, {
        id: course.id,
        assessment_id: course.assessment_id,
        title: course.title,
      });
    }

    const sortedContents = result.Contents.sort(
      (a, b) => (b.LastModified?.getTime() || 0) - (a.LastModified?.getTime() || 0),
    );
    const mostRecentPdf = sortedContents[0];

    if (!mostRecentPdf.Key) {
      throw new NotFoundException(
        `Pre-BAT PDF not found for participant ${participantId} in learning group ${learningGroupId}. Report generation failed. Please contact the administrator.`,
      );
    }

    this.logger.log(`Pre-BAT PDF found in S3: ${mostRecentPdf.Key}`);
    return mostRecentPdf.Key;
  }

  /**
   * Generate PDF and upload to S3
   */
  async generateAndUploadPreBatPdf(
    learningGroupId: number,
    participantId: number,
    course: { id: number; assessment_id: number | null; title: string | null },
    authorization?: string,
    freshAnalysisData?: string,
  ): Promise<string> {
    const reportDataRaw = await this.getPreBatAnalysisData(
      learningGroupId,
      participantId,
      course,
      true,
      freshAnalysisData,
    );

    const skillsParsed = JSON.parse(reportDataRaw.skills);
    const preTrainingResultsParsed = JSON.parse(reportDataRaw.preTrainingResults);

    const reportData = {
      courseTitle: reportDataRaw.courseTitle,
      participantName: reportDataRaw.participantName,
      skills: skillsParsed,
      ipq: reportDataRaw.ipq,
      preTrainingResults: preTrainingResultsParsed,
      preReport: reportDataRaw.preReport,
      chartImage: reportDataRaw.chartImage,
    };

    const pdfBuffer = await this.reportService.generateReportPdf(
      "pre-bat-analysis",
      "paged",
      reportData,
      authorization,
    );

    const s3Path = `private/report-log/${learningGroupId}/document/${participantId}-pre-bat.pdf`;
    const bucketName = this.config.get("AWS_S3_MEDIA_BUCKET");

    if (!bucketName) {
      throw new Error("AWS_S3_MEDIA_BUCKET environment variable is required");
    }

    if (!this.s3) {
      this.logger.warn(`⚠ S3 disabled - Skipping upload for: ${s3Path}`);
      return s3Path;
    }

    await this.s3.send(
      new PutObjectCommand({
        Bucket: bucketName,
        Key: s3Path,
        Body: pdfBuffer,
        ContentType: "application/pdf",
        ContentDisposition: "attachment",
      }),
    );
    this.logger.log(`✓ Uploaded to S3: ${s3Path} (Content-Type: application/pdf)`);

    return s3Path;
  }
}

results matching ""

    No results matching ""