apps/recallassess/recallassess-api/src/api/admin/participant/participant.service.ts
BNestBaseModuleService
Methods |
|
constructor(prisma: BNestPrismaService, systemLogService: SystemLogService)
|
|||||||||
|
Parameters :
|
| Async add | ||||||
add(data: any)
|
||||||
|
Override add method to validate company exists and remove foreign key fields before creating participant Prisma only accepts relation objects (company), not raw foreign keys (company_id)
Parameters :
Returns :
Promise<any>
|
| Private Async assignParticipantToGroup | ||||||||||||||||||||
assignParticipantToGroup(participantId: number, groupName: string, companyId: number, participantIdAddedBy: number)
|
||||||||||||||||||||
|
Assign a participant to a participant group (department)
Parameters :
Returns :
Promise<void>
|
| Async delete | ||||||
delete(id: number)
|
||||||
|
Override delete method to handle related records and log deletion
Parameters :
Returns :
Promise<void>
|
| Async findCoursesByParticipantId | ||||||
findCoursesByParticipantId(participantId: number)
|
||||||
|
Get courses (e-learning enrollments) for a participant. Returns ELearningParticipant records with course, learning group, and learning group participant info.
Parameters :
Returns :
Promise<literal type>
|
| Async getDetail | ||||||
getDetail(id: number)
|
||||||
|
Override detail read to always hydrate company relation used by DTO transforms (company_name, company_country, company_timezone).
Parameters :
Returns :
Promise<any>
|
| Async resetPassword | ||||||||||||
resetPassword(participantId: number, password: string)
|
||||||||||||
|
Reset participant password Updates the participant password with the provided password
Parameters :
Returns :
Promise<literal type>
Success message |
| Async save |
save(id: number, data: any)
|
|
Override save method to handle company_id updates and remove foreign key fields
Returns :
Promise<any>
|
| Async setDevMode |
setDevMode(participantId: number, isDev: boolean)
|
|
Set dev mode for a participant (show correct answers in PWA assessments). Allowed for both verified and unverified participants.
Returns :
Promise<literal type>
|
import { BNestBaseModuleService } from "@bish-nest/core/data/module-service/base-module.service";
import { BNestPrismaService } from "@bish-nest/core/services";
import { SystemLogService } from "@api/shared/services";
import { resolveCompanyTimezone } from "@api/shared/timezone/company-timezone.service";
import { Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { SystemLogEntityType } from "@prisma/client";
import * as argon from "argon2";
import { plainToInstance } from "class-transformer";
@Injectable()
export class ParticipantService extends BNestBaseModuleService {
constructor(
protected prisma: BNestPrismaService,
private readonly systemLogService: SystemLogService,
) {
super();
}
/**
* Override detail read to always hydrate company relation used by DTO transforms
* (company_name, company_country, company_timezone).
*/
override async getDetail(id: number): Promise<any> {
const moduleCurrentCfg = this.gVars.moduleCurrentCfg;
const participant = await this.prisma.client.participant.findUnique({
where: { id },
include: {
company: true,
userCreatedBy: true,
userUpdatedBy: true,
},
});
if (!participant) {
throw new NotFoundException(`Participant with ID ${id} not found`);
}
// Always fetch minimal company fields by FK so company display fields are reliable
// even if relation include is impacted by middleware/query shaping.
const companyByFk = participant.company_id
? await this.prisma.client.company.findUnique({
where: { id: participant.company_id },
select: {
id: true,
name: true,
country: true,
preferred_timezone: true,
},
})
: null;
const hydratedCompany: (typeof participant)["company"] | null =
(companyByFk as (typeof participant)["company"] | null) ?? participant.company ?? null;
const detailSource = {
...participant,
company: hydratedCompany ?? null,
company_name: hydratedCompany?.name ?? "",
company_country: hydratedCompany?.country ?? null,
company_timezone: resolveCompanyTimezone({
country: hydratedCompany?.country ?? null,
preferred_timezone: hydratedCompany?.preferred_timezone ?? null,
}),
};
const data = plainToInstance(moduleCurrentCfg.detailDto, detailSource);
return this.moduleMethods.getReturnDataForDetail(data);
}
/**
* Override add method to validate company exists and remove foreign key fields before creating participant
* Prisma only accepts relation objects (company), not raw foreign keys (company_id)
*/
async add(data: any): Promise<any> {
// Validate company_id is provided for creation
if (!data.company_id) {
throw new UnprocessableEntityException({
message: [
{
colName: "company_id",
errorMessage: "Company is required. Please select a company.",
},
],
code: "FORM_VALIDATION_ERROR",
});
}
// Validate company exists
const company = await this.prisma.client.company.findUnique({
where: { id: data.company_id },
});
if (!company) {
throw new UnprocessableEntityException({
message: [
{
colName: "company_id",
errorMessage: "Company not found. Please select a valid company.",
},
],
code: "FORM_VALIDATION_ERROR",
});
}
// Validate email is not already used by another participant
if (data.email) {
const normalizedEmail = data.email.trim().toLowerCase();
const existingParticipant = await this.prisma.client.participant.findUnique({
where: { email: normalizedEmail },
});
if (existingParticipant) {
throw new UnprocessableEntityException({
message: [
{
colName: "email",
errorMessage: `A participant with the email "${data.email}" already exists. Please use a different email address.`,
},
],
code: "FORM_VALIDATION_ERROR",
});
}
}
// Extract department before creating participant (it's not a direct field on Participant)
const department = data.department;
// Create a copy of data without the foreign key field and department
// The transformed relation field (company) is already present
const { company_id, department: _, ...prismaData } = data;
// Call parent add method with cleaned data
const addResponse = await super.add(prismaData);
const participant = addResponse.data;
// Handle department assignment via ParticipantGroupHistory
if (department?.trim()) {
await this.assignParticipantToGroup(
participant.id,
department.trim(),
data.company_id,
participant.id, // Use participant's own ID for admin-initiated assignment
);
}
// Prepare participant data for logging (include department if assigned)
const participantDataForLog: Record<string, unknown> = {
...participant,
};
// Add department to the log data if it was assigned
if (department?.trim()) {
participantDataForLog['department'] = department.trim();
} else {
// Even if no department was provided, check if one exists from the assignment
const groupHistory = await this.prisma.client.participantGroupHistory.findFirst({
where: { participant_id: participant.id },
include: { participantGroup: { select: { name: true } } },
orderBy: { created_at: 'desc' },
});
if (groupHistory?.participantGroup?.name) {
participantDataForLog['department'] = groupHistory.participantGroup.name;
}
}
// Log the creation
await this.systemLogService.logInsert(
SystemLogEntityType.PARTICIPANT,
participant.id,
participantDataForLog,
{ company_id: participant.company_id },
);
return addResponse;
}
/**
* Override save method to handle company_id updates and remove foreign key fields
*/
async save(id: number, data: any): Promise<any> {
// Get old data before update for logging
const oldParticipant = await this.prisma.client.participant.findUnique({
where: { id },
});
if (!oldParticipant) {
throw new NotFoundException(`Participant with ID ${id} not found`);
}
// If email is verified, only allow is_active and is_dev updates
// Note: Password reset is handled separately via resetPassword() method
if (oldParticipant.email_verified) {
// Allow admin UIs that send full records by enforcing restrictions on changed fields only.
const allowedFieldsForVerified = new Set(["is_active", "is_dev"]);
const ignoredDerivedFields = new Set(["company"]); // relation helper generated from company_id transform
const changedFields = Object.keys(data).filter((field) => {
if (ignoredDerivedFields.has(field)) return false;
const newValue = data[field];
if (newValue === undefined) return false;
// Keep comparison deterministic for common types.
if (field === "email") {
return String(newValue).trim().toLowerCase() !== oldParticipant.email.trim().toLowerCase();
}
if (field === "company_id") {
return Number(newValue) !== oldParticipant.company_id;
}
if (field === "email_verified_at" || field === "last_login") {
const oldTs = oldParticipant[field]?.getTime?.() ?? null;
const newTs = newValue ? new Date(newValue).getTime() : null;
return oldTs !== newTs;
}
return newValue !== (oldParticipant as Record<string, unknown>)[field];
});
const disallowedFields = changedFields.filter((field) => !allowedFieldsForVerified.has(field));
if (disallowedFields.length > 0) {
throw new UnprocessableEntityException({
message: [
{
colName: disallowedFields[0] || "email_verified",
errorMessage:
"Cannot modify participant: Email has been verified. Only is_active, dev mode, and password reset are allowed for verified participants.",
},
],
code: "FORM_VALIDATION_ERROR",
});
}
}
// If company_id is being updated, validate it exists
if (data.company_id !== undefined) {
const company = await this.prisma.client.company.findUnique({
where: { id: data.company_id },
});
if (!company) {
throw new UnprocessableEntityException({
message: [
{
colName: "company_id",
errorMessage: "Company not found. Please select a valid company.",
},
],
code: "FORM_VALIDATION_ERROR",
});
}
}
// If email is being updated, validate it's not already used by another participant
if (data.email) {
const normalizedEmail = data.email.trim().toLowerCase();
const existingParticipant = await this.prisma.client.participant.findUnique({
where: { email: normalizedEmail },
});
// Allow update if it's the same participant (updating other fields)
if (existingParticipant && existingParticipant.id !== id) {
throw new UnprocessableEntityException({
message: [
{
colName: "email",
errorMessage: `A participant with the email "${data.email}" already exists. Please use a different email address.`,
},
],
code: "FORM_VALIDATION_ERROR",
});
}
}
// Extract department before updating participant (it's not a direct field on Participant)
const department = data.department;
// Get old department for logging
const oldGroupHistory = await this.prisma.client.participantGroupHistory.findFirst({
where: { participant_id: id },
include: { participantGroup: { select: { name: true } } },
orderBy: { created_at: 'desc' },
});
const oldDepartment = oldGroupHistory?.participantGroup?.name || null;
// Create a copy of data without the foreign key field and department
// The transformed relation field (company) is already present if company_id was provided
const { company_id, department: _, ...prismaData } = data;
// Call parent save method with cleaned data
const saveResponse = await super.save(id, prismaData);
const updatedParticipant = saveResponse.data;
// Get company_id from updated participant or data
const companyId = data.company_id || oldParticipant.company_id;
// Handle department assignment via ParticipantGroupHistory
if (department !== undefined) {
// Get existing group assignments to decrement participant counts
const existingAssignments = await this.prisma.client.participantGroupHistory.findMany({
where: {
participant_id: id,
},
include: {
participantGroup: {
select: { id: true },
},
},
});
// Delete existing group assignments
await this.prisma.client.participantGroupHistory.deleteMany({
where: {
participant_id: id,
},
});
// Decrement participant counts for old groups
for (const assignment of existingAssignments) {
await this.prisma.client.participantGroup.update({
where: { id: assignment.participantGroup.id },
data: {
participant_count: {
decrement: 1,
},
},
});
}
// Assign to new group if department is provided
if (department?.trim()) {
await this.assignParticipantToGroup(
id,
department.trim(),
companyId,
id, // Use participant's own ID for admin-initiated assignment
);
}
}
// Get new department for logging
const newGroupHistory = await this.prisma.client.participantGroupHistory.findFirst({
where: { participant_id: id },
include: { participantGroup: { select: { name: true } } },
orderBy: { created_at: 'desc' },
});
const newDepartment = newGroupHistory?.participantGroup?.name || null;
// Prepare participant data for logging (include department)
const oldParticipantData: Record<string, unknown> = {
...oldParticipant,
};
if (oldDepartment) {
oldParticipantData['department'] = oldDepartment;
}
const updatedParticipantData: Record<string, unknown> = {
...updatedParticipant,
};
if (newDepartment) {
updatedParticipantData['department'] = newDepartment;
}
// Calculate changed fields and log the update
const changedFields = SystemLogService.calculateChangedFields(
oldParticipantData,
updatedParticipantData,
);
await this.systemLogService.logUpdate(
SystemLogEntityType.PARTICIPANT,
id,
oldParticipantData,
updatedParticipantData,
changedFields,
{ company_id: updatedParticipant.company_id ?? oldParticipant.company_id },
);
return saveResponse;
}
/**
* Override delete method to handle related records and log deletion
*/
async delete(id: number): Promise<void> {
// Get participant data before deletion for logging
const participant = await this.prisma.client.participant.findUnique({
where: { id },
});
if (!participant) {
throw new NotFoundException(`Participant with ID ${id} not found`);
}
// Prevent deletion if email is verified
if (participant.email_verified) {
throw new UnprocessableEntityException(
"Cannot delete participant: Email has been verified. Verified participants cannot be deleted. Only password reset is allowed."
);
}
// Log the deletion BEFORE deleting the participant
// This must happen before deletion because the system log needs to connect to the participant
await this.systemLogService.logDelete(
SystemLogEntityType.PARTICIPANT,
id,
participant,
{ company_id: participant.company_id },
);
// Delete related records that have RESTRICT foreign key constraints
// These must be deleted before the participant can be deleted
await this.prisma.client.$transaction(async (tx) => {
// Delete email verification token if it exists
await tx.emailVerificationToken.deleteMany({
where: { participant_id: id },
});
// Delete password setup token if it exists
await tx.passwordSetupToken.deleteMany({
where: { participant_id: id },
});
});
// Call parent delete method
await super.delete(id);
}
/**
* Assign a participant to a participant group (department)
* @param participantId Participant ID
* @param groupName Participant group name
* @param companyId Company ID
* @param participantIdAddedBy Participant ID who is making this assignment
*/
private async assignParticipantToGroup(
participantId: number,
groupName: string,
companyId: number,
participantIdAddedBy: number,
): Promise<void> {
// Find the participant group by name and company
const participantGroup = await this.prisma.client.participantGroup.findFirst({
where: {
name: groupName.trim(),
company_id: companyId,
},
});
if (!participantGroup) {
// Group not found - could optionally create it or just skip assignment
console.warn(
`Participant group "${groupName}" not found for company ${companyId}. Skipping department assignment.`,
);
return;
}
// Check if there's already an assignment to this group
const existingAssignment = await this.prisma.client.participantGroupHistory.findFirst({
where: {
participant_id: participantId,
participant_group_id: participantGroup.id,
},
});
if (existingAssignment) {
// Already assigned to this group
return;
}
// Create new group assignment
await this.prisma.client.participantGroupHistory.create({
data: {
participant_id: participantId,
participant_group_id: participantGroup.id,
participant_id_added_by: participantIdAddedBy,
},
});
// Update participant count in the group
await this.prisma.client.participantGroup.update({
where: { id: participantGroup.id },
data: {
participant_count: {
increment: 1,
},
},
});
}
/**
* Reset participant password
* Updates the participant password with the provided password
* @param participantId - The ID of the participant
* @param password - The new password to set
* @returns Success message
*/
async resetPassword(participantId: number, password: string): Promise<{ message: string }> {
// Check if participant exists
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
throw new NotFoundException(`Participant with ID ${participantId} not found`);
}
const oldData = {
password_hash: participant.password_hash,
updated_at: participant.updated_at,
};
// Hash the new password
const password_hash = await argon.hash(password);
// Update the participant with new password hash
const updatedParticipant = await this.prisma.client.participant.update({
where: { id: participantId },
data: {
password_hash,
updated_at: new Date(),
},
});
const newData = {
password_hash: updatedParticipant.password_hash,
updated_at: updatedParticipant.updated_at,
};
// Log the password reset
await this.systemLogService.logUpdate(
SystemLogEntityType.PARTICIPANT,
participantId,
oldData,
newData,
{ password_hash: { old: "[REDACTED]", new: "[REDACTED]" }, updated_at: { old: oldData.updated_at, new: newData.updated_at } },
{ company_id: participant.company_id },
);
return {
message: "Password reset successfully",
};
}
/**
* Set dev mode for a participant (show correct answers in PWA assessments).
* Allowed for both verified and unverified participants.
*/
async setDevMode(participantId: number, isDev: boolean): Promise<{ data: { id: number; is_dev: boolean } }> {
const participant = await this.prisma.client.participant.findUnique({
where: { id: participantId },
});
if (!participant) {
throw new NotFoundException(`Participant with ID ${participantId} not found`);
}
const oldData = { is_dev: participant.is_dev, updated_at: participant.updated_at };
const updated = await this.prisma.client.participant.update({
where: { id: participantId },
data: { is_dev: isDev },
});
const newData = { is_dev: updated.is_dev, updated_at: updated.updated_at };
await this.systemLogService.logUpdate(
SystemLogEntityType.PARTICIPANT,
participantId,
oldData,
newData,
{ is_dev: { old: oldData.is_dev, new: newData.is_dev }, updated_at: { old: oldData.updated_at, new: newData.updated_at } },
{ company_id: participant.company_id },
);
return { data: { id: updated.id, is_dev: updated.is_dev } };
}
/**
* Get courses (e-learning enrollments) for a participant.
* Returns ELearningParticipant records with course, learning group, and learning group participant info.
*/
async findCoursesByParticipantId(participantId: number): Promise<{
data: Array<{
id: number;
course_id: number;
course_name: string | null;
course_code: string | null;
learning_group_id: number;
learning_group_name: string | null;
learning_group_participant_id: number | null;
learning_group_participant_status: string | null;
invited_at: Date | null;
accepted_at: Date | null;
status: string;
progress_percentage: number | null;
start_date: Date | null;
complete_date: Date | null;
last_activity: Date | null;
course_modules_completed: number;
total_course_modules: number;
}>;
}> {
const [rows, lgpList] = await Promise.all([
this.prisma.client.eLearningParticipant.findMany({
where: { participant_id: participantId },
include: {
course: { select: { id: true, title: true, course_code: true } },
learningGroup: { select: { id: true, name: true } },
},
orderBy: { start_date: "desc" },
}),
this.prisma.client.learningGroupParticipant.findMany({
where: { participant_id: participantId },
select: {
id: true,
learning_group_id: true,
course_id: true,
status: true,
invited_at: true,
accepted_at: true,
completion_percentage: true,
},
}),
]);
const lgpByKey = new Map<
string,
{ id: number; status: string; invited_at: Date | null; accepted_at: Date | null; completion_percentage: number | null }
>();
for (const lgp of lgpList) {
lgpByKey.set(`${lgp.learning_group_id}:${lgp.course_id}`, {
id: lgp.id,
status: lgp.status,
invited_at: lgp.invited_at,
accepted_at: lgp.accepted_at,
completion_percentage: lgp.completion_percentage != null ? Number(lgp.completion_percentage) : null,
});
}
const data = rows.map((r) => {
const lgp = r.learning_group_id != null && r.course_id != null
? lgpByKey.get(`${r.learning_group_id}:${r.course_id}`)
: undefined;
// Use LGP completion_percentage (authoritative, matches client portal) when available; else e-learning progress
const completionPct = lgp?.completion_percentage ?? null;
const eLearningPct = r.progress_percentage != null ? Number(r.progress_percentage) : null;
const progress_percentage = completionPct != null ? completionPct : eLearningPct;
// Align status with progress: avoid showing NOT_STARTED when there is progress (fixes admin/client mismatch)
const hasProgress = (completionPct != null && completionPct > 0) || (eLearningPct != null && eLearningPct > 0);
const isInvitedNotAccepted = lgp?.status === "INVITED" && lgp?.accepted_at == null;
const status =
r.status === "NOT_STARTED" && hasProgress && !isInvitedNotAccepted ? "IN_PROGRESS" : r.status;
return {
id: r.id,
course_id: r.course_id,
course_name: r.course?.title ?? r.course?.course_code ?? null,
course_code: r.course?.course_code ?? null,
learning_group_id: r.learning_group_id,
learning_group_name: r.learningGroup?.name ?? null,
learning_group_participant_id: lgp?.id ?? null,
learning_group_participant_status: lgp?.status ?? null,
invited_at: lgp?.invited_at ?? null,
accepted_at: lgp?.accepted_at ?? null,
status,
progress_percentage,
start_date: r.start_date,
complete_date: r.complete_date,
last_activity: r.last_activity,
course_modules_completed: r.course_modules_completed,
total_course_modules: r.total_course_modules,
};
});
return { data };
}
}