apps/recallassess/recallassess-api/src/api/integration/integration.service.ts
IntegrationService
Integration API — Integration. Business Logic Service for RecallAssess.
Properties |
|
Methods |
|
constructor(prisma: BNestPrismaService, browserService: BNestBrowserService, config: ConfigService, reportService: BNestReportService, angularSSR: BNestAngularSSRService, preBatAnalysisService: PreBatAnalysisService, postBatAnalysisService: PostBatAnalysisService, systemLogService: SystemLogService)
|
|||||||||||||||||||||||||||
|
Parameters :
|
| Async checkPostBatPdfExists | ||||||||||||||||
checkPostBatPdfExists(learningGroupId: number, participantId: number, forceRegenerate: unknown)
|
||||||||||||||||
|
Check if Post-BAT PDF exists in S3 Delegates to PostBatAnalysisService
Parameters :
Returns :
Promise<string>
|
| Async checkPreBatPdfExists | ||||||||||||||||
checkPreBatPdfExists(learningGroupId: number, participantId: number, forceRegenerate: unknown)
|
||||||||||||||||
|
Check if Pre-BAT PDF exists in S3 Delegates to PreBatAnalysisService
Parameters :
Returns :
Promise<string>
|
| Private Async createReportLogPlaceholder | ||||||
createReportLogPlaceholder(params: literal type)
|
||||||
|
Create a report log entry with a placeholder title, before the PDF is generated. The integration endpoints respond as soon as the analysis data is stored; the PDF is generated in the background and finalizeReportLogTitle replaces the placeholder with the final title (including the S3 filename) afterwards.
Parameters :
Returns :
Promise<number | null>
the created |
| Async generateAndUploadPostBatPdf | |||||||||||||||
generateAndUploadPostBatPdf(learningGroupId: number, participantId: number, course: literal type, authorization?: string)
|
|||||||||||||||
|
Generate POST-BAT PDF and upload to S3 Delegates to PostBatAnalysisService
Parameters :
Returns :
Promise<string>
|
| Async generateAndUploadPreBatPdf | ||||||||||||||||||
generateAndUploadPreBatPdf(learningGroupId: number, participantId: number, course: literal type, authorization?: string, freshAnalysisData?: string)
|
||||||||||||||||||
|
Generate PDF and upload to S3 Delegates to PreBatAnalysisService
Parameters :
Returns :
Promise<string>
|
| Async generatePostBatHtml | ||||||||||||
generatePostBatHtml(learningGroupId: number, participantId: number, courseCode: string)
|
||||||||||||
|
Generate POST-BAT HTML preview Delegates to PostBatAnalysisService
Parameters :
Returns :
Promise<string>
|
| Private Async generatePostBatPdfInBackground | ||||||
generatePostBatPdfInBackground(params: literal type)
|
||||||
|
Generate a Post-BAT PDF in the background and finalize its report log title. See generatePreBatPdfInBackground for the un-awaited contract.
Parameters :
Returns :
Promise<void>
|
| Async generatePreBatHtml |
generatePreBatHtml(learningGroupId: number, participantId: number)
|
|
Generate pre-BAT HTML for a participant (preview) Delegates to PreBatAnalysisService
Returns :
Promise<string>
|
| Async generatePreBatPdfDirect | ||||||||||||
generatePreBatPdfDirect(learningGroupId: number, participantId: number, course: literal type)
|
||||||||||||
|
Generate Pre-BAT PDF directly and return buffer (for download) Delegates to PreBatAnalysisService
Parameters :
Returns :
Promise<Buffer>
|
| Private Async generatePreBatPdfInBackground | ||||||
generatePreBatPdfInBackground(params: literal type)
|
||||||
|
Generate a Pre-BAT PDF in the background and finalize its report log title. Intentionally un-awaited by callers. All errors are caught and logged so failures never surface as unhandled rejections or affect the already-sent HTTP response.
Parameters :
Returns :
Promise<void>
|
| Async getCourseForLearningGroup | ||||||||
getCourseForLearningGroup(learningGroupId: number)
|
||||||||
|
Get course information for a learning group
Parameters :
Returns :
Promise<literal type>
|
| Async getCoursesForIntegration |
getCoursesForIntegration()
|
|
Get list of published courses for CodeRythm integration. Returns course_code, course_title, and related modules (published course modules).
Returns :
Promise<Array<literal type>>
|
| Async getLearningGroupIdForCourse | ||||||||
getLearningGroupIdForCourse(courseId: number)
|
||||||||
|
Get learning group ID for a course
Parameters :
Returns :
Promise<number>
|
| Async getPostBatAnalysisData | |||||||||||||||
getPostBatAnalysisData(learningGroupId: number, participantId: number, course: literal type, options?: literal type)
|
|||||||||||||||
|
Get Post-BAT analysis data for individual report rendering Delegates to PostBatAnalysisService
Parameters :
Returns :
Promise<literal type>
|
| Async getPreBatAnalysisData | ||||||||||||||||||||||||
getPreBatAnalysisData(learningGroupId: number, participantId: number, course: literal type, includeChartImage: unknown, freshAnalysisData?: string)
|
||||||||||||||||||||||||
|
Get Pre-BAT analysis data for individual report rendering Delegates to PreBatAnalysisService
Parameters :
Returns :
Promise<literal type>
|
| Async handlePostBatAnalysis | ||||||||
handlePostBatAnalysis(dto: IntegrationPostBatAnalysisDto)
|
||||||||
|
Handle post-bat-analysis request (comparative matrix with module-level data)
Parameters :
Returns :
Promise<literal type>
Success response |
| Async handlePostBatGroupAnalysis | ||||||||
handlePostBatGroupAnalysis(dto: IntegrationPostBatGroupAnalysisDto)
|
||||||||
|
Handle post-bat-group-analysis request (team analysis - POST BAT)
Parameters :
Returns :
Promise<literal type>
Success response |
| Async handlePreBatAnalysis | ||||||||
handlePreBatAnalysis(dto: IntegrationPreBatAnalysisDto)
|
||||||||
|
Handle pre-bat-analysis request
Parameters :
Returns :
Promise<literal type>
Success response with sample data |
| Async handlePreBatGroupAnalysis | ||||||||
handlePreBatGroupAnalysis(dto: IntegrationPreBatGroupAnalysisDto)
|
||||||||
|
Handle pre-bat-group-analysis request (team analysis - PRE BAT)
Parameters :
Returns :
Promise<literal type>
Success response |
| Private Readonly logger |
Type : unknown
|
Default value : new Logger(IntegrationService.name)
|
import {
BAT_COLOR_HEX,
BAT_LEVEL_TO_QUOTIENT,
BAT_LEVEL_TO_SCORE,
getColorClassFromScore,
SCORE_COLOR_THRESHOLDS,
SCORE_TO_METRIC,
} from "@api/shared/constants/assessment-scores.constants";
import { SystemLogService } from "@api/shared/services";
import { BNestReportService } from "@bish-nest/admin/modules/report/report.service";
import { BNestBrowserService } from "@bish-nest/reporting";
import { BNestPrismaService } from "@bish-nest/prisma";
import { BNestAngularSSRService } from "@bish-nest/reporting";
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AssessmentType, SystemLogEntityType } from "@prisma/client";
import {
IntegrationPostBatAnalysisDto,
IntegrationPostBatGroupAnalysisDto,
IntegrationPreBatAnalysisDto,
IntegrationPreBatGroupAnalysisDto,
} from "./dto";
import { PostBatAnalysisService } from "./post-bat-analysis.service";
import { PreBatAnalysisService } from "./pre-bat-analysis.service";
/**
* IntegrationService
*
* Integration API — Integration. Business Logic Service for RecallAssess.
*/
@Injectable()
export class IntegrationService {
private readonly logger = new Logger(IntegrationService.name);
constructor(
private readonly prisma: BNestPrismaService,
@Inject() private readonly browserService: BNestBrowserService,
private readonly config: ConfigService,
@Inject() private readonly reportService: BNestReportService,
@Inject() private readonly angularSSR: BNestAngularSSRService,
private readonly preBatAnalysisService: PreBatAnalysisService,
private readonly postBatAnalysisService: PostBatAnalysisService,
private readonly systemLogService: SystemLogService,
) {}
/**
* Handle pre-bat-analysis request
* @param dto Request DTO containing result with metadata and trainees array
* @returns Success response with sample data
*/
async handlePreBatAnalysis(dto: IntegrationPreBatAnalysisDto): Promise<{ success: boolean; message: string }> {
// Validate DTO structure
if (!dto.result) {
throw new BadRequestException('Request body must contain a "result" object');
}
if (!dto.result.metadata) {
throw new BadRequestException('Request body must contain "result.metadata" object');
}
if (!dto.result.trainees) {
throw new BadRequestException('Request body must contain "result.trainees" array');
}
const learningGroupId = dto.result.metadata.learning_group_id;
const trainees = dto.result.trainees;
this.logger.log(
`Processing pre-bat-analysis for ${trainees.length} trainee(s) in learning group ${learningGroupId}`,
);
// 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`);
}
// Get course information
const course = await this.prisma.client.course.findUnique({
where: { id: learningGroup.course_id },
});
if (!course) {
throw new NotFoundException(`Course not found for learning group ${learningGroupId}`);
}
// Get assessment_id from course
if (!course.assessment_id) {
throw new NotFoundException(`Course ${course.id} does not have an assessment assigned`);
}
// Log full raw CR JSON payload in system_log (IMPORT) for debug/audit
try {
await this.systemLogService.logImport(SystemLogEntityType.ASSESSMENT, {
entity_id: course.assessment_id,
request_body: dto as unknown as Record<string, unknown>,
});
} catch {
// System logging failure must not break main flow
}
// Process each trainee
const results = [];
for (const trainee of trainees) {
const participantId = trainee.participant_id;
// Verify participant exists
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
this.logger.warn(`Participant with ID ${participantId} not found, skipping`);
continue;
}
// Verify participant is enrolled in this learning group
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
learning_group_id: learningGroupId,
participant_id: participantId,
},
});
if (!enrollment) {
this.logger.warn(
`Participant ${participantId} is not enrolled in learning group ${learningGroupId}, skipping`,
);
continue;
}
// Check if PRE-BAT assessment is completed for this participant (required before processing analysis)
const existingPreBat = await this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: course.assessment_id,
participant_id: participantId,
learning_group_id: learningGroupId,
assessment_type: AssessmentType.PRE_BAT,
},
});
if (!existingPreBat?.assessment_completion_date) {
const errorMsg = `PRE-BAT assessment not completed for participant ${participantId}. Participant must complete the assessment before analysis can be processed.`;
this.logger.error(errorMsg);
throw new BadRequestException(errorMsg);
}
// Find or create AssessmentParticipant record and update ai_analysis
await this.prisma.client.assessmentParticipant.upsert({
where: {
assessment_id_participant_id_assessment_type: {
assessment_id: course.assessment_id,
participant_id: participantId,
assessment_type: AssessmentType.PRE_BAT,
},
},
update: {
ai_analysis: trainee.pre_report,
},
create: {
course_id: course.id,
assessment_id: course.assessment_id,
participant_id: participantId,
learning_group_id: learningGroupId,
assessment_type: AssessmentType.PRE_BAT,
ai_analysis: trainee.pre_report,
},
});
this.logger.log(
`Pre-BAT analysis saved for participant ${participantId} in assessment ${course.assessment_id}`,
);
// Create report log entry immediately (placeholder title) so the caller can
// respond without waiting for PDF generation. The title is updated in the
// background once the PDF has been generated (or failed).
const reportLogId = await this.createReportLogPlaceholder({
participantId,
courseId: course.id,
assessmentId: course.assessment_id,
learningGroupId,
reportLabel: "Pre-BAT",
courseName: course.title || course.course_code || "Course",
});
// Kick off PDF generation in the background — do NOT await.
// The analysis data is already persisted, so the integration request can
// return immediately; the PDF is uploaded to S3 and the report log title
// is finalized once generation completes.
void this.generatePreBatPdfInBackground({
learningGroupId,
participantId,
course,
freshAnalysisData: trainee.pre_report,
reportLogId,
});
results.push({ participantId, success: true });
}
// Check if any participants were processed
if (results.length === 0) {
throw new BadRequestException(
"No participants were processed. Please verify that participants exist, are enrolled in the learning group, and trainees data is valid.",
);
}
return {
success: true,
message: `Pre-BAT analysis processed successfully for ${results.length} participant(s)`,
};
}
/**
* Handle post-bat-analysis request (comparative matrix with module-level data)
* @param dto Request DTO containing result with metadata and trainees array
* @returns Success response
*/
async handlePostBatAnalysis(dto: IntegrationPostBatAnalysisDto): Promise<{ success: boolean; message: string }> {
const learningGroupId = dto.result.metadata.learning_group_id;
const courseCode = dto.result.metadata.course_code;
const trainees = dto.result.trainees;
this.logger.log(
`Processing post-bat-analysis for ${trainees.length} trainee(s) in learning group ${learningGroupId}`,
);
// 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 course code matches
const course = await this.prisma.client.course.findUnique({
where: { id: learningGroup.course_id },
});
if (!course) {
throw new NotFoundException(`Course not found for learning group ${learningGroupId}`);
}
// Verify course code matches the provided course_code
if (course.course_code !== courseCode) {
throw new NotFoundException(
`Course code mismatch. Expected ${course.course_code}, but received ${courseCode}`,
);
}
// Get assessment_id from course
if (!course.assessment_id) {
throw new NotFoundException(`Course ${course.id} does not have an assessment assigned`);
}
// Resolve module codes from payload to CourseModule ids (no course-scoped mapping)
// Only needed when trainees use legacy modules format
const allModuleCodes = new Set<string>();
for (const trainee of trainees) {
if (trainee.modules && typeof trainee.modules === "object") {
for (const code of Object.keys(trainee.modules)) {
allModuleCodes.add(code);
}
}
}
const moduleCodeToIdMap = new Map<string, number>();
if (allModuleCodes.size > 0) {
const modules = await this.prisma.client.courseModule.findMany({
where: { course_module_code: { in: [...allModuleCodes] } },
select: { id: true, course_module_code: true },
});
for (const m of modules) {
if (m.course_module_code) {
moduleCodeToIdMap.set(m.course_module_code, m.id);
}
}
}
// Log full raw CR JSON payload in system_log (IMPORT) for debug/audit
try {
await this.systemLogService.logImport(SystemLogEntityType.ASSESSMENT, {
entity_id: course.assessment_id,
request_body: dto as unknown as Record<string, unknown>,
});
} catch {
// System logging failure must not break main flow
}
// Process each trainee
const results = [];
for (const trainee of trainees) {
const participantId = trainee.participant_id;
// Verify participant exists
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
this.logger.warn(`Participant with ID ${participantId} not found, skipping`);
continue;
}
// Verify participant is enrolled in this learning group
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
learning_group_id: learningGroupId,
participant_id: participantId,
},
});
if (!enrollment) {
this.logger.warn(
`Participant ${participantId} is not enrolled in learning group ${learningGroupId}, skipping`,
);
continue;
}
// Check if PRE-BAT assessment is completed (required before POST-BAT)
const existingPreBat = await this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: course.assessment_id,
participant_id: participantId,
learning_group_id: learningGroupId,
assessment_type: AssessmentType.PRE_BAT,
},
});
if (!existingPreBat?.assessment_completion_date) {
const errorMsg = `PRE-BAT assessment not completed for participant ${participantId}. Participant must complete PRE-BAT before POST-BAT analysis can be processed.`;
this.logger.error(errorMsg);
throw new BadRequestException(errorMsg);
}
// Check if POST-BAT assessment is completed for this participant (required before processing analysis)
const existingPostBat = await this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: course.assessment_id,
participant_id: participantId,
learning_group_id: learningGroupId,
assessment_type: AssessmentType.POST_BAT,
},
});
if (!existingPostBat?.assessment_completion_date) {
const errorMsg = `POST-BAT assessment not completed for participant ${participantId}. Participant must complete the assessment before analysis can be processed.`;
this.logger.error(errorMsg);
throw new BadRequestException(errorMsg);
}
// Store the complete analysis in AssessmentParticipant (post_ai_analysis column for POST_BAT)
await this.prisma.client.assessmentParticipant.upsert({
where: {
assessment_id_participant_id_assessment_type: {
assessment_id: course.assessment_id,
participant_id: participantId,
assessment_type: AssessmentType.POST_BAT,
},
},
update: {
post_ai_analysis: JSON.stringify(trainee),
} as any,
create: {
course_id: course.id,
assessment_id: course.assessment_id,
participant_id: participantId,
learning_group_id: learningGroupId,
assessment_type: AssessmentType.POST_BAT,
post_ai_analysis: JSON.stringify(trainee),
} as any,
});
const hasOverallSummary =
typeof (trainee as { overall_summary?: string }).overall_summary === "string" &&
((trainee as { overall_summary?: string }).overall_summary ?? "").trim().length > 0;
const hasModules =
trainee.modules && typeof trainee.modules === "object" && Object.keys(trainee.modules).length > 0;
if (!hasOverallSummary && !hasModules) {
throw new BadRequestException(
`Each trainee must have either overall_summary or modules. Participant ${participantId} has neither.`,
);
}
// New format: one summary in post_ai_analysis only. Skip per-topic AssessmentResult updates.
if (hasOverallSummary) {
this.logger.log(
`Post-BAT analysis saved for participant ${participantId} (overall summary in post_ai_analysis)`,
);
const reportLogId = await this.createReportLogPlaceholder({
participantId,
courseId: course.id,
assessmentId: course.assessment_id,
learningGroupId,
reportLabel: "Post-BAT",
courseName: course.title || course.course_code || "Course",
});
// Generate PDF in the background so the integration request returns
// as soon as the analysis data is stored.
void this.generatePostBatPdfInBackground({
learningGroupId,
participantId,
course,
reportLogId,
});
results.push({ participantId, modulesProcessed: 0, success: true });
continue;
}
// Find POST BAT participant record (we'll need its ID for FK)
const postBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: course.assessment_id,
participant_id: participantId,
learning_group_id: learningGroupId,
assessment_type: AssessmentType.POST_BAT,
},
});
if (!postBatParticipant) {
this.logger.warn(
`POST BAT AssessmentParticipant not found for participant ${participantId}, skipping module updates`,
);
continue;
}
// Process each module in the trainee's data
let modulesProcessed = 0;
const traineeModules = trainee.modules ?? {};
for (const [moduleCode, moduleData] of Object.entries(traineeModules)) {
const moduleId = moduleCodeToIdMap.get(moduleCode);
if (!moduleId) {
this.logger.warn(`Module code ${moduleCode} not found, skipping`);
continue;
}
// Update assessment results for this module with the analysis data
// Use assessment_participant_id FK instead of timestamp filtering
const updateResult = await this.prisma.client.assessmentResult.updateMany({
where: {
assessment_participant_id: postBatParticipant.id, // ✅ Use FK instead of timestamps
course_id: course.id,
assessment_id: course.assessment_id,
participant_id: participantId,
course_module_id: moduleId,
},
data: {
learning_path: moduleData.learning_path,
additional_learning_path: moduleData.additional_learning_path || null,
references:
moduleData.references && moduleData.references.length > 0
? JSON.parse(JSON.stringify(moduleData.references))
: null,
},
});
if (updateResult.count > 0) {
this.logger.log(
`Updated ${updateResult.count} assessment result(s) for module ${moduleCode} (${moduleId}) - participant ${participantId}`,
);
modulesProcessed++;
} else {
this.logger.warn(
`No assessment results found for module ${moduleCode} (${moduleId}) - participant ${participantId} with assessment_participant_id ${postBatParticipant.id}`,
);
}
}
// Check if any modules were processed for this participant
if (modulesProcessed === 0) {
const errorMsg = `No modules were updated for participant ${participantId}. Please verify that module codes match the course modules and assessment results exist.`;
this.logger.error(errorMsg);
throw new BadRequestException(errorMsg);
}
this.logger.log(
`Post-BAT analysis saved for participant ${participantId}: ${modulesProcessed} modules processed`,
);
// Create report log entry immediately (placeholder title); the title is
// updated in the background once the PDF has been generated (or failed).
const reportLogId = await this.createReportLogPlaceholder({
participantId,
courseId: course.id,
assessmentId: course.assessment_id,
learningGroupId,
reportLabel: "Post-BAT",
courseName: course.title || course.course_code || "Course",
});
// Generate PDF off the request path.
void this.generatePostBatPdfInBackground({
learningGroupId,
participantId,
course,
reportLogId,
});
results.push({ participantId, modulesProcessed, success: true });
}
// Check if any participants were processed
if (results.length === 0) {
throw new BadRequestException(
"No participants were processed. Please verify that participants exist, are enrolled in the learning group, and trainees data is valid.",
);
}
return {
success: true,
message: `Post-BAT analysis processed successfully for ${results.length} participant(s)`,
};
}
/**
* Handle pre-bat-group-analysis request (team analysis - PRE BAT)
* @param dto Request DTO containing learning_group_id, course_code, and analysis
* @returns Success response
*/
async handlePreBatGroupAnalysis(
dto: IntegrationPreBatGroupAnalysisDto,
): Promise<{ success: boolean; message: string }> {
this.logger.log(`Processing pre-bat-group-analysis for learning group ${dto.learning_group_id}`);
// Verify learning group exists
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id: dto.learning_group_id },
});
if (!learningGroup) {
throw new NotFoundException(`Learning group with ID ${dto.learning_group_id} not found`);
}
// Verify course code matches
const course = await this.prisma.client.course.findUnique({
where: { id: learningGroup.course_id },
});
if (!course) {
throw new NotFoundException(`Course not found for learning group ${dto.learning_group_id}`);
}
// TODO: Store the analysis data or process it as needed
// This is a placeholder for the actual integration logic
this.logger.log(
`Pre-BAT group analysis (team analysis) received for learning group ${dto.learning_group_id}: ${dto.analysis.substring(0, 100)}...`,
);
return {
success: true,
message: "Pre-BAT group analysis (team analysis) processed successfully",
};
}
/**
* Handle post-bat-group-analysis request (team analysis - POST BAT)
* @param dto Request DTO containing learning_group_id, course_code, and analysis
* @returns Success response
*/
async handlePostBatGroupAnalysis(
dto: IntegrationPostBatGroupAnalysisDto,
): Promise<{ success: boolean; message: string }> {
this.logger.log(`Processing post-bat-group-analysis for learning group ${dto.learning_group_id}`);
// Verify learning group exists
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id: dto.learning_group_id },
});
if (!learningGroup) {
throw new NotFoundException(`Learning group with ID ${dto.learning_group_id} not found`);
}
// Verify course code matches
const course = await this.prisma.client.course.findUnique({
where: { id: learningGroup.course_id },
});
if (!course) {
throw new NotFoundException(`Course not found for learning group ${dto.learning_group_id}`);
}
// TODO: Store the analysis data or process it as needed
// This is a placeholder for the actual integration logic
this.logger.log(
`Post-BAT group analysis (team analysis) received for learning group ${dto.learning_group_id}: ${dto.analysis.substring(0, 100)}...`,
);
return {
success: true,
message: "Post-BAT group analysis (team analysis) processed successfully",
};
}
/**
* Create a report log entry with a placeholder title, before the PDF is generated.
*
* The integration endpoints respond as soon as the analysis data is stored; the
* PDF is generated in the background and {@link finalizeReportLogTitle} replaces
* the placeholder with the final title (including the S3 filename) afterwards.
*
* @returns the created `report_log.id`, or `null` if creation failed (non-fatal).
*/
private async createReportLogPlaceholder(params: {
participantId: number;
courseId: number;
assessmentId: number | null;
learningGroupId: number;
/** Human-readable report family, e.g. `Pre-BAT` / `Post-BAT`. */
reportLabel: string;
courseName: string;
}): Promise<number | null> {
const { participantId, courseId, assessmentId, learningGroupId, reportLabel, courseName } = params;
try {
const log = await this.prisma.client.reportLog.create({
data: {
participant_id: participantId,
course_id: courseId,
assessment_id: assessmentId,
course_group_id: learningGroupId,
title: `${reportLabel} Analysis Report - ${courseName} [generating...]`,
sent_status: false,
},
select: { id: true },
});
this.logger.log(
`Report log created for ${reportLabel} analysis - participant ${participantId} (log ${log.id}, PDF pending)`,
);
return log.id;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn(
`Failed to create report log for ${reportLabel} analysis - participant ${participantId}: ${errorMessage}`,
);
return null;
}
}
/**
* Update a report log title once background PDF generation has settled.
* No-op when `reportLogId` is `null` (placeholder creation failed).
*/
private async finalizeReportLogTitle(params: {
reportLogId: number | null;
reportLabel: string;
courseName: string;
participantId: number;
pdfS3Path: string | null;
}): Promise<void> {
const { reportLogId, reportLabel, courseName, participantId, pdfS3Path } = params;
if (reportLogId == null) {
return;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, -5);
const s3FileName = pdfS3Path ? pdfS3Path.split("/").pop() : null;
const title = s3FileName
? `${reportLabel} Analysis Report - ${courseName} [${s3FileName}]`
: `${reportLabel} Analysis Report - ${courseName} [${timestamp}] [PDF Generation Failed]`;
try {
await this.prisma.client.reportLog.update({
where: { id: reportLogId },
data: { title },
});
this.logger.log(
`Report log ${reportLogId} finalized for ${reportLabel} analysis - participant ${participantId}` +
(pdfS3Path ? ` (S3: ${pdfS3Path})` : " (PDF generation failed)"),
);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to finalize report log ${reportLogId}: ${errorMessage}`);
}
}
/**
* Generate a Pre-BAT PDF in the background and finalize its report log title.
*
* Intentionally un-awaited by callers. All errors are caught and logged so failures
* never surface as unhandled rejections or affect the already-sent HTTP response.
*/
private async generatePreBatPdfInBackground(params: {
learningGroupId: number;
participantId: number;
course: { id: number; assessment_id: number | null; title: string | null; course_code?: string | null };
freshAnalysisData?: string;
reportLogId: number | null;
}): Promise<void> {
const { learningGroupId, participantId, course, freshAnalysisData, reportLogId } = params;
const courseName = course.title || course.course_code || "Course";
let pdfS3Path: string | null = null;
try {
pdfS3Path = await this.preBatAnalysisService.generateAndUploadPreBatPdf(
learningGroupId,
participantId,
course,
undefined,
freshAnalysisData,
);
this.logger.log(`Pre-BAT PDF generated and uploaded to S3: ${pdfS3Path}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(
`Failed to generate/upload Pre-BAT PDF for participant ${participantId}: ${errorMessage}`,
);
} finally {
await this.finalizeReportLogTitle({
reportLogId,
reportLabel: "Pre-BAT",
courseName,
participantId,
pdfS3Path,
});
}
}
/**
* Generate a Post-BAT PDF in the background and finalize its report log title.
* See {@link generatePreBatPdfInBackground} for the un-awaited contract.
*/
private async generatePostBatPdfInBackground(params: {
learningGroupId: number;
participantId: number;
course: { id: number; assessment_id: number | null; title: string | null; course_code?: string | null };
reportLogId: number | null;
}): Promise<void> {
const { learningGroupId, participantId, course, reportLogId } = params;
const courseName = course.title || course.course_code || "Course";
let pdfS3Path: string | null = null;
try {
pdfS3Path = await this.postBatAnalysisService.generateAndUploadPostBatPdf(
learningGroupId,
participantId,
course,
);
this.logger.log(`Post-BAT PDF generated and uploaded to S3: ${pdfS3Path}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(
`Failed to generate/upload Post-BAT PDF for participant ${participantId}: ${errorMessage}`,
);
} finally {
await this.finalizeReportLogTitle({
reportLogId,
reportLabel: "Post-BAT",
courseName,
participantId,
pdfS3Path,
});
}
}
/**
* Generate pre-BAT HTML for a participant (preview)
* Delegates to PreBatAnalysisService
*/
async generatePreBatHtml(learningGroupId: number, participantId: number): Promise<string> {
return this.preBatAnalysisService.generatePreBatHtml(learningGroupId, participantId);
}
/**
* Generate Pre-BAT PDF directly and return buffer (for download)
* Delegates to PreBatAnalysisService
*/
async generatePreBatPdfDirect(
learningGroupId: number,
participantId: number,
course: { id: number; assessment_id: number | null; title: string | null },
): Promise<Buffer> {
return this.preBatAnalysisService.generatePreBatPdfDirect(learningGroupId, participantId, course);
}
/**
* Check if Pre-BAT PDF exists in S3
* Delegates to PreBatAnalysisService
*/
async checkPreBatPdfExists(
learningGroupId: number,
participantId: number,
forceRegenerate = false,
): Promise<string> {
return this.preBatAnalysisService.checkPreBatPdfExists(learningGroupId, participantId, forceRegenerate);
}
/**
* Generate PDF and upload to S3
* Delegates to PreBatAnalysisService
*/
async generateAndUploadPreBatPdf(
learningGroupId: number,
participantId: number,
course: { id: number; assessment_id: number | null; title: string | null },
authorization?: string,
freshAnalysisData?: string,
): Promise<string> {
return this.preBatAnalysisService.generateAndUploadPreBatPdf(
learningGroupId,
participantId,
course,
authorization,
freshAnalysisData,
);
}
/**
* Get Pre-BAT analysis data for individual report rendering
* Delegates to PreBatAnalysisService
*/
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;
ipq: number;
preTrainingResults: string;
preReport: string;
chartImage?: string | null;
}> {
return this.preBatAnalysisService.getPreBatAnalysisData(
learningGroupId,
participantId,
course,
includeChartImage,
freshAnalysisData,
);
}
// Removed: calculateSkillsFromResults - moved to PreBatAnalysisService
// Removed: calculateIpqFromResults - moved to PreBatAnalysisService
// Removed: calculatePreTrainingResults - moved to PreBatAnalysisService
// Removed: renderSkillChart - moved to BatChartService
// Removed: getColorHex - moved to BatChartService
/**
* Get Post-BAT analysis data for individual report rendering
* Delegates to PostBatAnalysisService
*/
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;
overallSummary?: string | null;
preChartImage?: string | null;
postChartImage?: string | null;
}> {
return this.postBatAnalysisService.getPostBatAnalysisData(learningGroupId, participantId, course, options);
}
/**
* Generate POST-BAT HTML preview
* Delegates to PostBatAnalysisService
*/
async generatePostBatHtml(learningGroupId: number, participantId: number, courseCode: string): Promise<string> {
return this.postBatAnalysisService.generatePostBatHtml(learningGroupId, participantId, courseCode);
}
/**
* Check if Post-BAT PDF exists in S3
* Delegates to PostBatAnalysisService
*/
async checkPostBatPdfExists(
learningGroupId: number,
participantId: number,
forceRegenerate = false,
): Promise<string> {
return this.postBatAnalysisService.checkPostBatPdfExists(learningGroupId, participantId, forceRegenerate);
}
/**
* Generate POST-BAT PDF and upload to S3
* Delegates to PostBatAnalysisService
*/
async generateAndUploadPostBatPdf(
learningGroupId: number,
participantId: number,
course: { id: number; assessment_id: number | null; title: string | null },
authorization?: string,
): Promise<string> {
return this.postBatAnalysisService.generateAndUploadPostBatPdf(
learningGroupId,
participantId,
course,
authorization,
);
}
/**
* Get learning group ID for a course
* @param courseId Course ID
*/
async getLearningGroupIdForCourse(courseId: number): Promise<number> {
const learningGroup = await this.prisma.client.learningGroup.findFirst({
where: { course_id: courseId },
select: { id: true },
});
if (!learningGroup) {
throw new NotFoundException(`No learning group found for course ${courseId}`);
}
return learningGroup.id;
}
/**
* Get course information for a learning group
* @param learningGroupId Learning group ID
*/
async getCourseForLearningGroup(
learningGroupId: number,
): Promise<{ id: number; assessment_id: number | null; title: string | null }> {
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id: learningGroupId },
select: {
course_id: true,
course: {
select: {
id: true,
title: true,
assessment_id: true,
},
},
},
});
if (!learningGroup || !learningGroup.course) {
throw new NotFoundException(`Course not found for learning group ${learningGroupId}`);
}
return {
id: learningGroup.course.id,
assessment_id: learningGroup.course.assessment_id,
title: learningGroup.course.title,
};
}
/**
* Get list of published courses for CodeRythm integration.
* Returns course_code, course_title, and related modules (published course modules).
*/
async getCoursesForIntegration(): Promise<
Array<{
course_id: number;
course_code: string;
course_title: string;
modules: Array<{ module_id: number; module_code: string; module_title: string }>;
}>
> {
const courses = await this.prisma.client.course.findMany({
where: {
is_published: true,
},
orderBy: [{ sort_order: "asc" }, { title: "asc" }],
include: {
courseModulePages: {
where: { is_published: true },
orderBy: { sort_order: "asc" },
select: { courseModule: true },
},
},
});
return courses.map((course) => {
const seenModuleIds = new Set<number>();
const modules: Array<{ module_id: number; module_code: string; module_title: string }> = [];
for (const page of course.courseModulePages) {
const mod = page.courseModule;
if (mod?.is_published && !seenModuleIds.has(mod.id)) {
seenModuleIds.add(mod.id);
modules.push({
module_id: mod.id,
module_code: mod.course_module_code,
module_title: mod.title,
});
}
}
return {
course_id: course.id,
course_code: course.course_code,
course_title: course.title,
modules,
};
});
}
}