apps/recallassess/recallassess-api/src/api/admin/learning-group/learning-group.service.ts
LearningGroupService
Admin API — Learning Group (Learning Group). Business Logic Service for RecallAssess.
BNestBaseModuleService
Methods |
|
constructor(prisma: BNestPrismaService, systemLogService: SystemLogService, emailReminderService: EmailReminderService, clLearningGroupService: CLLearningGroupService, craiBatRegenerationService: CraiBatRegenerationService)
|
||||||||||||||||||
|
Parameters :
|
| Async add | ||||||
add(data: any)
|
||||||
|
Override add method to remove foreign key fields before passing to Prisma Prisma only accepts relation objects (company, course, createdBy), not raw foreign keys (company_id, course_id, participant_id_created_by)
Parameters :
Returns :
Promise<any>
|
| Private deriveProgressFields | |||||||||
deriveProgressFields(participants: ProgressParticipant[], fallback: literal type)
|
|||||||||
|
Same rules as client portal: E_LEARNING/COMPLETED = completed seat; all cancelled → CANCELLED; all active seats completed → COMPLETED.
Parameters :
Returns :
literal type
|
| Async generatePostBatHtmlForParticipant |
generatePostBatHtmlForParticipant(learningGroupId: number, learningGroupParticipantId: number)
|
|
Returns :
Promise<string>
|
| Async generatePreBatHtmlForParticipant |
generatePreBatHtmlForParticipant(learningGroupId: number, learningGroupParticipantId: number)
|
|
Returns :
Promise<string>
|
| Async getDetail | ||||||
getDetail(id: number)
|
||||||
|
Override getDetail so that admin learning-group detail (and the Participants tab) receive the same rich participant tracking data that the portal sees. This mirrors the client learning-group detail include, ensuring that learningGroupParticipants with their progress fields are loaded.
Parameters :
|
| Private Async getLearningGroupCompanyId | ||||||
getLearningGroupCompanyId(learningGroupId: number)
|
||||||
|
Parameters :
Returns :
Promise<number>
|
| Async getPostBatPdfUrlForParticipant | ||||||||||||||||
getPostBatPdfUrlForParticipant(learningGroupId: number, learningGroupParticipantId: number, forceRegenerate: unknown)
|
||||||||||||||||
|
Parameters :
Returns :
Promise<string>
|
| Async getPreBatPdfUrlForParticipant | ||||||||||||||||
getPreBatPdfUrlForParticipant(learningGroupId: number, learningGroupParticipantId: number, forceRegenerate: unknown)
|
||||||||||||||||
|
Parameters :
Returns :
Promise<string>
|
| Async regeneratePostBatAnalysis |
regeneratePostBatAnalysis(learningGroupId: number, learningGroupParticipantId: number)
|
|
Regenerate a participant's POST-BAT AI analysis by re-submitting their data to the external CR(AI) API (same behaviour as regeneratePreBatAnalysis).
Returns :
Promise<literal type>
|
| Async regeneratePreBatAnalysis |
regeneratePreBatAnalysis(learningGroupId: number, learningGroupParticipantId: number)
|
|
Regenerate a participant's PRE-BAT AI analysis by re-submitting their data to the external CR(AI) API. Mirrors the participant portal "Regenerate" action so admins can refresh the
underlying analysis that the PRE-BAT PDF is rendered from. CRAI regenerates
asynchronously and posts the result back to
Returns :
Promise<literal type>
|
| Async resendInvitation |
resendInvitation(learningGroupId: number, participantId: number)
|
|
Resend invitation to a specific participant (admin). Delegates to client learning-group service with company_id from the learning group; uses 0 for "invited by" (admin action).
Returns :
Promise<literal type>
|
| Private Async resolveBatEnrollment |
resolveBatEnrollment(learningGroupId: number, learningGroupParticipantId: number)
|
|
Resolve the learning-group enrollment (participant + course) for a BAT regeneration request, ensuring it belongs to the given learning group.
Returns :
unknown
|
| Async save |
save(id: number, data: any)
|
|
Returns :
unknown
|
| Async updateKnowledgeReviewEmailDate | ||||||
updateKnowledgeReviewEmailDate(learningGroupParticipantId: number)
|
||||||
|
Update knowledge_review_email_date to NOW() for a learning group participant. Mirrors update-knowledge-review-email-date.sh for use from the admin UI.
Parameters :
|
| Async updatePostBatInviteEmailDate | ||||||
updatePostBatInviteEmailDate(learningGroupParticipantId: number)
|
||||||
|
Send post BAT invite email now and ensure post_bat_email_date is set (and not in the future) for a learning group participant. Used from admin UI "Update" (send now) for post BAT invite.
Parameters :
|
import { CLLearningGroupService } from "@api/client/learning-group/learning-group.service";
import { CraiBatRegenerationService } from "@api/integration/crai-bat-regeneration.service";
import { EmailReminderService } from "@api/shared/email/services/email-reminder.service";
import { SystemLogService } from "@api/shared/services";
import { BNestBaseModuleService, moduleNameToAuditEntityType, type PaginationOptions } from "@bish-nest/data";
import { DetailResponseDataInterface, ListResponseDataInterface } from "@bish-nest/http";
import { BNestPrismaService } from "@bish-nest/prisma";
import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { ParticipantLearningProgressStatus, SystemLogEntityType } from "@prisma/client";
import { plainToInstance } from "class-transformer";
export interface UpdateKnowledgeReviewEmailDateResponse {
success: boolean;
message: string;
knowledgeReviewEmailDate: string | null;
}
export interface UpdatePostBatInviteEmailDateResponse {
success: boolean;
message: string;
postBatInviteEmailDate: string | null;
}
type ProgressParticipant = {
cancelled: boolean;
status: string;
completion_percentage: unknown;
};
/**
* LearningGroupService
*
* Admin API — Learning Group (Learning Group). Business Logic Service for RecallAssess.
*/
@Injectable()
export class LearningGroupService extends BNestBaseModuleService {
constructor(
protected prisma: BNestPrismaService,
private readonly systemLogService: SystemLogService,
private readonly emailReminderService: EmailReminderService,
private readonly clLearningGroupService: CLLearningGroupService,
private readonly craiBatRegenerationService: CraiBatRegenerationService,
) {
super();
}
/**
* Same rules as client portal: E_LEARNING/COMPLETED = completed seat;
* all cancelled → CANCELLED; all active seats completed → COMPLETED.
*/
private deriveProgressFields(
participants: ProgressParticipant[],
fallback: {
participants_completed?: number;
completion_percentage?: number | null;
status?: string;
is_completed?: boolean;
},
): {
participants_completed: number;
completion_percentage: number | null;
status: string;
is_completed: boolean;
} {
if (participants.length === 0) {
return {
participants_completed: fallback.participants_completed ?? 0,
completion_percentage:
fallback.completion_percentage != null ? Number(fallback.completion_percentage) : null,
status: fallback.status ?? "PENDING",
is_completed: fallback.is_completed ?? false,
};
}
const totalProgress = participants.reduce((sum, p) => {
const n = p.completion_percentage != null ? Number(p.completion_percentage) : 0;
return sum + (Number.isNaN(n) ? 0 : n);
}, 0);
const completion_percentage = Math.round(totalProgress / participants.length);
const participants_completed = participants.filter(
(p) =>
p.status === ParticipantLearningProgressStatus.E_LEARNING ||
p.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
let status = fallback.status ?? "PENDING";
if (participants.every((p) => p.cancelled)) {
status = "CANCELLED";
} else {
const active = participants.filter((p) => !p.cancelled);
if (
active.length > 0 &&
active.every(
(p) =>
p.status === ParticipantLearningProgressStatus.E_LEARNING ||
p.status === ParticipantLearningProgressStatus.COMPLETED,
)
) {
status = "COMPLETED";
}
}
return {
participants_completed,
completion_percentage,
status,
is_completed: status === "COMPLETED" || participants_completed === participants.length,
};
}
/**
* Recalculate progress fields so admin list/company tab match client portal (avoid stale DB columns).
*/
override async getList(paginationOptions: PaginationOptions): Promise<ListResponseDataInterface<any>> {
const result = await super.getList(paginationOptions);
const rows = result.data ?? [];
const ids = rows
.map((row: { id?: number }) => row.id)
.filter((id: number | undefined): id is number => typeof id === "number");
if (ids.length === 0) {
return result;
}
const participants = await this.prisma.client.learningGroupParticipant.findMany({
where: { learning_group_id: { in: ids } },
select: {
learning_group_id: true,
cancelled: true,
status: true,
completion_percentage: true,
},
});
const byGroup = new Map<number, ProgressParticipant[]>();
for (const p of participants) {
const list = byGroup.get(p.learning_group_id) ?? [];
list.push({
cancelled: p.cancelled,
status: p.status,
completion_percentage: p.completion_percentage,
});
byGroup.set(p.learning_group_id, list);
}
result.data = rows.map((row: Record<string, unknown>) => {
const id = row["id"] as number;
const derived = this.deriveProgressFields(byGroup.get(id) ?? [], {
participants_completed: row["participants_completed"] as number | undefined,
completion_percentage: row["completion_percentage"] as number | null | undefined,
status: row["status"] as string | undefined,
is_completed: row["is_completed"] as boolean | undefined,
});
return {
...row,
...derived,
};
});
return result;
}
/**
* Override add method to remove foreign key fields before passing to Prisma
* Prisma only accepts relation objects (company, course, createdBy), not raw foreign keys (company_id, course_id, participant_id_created_by)
*/
async add(data: any): Promise<any> {
// Create a copy of data without the foreign key fields
// The transformed relation fields (company, course, createdBy) are already present
const { company_id, course_id, participant_id_created_by, ...prismaData } = data;
// Call parent add method with cleaned data
return super.add(prismaData);
}
async save(id: number, data: any) {
// Get old data before update
const oldLearningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id },
});
if (!oldLearningGroup) {
throw new NotFoundException(`Learning group with ID ${id} not found`);
}
const repo = this.commonMethods.getRepo(this.gVars.moduleCurrentCfg.repoName);
let dataSave = plainToInstance(this.gVars.moduleCurrentCfg.saveDto, data);
// Use scalar audit columns to avoid relation update errors
dataSave = await this.dbUtilService.addUpdatedById(dataSave);
// Remove relation objects from dataSave - only keep scalar fields
const updateData: any = {};
const scalarFields = [
"company_id",
"course_id",
"participant_id_created_by",
"name",
"description",
"start_date",
"completion_percentage",
"participants_completed",
"total_participants",
"is_completed",
"completion_date",
"user_id_updated",
"updated_at",
];
for (const field of scalarFields) {
if (dataSave && field in dataSave) {
updateData[field] = (dataSave as any)[field];
}
}
await repo.update({ where: { id }, data: updateData });
// Get updated data directly from database for logging
const updatedLearningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id },
});
if (!updatedLearningGroup) {
throw new NotFoundException(`Learning group with ID ${id} not found after update`);
}
const detailedResponse = await this.getDetail(id);
this.emitModuleAuditEvent("updated", {
entityId: id,
entityType: moduleNameToAuditEntityType(this.gVars.moduleCurrentCfg.name),
oldData: oldLearningGroup as Record<string, unknown>,
newData: detailedResponse.data[0] as Record<string, unknown>,
});
return detailedResponse;
}
/**
* Override getDetail so that admin learning-group detail (and the Participants tab)
* receive the same rich participant tracking data that the portal sees.
*
* This mirrors the client learning-group detail include, ensuring that
* learningGroupParticipants with their progress fields are loaded.
*/
async getDetail(id: number): Promise<DetailResponseDataInterface<unknown>> {
const moduleCurrentCfg = this.gVars.moduleCurrentCfg;
// Load learning group with all relations needed for participant tracking
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id },
include: {
// Include completion_percentage so we can normalize it before sending to DTO
// Prisma returns this as Decimal | null
company: {
select: {
id: true,
name: true,
},
},
course: {
select: {
id: true,
title: true,
},
},
participantGroup: {
select: {
id: true,
name: true,
},
},
learningGroupParticipants: {
include: {
participant: {
select: {
id: true,
first_name: true,
last_name: true,
email: true,
},
},
},
orderBy: {
created_at: "asc",
},
},
userCreatedBy: {
select: {
id: true,
first_name: true,
last_name: true,
},
},
userUpdatedBy: {
select: {
id: true,
first_name: true,
last_name: true,
},
},
},
});
if (!learningGroup) {
const msg = "The record you are looking for is not found.";
throw new UnprocessableEntityException(msg);
}
// Keep the raw Prisma object shape (including learningGroupParticipants)
// and let the DTO handle only the scalar fields it cares about.
// Normalize completion_percentage to a plain number (or null) to avoid Decimal runtime errors.
const completionPercentage =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(learningGroup as any).completion_percentage != null
? Number((learningGroup as any).completion_percentage)
: null;
// e_learning_started_at is only set after the first completed module, but e-learning counts
// as started (and cancellation is blocked) as soon as the course is opened.
const eLearningStarts = await this.prisma.client.eLearningParticipant.findMany({
where: { learning_group_id: id, course_id: learningGroup.course_id, start_date: { not: null } },
select: { participant_id: true, start_date: true },
});
const eLearningStartByParticipant = new Map(eLearningStarts.map((e) => [e.participant_id, e.start_date]));
// Normalize completion_percentage in learningGroupParticipants to avoid Decimal errors
const normalizedParticipants = (learningGroup.learningGroupParticipants || []).map((participant: any) => ({
...participant,
completion_percentage:
participant.completion_percentage != null ? Number(participant.completion_percentage) : null,
e_learning_started_at:
participant.e_learning_started_at ?? eLearningStartByParticipant.get(participant.participant_id) ?? null,
}));
const derived = this.deriveProgressFields(normalizedParticipants, {
participants_completed: learningGroup.participants_completed,
completion_percentage: completionPercentage,
status: learningGroup.status,
is_completed: learningGroup.is_completed,
});
const sanitizedData: Record<string, unknown> = {
...learningGroup,
...derived,
learningGroupParticipants: normalizedParticipants,
};
// Transform to DTO for scalar fields (id, name, dates, etc.).
// Then re-attach participants relation so admin participants tab can render tracking data.
const data = plainToInstance(moduleCurrentCfg.detailDto, sanitizedData) as Record<string, unknown>;
const detailData: Record<string, unknown> = {
...data,
...derived,
learningGroupParticipants: normalizedParticipants,
};
return this.moduleMethods.getReturnDataForDetail(detailData);
}
/**
* Update knowledge_review_email_date to NOW() for a learning group participant.
* Mirrors update-knowledge-review-email-date.sh for use from the admin UI.
*/
async updateKnowledgeReviewEmailDate(
learningGroupParticipantId: number,
): Promise<UpdateKnowledgeReviewEmailDateResponse> {
const existing = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: learningGroupParticipantId },
include: {
learningGroup: { select: { company_id: true } },
},
});
if (!existing) {
throw new NotFoundException(`Learning group participant with ID ${learningGroupParticipantId} not found`);
}
const newDate = new Date();
const updated = await this.prisma.client.learningGroupParticipant.update({
where: { id: learningGroupParticipantId },
data: { knowledge_review_email_date: newDate },
});
// Log the update (mirrors other learning group participant changes)
const oldDate = existing.knowledge_review_email_date;
const changedFields: Record<string, unknown> = {
learning_group_participant_id: learningGroupParticipantId,
participant_id: existing.participant_id,
knowledge_review_email_date: {
from: oldDate?.toISOString() ?? null,
to: newDate.toISOString(),
},
};
await this.systemLogService.logUpdate(
SystemLogEntityType.LEARNING_GROUP,
existing.learning_group_id,
{
learning_group_participant_id: learningGroupParticipantId,
knowledge_review_email_date: oldDate?.toISOString() ?? null,
} as Record<string, unknown>,
{
learning_group_participant_id: learningGroupParticipantId,
knowledge_review_email_date: newDate.toISOString(),
} as Record<string, unknown>,
changedFields,
{
participant_id: existing.participant_id,
company_id: existing.learningGroup.company_id,
},
);
return {
success: true,
message: "Knowledge review email date updated successfully",
knowledgeReviewEmailDate: updated.knowledge_review_email_date?.toISOString() ?? null,
};
}
/**
* Send post BAT invite email now and ensure post_bat_email_date is set (and not in the future)
* for a learning group participant. Used from admin UI "Update" (send now) for post BAT invite.
*/
async updatePostBatInviteEmailDate(
learningGroupParticipantId: number,
): Promise<UpdatePostBatInviteEmailDateResponse> {
const existing = await this.prisma.client.learningGroupParticipant.findUnique({
where: { id: learningGroupParticipantId },
include: {
learningGroup: { select: { company_id: true } },
},
});
if (!existing) {
throw new NotFoundException(`Learning group participant with ID ${learningGroupParticipantId} not found`);
}
if (!existing.knowledge_review_completed_at) {
throw new BadRequestException(
"Knowledge Review must be completed before sending the Post BAT invite email.",
);
}
const newDate =
await this.emailReminderService.sendPostBatInviteForLearningGroupParticipant(learningGroupParticipantId);
if (!newDate) {
throw new NotFoundException(`Learning group participant with ID ${learningGroupParticipantId} not found`);
}
const oldDate = existing.post_bat_email_date;
const changedFields: Record<string, unknown> = {
learning_group_participant_id: learningGroupParticipantId,
participant_id: existing.participant_id,
post_bat_email_date: {
from: oldDate?.toISOString() ?? null,
to: newDate.toISOString(),
},
};
await this.systemLogService.logUpdate(
SystemLogEntityType.LEARNING_GROUP,
existing.learning_group_id,
{
learning_group_participant_id: learningGroupParticipantId,
post_bat_email_date: oldDate?.toISOString() ?? null,
} as Record<string, unknown>,
{
learning_group_participant_id: learningGroupParticipantId,
post_bat_email_date: newDate.toISOString(),
} as Record<string, unknown>,
changedFields,
{
participant_id: existing.participant_id,
company_id: existing.learningGroup.company_id,
},
);
return {
success: true,
message: "Post BAT invite email sent and date updated successfully",
postBatInviteEmailDate: newDate.toISOString(),
};
}
/**
* Resend invitation to a specific participant (admin). Delegates to client learning-group
* service with company_id from the learning group; uses 0 for "invited by" (admin action).
*/
async resendInvitation(learningGroupId: number, participantId: number): Promise<{ message: string }> {
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id: learningGroupId },
select: { company_id: true },
});
if (!learningGroup) {
throw new NotFoundException(`Learning group with ID ${learningGroupId} not found`);
}
return this.clLearningGroupService.resendInvitation(
learningGroup.company_id,
learningGroupId,
participantId,
0,
);
}
private async getLearningGroupCompanyId(learningGroupId: number): Promise<number> {
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id: learningGroupId },
select: { company_id: true },
});
if (!learningGroup) {
throw new NotFoundException(`Learning group with ID ${learningGroupId} not found`);
}
return learningGroup.company_id;
}
async generatePreBatHtmlForParticipant(
learningGroupId: number,
learningGroupParticipantId: number,
): Promise<string> {
const companyId = await this.getLearningGroupCompanyId(learningGroupId);
return this.clLearningGroupService.generatePreBatHtmlForParticipant(
companyId,
learningGroupId,
learningGroupParticipantId,
);
}
async generatePostBatHtmlForParticipant(
learningGroupId: number,
learningGroupParticipantId: number,
): Promise<string> {
const companyId = await this.getLearningGroupCompanyId(learningGroupId);
return this.clLearningGroupService.generatePostBatHtmlForParticipant(
companyId,
learningGroupId,
learningGroupParticipantId,
);
}
async getPreBatPdfUrlForParticipant(
learningGroupId: number,
learningGroupParticipantId: number,
forceRegenerate = false,
): Promise<string> {
const companyId = await this.getLearningGroupCompanyId(learningGroupId);
return this.clLearningGroupService.getPreBatPdfUrlForParticipant(
companyId,
learningGroupId,
learningGroupParticipantId,
forceRegenerate,
);
}
async getPostBatPdfUrlForParticipant(
learningGroupId: number,
learningGroupParticipantId: number,
forceRegenerate = false,
): Promise<string> {
const companyId = await this.getLearningGroupCompanyId(learningGroupId);
return this.clLearningGroupService.getPostBatPdfUrlForParticipant(
companyId,
learningGroupId,
learningGroupParticipantId,
forceRegenerate,
);
}
/**
* Regenerate a participant's PRE-BAT AI analysis by re-submitting their data to the
* external CR(AI) API.
*
* Mirrors the participant portal "Regenerate" action so admins can refresh the
* underlying analysis that the PRE-BAT PDF is rendered from. CRAI regenerates
* asynchronously and posts the result back to `/api/integration/cr/*`.
*/
async regeneratePreBatAnalysis(
learningGroupId: number,
learningGroupParticipantId: number,
): Promise<{ success: boolean; message: string }> {
const enrollment = await this.resolveBatEnrollment(learningGroupId, learningGroupParticipantId);
if (!enrollment.pre_bat_completed_at) {
throw new UnprocessableEntityException(
"PRE-BAT assessment must be completed before regenerating analysis",
);
}
return this.craiBatRegenerationService.regenerateBatAnalysis(
learningGroupId,
enrollment.participant_id,
"PRE_BAT",
);
}
/**
* Regenerate a participant's POST-BAT AI analysis by re-submitting their data to the
* external CR(AI) API (same behaviour as {@link regeneratePreBatAnalysis}).
*/
async regeneratePostBatAnalysis(
learningGroupId: number,
learningGroupParticipantId: number,
): Promise<{ success: boolean; message: string }> {
const enrollment = await this.resolveBatEnrollment(learningGroupId, learningGroupParticipantId);
const course = enrollment.learningGroup?.course;
if (!course?.assessment_id) {
throw new UnprocessableEntityException("Course does not have an assessment configured");
}
const postBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
where: {
assessment_id: course.assessment_id,
participant_id: enrollment.participant_id,
assessment_type: "POST_BAT",
},
});
if (!postBatParticipant) {
throw new UnprocessableEntityException(
"POST-BAT assessment must be completed before regenerating analysis",
);
}
return this.craiBatRegenerationService.regenerateBatAnalysis(
learningGroupId,
enrollment.participant_id,
"POST_BAT",
);
}
/**
* Resolve the learning-group enrollment (participant + course) for a BAT regeneration
* request, ensuring it belongs to the given learning group.
*/
private async resolveBatEnrollment(learningGroupId: number, learningGroupParticipantId: number) {
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
id: learningGroupParticipantId,
learning_group_id: learningGroupId,
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
assessment_id: true,
},
},
},
},
},
});
if (!enrollment) {
throw new NotFoundException("Participant not found in this learning group");
}
return enrollment;
}
}