File

apps/recallassess/recallassess-api/src/api/admin/participant/participant.service.ts

Extends

BNestBaseModuleService

Index

Methods

Constructor

constructor(prisma: BNestPrismaService, systemLogService: SystemLogService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
systemLogService SystemLogService No

Methods

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 :
Name Type Optional
data any No
Returns : Promise<any>
Private Async assignParticipantToGroup
assignParticipantToGroup(participantId: number, groupName: string, companyId: number, participantIdAddedBy: number)

Assign a participant to a participant group (department)

Parameters :
Name Type Optional Description
participantId number No

Participant ID

groupName string No

Participant group name

companyId number No

Company ID

participantIdAddedBy number No

Participant ID who is making this assignment

Returns : Promise<void>
Async delete
delete(id: number)

Override delete method to handle related records and log deletion

Parameters :
Name Type Optional
id number No
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 :
Name Type Optional
participantId number No
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 :
Name Type Optional
id number No
Returns : Promise<any>
Async resetPassword
resetPassword(participantId: number, password: string)

Reset participant password Updates the participant password with the provided password

Parameters :
Name Type Optional Description
participantId number No
  • The ID of the participant
password string No
  • The new password to set
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

Parameters :
Name Type Optional
id number No
data any No
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.

Parameters :
Name Type Optional
participantId number No
isDev boolean No
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 };
  }
}

results matching ""

    No results matching ""