import { CLLearningGroupService } from "@api/client/learning-group/learning-group.service";
import { SystemLogService } from "@api/shared/services";
import { BNestBaseModuleService } from "@bish-nest/core/data/module-service/base-module.service";
import { DetailResponseDataInterface } from "@bish-nest/core/interfaces/detail-response-data.interface";
import { BNestPrismaService } from "@bish-nest/core/services";
import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
export interface UpdateKnowledgeReviewEmailDateResponse {
success: boolean;
message: string;
knowledgeReviewEmailDate: string | null;
}
export interface UpdatePostBatInviteEmailDateResponse {
success: boolean;
message: string;
postBatInviteEmailDate: string | null;
}
import { SystemLogEntityType } from "@prisma/client";
import { plainToInstance } from "class-transformer";
import { EmailReminderService } from "@api/shared/email/services/email-reminder.service";
@Injectable()
export class LearningGroupService extends BNestBaseModuleService {
constructor(
protected prisma: BNestPrismaService,
private readonly systemLogService: SystemLogService,
private readonly emailReminderService: EmailReminderService,
private readonly clLearningGroupService: CLLearningGroupService,
) {
super();
}
/**
* 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
const addResponse = await super.add(prismaData);
const learningGroup = addResponse.data;
// Log the creation
await this.systemLogService.logInsert(
SystemLogEntityType.LEARNING_GROUP,
learningGroup.id,
learningGroup as Record<string, unknown>,
{ company_id: learningGroup.company_id },
);
return addResponse;
}
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`);
}
// Calculate changed fields and log the update
const changedFields = SystemLogService.calculateChangedFields(
oldLearningGroup as Record<string, unknown>,
updatedLearningGroup as Record<string, unknown>,
);
await this.systemLogService.logUpdate(
SystemLogEntityType.LEARNING_GROUP,
id,
oldLearningGroup as Record<string, unknown>,
updatedLearningGroup as Record<string, unknown>,
changedFields,
{ company_id: updatedLearningGroup.company_id ?? oldLearningGroup.company_id },
);
return await this.getDetail(id);
}
/**
* Override delete method to log deletion
*/
async delete(id: number): Promise<void> {
// Get learning group data before deletion
const learningGroup = await this.prisma.client.learningGroup.findUnique({
where: { id },
});
if (!learningGroup) {
throw new NotFoundException(`Learning group with ID ${id} not found`);
}
// Call parent delete method
await super.delete(id);
// Log the deletion
await this.systemLogService.logDelete(
SystemLogEntityType.LEARNING_GROUP,
id,
learningGroup as Record<string, unknown>,
{ company_id: learningGroup.company_id },
);
}
/**
* 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;
// 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,
}));
const sanitizedData: Record<string, unknown> = {
...learningGroup,
completion_percentage: completionPercentage,
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,
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,
): Promise<string> {
const companyId = await this.getLearningGroupCompanyId(learningGroupId);
return this.clLearningGroupService.getPreBatPdfUrlForParticipant(
companyId,
learningGroupId,
learningGroupParticipantId,
);
}
async getPostBatPdfUrlForParticipant(
learningGroupId: number,
learningGroupParticipantId: number,
): Promise<string> {
const companyId = await this.getLearningGroupCompanyId(learningGroupId);
return this.clLearningGroupService.getPostBatPdfUrlForParticipant(
companyId,
learningGroupId,
learningGroupParticipantId,
);
}
}