File

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

Extends

CLBaseService

Index

Properties
Methods

Constructor

constructor(eventEmitter: EventEmitter2, systemLogService: SystemLogService, participantGroupService: CLParticipantGroupService, emailSender: BNestEmailSenderService)
Parameters :
Name Type Optional
eventEmitter EventEmitter2 No
systemLogService SystemLogService No
participantGroupService CLParticipantGroupService No
emailSender BNestEmailSenderService No

Methods

Async addParticipant
addParticipant(data: AddParticipantDto, companyId: number, participantIdAddedBy: number)

Add a new participant

Parameters :
Name Type Optional Description
data AddParticipantDto No

Participant data

companyId number No

Company ID from authenticated user

participantIdAddedBy number No

Participant ID of the user adding this participant (from auth token)

Added participant data

Private Async adjustDueDatesOnReactivation
adjustDueDatesOnReactivation(participantId: number, deactivatedAt: Date | null)

Adjust due dates for courses when participant is reactivated Extends due dates by the duration of deactivation

Parameters :
Name Type Optional
participantId number No
deactivatedAt Date | null No
Returns : Promise<void>
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 checkParticipantActiveWork
checkParticipantActiveWork(participantId: number, companyId: number)

Check if participant has active work (incomplete courses, pending assessments)

Parameters :
Name Type Optional Description
participantId number No

Participant ID

companyId number No

Company ID to ensure participant belongs to the company

Active work information

Async deleteParticipantForParticipantAdmin
deleteParticipantForParticipantAdmin(targetParticipantId: number, companyId: number, performedByParticipantId: number, performedByRole: string)

Participant administrators may remove a company contact only when no active course allocation exists (same rule as directory total_courses: non-cancelled enrollments).

Parameters :
Name Type Optional
targetParticipantId number No
companyId number No
performedByParticipantId number No
performedByRole string No
Returns : Promise<void>
Private Async enrichParticipantData
enrichParticipantData(participant: Record<string | unknown>)

Enrich participant data with additional fields for frontend

Parameters :
Name Type Optional Description
participant Record<string | unknown> No

Database participant object

Enriched participant DTO

Async getFilteredParticipants
getFilteredParticipants(companyId: number, page: number, limit: number, sq?: string, status?: string, participantGroup?: string, callerParticipantId?: number, callerRole?: string)

Get filtered participants for client consumption with pagination. When caller role is PARTICIPANT (not PARTICIPANT_ADMIN), returns only that participant's details.

Parameters :
Name Type Optional Default value Description
companyId number No
  • Company ID to filter participants by (required)
page number No 1
  • Page number (1-based)
limit number No 20
  • Number of items per page
sq string Yes
  • Search query to filter by name, email, or participant group
status string Yes
  • Filter by status (all, active, inactive, pending)
participantGroup string Yes
  • Filter by participant group
callerParticipantId number Yes
  • When callerRole is PARTICIPANT, restrict list to this id
callerRole string Yes
  • PARTICIPANT => only own details; PARTICIPANT_ADMIN => all in company

Paginated list response with participants and metadata

Async getParticipantAdmin
getParticipantAdmin(participantId: number, companyId: number)

Get the participant administrator (User who created/manages this participant)

Parameters :
Name Type Optional Description
participantId number No

Participant ID (from auth)

companyId number No

Company ID to ensure participant belongs to the company

Returns : Promise<literal type | null>

Admin user information (first_name, last_name, email, phone) or null if not found

Async getParticipantById
getParticipantById(id: number, companyId: number)

Get a single participant by ID

Parameters :
Name Type Optional Description
id number No

Participant ID

companyId number No

Company ID to ensure participant belongs to the company

Enriched participant data

Async getParticipantLicenseAvailability
getParticipantLicenseAvailability(companyId: number)

Get license availability information for adding participants

Parameters :
Name Type Optional Description
companyId number No

Company ID

Returns : Promise<literal type>

License availability info including total licenses, current active participants, and available slots

Private Async pauseScheduledEmails
pauseScheduledEmails(participantId: number)

Pause scheduled emails for a deactivated participant Moves scheduled_date far into the future to prevent sending

Parameters :
Name Type Optional
participantId number No
Returns : Promise<void>
Private Async resumeScheduledEmails
resumeScheduledEmails(participantId: number, deactivatedAt: Date | null)

Resume scheduled emails for a reactivated participant Adjusts scheduled_date by the deactivation period

Parameters :
Name Type Optional
participantId number No
deactivatedAt Date | null No
Returns : Promise<void>
Async saveParticipant
saveParticipant(id: number, companyId: number, data: SaveParticipantDto, participantIdUpdatedBy: number)

Save (update) a participant

Parameters :
Name Type Optional Description
id number No

Participant ID

companyId number No

Company ID to ensure participant belongs to the company

data SaveParticipantDto No

Participant data to save

participantIdUpdatedBy number No

Participant ID of the user updating this participant (from auth token)

Saved participant data

Async verifyProgressIntegrity
verifyProgressIntegrity(participantId: number)

Verify progress integrity for a participant Checks for missing or inconsistent progress data

Parameters :
Name Type Optional
participantId number No
Returns : Promise<literal type>
Protected buildCompanyWhere
buildCompanyWhere(companyId: number, additionalWhere?: Record)
Inherited from CLBaseService
Defined in CLBaseService:82

Build base WHERE clause with company scope Ensures all queries are scoped to the user's company

Parameters :
Name Type Optional Description
companyId number No
  • Company ID to scope queries to
additionalWhere Record<string | any> Yes
  • Additional where conditions to merge
Returns : Record<string, any>

Complete where clause object

Protected buildSearchWhere
buildSearchWhere(searchFields: string[], searchQuery?: string)
Inherited from CLBaseService
Defined in CLBaseService:64

Build a WHERE clause for search functionality Creates OR conditions for multiple fields

Parameters :
Name Type Optional Description
searchFields string[] No
  • Array of field names to search in
searchQuery string Yes
  • Search query string
Returns : [] | undefined

Array of search conditions or undefined if no query

Protected Async findByIdWithCompanyScope
findByIdWithCompanyScope(entityName: string, entityId: number, companyId: number)
Inherited from CLBaseService
Defined in CLBaseService:111

Find entity by ID with company scope verification Common pattern: get entity and ensure it belongs to the company

