File

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

Description

PreBatAnalysisService

Integration API — Pre Bat Analysis. Business Logic Service for RecallAssess.

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, forceRegenerate: unknown)

Resolve the Pre-BAT PDF S3 path for a participant.

this is the fast path for downloads. When true, the PDF is always rebuilt and re-uploaded (used by the admin "Regenerate" action).

Parameters :
Name Type Optional Default value
learningGroupId number No
participantId number No
forceRegenerate unknown No false
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 Async resolveCourseForPreBatPdf
resolveCourseForPreBatPdf(learningGroupId: number)
Parameters :
Name Type Optional
learningGroupId number No
Returns : Promise<literal type>
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,
  PutObjectCommand,
  S3Client,
} from "@aws-sdk/client-s3";
import { optionalEnv } from "@bish-nest/runtime-env";
import { BNestReportService } from "@bish-nest/admin/modules/report/report.service";
import { BNestPrismaService } from "@bish-nest/prisma";
import { BNestAngularSSRService } from "@bish-nest/reporting";
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AssessmentType } from "@prisma/client";
import { BatChartService } from "./bat-chart.service";

/**
 * PreBatAnalysisService
 *
 * Integration API — Pre Bat Analysis. Business Logic Service for RecallAssess.
 */


@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);
    let chartImage: string | null | undefined;
    if (includeChartImage) {
      const chartStart = Date.now();
      this.logger.log("[PRE-BAT-PDF] chartImage START");
      try {
        chartImage = await this.batChartService.renderPreBatChart(skills, {
          centerColor: this.batChartService.getCenterColorHexFromIpq(ipq),
          centerLabel: ipq.toFixed(2),
        });
        this.logger.log(
          `[PRE-BAT-PDF] chartImage DONE duration=${Date.now() - chartStart}ms present=${chartImage ? "yes" : "no"} approxBytes=${chartImage ? chartImage.length : 0}`,
        );
      } catch (error) {
        // renderPreBatChart is documented fault-tolerant, but log loudly if it ever throws.
        const message = error instanceof Error ? error.message : String(error);
        this.logger.warn(
          `[PRE-BAT-PDF] chartImage FAILED (non-fatal, continuing) duration=${Date.now() - chartStart}ms error=${message}`,
        );
        chartImage = null;
      }
    }

    // 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; percentage: number }>,
  ): Array<{
    color: string;
    colorClass: string;
    count: number;
    total: number;
    percentage: number;
  }> {
    const total = skills.length;

    let foundation = 0;
    let belowAverage = 0;
    let good = 0;
    let excellent = 0;

    skills.forEach((skill) => {
      switch (skill.colorClass) {
        case "red":
          foundation++;
          break;
        case "orange":
          belowAverage++;
          break;
        case "light-green":
        case "green":
          good++;
          break;
        case "gray":
          // Modules without answers are also "gray" (0%); only answered Average modules count here.
          if (skill.percentage > 0) good++;
          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", good),
      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;
  }

  /**
   * Ensure Pre-BAT PDF exists in S3 (always regenerates to pick up latest report template).
   */
  /**
   * Resolve the Pre-BAT PDF S3 path for a participant.
   *
   * @param forceRegenerate When `false` (default), an existing PDF in S3 is reused —
   *   this is the fast path for downloads. When `true`, the PDF is always rebuilt
   *   and re-uploaded (used by the admin "Regenerate" action).
   */
  async checkPreBatPdfExists(
    learningGroupId: number,
    participantId: number,
    forceRegenerate = false,
  ): Promise<string> {
    const t0 = Date.now();
    const prefix = `private/report-log/${learningGroupId}/document/${participantId}-pre-bat`;
    const bucketName = this.config.get("AWS_S3_MEDIA_BUCKET");

    this.logger.log(
      `[PRE-BAT-PDF][step 2] checkPreBatPdfExists START lg=${learningGroupId} participant=${participantId} forceRegenerate=${forceRegenerate} prefix=${prefix}`,
    );

    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.`,
      );
    }

    try {
      const s3ListStart = Date.now();
      const result = await this.s3.send(
        new ListObjectsV2Command({
          Bucket: bucketName,
          Prefix: prefix,
        }),
      );
      this.logger.log(
        `[PRE-BAT-PDF][step 3] S3 ListObjectsV2 done in ${Date.now() - s3ListStart}ms — matches=${result.Contents?.length ?? 0}`,
      );

      if (result.Contents && result.Contents.length > 0) {
        const sortedContents = result.Contents.sort(
          (a, b) => (b.LastModified?.getTime() || 0) - (a.LastModified?.getTime() || 0),
        );
        const mostRecentPdf = sortedContents[0];
        if (mostRecentPdf.Key) {
          if (!forceRegenerate) {
            this.logger.log(
              `[PRE-BAT-PDF][step 4] CACHE HIT — reusing S3 key=${mostRecentPdf.Key} (total ${Date.now() - t0}ms). NO render needed.`,
            );
            return mostRecentPdf.Key;
          }
          this.logger.log(
            `[PRE-BAT-PDF][step 4] S3 key=${mostRecentPdf.Key} found but forceRegenerate=true; rebuilding.`,
          );
        }
      } else {
        this.logger.log(
          `[PRE-BAT-PDF][step 4] CACHE MISS — no PDF in S3 for participant ${participantId} lg ${learningGroupId}. Falling into SLOW synchronous render path.`,
        );
      }
    } catch (error: unknown) {
      const errorMessage = error instanceof Error ? error.message : String(error);
      this.logger.error(`[PRE-BAT-PDF][step 4] S3 ListObjectsV2 FAILED after ${Date.now() - t0}ms: ${errorMessage}`);
      throw new NotFoundException(
        `Pre-BAT PDF not found for participant ${participantId} in learning group ${learningGroupId}. Error: ${errorMessage}`,
      );
    }

    const course = await this.resolveCourseForPreBatPdf(learningGroupId);
    this.logger.log(`[PRE-BAT-PDF][step 5] Resolved course id=${course.id}; starting synchronous render.`);
    return this.generateAndUploadPreBatPdf(learningGroupId, participantId, course);
  }

  private async resolveCourseForPreBatPdf(learningGroupId: number): Promise<{
    id: number;
    assessment_id: number | null;
    title: string | null;
  }> {
    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 {
      id: course.id,
      assessment_id: course.assessment_id,
      title: course.title,
    };
  }

  /**
   * 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 t0 = Date.now();
    const cid = `pretbat[lg=${learningGroupId} p=${participantId}]`;
    this.logger.log(`[PRE-BAT-PDF][${cid}] START`);
    this.logger.log(
      `[PRE-BAT-PDF][${cid}] generateAndUploadPreBatPdf START course=${course.id}`,
    );

    let reportDataRaw: Awaited<ReturnType<PreBatAnalysisService["getPreBatAnalysisData"]>>;
    const dataStart = Date.now();
    try {
      reportDataRaw = await this.getPreBatAnalysisData(
        learningGroupId,
        participantId,
        course,
        true,
        freshAnalysisData,
      );
      this.logger.log(
        `[PRE-BAT-PDF][${cid}] getPreBatAnalysisData DONE duration=${Date.now() - dataStart}ms`,
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      const stack = error instanceof Error ? error.stack : undefined;
      this.logger.error(
        `[PRE-BAT-PDF][${cid}] FAILED\n  stage=getPreBatAnalysisData\n  duration=${Date.now() - dataStart}ms\n  error=${message}\n  stack=${stack ?? "n/a"}`,
      );
      throw error;
    }

    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 renderStart = Date.now();
    this.logger.log(
      `[PRE-BAT-PDF][${cid}] reportService.generateReportPdf START (SSR + Paged.js — expected slowest stage)`,
    );
    let pdfBuffer: Buffer;
    try {
      pdfBuffer = await this.reportService.generateReportPdf(
        "pre-bat-analysis",
        "paged",
        reportData,
        authorization,
      );
      this.logger.log(
        `[PRE-BAT-PDF][${cid}] PDF BUFFER CREATED duration=${Date.now() - renderStart}ms size=${pdfBuffer.length} bytes`,
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      const stack = error instanceof Error ? error.stack : undefined;
      this.logger.error(
        `[PRE-BAT-PDF][${cid}] FAILED\n  stage=generateReportPdf(SSR+PagedJS)\n  duration=${Date.now() - renderStart}ms\n  error=${message}\n  stack=${stack ?? "n/a"}`,
      );
      throw error;
    }

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

    const uploadStart = Date.now();
    this.logger.log(`[PRE-BAT-PDF][${cid}] S3 UPLOAD START key=${s3Path}`);
    try {
      await this.s3.send(
        new PutObjectCommand({
          Bucket: bucketName,
          Key: s3Path,
          Body: pdfBuffer,
          ContentType: "application/pdf",
          ContentDisposition: "attachment",
        }),
      );
      this.logger.log(
        `[PRE-BAT-PDF][${cid}] S3 UPLOAD DONE duration=${Date.now() - uploadStart}ms key=${s3Path}`,
      );
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      const stack = error instanceof Error ? error.stack : undefined;
      this.logger.error(
        `[PRE-BAT-PDF][${cid}] FAILED\n  stage=S3.PutObject\n  duration=${Date.now() - uploadStart}ms\n  error=${message}\n  stack=${stack ?? "n/a"}`,
      );
      throw error;
    }

    this.logger.log(`[PRE-BAT-PDF][${cid}] COMPLETED totalDuration=${Date.now() - t0}ms`);
    return s3Path;
  }
}

results matching ""

    No results matching ""