apps/recallassess/recallassess-api/src/api/integration/pre-bat-analysis.service.ts
Properties |
Methods |
|
constructor(prisma: BNestPrismaService, config: ConfigService, reportService: BNestReportService, angularSSR: BNestAngularSSRService, batChartService: BatChartService)
|
||||||||||||||||||
|
Parameters :
|
| Private calculateIpqFromResults | ||||||
calculateIpqFromResults(assessmentResults: Array
|
||||||
|
Calculate IPQ (Individual Performance Quotient) from assessment results
Parameters :
Returns :
number
|
| Private calculateSkillsFromResults | ||||||||||||
calculateSkillsFromResults(assessmentResults: Array
|
||||||||||||
|
Calculate skills from actual assessment results
Parameters :
Returns :
Array<literal type>
|
| Async checkPreBatPdfExists |
checkPreBatPdfExists(learningGroupId: number, participantId: number)
|
|
Check if Pre-BAT PDF exists in S3
Returns :
Promise<string>
|
| Async generateAndUploadPreBatPdf | ||||||||||||||||||
generateAndUploadPreBatPdf(learningGroupId: number, participantId: number, course: literal type, authorization?: string, freshAnalysisData?: string)
|
||||||||||||||||||
|
Generate PDF and upload to S3
Parameters :
Returns :
Promise<string>
|
| Async generatePreBatHtml |
generatePreBatHtml(learningGroupId: number, participantId: number)
|
|
Generate Pre-BAT HTML preview
Returns :
Promise<string>
|
| Async generatePreBatPdfDirect | ||||||||||||
generatePreBatPdfDirect(learningGroupId: number, participantId: number, course: literal type)
|
||||||||||||
|
Generate Pre-BAT PDF directly and return buffer
Parameters :
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 :
Returns :
Promise<literal type>
Report data object for the Angular component |
| Private toNumber |
toNumber(value: unknown, fallback: number)
|
|
Returns :
number
|
| 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;
}
}