Parameters :
Name Type Optional Description
entityName string No
  • Prisma model name
entityId number No
  • Entity ID
companyId number No
  • Company ID to verify ownership
Returns : Promise<any | null>

Entity if found and belongs to company, null otherwise

Protected getRepo
getRepo(repoName: string)
Inherited from CLBaseService
Defined in CLBaseService:96

Get a Prisma repository (table) dynamically Useful for generic operations across different entities

Parameters :
Name Type Optional Description
repoName string No
  • The name of the Prisma repository (table)
Returns : any

The Prisma repository instance

Protected toDto
toDto(entity: any, dtoClass: unknown)
Inherited from CLBaseService
Defined in CLBaseService:20
Type parameters :
  • TDto

Transform database entity to DTO using class-transformer

Parameters :
Name Type Optional Description
entity any No
  • Raw database entity
dtoClass unknown No
  • DTO class constructor
Returns : TDto

Transformed DTO instance

Protected toDtoArray
toDtoArray(entities: any[], dtoClass: unknown)
Inherited from CLBaseService
Defined in CLBaseService:30
Type parameters :
  • TDto

Transform array of database entities to DTOs

Parameters :
Name Type Optional Description
entities any[] No
  • Array of raw database entities
dtoClass unknown No
  • DTO class constructor
Returns : TDto[]

Array of transformed DTO instances

Protected Async verifyCompanyOwnership
verifyCompanyOwnership(entityName: string, entityId: number, companyId: number)
Inherited from CLBaseService
Defined in CLBaseService:42

Verify that an entity belongs to a specific company Common security check to prevent cross-company data access

Parameters :
Name Type Optional Description
entityName string No
  • Prisma model name (e.g., 'participant', 'participantGroup')
entityId number No
  • Entity ID to check
companyId number No
  • Company ID to verify ownership
Returns : Promise<boolean>

True if entity belongs to company, false otherwise

Properties

