apps/recallassess/recallassess-api/src/api/integration/post-bat-analysis.service.ts
Properties |
Methods |
|
constructor(prisma: BNestPrismaService, config: ConfigService, reportService: BNestReportService, angularSSR: BNestAngularSSRService, batChartService: BatChartService)
|
||||||||||||||||||
|
Parameters :
|
| Async checkPostBatPdfExists |
checkPostBatPdfExists(learningGroupId: number, participantId: number)
|
|
Check if Post-BAT PDF exists in S3
Returns :
Promise<string>
|
| Async generateAndUploadPostBatPdf | |||||||||||||||
generateAndUploadPostBatPdf(learningGroupId: number, participantId: number, course: literal type, authorization?: string)
|
|||||||||||||||
|
Generate POST-BAT PDF and upload to S3
Parameters :
Returns :
Promise<string>
|
| Async generatePostBatHtml | ||||||||||||
generatePostBatHtml(learningGroupId: number, participantId: number, courseCode: string)
|
||||||||||||
|
Generate POST-BAT HTML preview
Parameters :
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 :
Returns :
Promise<literal type>
Report data object for the Angular component |
| 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;
}
}