File

apps/recallassess/recallassess-api/src/api/integration/post-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

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

Check if Post-BAT PDF exists in S3

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

Generate POST-BAT PDF and upload to S3

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

Generate POST-BAT HTML preview

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

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

Parameters :
Name Type Optional Description
learningGroupId number No

Learning group ID

participantId number No

Participant ID

course literal type No

Course object

options literal type Yes

Optional options including includeChartImages

Returns : Promise<literal type>

Report data object for the Angular component

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(PostBatAnalysisService.name)
Private Readonly s3
Type : S3Client | null
import { BAT_LEVEL_TO_SCORE, SCORE_TO_METRIC } from "@api/shared/constants/assessment-scores.constants";
import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { requireEnv } 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 PostBatAnalysisService {
  private readonly logger = new Logger(PostBatAnalysisService.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 Post-BAT analysis data for the Angular report component
   * Extracts and formats data needed by post-bat-analysis.component
   * @param learningGroupId Learning group ID
   * @param participantId Participant ID
   * @param course Course object
   * @param options Optional options including includeChartImages
   * @returns Report data object for the Angular component
   */
  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; // JSON stringified array
    overallSummary?: string | null; // When post uses one-summary format (same as pre)
    preChartImage?: string | null;
    postChartImage?: string | null;
  }> {
    // 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 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 },
      include: {
        courseModulePages: {
          where: {
            courseModule: {
              exclude_from_bat: false,
            },
          },
          select: {
            courseModule: {
              select: {
                id: true,
                course_module_code: true,
                title: true,
                sort_order: true,
              },
            },
          },
        },
      },
    });

    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 PRE-BAT assessment participant
    const preBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
      where: {
        assessment_id: fullCourse.assessment_id,
        participant_id: participantId,
        learning_group_id: learningGroupId,
        assessment_type: AssessmentType.PRE_BAT,
      },
    });

    // Get POST-BAT assessment participant
    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,
      },
    });

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

    // Detect new format: one overall_summary in post_ai_analysis (column on AssessmentParticipant)
    const postAnalysisRaw = (postBatParticipant as { post_ai_analysis?: string | null }).post_ai_analysis;
    let overallSummary: string | null = null;
    if (postAnalysisRaw && postAnalysisRaw.trim().length > 0) {
      try {
        const parsed = JSON.parse(postAnalysisRaw) as { overall_summary?: string };
        if (typeof parsed.overall_summary === "string" && parsed.overall_summary.trim().length > 0) {
          overallSummary = parsed.overall_summary.trim();
        }
      } catch {
        // Not JSON or missing overall_summary; keep overallSummary null
      }
    }

    // Get all assessment results (both PRE and POST)
    const allResults = await this.prisma.client.assessmentResult.findMany({
      where: {
        course_id: course.id,
        assessment_id: fullCourse.assessment_id,
        participant_id: participantId,
      },
      select: {
        id: true,
        course_id: true,
        assessment_id: true,
        assessment_question_id: true,
        course_module_id: true,
        assessment_answer_id: true,
        participant_id: true,
        assessment_participant_id: true,
        answer_text: true,
        grade_level: true,
        learning_path: true,
        additional_learning_path: true,
        references: true,
        created_at: true,
        updated_at: true,
        assessmentQuestion: {
          select: {
            id: true,
            sort_order: true,
            courseModule: {
              select: {
                id: true,
                title: true,
                course_module_code: true,
              },
            },
          },
        },
        assessmentAnswer: {
          select: {
            answer_level: true,
            answer_text: true,
          },
        },
        courseModule: {
          select: {
            id: true,
            title: true,
            course_module_code: true,
          },
        },
      },
    });

    // Separate PRE and POST results based on assessment_participant_id
    const preResults = allResults.filter((r: any) => r.assessment_participant_id === preBatParticipant?.id);
    const postResults = allResults.filter((r: any) => r.assessment_participant_id === postBatParticipant.id);

    // Sort PRE and POST results by question sort_order
    preResults.sort((a: any, b: any) => {
      const sortOrderA = a.assessmentQuestion?.sort_order ?? 999999;
      const sortOrderB = b.assessmentQuestion?.sort_order ?? 999999;
      if (sortOrderA === sortOrderB) {
        return (a.assessmentQuestion?.id ?? 0) - (b.assessmentQuestion?.id ?? 0);
      }
      return sortOrderA - sortOrderB;
    });

    postResults.sort((a: any, b: any) => {
      const sortOrderA = a.assessmentQuestion?.sort_order ?? 999999;
      const sortOrderB = b.assessmentQuestion?.sort_order ?? 999999;
      if (sortOrderA === sortOrderB) {
        return (a.assessmentQuestion?.id ?? 0) - (b.assessmentQuestion?.id ?? 0);
      }
      return sortOrderA - sortOrderB;
    });

    // Group results by module with PRE/POST comparison
    interface ModuleComparison {
      moduleName: string;
      moduleCode: string;
      preLevel: string;
      postLevel: string;
      preScore: number;
      postScore: number;
      improvement: string;
      learningPath: string | null;
      additionalLearningPath: string | null;
      references: string[] | null;
      sortOrder: number;
    }

    const moduleComparisons: ModuleComparison[] = [];

    // Extract unique modules from courseModulePages
    const courseModulesUnsorted = fullCourse.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);

    // Also get all modules that have assessment results
    const modulesWithResults = new Set(
      [
        ...preResults.map((r: any) => r.course_module_id),
        ...postResults.map((r: any) => r.course_module_id),
      ].filter((id) => id !== null),
    );

    // For modules not in courseModulePages, fetch their details
    const missingModuleIds = Array.from(modulesWithResults).filter(
      (moduleId) => !courseModulesUnsorted.some((m) => m.id === moduleId),
    );

    let additionalModules: any[] = [];
    if (missingModuleIds.length > 0) {
      additionalModules = await this.prisma.client.courseModule.findMany({
        where: {
          id: { in: missingModuleIds },
        },
        select: {
          id: true,
          course_module_code: true,
          title: true,
          sort_order: true,
        },
      });
    }

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

      const questionSortOrder = 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
    const courseModules = courseModulesUnsorted.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;
    });

    // Sort additional modules by question sort_order as well
    const additionalModulesSorted = additionalModules.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;
    });

    // Combine course modules and additional modules
    const allModules = [...courseModules, ...additionalModulesSorted];

    for (const module of allModules) {
      const modulePreResults = preResults.filter((r) => r.course_module_id === module.id);
      const modulePostResults = postResults.filter((r) => r.course_module_id === module.id);

      if (modulePostResults.length === 0) continue;

      // Calculate average scores
      const preScore =
        modulePreResults.length > 0
          ? modulePreResults.reduce((sum: number, r: any) => {
              const level = r.grade_level || r.assessmentAnswer?.answer_level || "INTERMEDIATE";
              return sum + (BAT_LEVEL_TO_SCORE[level as keyof typeof BAT_LEVEL_TO_SCORE] ?? 0);
            }, 0) / modulePreResults.length
          : 0;

      const postScore =
        modulePostResults.reduce((sum: number, r: any) => {
          const level = r.grade_level || r.assessmentAnswer?.answer_level || "INTERMEDIATE";
          return sum + (BAT_LEVEL_TO_SCORE[level as keyof typeof BAT_LEVEL_TO_SCORE] ?? 0);
        }, 0) / modulePostResults.length;

      // Get level labels
      const getLevelLabel = (score: number): string => {
        if (score <= -1.5) return "Foundation";
        if (score <= -0.5) return "Intermediate";
        if (score <= 0.5) return "Intermediate";
        if (score <= 1.5) return "Advanced";
        return "Expert";
      };

      const preLevel = getLevelLabel(preScore);
      const postLevel = getLevelLabel(postScore);

      // Calculate improvement
      const scoreDiff = postScore - preScore;
      let improvement = "No change";
      if (scoreDiff > 0.5) improvement = "Improved";
      else if (scoreDiff < -0.5) improvement = "Declined";

      // Get learning path from the first post result
      const postResult = modulePostResults[0] as any;
      const learningPath = postResult.learning_path || null;
      const additionalLearningPath = postResult.additional_learning_path || null;
      const references = postResult.references ? (postResult.references as string[]) : null;

      // Get the sort order for this module
      const moduleSortOrder = moduleQuestionSortOrder.get(module.id) ?? 999999;

      moduleComparisons.push({
        moduleName: module.title,
        moduleCode: module.course_module_code || "",
        preLevel,
        postLevel,
        preScore,
        postScore,
        improvement,
        learningPath,
        additionalLearningPath,
        references,
        sortOrder: moduleSortOrder,
      });
    }

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

    // Generate chart images if requested
    let preChartImage: string | null = null;
    let postChartImage: string | null = null;

    if (options?.includeChartImages && moduleComparisons.length > 0) {
      const preIpq = preBatParticipant?.individual_quotient ? Number(preBatParticipant.individual_quotient) : 0;
      const postIpq = postBatParticipant.individual_quotient ? Number(postBatParticipant.individual_quotient) : 0;

      // Generate PRE chart data
      const preSkills = moduleComparisons.map((mc) => {
        const score = mc.preScore;
        const roundedScore = Math.round(score * 2) / 2;
        const metric = SCORE_TO_METRIC[roundedScore as keyof typeof SCORE_TO_METRIC] || SCORE_TO_METRIC[0];
        return {
          name: mc.moduleName,
          percentage: metric.percentage,
          colorClass: metric.colorClass,
          score: roundedScore,
        };
      });

      // Generate POST chart data
      const postSkills = moduleComparisons.map((mc) => {
        const score = mc.postScore;
        const roundedScore = Math.round(score * 2) / 2;
        const metric = SCORE_TO_METRIC[roundedScore as keyof typeof SCORE_TO_METRIC] || SCORE_TO_METRIC[0];
        return {
          name: mc.moduleName,
          percentage: metric.percentage,
          colorClass: metric.colorClass,
          score: roundedScore,
        };
      });

      preChartImage = await this.batChartService.renderPostBatChart(preSkills, "pre", {
        centerColor: this.batChartService.getCenterColorHexFromIpq(preIpq),
        centerLabel: preIpq.toFixed(2),
      });
      postChartImage = await this.batChartService.renderPostBatChart(postSkills, "post", {
        centerColor: this.batChartService.getCenterColorHexFromIpq(postIpq),
        centerLabel: postIpq.toFixed(2),
      });
    }

    return {
      courseTitle: fullCourse.title || fullCourse.course_code || `course-${course.id}`,
      participantName,
      preIpq: preBatParticipant?.individual_quotient ? Number(preBatParticipant.individual_quotient) : 0,
      postIpq: postBatParticipant.individual_quotient ? Number(postBatParticipant.individual_quotient) : 0,
      moduleComparisons: JSON.stringify(moduleComparisons),
      overallSummary: overallSummary ?? undefined,
      preChartImage,
      postChartImage,
    };
  }

  /**
   * Generate POST-BAT HTML preview
   */
  async generatePostBatHtml(learningGroupId: number, participantId: number, courseCode: string): Promise<string> {
    this.logger.log(
      `Generating post-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}`);
    }

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

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

    const moduleComparisonsParsed = JSON.parse(reportDataRaw.moduleComparisons);

    const reportData = {
      courseTitle: reportDataRaw.courseTitle,
      participantName: reportDataRaw.participantName,
      preIpq: reportDataRaw.preIpq,
      postIpq: reportDataRaw.postIpq,
      moduleComparisons: moduleComparisonsParsed,
      overallSummary: reportDataRaw.overallSummary ?? null,
      preChartImage: reportDataRaw.preChartImage ?? null,
      postChartImage: reportDataRaw.postChartImage ?? null,
    };

    const serverDistPath = requireEnv("ANGULAR_SERVER_DIST_PATH");
    const reportRoute = `/report/preview/paged/post-bat-analysis`;
    const html = await this.angularSSR.renderReport(serverDistPath, reportRoute, reportData);

    return html;
  }

  /**
   * Check if Post-BAT PDF exists in S3
   */
  async checkPostBatPdfExists(learningGroupId: number, participantId: number): Promise<string> {
    const prefix = `private/report-log/${learningGroupId}/document/${participantId}-post-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(
        `Post-BAT PDF not found for participant ${participantId} in learning group ${learningGroupId}. Report generation failed. Please contact the administrator.`,
      );
    }

    try {
      const result = await this.s3.send(
        new ListObjectsV2Command({
          Bucket: bucketName,
          Prefix: prefix,
        }),
      );

      if (!result.Contents || result.Contents.length === 0) {
        this.logger.log(
          `Post-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 Post-BAT PDF.`,
          );
        }

        const course = learningGroup.course;

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

        return this.generateAndUploadPostBatPdf(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(
          `Post-BAT PDF not found for participant ${participantId} in learning group ${learningGroupId}. Report generation failed. Please contact the administrator.`,
        );
      }

      this.logger.log(`Post-BAT PDF found in S3: ${mostRecentPdf.Key}`);
      return mostRecentPdf.Key;
    } catch (error: unknown) {
      if (error instanceof NotFoundException) {
        throw error;
      }
      const errorMessage = error instanceof Error ? error.message : String(error);
      throw new NotFoundException(
        `Post-BAT PDF not found for participant ${participantId} in learning group ${learningGroupId}. Error: ${errorMessage}`,
      );
    }
  }

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

    const moduleComparisonsParsed = JSON.parse(reportDataRaw.moduleComparisons);

    const reportData = {
      courseTitle: reportDataRaw.courseTitle,
      participantName: reportDataRaw.participantName,
      preIpq: reportDataRaw.preIpq,
      postIpq: reportDataRaw.postIpq,
      moduleComparisons: moduleComparisonsParsed,
      overallSummary: reportDataRaw.overallSummary ?? null,
      preChartImage: reportDataRaw.preChartImage ?? null,
      postChartImage: reportDataRaw.postChartImage ?? null,
    };

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

    const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
    const s3Path = `private/report-log/${learningGroupId}/document/${participantId}-post-bat-${timestamp}.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 ""