Protected Readonly prisma
Type : BNestPrismaService
Decorators :
@Inject()
Inherited from CLBaseService
Defined in CLBaseService:12
import { CLBaseService, SystemLogService } from "@api/shared/services";
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";
import {
  BadRequestException,
  ConflictException,
  ForbiddenException,
  Injectable,
  NotFoundException,
  UnprocessableEntityException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { ParticipantLearningProgressStatus, ParticipantRole, SystemLogEntityType } from "@prisma/client";
import { CLParticipantGroupService } from "../participant-group/participant-group.service";
import { AddParticipantDto, CLParticipantDto, SaveParticipantDto } from "./dto";
import { ParticipantActiveWorkDto } from "./dto/participant-active-work.dto";
import { CLParticipantListResponse } from "./dto/participant-list-response.dto";
import {
  PARTICIPANT_EVENTS,
  ParticipantCreatedEvent,
  ParticipantDeletedEvent,
  ParticipantReactivatedEvent,
  ParticipantStatusUpdatedEvent,
} from "./events/participant.events";

@Injectable()
export class CLParticipantService extends CLBaseService {
  constructor(
    private readonly eventEmitter: EventEmitter2,
    private readonly systemLogService: SystemLogService,
    private readonly participantGroupService: CLParticipantGroupService,
    private readonly emailSender: BNestEmailSenderService,
  ) {
    super();
  }
  /**
   * Get filtered participants for client consumption with pagination.
   * When caller role is PARTICIPANT (not PARTICIPANT_ADMIN), returns only that participant's details.
   *
   * @param companyId - Company ID to filter participants by (required)
   * @param page - Page number (1-based)
   * @param limit - Number of items per page
   * @param sq - Search query to filter by name, email, or participant group
   * @param status - Filter by status (all, active, inactive, pending)
   * @param participantGroup - Filter by participant group
   * @param callerParticipantId - When callerRole is PARTICIPANT, restrict list to this id
   * @param callerRole - PARTICIPANT => only own details; PARTICIPANT_ADMIN => all in company
   * @returns Paginated list response with participants and metadata
   */
  async getFilteredParticipants(
    companyId: number,
    page = 1,
    limit = 20,
    sq?: string,
    status?: string,
    participantGroup?: string,
    callerParticipantId?: number,
    callerRole?: string,
  ): Promise<CLParticipantListResponse> {
    // Build where clause based on filters
    // Using flexible typing since the actual DB schema may differ from strict Prisma types
    const where: Record<string, unknown> = {
      // Always filter by company_id - participants belong to a specific company
      company_id: companyId,
    };

    // When caller is PARTICIPANT (not PARTICIPANT_ADMIN), show only their own details (hide-data / UAT expectation)
    if (callerRole === "PARTICIPANT" && callerParticipantId != null) {
      where["id"] = callerParticipantId;
    }

    // Status filter - convert status string to is_active boolean
    if (status && status !== "all") {
      // "active" -> is_active = true
      // "inactive" or "pending" -> is_active = false
      where["is_active"] = status === "active";
    }

    // Participant group filter (department)
    if (participantGroup && participantGroup !== "all") {
      where["department"] = participantGroup;
    }

    // Search query filter (name, email, or department)
    if (sq?.trim()) {
      where["OR"] = [
        { first_name: { contains: sq, mode: "insensitive" } },
        { last_name: { contains: sq, mode: "insensitive" } },
        { email: { contains: sq, mode: "insensitive" } },
        // { department: { contains: sq, mode: "insensitive" } },
      ];
    }

    // Get total count for pagination metadata
    const totalCount = await this.prisma.client.participant.count({
      where,
    });

    // Base filter for status counts (same scope as list: company or single participant when caller is PARTICIPANT)
    const countBaseWhere: Record<string, unknown> = { company_id: companyId };
    if (callerRole === "PARTICIPANT" && callerParticipantId != null) {
      countBaseWhere["id"] = callerParticipantId;
    }

    // Calculate status counts directly from the participants table to avoid stale denormalized totals
    const [activeCount, inactiveCount, replacedCount] = await Promise.all([
      this.prisma.client.participant.count({
        where: {
          ...countBaseWhere,
          is_active: true,
          is_replaced: false,
        },
      }),
      this.prisma.client.participant.count({
        where: {
          ...countBaseWhere,
          is_active: false,
          is_replaced: false,
        },
      }),
      this.prisma.client.participant.count({
        where: {
          ...countBaseWhere,
          is_replaced: true,
        },
      }),
    ]);

    // Calculate pagination
    const skip = (page - 1) * limit;

    // Fetch participants from database with pagination
    const participants = await this.prisma.client.participant.findMany({
      where,
      orderBy: [
        { created_at: "desc" }, // Newest first
      ],
      skip,
      take: limit,
    });

    // Transform database participants to client-facing format
    // Load all group histories in one query for efficiency
    const participantIds = participants.map((p) => p.id as number);
    const groupHistories = await this.prisma.client.participantGroupHistory.findMany({
      where: {
        participant_id: {
          in: participantIds,
        },
      },
      include: {
        participantGroup: {
          select: {
            name: true,
          },
        },
      },
      orderBy: {
        created_at: "desc",
      },
    });

    // Create a map of participant_id -> group name (most recent)
    const participantGroupMap = new Map<number, string>();
    for (const history of groupHistories) {
      if (!participantGroupMap.has(history.participant_id)) {
        participantGroupMap.set(history.participant_id, history.participantGroup.name);
      }
    }

    // Get course enrollment data for all participants to calculate progress (exclude cancelled licenses)
    const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: {
          in: participantIds,
        },
        cancelled: false,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
              },
            },
            eLearningParticipants: {
              where: {
                participant_id: {
                  in: participantIds,
                },
              },
              take: 1,
            },
          },
        },
      },
    });

    // Create maps for efficient lookup
    const enrollmentMap = new Map<number, typeof enrollments>();
    enrollments.forEach((enrollment) => {
      const participantId = enrollment.participant_id;
      if (!enrollmentMap.has(participantId)) {
        enrollmentMap.set(participantId, []);
      }
      enrollmentMap.get(participantId)!.push(enrollment);
    });

    // Get email preferences for all participants (for email subscription status)
    // Use the participantIds already declared above
    const emailPreferences = await (this.prisma.client as any).emailPreference.findMany({
      where: {
        participant_id: { in: participantIds },
      },
      select: {
        participant_id: true,
        all_reminders_enabled: true,
        unsubscribed_at: true,
        unsubscribed_reason: true,
        resubscribed_at: true,
        resubscribe_count: true,
      },
    });

    // Create email status map
    const emailStatusMap = new Map();
    emailPreferences.forEach((pref: any) => {
      emailStatusMap.set(pref.participant_id, {
        isUnsubscribed: !pref.all_reminders_enabled,
        unsubscribedAt: pref.unsubscribed_at ? new Date(pref.unsubscribed_at).toISOString() : null,
        unsubscribedReason: pref.unsubscribed_reason,
        resubscribedAt: pref.resubscribed_at ? new Date(pref.resubscribed_at).toISOString() : null,
        resubscribeCount: pref.resubscribe_count || 0,
      });
    });

    // Transform participants with department and progress data
    const data = await Promise.all(
      participants.map(async (participant) => {
        const participantData = this.toDto(participant, CLParticipantDto);
        const groupName = participantGroupMap.get(participant.id as number);
        participantData.department = groupName || "Not Assigned";

        // Add email subscription status
        const emailStatus = emailStatusMap.get(participant.id as number);
        participantData.email_subscription_status = emailStatus || {
          isUnsubscribed: false,
          unsubscribedAt: null,
          unsubscribedReason: null,
          resubscribedAt: null,
          resubscribeCount: 0,
        };

        // Calculate progress metrics
        const participantEnrollments = enrollmentMap.get(participant.id as number) || [];
        const totalCourses = participantEnrollments.length;
        const completedCourses = participantEnrollments.filter(
          (e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
        ).length;

        // Calculate progress from current/active course (not average of all courses)
        // Use completion_percentage from LearningGroupParticipant (authoritative source)
        let participantProgress = 0;
        let lastActivity: Date | null = null as Date | null;
        let currentCourse: string | undefined;

        // Find current course (in progress, or most recent if all completed)
        const inProgressEnrollments = participantEnrollments.filter(
          (e) => e.status !== ParticipantLearningProgressStatus.COMPLETED,
        );

        if (inProgressEnrollments.length > 0) {
          // Get the most recent in-progress course
          const mostRecent = inProgressEnrollments.sort(
            (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
          )[0];
          currentCourse = mostRecent.learningGroup?.course?.title;
          // Use completion_percentage from the current course
          participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
        } else if (participantEnrollments.length > 0) {
          // All completed, get the most recent completed course
          const mostRecent = participantEnrollments.sort(
            (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
          )[0];
          currentCourse = mostRecent.learningGroup?.course?.title;
          // Use completion_percentage from the most recent completed course
          participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
        }

        // Get last activity from all enrollments
        participantEnrollments.forEach((enrollment) => {
          const progressData = enrollment.learningGroup?.eLearningParticipants?.find(
            (ep) => ep.participant_id === participant.id,
          );
          if (progressData?.last_activity) {
            const activityDate = new Date(progressData.last_activity);
            if (!lastActivity || activityDate > lastActivity) {
              lastActivity = activityDate;
            }
          }
        });

        // Set calculated fields
        participantData.total_courses = totalCourses;
        participantData.courses_completed = completedCourses;
        participantData.progress = participantProgress; // Use current course progress, not average
        participantData.current_course = currentCourse;
        if (lastActivity) {
          participantData.last_active = lastActivity.toISOString();
        } else if (participant.last_login) {
          participantData.last_active = new Date(participant.last_login).toISOString();
        } else {
          participantData.last_active = "";
        }

        // === Invitation tracking fields ===
        // current_stage = status of the most-recently allocated enrollment (drives the row's stage pill).
        // has_password = whether users.password_hash is set (distinguishes Invited vs Pending email templates).
        // eligible_for_next = most-recent enrollment has cleared e-Learning (drives "Allocate next" menu item).
        // enrolled_courses[] = full per-course breakdown for the View Details dialog.
        if (participantEnrollments.length > 0) {
          const sortedByAllocated = [...participantEnrollments].sort(
            (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
          );
          const mostRecentEnrollment = sortedByAllocated[0];
          participantData.current_stage = mostRecentEnrollment.status;

          // Eligible for next allocation when the most-recent course has cleared e-Learning.
          // The Prisma enum maxes out at COMPLETED; we treat anything past E_LEARNING (i.e.
          // POST_BAT or COMPLETED) plus an explicit e_learning_completed_at timestamp as eligible.
          participantData.eligible_for_next =
            mostRecentEnrollment.status === ParticipantLearningProgressStatus.POST_BAT ||
            mostRecentEnrollment.status === ParticipantLearningProgressStatus.COMPLETED ||
            (mostRecentEnrollment.status === ParticipantLearningProgressStatus.E_LEARNING &&
              !!mostRecentEnrollment.e_learning_completed_at);

          // Per-course breakdown for View Details dialog.
          // The eLearningParticipants relation is filtered by `participant_id IN
          // [batchIds]` and limited to take:1, which can return a row for a
          // *different* participant in the same learning group when multiple
          // batched participants are enrolled. We must .find() the entry that
          // matches THIS participant — same pattern as the lastActivity loop above.
          participantData.enrolled_courses = sortedByAllocated.map((e) => {
            const ownEpRecord = e.learningGroup?.eLearningParticipants?.find(
              (ep) => ep.participant_id === participant.id,
            );
            return {
              enrollment_id: e.id,
              learning_group_id: e.learning_group_id,
              course_name: e.learningGroup?.course?.title ?? 'Unknown course',
              stage: e.status,
              progress: e.completion_percentage ? Number(e.completion_percentage) : 0,
              allocated_at: (e.invited_at ?? e.created_at).toISOString(),
              last_active_at: ownEpRecord?.last_activity?.toISOString() ?? null,
            };
          });
        } else {
          participantData.current_stage = undefined;
          participantData.eligible_for_next = false;
          participantData.enrolled_courses = [];
        }
        participantData.has_password = !!participant.password_hash;

        return participantData;
      }),
    );

    // Return paginated response with metadata including status counts
    return new CLParticipantListResponse(data, page, limit, totalCount, activeCount, inactiveCount, replacedCount);
  }

  /**
   * Add a new participant
   * @param data Participant data
   * @param companyId Company ID from authenticated user
   * @param participantIdAddedBy Participant ID of the user adding this participant (from auth token)
   * @returns Added participant data
   * @throws ConflictException if email already exists
   */
  async addParticipant(
    data: AddParticipantDto,
    companyId: number,
    participantIdAddedBy: number,
  ): Promise<CLParticipantDto> {
    // Check if email already exists
    const normalizedEmail = data.email.trim().toLowerCase();
    const existingParticipant = await this.prisma.client.participant.findFirst({
      where: {
        email: normalizedEmail,
        company_id: companyId,
      },
    });

    if (existingParticipant) {
      throw new ConflictException(
        `A contact with the email "${data.email}" already exists. Please use a different email address.`,
      );
    }

    // No license check needed when adding participants (company contacts)
    // License validation only applies when allocating licenses (assigning participants to courses)
    // Participants can be added unlimited - they are just company contacts

    const participant = await this.prisma.client.participant.create({
      data: {
        company_id: companyId,
        first_name: data.first_name.trim(),
        last_name: data.last_name.trim(),
        email: normalizedEmail,
        is_active: true, // New participants start as active
        is_replaced: false,
      },
    });

    // Emit event for participant creation (listeners will update counts)
    this.eventEmitter.emit(
      PARTICIPANT_EVENTS.CREATED,
      new ParticipantCreatedEvent(participant.id, companyId, true, false),
    );

    // Handle department assignment via ParticipantGroupHistory
    // Skip if department is empty, undefined, or "Not Assigned" (treat "Not Assigned" as no department)
    const trimmedDepartment = data.department?.trim();
    if (trimmedDepartment && trimmedDepartment.toLowerCase() !== "not assigned") {
      await this.assignParticipantToGroup(participant.id, trimmedDepartment, companyId, participantIdAddedBy);
    }

    // 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
    // Note: Department is stored in ParticipantGroupHistory, not directly on participant
    // So we need to explicitly add it to the log data
    if (data.department?.trim()) {
      participantDataForLog["department"] = data.department.trim();
    } else {
      // Even if no department was provided, check if one exists from the assignment
      // (in case it was assigned in a previous step)
      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 await this.enrichParticipantData(participant);
  }

  /**
   * Get license availability information for adding participants
   * @param companyId Company ID
   * @returns License availability info including total licenses, current active participants, and available slots
   */
  async getParticipantLicenseAvailability(companyId: number): Promise<{
    totalLicenses: number;
    currentActiveParticipants: number;
    availableSlots: number;
    canAddMore: boolean;
  }> {
    // Get current active subscription
    const subscription = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: "ACTIVE",
      },
      orderBy: {
        created_at: "desc",
      },
    });

    if (!subscription) {
      return {
        totalLicenses: 0,
        currentActiveParticipants: 0,
        availableSlots: 0,
        canAddMore: false,
      };
    }

    // Count current active participants (not replaced)
    const currentActiveCount = await this.prisma.client.participant.count({
      where: {
        company_id: companyId,
        is_active: true,
        is_replaced: false,
      },
    });

    const totalLicenses = subscription.license_count;

    // Handle unlimited licenses (license_count === -1 means no limit)
    if (totalLicenses === -1) {
      return {
        totalLicenses: -1, // -1 indicates unlimited
        currentActiveParticipants: currentActiveCount,
        availableSlots: -1, // -1 indicates unlimited
        canAddMore: true, // Always can add more when unlimited
      };
    }

    const availableSlots = Math.max(0, totalLicenses - currentActiveCount);
    const canAddMore = currentActiveCount < totalLicenses;

    return {
      totalLicenses,
      currentActiveParticipants: currentActiveCount,
      availableSlots,
      canAddMore,
    };
  }

  /**
   * Get a single participant by ID
   * @param id Participant ID
   * @param companyId Company ID to ensure participant belongs to the company
   * @returns Enriched participant data
   */
  async getParticipantById(id: number, companyId: number): Promise<CLParticipantDto | null> {
    const participant = await this.findByIdWithCompanyScope("participant", id, companyId);

    if (!participant) {
      return null;
    }

    return this.enrichParticipantData(participant);
  }

  /**
   * Get the participant administrator (User who created/manages this participant)
   * @param participantId Participant ID (from auth)
   * @param companyId Company ID to ensure participant belongs to the company
   * @returns Admin user information (first_name, last_name, email, phone) or null if not found
   */
  async getParticipantAdmin(
    participantId: number,
    companyId: number,
  ): Promise<{
    id: number;
    first_name: string;
    last_name: string;
    email: string;
    phone: string | null;
  } | null> {
    // Get the participant to find the admin user ID
    const participant = await this.findByIdWithCompanyScope("participant", participantId, companyId);

    if (!participant) {
      return null;
    }

    // Get the user_id_created_by (admin who created/manages this participant)
    const adminUserId = participant["user_id_created_by"] as number | null | undefined;

    if (!adminUserId) {
      // No admin assigned - return null
      return null;
    }

    // Fetch the admin user
    const adminUser = await this.prisma.client.user.findUnique({
      where: { id: adminUserId },
      select: {
        id: true,
        first_name: true,
        last_name: true,
        email: true,
        phone: true,
      },
    });

    return adminUser;
  }

  /**
   * Check if participant has active work (incomplete courses, pending assessments)
   * @param participantId Participant ID
   * @param companyId Company ID to ensure participant belongs to the company
   * @returns Active work information
   */
  async checkParticipantActiveWork(participantId: number, companyId: number): Promise<ParticipantActiveWorkDto> {
    // First verify the participant belongs to the company
    const participant = await this.findByIdWithCompanyScope("participant", participantId, companyId);

    if (!participant) {
      throw new BadRequestException(`Participant with ID ${participantId} not found`);
    }

    // Get all enrollments for this participant
    const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: participantId,
        cancelled: false, // Only non-cancelled enrollments
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
              },
            },
          },
        },
      },
    });

    // Filter incomplete courses (status is not COMPLETED)
    const incompleteCourses = enrollments
      .filter((e) => e.status !== ParticipantLearningProgressStatus.COMPLETED)
      .map((e) => ({
        courseName: e.learningGroup.course.title,
        courseId: e.learningGroup.course.id,
        progress: e.completion_percentage ? Number(e.completion_percentage) : 0,
        status: e.status.toString(),
        enrollmentId: e.id,
      }));

    // Get pending assessments (assessments that haven't been completed)
    const assessmentParticipants = await this.prisma.client.assessmentParticipant.findMany({
      where: {
        participant_id: participantId,
        assessment_completion_date: null, // Not completed
        learning_group_id: {
          not: null, // Only assessments assigned through learning groups
        },
      },
      include: {
        course: {
          select: {
            id: true,
            title: true,
          },
        },
        learningGroup: {
          select: {
            id: true,
          },
        },
      },
    });

    const pendingAssessments = assessmentParticipants.map((ap) => ({
      assessmentType: ap.assessment_type === "PRE_BAT" ? "Pre-BAT" : "Post-BAT",
      courseName: ap.course.title,
      courseId: ap.course.id,
      enrollmentId: ap.learning_group_id!,
    }));

    const activeEnrollments = enrollments.filter(
      (e) => e.status !== ParticipantLearningProgressStatus.COMPLETED,
    ).length;

    const hasActiveWork = incompleteCourses.length > 0 || pendingAssessments.length > 0 || activeEnrollments > 0;

    return this.toDto(
      {
        hasActiveWork,
        incompleteCourses,
        pendingAssessments,
        activeEnrollments,
      },
      ParticipantActiveWorkDto,
    );
  }

  /**
   * Save (update) a participant
   * @param id Participant ID
   * @param companyId Company ID to ensure participant belongs to the company
   * @param data Participant data to save
   * @param participantIdUpdatedBy Participant ID of the user updating this participant (from auth token)
   * @returns Saved participant data
   */
  async saveParticipant(
    id: number,
    companyId: number,
    data: SaveParticipantDto,
    participantIdUpdatedBy: number,
  ): Promise<CLParticipantDto | null> {
    const updateData: Record<string, unknown> = {};

    if (data.first_name !== undefined) {
      updateData["first_name"] = data.first_name.trim();
    }

    if (data.last_name !== undefined) {
      updateData["last_name"] = data.last_name.trim();
    }

    // Email should not be updated via this endpoint (readonly)
    // if (data.email !== undefined) {
    //   updateData["email"] = data.email.trim().toLowerCase();
    // }

    if (data.is_active !== undefined) {
      updateData["is_active"] = data.is_active;
    }

    // First verify the participant belongs to the company
    const existingParticipant = await this.findByIdWithCompanyScope("participant", id, companyId);

    if (!existingParticipant) {
      return null;
    }

    // Track old status for count updates
    const oldIsActive = existingParticipant["is_active"] as boolean;
    const oldIsReplaced = (existingParticipant["is_replaced"] as boolean) || false;

    // Get old data for logging
    const oldParticipant = await this.prisma.client.participant.findUnique({
      where: { id },
    });

    const participant = await this.prisma.client.participant.update({
      where: { id },
      data: updateData,
    });

    if (!participant) {
      return null;
    }

    // Emit event if status changed (listeners will update counts)
    const newIsActive = (participant["is_active"] as boolean) || false;
    const newIsReplaced = (participant["is_replaced"] as boolean) || false;

    if (oldIsActive !== newIsActive || oldIsReplaced !== newIsReplaced) {
      this.eventEmitter.emit(
        PARTICIPANT_EVENTS.STATUS_UPDATED,
        new ParticipantStatusUpdatedEvent(id, companyId, oldIsActive, newIsActive, oldIsReplaced, newIsReplaced),
      );

      // Log deactivation/reactivation history
      if (oldIsActive !== newIsActive) {
        const actionType = newIsActive ? "REACTIVATED" : "DEACTIVATED";
        const oldData = {
          is_active: oldIsActive,
          participant_id: id,
          participant_name: `${oldParticipant?.first_name || ""} ${oldParticipant?.last_name || ""}`.trim(),
          participant_email: oldParticipant?.email || "",
        };
        const newData = {
          is_active: newIsActive,
          participant_id: id,
          participant_name: `${participant.first_name} ${participant.last_name}`,
          participant_email: participant.email,
          action: actionType,
          company_id: companyId,
        };
        await this.systemLogService.logUpdate(
          SystemLogEntityType.PARTICIPANT,
          id,
          oldData,
          newData,
          { is_active: actionType },
          { company_id: companyId },
        );
      }

      // Handle scheduled email pause/resume
      if (oldIsActive !== newIsActive) {
        if (newIsActive === false) {
          // Pause scheduled emails when deactivating
          await this.pauseScheduledEmails(id);
        } else {
          // Resume scheduled emails when reactivating
          const deactivatedAt = oldParticipant?.updated_at ? new Date(oldParticipant.updated_at as Date) : null;
          await this.resumeScheduledEmails(id, deactivatedAt);
        }
      }

      // If participant was reactivated, emit reactivation event and send email
      if (oldIsActive === false && newIsActive === true) {
        // Get active work info for email
        const activeWork = await this.checkParticipantActiveWork(id, companyId);

        // Adjust due dates if participant was deactivated for a period
        // Use updated_at from oldParticipant as proxy for deactivation time
        const deactivatedAt = oldParticipant?.updated_at ? new Date(oldParticipant.updated_at as Date) : null;
        await this.adjustDueDatesOnReactivation(id, deactivatedAt);

        // Verify progress integrity
        const integrityCheck = await this.verifyProgressIntegrity(id);
        if (!integrityCheck.isValid && integrityCheck.issues.length > 0) {
          // Log integrity issues but don't fail reactivation
          console.warn(`Progress integrity issues found for participant ${id}:`, integrityCheck.issues);
        }

        this.eventEmitter.emit(
          PARTICIPANT_EVENTS.REACTIVATED,
          new ParticipantReactivatedEvent(
            id,
            companyId,
            activeWork.incompleteCourses.length,
            activeWork.pendingAssessments.length,
            new Date(),
          ),
        );

        // Send reactivation welcome email
        try {
          const frontendUrl = requireEnv("FRONTEND_URL");

          await this.emailSender.sendTemplatedEmail({
            to: participant.email,
            templateKey: "PARTICIPANT_REACTIVATED",
            variables: {
              "user.name": participant.first_name,
              "user.email": participant.email,
              "user.first_name": participant.first_name,
              "incomplete_courses.count": activeWork.incompleteCourses.length.toString(),
              "pending_assessments.count": activeWork.pendingAssessments.length.toString(),
              "system.loginUrl": `${frontendUrl}/portal/login`,
              "system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
            },
            metadata: {
              participant_id: id,
              company_id: companyId,
              triggeredBy: "participant_reactivation",
              incompleteCourses: activeWork.incompleteCourses.length,
              pendingAssessments: activeWork.pendingAssessments.length,
            },
          });
        } catch (emailError) {
          // Log error but don't fail the request
          console.error("Failed to send reactivation email:", emailError);
        }
      }
    }

    // Get old department for logging
    const oldGroupHistory = await this.prisma.client.participantGroupHistory.findFirst({
      where: { participant_id: id },
      include: { participantGroup: { select: { name: true } } },
    });
    const oldDepartment = oldGroupHistory?.participantGroup?.name || null;

    // Handle department assignment via ParticipantGroupHistory
    if (data.department !== undefined) {
      // Delete existing group assignments (we'll create a new one)
      // Note: In a production system, you might want to soft-delete by setting removed_at
      // For now, we'll delete existing assignments and create a new one
      await this.prisma.client.participantGroupHistory.deleteMany({
        where: {
          participant_id: id,
        },
      });

      // Assign to new group if department is provided
      if (data.department.trim()) {
        await this.assignParticipantToGroup(id, data.department.trim(), companyId, participantIdUpdatedBy);
      }
    }

    // Get new department for logging
    const newGroupHistory = await this.prisma.client.participantGroupHistory.findFirst({
      where: { participant_id: id },
      include: { participantGroup: { select: { name: true } } },
    });
    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> = {
      ...participant,
    };
    if (newDepartment) {
      updatedParticipantData["department"] = newDepartment;
    }

    // Calculate changed fields and log the update
    if (oldParticipant) {
      const changedFields = SystemLogService.calculateChangedFields(oldParticipantData, updatedParticipantData);

      await this.systemLogService.logUpdate(
        SystemLogEntityType.PARTICIPANT,
        id,
        oldParticipantData,
        updatedParticipantData,
        changedFields,
        { company_id: participant.company_id },
      );
    }

    return await this.enrichParticipantData(participant);
  }

  /**
   * 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
   * @throws BadRequestException if the group doesn't exist
   */
  private async assignParticipantToGroup(
    participantId: number,
    groupName: string,
    companyId: number,
    participantIdAddedBy: number,
  ): Promise<void> {
    // Skip if groupName is empty or "Not Assigned" (treat as no department)
    const trimmedGroupName = groupName.trim();
    if (!trimmedGroupName || trimmedGroupName.toLowerCase() === "not assigned") {
      return; // No department assignment needed
    }

    // Find the participant group by name and company
    const participantGroup = await this.prisma.client.participantGroup.findFirst({
      where: {
        name: trimmedGroupName,
        company_id: companyId,
      },
    });

    if (!participantGroup) {
      // Group not found - throw error instead of auto-creating
      // Get available groups for error message
      const availableGroups = await this.prisma.client.participantGroup.findMany({
        where: { company_id: companyId },
        select: { name: true },
        orderBy: { name: "asc" },
      });

      const availableGroupNames = availableGroups.map((g) => g.name);
      const errorMessage =
        availableGroupNames.length > 0
          ? `Invalid department "${trimmedGroupName}". Available departments: ${availableGroupNames.join(", ")}`
          : `Invalid department "${trimmedGroupName}". No departments are available. Leave empty for "Not Assigned".`;

      throw new BadRequestException(errorMessage);
    }

    // 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,
        },
      },
    });
  }

  /**
   * Enrich participant data with additional fields for frontend
   * @param participant Database participant object
   * @returns Enriched participant DTO
   */
  private async enrichParticipantData(participant: Record<string, unknown>): Promise<CLParticipantDto> {
    const participantData = this.toDto(participant, CLParticipantDto);
    const participantId = participant["id"] as number;

    // Load department from ParticipantGroupHistory
    const groupHistory = await this.prisma.client.participantGroupHistory.findFirst({
      where: {
        participant_id: participantId,
      },
      include: {
        participantGroup: {
          select: {
            name: true,
          },
        },
      },
      orderBy: {
        created_at: "desc", // Get the most recent assignment
      },
    });

    // Set department from the group name
    if (groupHistory?.participantGroup) {
      participantData.department = groupHistory.participantGroup.name;
    } else {
      participantData.department = "Not Assigned";
    }

    // Calculate progress metrics (exclude cancelled licenses)
    const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: participantId,
        cancelled: false,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
              },
            },
            eLearningParticipants: {
              where: {
                participant_id: participantId,
              },
              take: 1,
            },
          },
        },
      },
    });

    const totalCourses = enrollments.length;
    const completedCourses = enrollments.filter(
      (e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
    ).length;

    // Calculate progress from current/active course (not average of all courses)
    // Use completion_percentage from LearningGroupParticipant (authoritative source)
    // This is set by event listeners and updated incrementally as modules are completed
    let participantProgress = 0;
    let lastActivity: Date | null = null as Date | null;
    let currentCourse: string | undefined;
    let currentEnrollment: (typeof enrollments)[0] | undefined;

    // Find current course (in progress, or most recent if all completed)
    const inProgressEnrollments = enrollments.filter(
      (e) => e.status !== ParticipantLearningProgressStatus.COMPLETED,
    );

    if (inProgressEnrollments.length > 0) {
      // Get the most recent in-progress course
      const mostRecent = inProgressEnrollments.sort(
        (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
      )[0];
      currentEnrollment = mostRecent;
      currentCourse = mostRecent.learningGroup?.course?.title;
      // Use completion_percentage from the current course
      participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
    } else if (enrollments.length > 0) {
      // All completed, get the most recent completed course
      const mostRecent = enrollments.sort(
        (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
      )[0];
      currentEnrollment = mostRecent;
      currentCourse = mostRecent.learningGroup?.course?.title;
      // Use completion_percentage from the most recent completed course
      participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
    }

    // Get last activity from all enrollments
    enrollments.forEach((enrollment) => {
      const progressData = enrollment.learningGroup?.eLearningParticipants?.[0];
      if (progressData?.last_activity) {
        const activityDate = new Date(progressData.last_activity);
        if (!lastActivity || activityDate > lastActivity) {
          lastActivity = activityDate;
        }
      }
    });

    // Set calculated fields
    participantData.total_courses = totalCourses;
    participantData.courses_completed = completedCourses;
    participantData.progress = participantProgress; // Use current course progress, not average
    participantData.current_course = currentCourse;
    if (lastActivity) {
      participantData.last_active = lastActivity.toISOString();
    } else if (participant["last_login"]) {
      participantData.last_active = new Date(participant["last_login"] as Date).toISOString();
    } else {
      participantData.last_active = "";
    }

    return participantData;
  }

  /**
   * Adjust due dates for courses when participant is reactivated
   * Extends due dates by the duration of deactivation
   */
  private async adjustDueDatesOnReactivation(participantId: number, deactivatedAt: Date | null): Promise<void> {
    if (!deactivatedAt) {
      return; // Can't adjust if we don't know when they were deactivated
    }

    const reactivatedAt = new Date();
    const daysDeactivated = Math.floor(
      (reactivatedAt.getTime() - deactivatedAt.getTime()) / (1000 * 60 * 60 * 24),
    );

    if (daysDeactivated <= 0) {
      return; // No adjustment needed
    }

    // Get all active enrollments with due dates
    const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: participantId,
        cancelled: false,
        status: { not: ParticipantLearningProgressStatus.COMPLETED },
      },
      include: {
        learningGroup: {
          select: {
            id: true,
            due_date: true,
          },
        },
      },
    });

    // Update due dates for learning groups
    for (const enrollment of enrollments) {
      if (enrollment.learningGroup.due_date) {
        const newDueDate = new Date(enrollment.learningGroup.due_date);
        newDueDate.setDate(newDueDate.getDate() + daysDeactivated);

        await this.prisma.client.learningGroup.update({
          where: { id: enrollment.learning_group_id },
          data: { due_date: newDueDate },
        });
      }
    }
  }

  /**
   * Verify progress integrity for a participant
   * Checks for missing or inconsistent progress data
   */
  async verifyProgressIntegrity(participantId: number): Promise<{
    isValid: boolean;
    issues: Array<{
      type: "missing_progress" | "orphaned_data" | "inconsistent_status";
      description: string;
      courseId?: number;
    }>;
  }> {
    const issues: Array<{
      type: "missing_progress" | "orphaned_data" | "inconsistent_status";
      description: string;
      courseId?: number;
    }> = [];

    // Get all enrollments
    const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: participantId,
        cancelled: false,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
              },
            },
          },
        },
      },
    });

    for (const enrollment of enrollments) {
      const courseId = enrollment.learningGroup.course.id;

      // Check for inconsistent status vs completion percentage
      if (
        enrollment.status === ParticipantLearningProgressStatus.COMPLETED &&
        enrollment.completion_percentage &&
        Number(enrollment.completion_percentage) < 100
      ) {
        issues.push({
          type: "inconsistent_status",
          description: `Course "${enrollment.learningGroup.course.title}" marked as COMPLETED but completion percentage is ${enrollment.completion_percentage}%`,
          courseId,
        });
      }

      // Check for missing e-learning progress when status suggests it should exist
      if (
        enrollment.status === ParticipantLearningProgressStatus.E_LEARNING ||
        enrollment.status === ParticipantLearningProgressStatus.POST_BAT ||
        enrollment.status === ParticipantLearningProgressStatus.COMPLETED
      ) {
        const eLearningProgress = await this.prisma.client.eLearningParticipant.findFirst({
          where: {
            participant_id: participantId,
            course_id: courseId,
            learning_group_id: enrollment.learning_group_id,
          },
        });

        if (!eLearningProgress) {
          issues.push({
            type: "missing_progress",
            description: `Course "${enrollment.learningGroup.course.title}" has status ${enrollment.status} but no e-learning progress record found`,
            courseId,
          });
        }
      }

      // Check for orphaned assessment participants
      const assessmentParticipants = await this.prisma.client.assessmentParticipant.findMany({
        where: {
          participant_id: participantId,
          course_id: courseId,
          learning_group_id: enrollment.learning_group_id,
        },
      });

      for (const ap of assessmentParticipants) {
        // Check if assessment participant has results but enrollment doesn't reflect completion
        const hasResults = await this.prisma.client.assessmentResult.findFirst({
          where: {
            assessment_participant_id: ap.id,
          },
        });

        if (hasResults && !ap.assessment_completion_date) {
          issues.push({
            type: "orphaned_data",
            description: `Assessment "${ap.assessment_type}" for course "${enrollment.learningGroup.course.title}" has results but no completion date`,
            courseId,
          });
        }
      }
    }

    return {
      isValid: issues.length === 0,
      issues,
    };
  }

  /**
   * Pause scheduled emails for a deactivated participant
   * Moves scheduled_date far into the future to prevent sending
   */
  private async pauseScheduledEmails(participantId: number): Promise<void> {
    // Find all PENDING scheduled emails for this participant
    const pendingEmails = await this.prisma.client.emailLog.findMany({
      where: {
        participant_id: participantId,
        status: "PENDING",
        scheduled_date: {
          gte: new Date(), // Only pause future emails
        },
      },
    });

    if (pendingEmails.length === 0) {
      return;
    }

    // Move scheduled_date far into the future (1 year from now) to effectively pause them
    const farFutureDate = new Date();
    farFutureDate.setFullYear(farFutureDate.getFullYear() + 1);

    await this.prisma.client.emailLog.updateMany({
      where: {
        participant_id: participantId,
        status: "PENDING",
        scheduled_date: {
          gte: new Date(),
        },
      },
      data: {
        scheduled_date: farFutureDate,
      },
    });
  }

  /**
   * Participant administrators may remove a company contact only when no active course
   * allocation exists (same rule as directory `total_courses`: non-cancelled enrollments).
   */
  async deleteParticipantForParticipantAdmin(
    targetParticipantId: number,
    companyId: number,
    performedByParticipantId: number,
    performedByRole: string,
  ): Promise<void> {
    if (performedByRole !== ParticipantRole.PARTICIPANT_ADMIN) {
      throw new ForbiddenException("Only participant administrators can delete contacts.");
    }

    if (targetParticipantId === performedByParticipantId) {
      throw new BadRequestException("You cannot delete your own account from the directory.");
    }

    const participant = await this.prisma.client.participant.findFirst({
      where: { id: targetParticipantId, company_id: companyId },
    });

    if (!participant) {
      throw new NotFoundException(`Participant with ID ${targetParticipantId} not found`);
    }

    const activeEnrollmentCount = await this.prisma.client.learningGroupParticipant.count({
      where: {
        participant_id: targetParticipantId,
        cancelled: false,
      },
    });

    if (activeEnrollmentCount > 0) {
      throw new UnprocessableEntityException(
        "This contact has an active course allocation. Remove the license allocation before deleting this contact.",
      );
    }

    await this.systemLogService.logDelete(
      SystemLogEntityType.PARTICIPANT,
      targetParticipantId,
      participant as unknown as Record<string, unknown>,
      { company_id: participant.company_id },
    );

    await this.prisma.client.$transaction(async (tx) => {
      await tx.systemLog.updateMany({
        where: { participant_id: targetParticipantId },
        data: { participant_id: null },
      });

      await tx.participant.updateMany({
        where: { replaced_by_participant_id: targetParticipantId },
        data: { replaced_by_participant_id: null, replaced_at: null },
      });

      await tx.participantGroupHistory.updateMany({
        where: { participant_id_added_by: targetParticipantId },
        data: { participant_id_added_by: performedByParticipantId },
      });

      await tx.participantGroupHistory.deleteMany({
        where: { participant_id: targetParticipantId },
      });

      await tx.participantGroup.updateMany({
        where: { participant_id_created_by: targetParticipantId },
        data: { participant_id_created_by: performedByParticipantId },
      });

      await tx.learningGroup.updateMany({
        where: { participant_id_created_by: targetParticipantId },
        data: { participant_id_created_by: performedByParticipantId },
      });

      await tx.learningGroupParticipant.updateMany({
        where: { participant_id_invited_by: targetParticipantId },
        data: { participant_id_invited_by: performedByParticipantId },
      });

      await tx.reportLog.deleteMany({
        where: { participant_id: targetParticipantId },
      });

      await tx.emailLog.updateMany({
        where: { participant_id: targetParticipantId },
        data: { participant_id: null },
      });

      await tx.media.updateMany({
        where: { participant_id: targetParticipantId },
        data: { participant_id: null },
      });

      await tx.emailVerificationToken.deleteMany({
        where: { participant_id: targetParticipantId },
      });

      await tx.passwordSetupToken.deleteMany({
        where: { participant_id: targetParticipantId },
      });

      await tx.participant.delete({
        where: { id: targetParticipantId },
      });
    });

    this.eventEmitter.emit(
      PARTICIPANT_EVENTS.DELETED,
      new ParticipantDeletedEvent(targetParticipantId, companyId, participant.is_active, participant.is_replaced),
    );
  }

  /**
   * Resume scheduled emails for a reactivated participant
   * Adjusts scheduled_date by the deactivation period
   */
  private async resumeScheduledEmails(participantId: number, deactivatedAt: Date | null): Promise<void> {
    if (!deactivatedAt) {
      return; // Can't adjust if we don't know when they were deactivated
    }

    const reactivatedAt = new Date();
    const daysDeactivated = Math.floor(
      (reactivatedAt.getTime() - deactivatedAt.getTime()) / (1000 * 60 * 60 * 24),
    );

    if (daysDeactivated <= 0) {
      return; // No adjustment needed
    }

    // Find all PENDING emails that were paused (scheduled_date far in future)
    // We'll identify paused emails as those scheduled more than 6 months from now
    const sixMonthsFromNow = new Date();
    sixMonthsFromNow.setMonth(sixMonthsFromNow.getMonth() + 6);

    const pausedEmails = await this.prisma.client.emailLog.findMany({
      where: {
        participant_id: participantId,
        status: "PENDING",
        scheduled_date: {
          gte: sixMonthsFromNow, // Likely paused emails
        },
      },
    });

    if (pausedEmails.length === 0) {
      return;
    }

    // Adjust scheduled dates by adding the deactivation period
    for (const email of pausedEmails) {
      // Skip if scheduled_date is null
      if (!email.scheduled_date) {
        continue;
      }

      const newScheduledDate = new Date(email.scheduled_date);
      newScheduledDate.setDate(newScheduledDate.getDate() + daysDeactivated);

      await this.prisma.client.emailLog.update({
        where: { id: email.id },
        data: { scheduled_date: newScheduledDate },
      });
    }
  }
}

results matching ""

    No results matching ""