File

apps/recallassess/recallassess-api/src/api/client/learning-group/learning-group.service.ts

Extends

CLBaseService

Index

Properties
Methods

Constructor

constructor(emailSender: BNestEmailSenderService, systemLogService: SystemLogService, eventEmitter: EventEmitter2, integrationService: IntegrationService, configService: ConfigService, mediaUtilService: BNestMediaUtilService, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
Parameters :
Name Type Optional
emailSender BNestEmailSenderService No
systemLogService SystemLogService No
eventEmitter EventEmitter2 No
integrationService IntegrationService No
configService ConfigService No
mediaUtilService BNestMediaUtilService No
subscriptionCourseAccess ParticipantSubscriptionCourseAccessService No

Methods

Async acceptCourseInvitation
acceptCourseInvitation(participantId: number, learningGroupParticipantId: number)

Accept course invitation

Parameters :
Name Type Optional Description
participantId number No
  • Current participant ID
learningGroupParticipantId number No
  • LearningGroupParticipant ID
Returns : Promise<literal type>

Success message

Private Async assertParticipantPassesEnrollmentCooling
assertParticipantPassesEnrollmentCooling(companyId: number, participantId: number, targetCourseId: number)

Blocks allocating a participant to targetCourseId until coolingPeriodDays have passed since their most recent non-cancelled, in-flight enrollment (not E_LEARNING/COMPLETED) in any other course under the same company. Matches getParticipantsInCoolingPeriod logic.

Parameters :
Name Type Optional
companyId number No
participantId number No
targetCourseId number No
Returns : Promise<void>
Async cancelParticipantLicense
cancelParticipantLicense(learningGroupParticipantId: number, companyId: number, cancelledByParticipantId: number, reason: string, notes?: string)

Cancel a participant license allocation IMPORTANT: This immediately revokes access to the course. Once cancelled:

  • Participant cannot start the course (startCourse endpoint checks cancelled field)
  • Participant cannot access any pages (validatePageAccess, getAllCoursePages check cancelled field)
  • Participant cannot see the course in their course list (getMyCoursesSequential filters out cancelled)
  • All course access endpoints return ForbiddenException for cancelled enrollments

The cancelled flag is set to true and status is preserved - participant can resume at the same level later. The update is atomic and takes effect immediately - participant loses access on their next API call.

Parameters :
Name Type Optional Description
learningGroupParticipantId number No
  • LearningGroupParticipant ID
companyId number No
  • Company ID
cancelledByParticipantId number No
  • Participant ID of admin who is cancelling
reason string No
  • Cancellation reason
notes string Yes
  • Optional cancellation notes
Returns : Promise<literal type>

Success message

Async checkCancellationEligibility
checkCancellationEligibility(learningGroupParticipantId: number, companyId: number)

Check cancellation eligibility and return progress information

Parameters :
Name Type Optional Description
learningGroupParticipantId number No
  • LearningGroupParticipant ID
companyId number No
  • Company ID for authorization
Returns : Promise<literal type>

Cancellation eligibility info with progress details

Async createLearningGroup
createLearningGroup(companyId: number, participantIdCreatedBy: number, data: AddLearningGroupDto)

Create a new learning group (license allocation) with participants

Parameters :
Name Type Optional Description
companyId number No
  • Company ID from authenticated user
participantIdCreatedBy number No
  • Participant ID of the user creating this learning group
data AddLearningGroupDto No
  • Learning group data including course, team, due date, and participant IDs

Created learning group data

Private generatePasswordSetupToken
generatePasswordSetupToken()

Generate a secure password setup token

Returns : string
Async generatePostBatHtmlForParticipant
generatePostBatHtmlForParticipant(companyId: number, learningGroupId: number, learningGroupParticipantId: number)

Generate post-BAT HTML for a participant in a learning group (company-scoped).

Parameters :
Name Type Optional
companyId number No
learningGroupId number No
learningGroupParticipantId number No
Returns : Promise<string>
Async generatePreBatHtmlForParticipant
generatePreBatHtmlForParticipant(companyId: number, learningGroupId: number, learningGroupParticipantId: number)

Generate pre-BAT HTML for a participant in a learning group (company-scoped, for allocation details view).

Parameters :
Name Type Optional
companyId number No
learningGroupId number No
learningGroupParticipantId number No
Returns : Promise<string>
Async getAllAllocatedParticipants
getAllAllocatedParticipants(companyId: number)

Get all participant-course allocations (non-cancelled only)

Parameters :
Name Type Optional Description
companyId number No
  • Company ID
Returns : Promise<literal type>

Array of participant-course pairs with completion status (one entry per allocation)

Async getAllocatedParticipantsForCourse
getAllocatedParticipantsForCourse(companyId: number, courseId: number)

Get participant IDs that are already allocated to a specific course

Parameters :
Name Type Optional Description
companyId number No
  • Company ID
courseId number No
  • Course ID
Returns : Promise<literal type>

Array of participant IDs with completion status for the specific course

Async getCourseLicenseUtilization
getCourseLicenseUtilization(companyId: number, filters?: literal type)

Get course license utilization report Returns utilization data for all courses in the company

Parameters :
Name Type Optional
companyId number No
filters literal type Yes
Private Async getEnrollmentForParticipantReport
getEnrollmentForParticipantReport(companyId: number, learningGroupId: number, learningGroupParticipantId: number)

Resolve enrollment for a participant in a learning group and verify company access. Used by pre/post BAT report endpoints for license allocation details view.

Parameters :
Name Type Optional
companyId number No
learningGroupId number No
learningGroupParticipantId number No
Returns : Promise<literal type>
Async getFilteredLearningGroups
getFilteredLearningGroups(companyId: number, page: number, limit: number, sq?: string, status?: string)

Get filtered learning groups (license allocations) for client consumption with pagination

Parameters :
Name Type Optional Default value Description
companyId number No
  • Company ID to filter learning groups 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 course name, group name, or description
status string Yes
  • Filter by status (all, active, completed, pending)

Paginated list response with learning groups

Async getLearningGroupDetail
getLearningGroupDetail(companyId: number, learningGroupId: number)

Get a single learning group (license allocation) with participants

Parameters :
Name Type Optional Description
companyId number No
  • Company ID from authenticated user
learningGroupId number No
  • Learning group ID
Returns : Promise<unknown>

Learning group detail with participants

Async getLicenseInfo
getLicenseInfo(companyId: number)

Get license information for the company

Parameters :
Name Type Optional Description
companyId number No
  • Company ID
Returns : Promise<literal type>

License information including total, available, consumed, allocated, and inTraining counts

Async getParticipantsInCoolingPeriod
getParticipantsInCoolingPeriod(companyId: number, excludeCourseId: number)

Get participants currently in the enrollment cooling period for a given course. Used by allocate-license dialog to disable those participants and show tooltip.

Parameters :
Name Type Optional
companyId number No
excludeCourseId number No
Returns : Promise<literal type>
Async getPostBatPdfUrlForParticipant
getPostBatPdfUrlForParticipant(companyId: number, learningGroupId: number, learningGroupParticipantId: number)

Get post-BAT PDF download URL for a participant in a learning group (company-scoped).

Parameters :
Name Type Optional
companyId number No
learningGroupId number No
learningGroupParticipantId number No
Returns : Promise<string>
Async getPreBatPdfUrlForParticipant
getPreBatPdfUrlForParticipant(companyId: number, learningGroupId: number, learningGroupParticipantId: number)

Get pre-BAT PDF download URL for a participant in a learning group (company-scoped).

Parameters :
Name Type Optional
companyId number No
learningGroupId number No
learningGroupParticipantId number No
Returns : Promise<string>
Private getSafeEmailErrorMessage
getSafeEmailErrorMessage(error: unknown)

Build a user-safe error message for email send failures (no credentials or internals).

Parameters :
Name Type Optional
error unknown No
Returns : string
Async getSubscriptionBillingInfo
getSubscriptionBillingInfo(companyId: number)

Get subscription billing information for the company

Parameters :
Name Type Optional Description
companyId number No
  • Company ID
Returns : Promise<literal type>

Subscription billing info including monthly total and next renewal date

Private normalizeProgressPercent
normalizeProgressPercent(value: unknown)

Normalize progress to 0-100 percentage. Some records store progress as fraction (0-1), others as percentage (0-100).

Parameters :
Name Type Optional
value unknown No
Returns : number
Async recalculateSubscriptionLicenseCounts
recalculateSubscriptionLicenseCounts(companyId: number, options?: literal type)

Recalculate and persist subscription license counts (allocated, consumed, available). Call this whenever participant allocation or progress status changes so admin subscription view stays correct. Consumed = allocated and not yet completed (INVITED, ACCEPTED, PRE_BAT, POST_BAT).

Parameters :
Name Type Optional Description
companyId number No
  • Company ID
options literal type Yes
Returns : Promise<void>
Async resendInvitation
resendInvitation(companyId: number, learningGroupId: number, participantId: number, participantIdInvitedBy: number)

Resend invitation to a specific participant

Parameters :
Name Type Optional Description
companyId number No
  • Company ID to verify ownership
learningGroupId number No
  • Learning group ID
participantId number No
  • Participant ID to resend invitation to
participantIdInvitedBy number No
  • Participant ID of the user resending invitation
Returns : Promise<literal type>

Success message

Async resumeParticipantLicense
resumeParticipantLicense(learningGroupParticipantId: number, companyId: number, resumedByParticipantId: number)

Resume a cancelled participant license allocation IMPORTANT: This restores access to the course. The participant resumes at the same level/status they were at before cancellation.

Parameters :
Name Type Optional Description
learningGroupParticipantId number No
  • LearningGroupParticipant ID
companyId number No
  • Company ID
resumedByParticipantId number No
  • Participant ID of admin who is resuming
Returns : Promise<literal type>

Success message with restored status

Private Async sendInvitationEmail
sendInvitationEmail(data: literal type)

Send invitation email to a participant

Parameters :
Name Type Optional
data literal type No
Returns : Promise<void>
Async sendInvitationsToAll
sendInvitationsToAll(companyId: number, learningGroupId: number, participantIdInvitedBy: number)

Send invitations to all participants in a learning group

Parameters :
Name Type Optional Description
companyId number No
  • Company ID to verify ownership
learningGroupId number No
  • Learning group ID
participantIdInvitedBy number No
  • Participant ID of the user sending invitations
Returns : Promise<literal type>

Success message

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

Private Static Readonly coolingEnrollmentStatusWhere
Type : object
Default value : { notIn: [ParticipantLearningProgressStatus.E_LEARNING, ParticipantLearningProgressStatus.COMPLETED], }

Cooling applies to in-flight allocations only — same idea as license counts that exclude E_LEARNING/COMPLETED (license released). Finished journeys do not block new course allocation.

Private Readonly logger
Type : unknown
Default value : new Logger(CLLearningGroupService.name)
Protected Readonly prisma
Type : BNestPrismaService
Decorators :
@Inject()
Inherited from CLBaseService
Defined in CLBaseService:12
import { ParticipantSubscriptionCourseAccessService } from "@api/client/shared/participant-subscription-course-access.service";
import { IntegrationService } from "@api/integration/integration.service";
import { CLBaseService, SystemLogService } from "@api/shared/services";
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";
import { BNestMediaUtilService } from "@bish-nest/core/services/media-util.service";
import {
  BadRequestException,
  ForbiddenException,
  Injectable,
  InternalServerErrorException,
  Logger,
  NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { ParticipantLearningProgressStatus, SystemLogEntityType } from "@prisma/client";
import * as crypto from "crypto";
import { ENROLLMENT_COOLING_CONFIG } from "../../../config/enrollment-cooling.config";
import {
  AddLearningGroupDto,
  CLLearningGroupDto,
  CLLearningGroupListResponse,
  CourseLicenseUtilizationRowDto,
} from "./dto";
import {
  LICENSE_ALLOCATION_EVENTS,
  LicenseAllocatedEvent,
  LicenseReleasedEvent,
} from "./events/license-allocation.events";

@Injectable()
export class CLLearningGroupService extends CLBaseService {
  private readonly logger = new Logger(CLLearningGroupService.name);

  /**
   * Normalize progress to 0-100 percentage.
   * Some records store progress as fraction (0-1), others as percentage (0-100).
   */
  private normalizeProgressPercent(value: unknown): number {
    if (value == null) return 0;
    const n = Number(value);
    if (Number.isNaN(n)) return 0;
    const percentage = n > 0 && n <= 1 ? n * 100 : n;
    return Math.min(100, Math.max(0, percentage));
  }

  /**
   * Cooling applies to in-flight allocations only — same idea as license counts that exclude
   * E_LEARNING/COMPLETED (license released). Finished journeys do not block new course allocation.
   */
  private static readonly coolingEnrollmentStatusWhere = {
    notIn: [ParticipantLearningProgressStatus.E_LEARNING, ParticipantLearningProgressStatus.COMPLETED],
  };

  constructor(
    private readonly emailSender: BNestEmailSenderService,
    private readonly systemLogService: SystemLogService,
    private readonly eventEmitter: EventEmitter2,
    private readonly integrationService: IntegrationService,
    private readonly configService: ConfigService,
    private readonly mediaUtilService: BNestMediaUtilService,
    private readonly subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService,
  ) {
    super();
  }
  /**
   * Get filtered learning groups (license allocations) for client consumption with pagination
   * @param companyId - Company ID to filter learning groups by (required)
   * @param page - Page number (1-based)
   * @param limit - Number of items per page
   * @param sq - Search query to filter by course name, group name, or description
   * @param status - Filter by status (all, active, completed, pending)
   * @returns Paginated list response with learning groups
   */
  async getFilteredLearningGroups(
    companyId: number,
    page = 1,
    limit = 20,
    sq?: string,
    status?: string,
  ): Promise<CLLearningGroupListResponse> {
    // Build base where clause
    const baseWhere: Record<string, unknown> = {
      company_id: companyId,
    };

    // Status filter - map frontend status to database status enum
    if (status && status !== "all") {
      if (status === "completed") {
        (baseWhere as Record<string, unknown> & { status?: string })["status"] = "COMPLETED";
      } else if (status === "active") {
        (baseWhere as Record<string, unknown> & { status?: string })["status"] = "ACTIVE";
      } else if (status === "pending") {
        (baseWhere as Record<string, unknown> & { status?: string })["status"] = "PENDING";
      } else if (status === "cancelled") {
        // Cancelled includes: DB status CANCELLED OR groups where all participants are cancelled (stored as PENDING/ACTIVE)
        (baseWhere as Record<string, unknown>)["OR"] = [
          { status: "CANCELLED" },
          {
            status: { in: ["PENDING", "ACTIVE"] },
            learningGroupParticipants: {
              some: {},
              every: { cancelled: true },
            },
          },
        ];
      }
    }

    // Build final where clause
    let where: Record<string, unknown>;

    // Search query filter (name, description, or course title)
    if (sq?.trim()) {
      const searchConditions: Array<Record<string, unknown>> = [
        { name: { contains: sq.trim(), mode: "insensitive" } },
        { description: { contains: sq.trim(), mode: "insensitive" } },
        {
          course: {
            title: { contains: sq.trim(), mode: "insensitive" },
          },
        },
      ];

      // Wrap base conditions and search in AND
      where = {
        AND: [
          baseWhere,
          {
            OR: searchConditions,
          },
        ],
      };
    } else {
      // No search query, use base where directly
      where = baseWhere;
    }

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

    // Get status counts for all learning groups (not filtered by search/status)
    const allLearningGroupsWhere: Record<string, unknown> = {
      company_id: companyId,
    };

    // Get counts for each status
    // Note: Active count will be adjusted later to exclude groups where all participants are cancelled
    const activeCountFromStatus = await this.prisma.client.learningGroup.count({
      where: {
        company_id: companyId,
        status: "ACTIVE",
      },
    });

    const completedCount = await this.prisma.client.learningGroup.count({
      where: {
        company_id: companyId,
        status: "COMPLETED",
      },
    });

    const pendingCount = await this.prisma.client.learningGroup.count({
      where: {
        company_id: companyId,
        status: "PENDING",
      },
    });

    // Count cancelled: includes both database status CANCELLED and groups where all participants are cancelled
    const cancelledCountFromStatus = await this.prisma.client.learningGroup.count({
      where: {
        company_id: companyId,
        status: "CANCELLED",
      },
    });

    // Find learning groups where all participants are cancelled (but status might not be CANCELLED)
    const learningGroupsWithParticipants = await this.prisma.client.learningGroup.findMany({
      where: {
        company_id: companyId,
        status: { not: "CANCELLED" }, // Only check non-cancelled status groups
      },
      include: {
        learningGroupParticipants: {
          select: {
            cancelled: true,
          },
        },
      },
    });

    // Count groups where all participants are cancelled
    const allParticipantsCancelledCount = learningGroupsWithParticipants.filter((lg) => {
      return lg.learningGroupParticipants.length > 0 && lg.learningGroupParticipants.every((p) => p.cancelled);
    }).length;

    const cancelledCount = cancelledCountFromStatus + allParticipantsCancelledCount;

    // Adjust active count: exclude groups where all participants are cancelled
    const activeGroupsWithAllCancelled = learningGroupsWithParticipants.filter((lg) => {
      return (
        lg.status === "ACTIVE" &&
        lg.learningGroupParticipants.length > 0 &&
        lg.learningGroupParticipants.every((p) => p.cancelled)
      );
    }).length;
    const activeCount = activeCountFromStatus - activeGroupsWithAllCancelled;

    // Adjust pending count: exclude groups where all participants are cancelled (they display as Cancelled)
    const pendingGroupsWithAllCancelled = learningGroupsWithParticipants.filter((lg) => {
      return (
        lg.status === "PENDING" &&
        lg.learningGroupParticipants.length > 0 &&
        lg.learningGroupParticipants.every((p) => p.cancelled)
      );
    }).length;
    const adjustedPendingCount = pendingCount - pendingGroupsWithAllCancelled;

    // Get total participants count across all learning groups for this company
    const totalParticipantsResult = await this.prisma.client.learningGroup.aggregate({
      where: allLearningGroupsWhere,
      _sum: {
        total_participants: true,
      },
    });
    const totalParticipantsCount = totalParticipantsResult._sum.total_participants || 0;

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

    // Fetch learning groups from database with related course and participants
    const learningGroups = await this.prisma.client.learningGroup.findMany({
      where,
      include: {
        course: {
          select: {
            id: true,
            title: true,
          },
        },
        participantGroup: {
          select: {
            id: true,
            name: true,
          },
        },
        learningGroupParticipants: {
          select: {
            completion_percentage: true,
            cancelled: true,
            participant: {
              select: {
                first_name: true,
                last_name: true,
                email: true,
              },
            },
          },
        },
      },
      orderBy: {
        created_at: "desc",
      },
      skip,
      take: limit,
    });

    // Fetch participant groups separately for learning groups that have participant_group_id
    // (This is now redundant since we include participantGroup above, but keeping for backward compatibility)
    const participantGroupIds = learningGroups
      .map((lg) => lg.participant_group_id)
      .filter((id): id is number => id !== null);

    const participantGroupsMap = new Map<number, { id: number; name: string }>();
    if (participantGroupIds.length > 0) {
      const participantGroups = await this.prisma.client.participantGroup.findMany({
        where: {
          id: { in: participantGroupIds },
        },
        select: {
          id: true,
          name: true,
        },
      });
      for (const pg of participantGroups) {
        participantGroupsMap.set(pg.id, pg);
      }
    }

    // Transform and enrich data
    const enrichedData = learningGroups.map((lg) => {
      const learningGroupWithFields = lg as typeof lg & {
        course: { id: number; title: string };
        due_date: Date | null;
        participant_group_id: number | null;
        status: string;
        participantGroup?: { id: number; name: string } | null;
        learningGroupParticipants: Array<{
          completion_percentage: unknown;
          cancelled: boolean;
          participant: { first_name: string; last_name: string; email: string };
        }>;
      };
      const participantGroupId = learningGroupWithFields.participant_group_id;
      // Use participantGroup from include if available, otherwise fallback to map lookup
      const participantGroup =
        learningGroupWithFields.participantGroup ||
        (participantGroupId ? participantGroupsMap.get(participantGroupId) : null);

      // Calculate completion percentage from participants' actual progress
      // This ensures accuracy even if the stored value is stale
      let calculatedCompletionPercentage: number | null = null;
      if (learningGroupWithFields.learningGroupParticipants.length > 0) {
        const totalProgress = learningGroupWithFields.learningGroupParticipants.reduce((sum, p) => {
          const progress = this.normalizeProgressPercent(p.completion_percentage);
          return sum + progress;
        }, 0);
        calculatedCompletionPercentage = Math.round(
          totalProgress / learningGroupWithFields.learningGroupParticipants.length,
        );
      }

      // Use calculated value if available, otherwise fall back to stored value
      const finalCompletionPercentage =
        calculatedCompletionPercentage !== null
          ? calculatedCompletionPercentage
          : this.normalizeProgressPercent(learningGroupWithFields.completion_percentage);

      // Check if all participants are cancelled - if so, override status to CANCELLED
      let finalStatus = learningGroupWithFields.status;
      if (
        learningGroupWithFields.learningGroupParticipants.length > 0 &&
        learningGroupWithFields.learningGroupParticipants.every((p) => p.cancelled)
      ) {
        finalStatus = "CANCELLED";
      }

      // Full initials and tooltip data (full name, email) per participant for License Allocations UI
      const participantSummaries =
        learningGroupWithFields.learningGroupParticipants?.map((p) => {
          const first = (p.participant?.first_name ?? "").trim();
          const last = (p.participant?.last_name ?? "").trim();
          const initials = ((first.charAt(0) || "") + (last.charAt(0) || "")).toUpperCase() || "?";
          const fullName = [first, last].filter(Boolean).join(" ") || "—";
          const email = p.participant?.email ?? "";
          return { initials, fullName, email };
        }) ?? [];
      const participantInitials = participantSummaries.map((s) => s.initials);

      return {
        ...learningGroupWithFields,
        completion_percentage: finalCompletionPercentage, // Set this so DTO transform uses calculated value
        courseId: learningGroupWithFields.course_id,
        courseName: learningGroupWithFields.course.title,
        expectedCompletionDate: learningGroupWithFields.due_date,
        participantGroupId,
        participantGroupName: participantGroup?.name || null,
        participantInitials,
        participantSummaries,
        status: finalStatus, // Use calculated status (CANCELLED if all participants cancelled)
        startDate: learningGroupWithFields.start_date,
        completionPercentage: finalCompletionPercentage, // Also set explicitly for direct access
      };
    });

    // Transform to DTOs
    const data = this.toDtoArray(enrichedData, CLLearningGroupDto);

    return new CLLearningGroupListResponse(
      data,
      page,
      limit,
      totalCount,
      activeCount,
      completedCount,
      adjustedPendingCount,
      cancelledCount,
      totalParticipantsCount,
    );
  }

  /**
   * Create a new learning group (license allocation) with participants
   * @param companyId - Company ID from authenticated user
   * @param participantIdCreatedBy - Participant ID of the user creating this learning group
   * @param data - Learning group data including course, team, due date, and participant IDs
   * @returns Created learning group data
   */
  async createLearningGroup(
    companyId: number,
    participantIdCreatedBy: number,
    data: AddLearningGroupDto,
  ): Promise<CLLearningGroupDto> {
    // Validate that at least one participant is selected
    if (!data.participants || data.participants.length === 0) {
      throw new BadRequestException("At least one participant must be selected to create a learning group");
    }

    // Verify course exists and is published
    const course = await this.prisma.client.course.findFirst({
      where: {
        id: data.courseId,
        is_published: true,
      },
    });

    if (!course) {
      throw new BadRequestException(`Course with ID ${data.courseId} not found or not published`);
    }

    // Validate license availability before allocating licenses
    const subscription = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: "ACTIVE",
      },
      orderBy: {
        created_at: "desc",
      },
      include: {
        package: {
          select: {
            id: true,
            package_type: true,
          },
        },
      },
    });

    if (!subscription) {
      throw new BadRequestException(
        "No active subscription found. Please subscribe to a plan before allocating licenses.",
      );
    }

    // VIP package allocation now permits all courses

    // Verify all participants belong to the same company
    const participantCount = await this.prisma.client.participant.count({
      where: {
        id: { in: data.participants },
        company_id: companyId,
      },
    });

    if (participantCount !== data.participants.length) {
      throw new BadRequestException("One or more participants not found or do not belong to your company");
    }

    // Count currently allocated licenses (excluding completed participants)
    // Completed participants (E_LEARNING or COMPLETED status) have their licenses released
    const currentlyAllocated = await this.prisma.client.learningGroupParticipant.count({
      where: {
        learningGroup: {
          company_id: companyId,
        },
        cancelled: false, // Exclude cancelled participants - their licenses are released
        status: {
          notIn: [ParticipantLearningProgressStatus.E_LEARNING, ParticipantLearningProgressStatus.COMPLETED], // Exclude completed participants
        },
      },
    });

    // Handle unlimited licenses (license_count === -1 means no limit)
    if (subscription.license_count === -1) {
      // Skip license validation for unlimited licenses
    } else {
      // Calculate available licenses
      const availableLicenses = subscription.license_count - currentlyAllocated;

      // Additional validation: Ensure we're not trying to allocate when no licenses are available
      if (availableLicenses <= 0 && data.participants.length > 0) {
        throw new BadRequestException(
          `No available licenses. You have ${subscription.license_count} total purchased licenses, and all ${currentlyAllocated} are currently allocated. Please wait for participants to complete their courses or upgrade your subscription.`,
        );
      }

      // Validate: Cannot allocate more licenses than purchased
      // Check if adding new participants would exceed license limit
      const newAllocationCount = currentlyAllocated + data.participants.length;
      if (newAllocationCount > subscription.license_count) {
        throw new BadRequestException(
          `Cannot allocate licenses to ${data.participants.length} participant(s). You have ${subscription.license_count} total purchased licenses, ${currentlyAllocated} already allocated, and only ${availableLicenses} available. Please upgrade your subscription to allocate more licenses.`,
        );
      }
    }

    // Get participant group ID if team name is provided
    let participantGroupId: number | null = null;
    if (data.team?.trim()) {
      const participantGroup = await this.prisma.client.participantGroup.findFirst({
        where: {
          name: data.team.trim(),
          company_id: companyId,
        },
      });
      participantGroupId = participantGroup?.id || null;
    }

    // Generate learning group name: {Course title} - [team name] - {today's date}
    const today = new Date().toLocaleDateString();
    const groupName = data.team?.trim()
      ? `${course.title} - ${data.team.trim()} - ${today}`
      : `${course.title} - ${today}`;

    // Determine initial status based on start date
    // Will be updated to ACTIVE when participants start

    // Build description with team
    const descriptionParts = [];
    if (data.team) {
      descriptionParts.push(`Team: ${data.team}`);
    }
    const description = descriptionParts.length > 0 ? descriptionParts.join(" | ") : null;

    // Get module assignment rule
    // For trial packages, default to RED_AMBER_ONLY (restrict to only modules where participant scored Red/Amber in PRE-BAT)
    // This limits trial users to only a few modules instead of the whole training
    let moduleRule = data.moduleAssignmentRule;
    if (!moduleRule) {
      // Use the package already fetched above (subscription.package)
      const packageObj = subscription.package
        ? await this.prisma.client.package.findUnique({
            where: { id: subscription.package.id },
          })
        : null;
      if (packageObj?.is_trial_package) {
        // For trial users, restrict to RED_AMBER_ONLY (only modules where they need improvement)
        moduleRule = "RED_AMBER_ONLY";
      } else {
        // For paid plans, default to ALL_MODULES
        moduleRule = "ALL_MODULES";
      }
    }

    // Create learning group with new fields
    const learningGroup = await this.prisma.client.learningGroup.create({
      data: {
        company_id: companyId,
        course_id: data.courseId,
        participant_id_created_by: participantIdCreatedBy,
        participant_group_id: participantGroupId,
        name: groupName,
        description: description,
        start_date: new Date(),
        due_date: data.dueDate || null,
        status: "PENDING",
        total_participants: data.participants.length,
        participants_completed: 0,
        is_completed: false,
        module_assignment_rule: moduleRule,
      },
    });

    // Create learning group participants (license allocations)
    // Handle re-allocation: If participant was previously cancelled for this course, update instead of create
    // IMPORTANT: Due to unique constraint [participant_id, course_id], we must check for ANY existing record
    await Promise.all(
      data.participants.map(async (participantId) => {
        // Check if participant has ANY existing enrollment for this course (including cancelled)
        // The unique constraint prevents duplicates, so we must update existing records
        const existingEnrollment = await this.prisma.client.learningGroupParticipant.findFirst({
          where: {
            participant_id: participantId,
            course_id: learningGroup.course_id,
          },
        });

        if (existingEnrollment) {
          // If cancelled, throw error (cannot re-allocate cancelled participants)
          if (existingEnrollment.cancelled) {
            throw new BadRequestException(
              `Participant was previously allocated to this course but the license was cancelled. Cannot re-allocate cancelled participants.`,
            );
          }

          // If not cancelled, throw error (can't re-allocate active participants)
          throw new BadRequestException(
            `Participant is already allocated to this course with status "${existingEnrollment.status}". Cannot re-allocate active participants.`,
          );
        } else {
          await this.assertParticipantPassesEnrollmentCooling(companyId, participantId, learningGroup.course_id);

          // Create new enrollment
          // Use upsert to handle race conditions where record might be created between check and create
          try {
            await this.prisma.client.learningGroupParticipant.create({
              data: {
                learning_group_id: learningGroup.id,
                course_id: learningGroup.course_id,
                participant_id: participantId,
                participant_id_invited_by: participantIdCreatedBy,
                invited_at: new Date(),
                status: "INVITED",
                completion_percentage: null,
              },
            });
          } catch (error) {
            // If unique constraint violation, check if it's a cancelled record we can re-allocate
            if (error instanceof Error && error.message.includes("Unique constraint")) {
              const existingRecord = await this.prisma.client.learningGroupParticipant.findFirst({
                where: {
                  participant_id: participantId,
                  course_id: learningGroup.course_id,
                },
              });

              if (existingRecord?.cancelled) {
                // Cannot re-allocate cancelled participants
                throw new BadRequestException(
                  `Participant was previously allocated to this course but the license was cancelled. Cannot re-allocate cancelled participants.`,
                );
              } else {
                // Not cancelled, throw original error
                throw error;
              }
            } else {
              // Different error, re-throw
              throw error;
            }
          }
        }
      }),
    );

    // Update subscription license counts
    // Note: subscription was already fetched above for validation, but we fetch again to ensure we have the latest data
    const subscriptionForUpdate = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: "ACTIVE",
      },
      orderBy: {
        created_at: "desc",
      },
    });

    if (subscriptionForUpdate) {
      // Count total allocated licenses (excluding completed participants)
      // Completed participants (E_LEARNING or COMPLETED status) have their licenses released
      const totalAllocated = await this.prisma.client.learningGroupParticipant.count({
        where: {
          learningGroup: {
            company_id: companyId,
          },
          cancelled: false, // Exclude cancelled participants - their licenses are released
          status: {
            notIn: ["E_LEARNING", "COMPLETED"], // Exclude completed participants
          },
        },
      });

      // Count consumed licenses (allocated and not yet completed: INVITED, ACCEPTED, PRE_BAT, POST_BAT)
      const totalConsumed = await this.prisma.client.learningGroupParticipant.count({
        where: {
          learningGroup: {
            company_id: companyId,
          },
          cancelled: false, // Exclude cancelled from consumed
          status: {
            notIn: ["E_LEARNING", "COMPLETED"], // Only completed participants are not consumed
          },
        },
      });

      // Update subscription with new counts
      await this.prisma.client.subscription.update({
        where: { id: subscriptionForUpdate.id },
        data: {
          licenses_consumed: totalConsumed,
          licenses_available: Math.max(0, subscriptionForUpdate.license_count - totalAllocated),
          last_license_assignment: new Date(),
        },
      });
    }

    // Log the creation
    await this.systemLogService.logInsert(
      SystemLogEntityType.LEARNING_GROUP,
      learningGroup.id,
      learningGroup as Record<string, unknown>,
      { company_id: companyId },
    );

    // Emit license allocation event for immediate monitoring
    this.eventEmitter.emit(
      LICENSE_ALLOCATION_EVENTS.LICENSE_ALLOCATED,
      new LicenseAllocatedEvent(
        companyId,
        data.participants.length, // newly allocated count
        subscription.license_count, // total licenses
        learningGroup.id, // learning group ID
      ),
    );

    this.logger.debug(
      `License allocation event emitted for company ${companyId}: ${data.participants.length} licenses allocated`,
    );

    // Enrich the response with course name and participant group info
    const enrichedData = {
      ...learningGroup,
      courseId: learningGroup.course_id,
      courseName: course.title,
      expectedCompletionDate: learningGroup.due_date,
      startDate: learningGroup.start_date,
      completionPercentage: learningGroup.completion_percentage
        ? Number(learningGroup.completion_percentage)
        : null,
    };

    return this.toDto(enrichedData, CLLearningGroupDto);
  }

  /**
   * Get a single learning group (license allocation) with participants
   * @param companyId - Company ID from authenticated user
   * @param learningGroupId - Learning group ID
   * @returns Learning group detail with participants
   */
  async getLearningGroupDetail(
    companyId: number,
    learningGroupId: number,
  ): Promise<import("./dto").CLLearningGroupDetailDto> {
    const { CLLearningGroupDetailDto } = await import("./dto");

    // Fetch learning group with related data
    const learningGroup = await this.prisma.client.learningGroup.findFirst({
      where: {
        id: learningGroupId,
        company_id: companyId,
      },
      include: {
        course: {
          select: {
            id: true,
            title: true,
            courseModulePages: {
              select: {
                id: true,
                title: true,
                sort_order: true,
                course_module_id: true,
                courseModule: {
                  select: {
                    id: true,
                    title: true,
                    sort_order: true,
                  },
                },
              },
              orderBy: { sort_order: "asc" },
            },
          },
        },
        participantGroup: {
          select: {
            id: true,
            name: true,
          },
        },
        learningGroupParticipants: {
          include: {
            participant: {
              select: {
                id: true,
                first_name: true,
                last_name: true,
                email: true,
              },
            },
          },
          orderBy: {
            created_at: "asc",
          },
        },
      },
    });

    if (!learningGroup) {
      throw new BadRequestException(`Learning group with ID ${learningGroupId} not found`);
    }

    // Type assertion for included relations and new schema fields
    // Prisma types may not include new fields until TypeScript server restarts
    const learningGroupWithRelations = learningGroup as typeof learningGroup & {
      due_date?: Date | null;
      participant_group_id?: number | null;
      status?: string;
      course: {
        id: number;
        title: string;
        courseModulePages?: Array<{
          id: number;
          title: string;
          sort_order: number;
          course_module_id: number;
          courseModule: { id: number; title: string; sort_order: number };
        }>;
      };
      participantGroup?: { id: number; name: string } | null;
      learningGroupParticipants: Array<{
        id: number;
        participant_id: number;
        participant: {
          id: number;
          first_name: string | null;
          last_name: string | null;
          email: string;
          full_name?: string | null;
        };
        status: string;
        completion_percentage: unknown;
        invited_at: Date | null;
        accepted_at: Date | null;
        pre_bat_completed_at: Date | null;
        e_learning_started_at: Date | null;
        e_learning_completed_at: Date | null;
        hundred_dj_email1_date: Date | null;
        hundred_dj_email2_date: Date | null;
        hundred_dj_email3_date: Date | null;
        hundred_dj_email4_date: Date | null;
        knowledge_review_email_date: Date | null;
        knowledge_review_completed_at: Date | null;
        post_bat_email_date: Date | null;
        completed_at: Date | null;
      }>;
    };

    // Get cancellation info from system logs for cancelled participants
    const cancelledParticipants = learningGroupWithRelations.learningGroupParticipants.filter(
      (lgp) => lgp.cancelled,
    );

    const cancellationInfoMap = new Map<
      number,
      { reason: string | null; notes: string | null; cancelledAt: Date | null; cancelledBy: number | null }
    >();

    if (cancelledParticipants.length > 0) {
      // Query system logs for cancellation entries
      const cancellationLogs = await this.prisma.client.systemLog.findMany({
        where: {
          entity_type: SystemLogEntityType.LEARNING_GROUP,
          learning_group_id: learningGroupId,
          new_data: {
            path: ["cancelled"],
            equals: true,
          },
        },
        orderBy: {
          created_at: "desc",
        },
        select: {
          created_at: true,
          new_data: true,
          user_id: true,
          learning_group_participant_id: true,
        },
      });

      // Match cancellation logs to participants using learning_group_participant_id
      for (const log of cancellationLogs) {
        const newData = log.new_data as Record<string, unknown>;
        const cancellationReason = (newData["cancellation_reason"] as string) || null;
        const cancellationNotes = (newData["cancellation_notes"] as string) || null;
        const learningGroupParticipantId =
          log.learning_group_participant_id || (newData["learning_group_participant_id"] as number) || null;

        if (cancellationReason && learningGroupParticipantId) {
          // Find the matching participant
          const matchingParticipant = cancelledParticipants.find((p) => p.id === learningGroupParticipantId);
          if (matchingParticipant && !cancellationInfoMap.has(matchingParticipant.id)) {
            cancellationInfoMap.set(matchingParticipant.id, {
              reason: cancellationReason,
              notes: cancellationNotes,
              cancelledAt: log.created_at,
              cancelledBy: log.user_id,
            });
          }
        }
      }
    }

    // Transform participants to the format expected by the DTO
    const participantsData = learningGroupWithRelations.learningGroupParticipants.map((lgp) => {
      const cancellationInfo = lgp.cancelled ? cancellationInfoMap.get(lgp.id) : null;
      return {
        id: lgp.id,
        participantId: lgp.participant_id,
        participant: lgp.participant,
        status: lgp.status,
        cancelled: lgp.cancelled,
        completion_percentage: lgp.completion_percentage,
        invited_at: lgp.invited_at,
        accepted_at: lgp.accepted_at,
        pre_bat_completed_at: lgp.pre_bat_completed_at,
        e_learning_started_at: lgp.e_learning_started_at,
        e_learning_completed_at: lgp.e_learning_completed_at,
        hundred_dj_email1_date: lgp.hundred_dj_email1_date,
        hundred_dj_email2_date: lgp.hundred_dj_email2_date,
        hundred_dj_email3_date: lgp.hundred_dj_email3_date,
        hundred_dj_email4_date: lgp.hundred_dj_email4_date,
        knowledge_review_email_date: lgp.knowledge_review_email_date,
        knowledge_review_completed_at: lgp.knowledge_review_completed_at,
        post_bat_email_date: lgp.post_bat_email_date,
        completed_at: lgp.completed_at,
        cancellation_reason: cancellationInfo?.reason || null,
        cancellation_notes: cancellationInfo?.notes || null,
        cancelled_at: cancellationInfo?.cancelledAt || null,
        cancelled_by: cancellationInfo?.cancelledBy || null,
      };
    });

    // Transform participants array to DTOs
    const { CLLearningGroupParticipantDto } = await import("./dto");
    const transformedParticipants = this.toDtoArray(participantsData, CLLearningGroupParticipantDto);

    // Calculate completion percentage from participants' actual progress
    // This ensures accuracy even if the stored value is stale
    let calculatedCompletionPercentage: number | null = null;
    if (learningGroupWithRelations.learningGroupParticipants.length > 0) {
      const totalProgress = learningGroupWithRelations.learningGroupParticipants.reduce((sum, p) => {
        const progress = this.normalizeProgressPercent(p.completion_percentage);
        return sum + progress;
      }, 0);
      calculatedCompletionPercentage = Math.round(
        totalProgress / learningGroupWithRelations.learningGroupParticipants.length,
      );
    }

    // Use calculated value if available, otherwise fall back to stored value
    const finalCompletionPercentage =
      calculatedCompletionPercentage !== null
        ? calculatedCompletionPercentage
        : this.normalizeProgressPercent(learningGroupWithRelations.completion_percentage);

    // Check if all participants are cancelled - if so, override status to CANCELLED
    let finalStatus = learningGroupWithRelations.status;
    if (
      learningGroupWithRelations.learningGroupParticipants.length > 0 &&
      learningGroupWithRelations.learningGroupParticipants.every((p) => p.cancelled)
    ) {
      finalStatus = "CANCELLED";
    }

    // Build e-learning content list from course module pages
    const courseModulePages = learningGroupWithRelations.course?.courseModulePages || [];
    const courseElearningContent = courseModulePages.map(
      (page: { id: number; title: string; sort_order: number; courseModule: { title: string } }) => ({
        id: page.id,
        title: page.title,
        sortOrder: page.sort_order,
        moduleTitle: page.courseModule?.title || "—",
      }),
    );

    // Enrich the response data
    // IMPORTANT: Set completion_percentage in the object so the DTO transform uses our calculated value
    const enrichedData = {
      ...learningGroupWithRelations,
      completion_percentage: finalCompletionPercentage, // Set this so DTO transform uses calculated value
      courseId: learningGroupWithRelations.course_id,
      courseName: learningGroupWithRelations.course.title,
      courseElearningContent,
      participantGroupName: learningGroupWithRelations.participantGroup?.name || null,
      expectedCompletionDate: learningGroupWithRelations.due_date,
      startDate: learningGroupWithRelations.start_date,
      completionPercentage: finalCompletionPercentage, // Also set explicitly for direct access
      status: finalStatus, // Use calculated status (CANCELLED if all participants cancelled)
      participants: transformedParticipants,
    };

    return this.toDto(enrichedData, CLLearningGroupDetailDto);
  }

  /**
   * Send invitations to all participants in a learning group
   * @param companyId - Company ID to verify ownership
   * @param learningGroupId - Learning group ID
   * @param participantIdInvitedBy - Participant ID of the user sending invitations
   * @returns Success message
   */
  async sendInvitationsToAll(
    companyId: number,
    learningGroupId: number,
    participantIdInvitedBy: number,
  ): Promise<{ message: string; invitationsSent: number }> {
    // Verify learning group exists and belongs to company
    const learningGroup = await this.prisma.client.learningGroup.findFirst({
      where: {
        id: learningGroupId,
        company_id: companyId,
      },
      include: {
        course: {
          select: {
            id: true,
            title: true,
          },
        },
        participantGroup: {
          select: {
            name: true,
          },
        },
        learningGroupParticipants: {
          where: {
            status: "INVITED",
          },
          include: {
            participant: {
              select: {
                id: true,
                email: true,
                first_name: true,
                last_name: true,
                password_hash: true,
              },
            },
          },
        },
      },
    });

    if (!learningGroup) {
      throw new BadRequestException("Learning group not found or access denied");
    }

    if (learningGroup.status !== "PENDING") {
      throw new BadRequestException("Invitations can only be sent for PENDING learning groups");
    }

    // Get company info
    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: { id: true, name: true },
    });

    if (!company) {
      throw new BadRequestException("Company not found");
    }

    const frontendUrl = requireEnv("FRONTEND_URL");
    let invitationsSent = 0;

    // Send invitation email to each participant
    for (const lgParticipant of learningGroup.learningGroupParticipants) {
      try {
        // Check if participant needs password setup
        const needsPasswordSetup = !lgParticipant.participant.password_hash;
        let passwordSetupUrl: string | null = null;

        if (needsPasswordSetup) {
          // Check if password setup token already exists
          const existingToken = await this.prisma.client.passwordSetupToken.findUnique({
            where: { participant_id: lgParticipant.participant_id },
          });

          let setupToken: string;
          if (existingToken && !existingToken.used_at && new Date() < existingToken.expires_at) {
            // Use existing valid token
            setupToken = existingToken.token;
          } else {
            // Generate new token (7 days expiry)
            setupToken = this.generatePasswordSetupToken();
            const tokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

            // Create or update password setup token
            await this.prisma.client.passwordSetupToken.upsert({
              where: { participant_id: lgParticipant.participant_id },
              create: {
                participant_id: lgParticipant.participant_id,
                token: setupToken,
                expires_at: tokenExpiry,
              },
              update: {
                token: setupToken,
                expires_at: tokenExpiry,
                used_at: null, // Reset if previously used
              },
            });
          }

          passwordSetupUrl = `${frontendUrl}/setup-password?token=${setupToken}`;
        }

        await this.sendInvitationEmail({
          participantEmail: lgParticipant.participant.email,
          participantFirstName: lgParticipant.participant.first_name,
          participantLastName: lgParticipant.participant.last_name,
          courseName: learningGroup.course.title,
          companyName: company.name,
          teamName:
            (learningGroup as { participantGroup?: { name: string } | null }).participantGroup?.name || null,
          loginUrl: `${frontendUrl}/portal/my-courses/${lgParticipant.id}`, // Direct link to course detail page (using LearningGroupParticipant ID)
          passwordSetupUrl: passwordSetupUrl, // Password setup URL if needed
          participantId: lgParticipant.participant_id,
          companyId: company.id,
          courseId: learningGroup.course_id,
          learningGroupId: learningGroup.id,
          learningGroupParticipantId: lgParticipant.id,
        });

        // Update invited_at timestamp
        await this.prisma.client.learningGroupParticipant.update({
          where: { id: lgParticipant.id },
          data: { invited_at: new Date() },
        });

        invitationsSent++;
      } catch (emailError) {
        this.logger.error(`Failed to send invitation to ${lgParticipant.participant.email}:`, emailError);
        // Continue sending to other participants even if one fails
      }
    }

    // Keep status as PENDING - it will change to ACTIVE when participants accept the invitation
    // Status should not be changed to ACTIVE just by sending invitations

    return {
      message: `Invitations sent successfully to ${invitationsSent} participant(s)`,
      invitationsSent,
    };
  }

  /**
   * Resend invitation to a specific participant
   * @param companyId - Company ID to verify ownership
   * @param learningGroupId - Learning group ID
   * @param participantId - Participant ID to resend invitation to
   * @param participantIdInvitedBy - Participant ID of the user resending invitation
   * @returns Success message
   */
  async resendInvitation(
    companyId: number,
    learningGroupId: number,
    participantId: number,
    participantIdInvitedBy: number,
  ): Promise<{ message: string }> {
    // Verify learning group exists and belongs to company
    const learningGroup = await this.prisma.client.learningGroup.findFirst({
      where: {
        id: learningGroupId,
        company_id: companyId,
      },
      include: {
        course: {
          select: {
            id: true,
            title: true,
          },
        },
        participantGroup: {
          select: {
            name: true,
          },
        },
      },
    });

    if (!learningGroup) {
      throw new BadRequestException("Learning group not found or access denied");
    }

    // Find the learning group participant
    const lgParticipant = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        learning_group_id: learningGroupId,
        participant_id: participantId,
      },
      include: {
        participant: {
          select: {
            id: true,
            email: true,
            first_name: true,
            last_name: true,
          },
        },
      },
    });

    if (!lgParticipant) {
      throw new BadRequestException("Participant not found in this learning group");
    }

    // Allow resend at INVITED (course email never opened / acted on) and ACCEPTED
    // (signed in but didn't accept the course assignment). The downstream logic at
    // line ~1207 picks the right email template via `needsPasswordSetup = !password_hash`,
    // so both states route to a meaningful email — either password-setup or direct sign-in.
    // Past ACCEPTED the participant has already started course content; resend is no longer
    // relevant and we keep that boundary firm to avoid duplicate emails late in the journey.
    if (lgParticipant.status !== "INVITED" && lgParticipant.status !== "ACCEPTED") {
      throw new BadRequestException(
        "Resend is only available before the participant has started course content. This participant is already at " +
          lgParticipant.status,
      );
    }

    // Get full participant data including password_hash
    const participant = await this.prisma.client.participant.findUnique({
      where: { id: lgParticipant.participant_id },
      select: { password_hash: true },
    });

    // Get company info
    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: { id: true, name: true },
    });

    if (!company) {
      throw new BadRequestException("Company not found");
    }

    const frontendUrl = requireEnv("FRONTEND_URL");

    try {
      // Check if participant needs password setup
      const needsPasswordSetup = !participant?.password_hash;
      let passwordSetupUrl: string | null = null;

      if (needsPasswordSetup) {
        // Check if password setup token already exists
        const existingToken = await this.prisma.client.passwordSetupToken.findUnique({
          where: { participant_id: lgParticipant.participant_id },
        });

        let setupToken: string;
        if (existingToken && !existingToken.used_at && new Date() < existingToken.expires_at) {
          // Use existing valid token
          setupToken = existingToken.token;
        } else {
          // Generate new token (7 days expiry)
          setupToken = this.generatePasswordSetupToken();
          const tokenExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

          // Create or update password setup token
          await this.prisma.client.passwordSetupToken.upsert({
            where: { participant_id: lgParticipant.participant_id },
            create: {
              participant_id: lgParticipant.participant_id,
              token: setupToken,
              expires_at: tokenExpiry,
            },
            update: {
              token: setupToken,
              expires_at: tokenExpiry,
              used_at: null, // Reset if previously used
            },
          });
        }

        passwordSetupUrl = `${frontendUrl}/setup-password?token=${setupToken}`;
      }

      // Send invitation email
      await this.sendInvitationEmail({
        participantEmail: lgParticipant.participant.email,
        participantFirstName: lgParticipant.participant.first_name,
        participantLastName: lgParticipant.participant.last_name,
        courseName: learningGroup.course.title,
        companyName: company.name,
        teamName: (learningGroup as { participantGroup?: { name: string } | null }).participantGroup?.name || null,
        loginUrl: `${frontendUrl}/portal/my-courses/${lgParticipant.id}`, // Direct link to course detail page (using LearningGroupParticipant ID)
        passwordSetupUrl: passwordSetupUrl, // Password setup URL if needed
        participantId: lgParticipant.participant_id,
        companyId: companyId,
        courseId: learningGroup.course_id,
        learningGroupId: learningGroupId,
        learningGroupParticipantId: lgParticipant.id,
      });

      // Update invited_at timestamp
      await this.prisma.client.learningGroupParticipant.update({
        where: { id: lgParticipant.id },
        data: { invited_at: new Date() },
      });

      return { message: "Invitation resent successfully" };
    } catch (emailError) {
      this.logger.error(`Failed to resend invitation to ${lgParticipant.participant.email}:`, emailError);
      const detail = this.getSafeEmailErrorMessage(emailError);
      throw new BadRequestException(detail);
    }
  }

  /**
   * Build a user-safe error message for email send failures (no credentials or internals).
   */
  private getSafeEmailErrorMessage(error: unknown): string {
    const base = "Failed to send invitation email";
    const msg = error instanceof Error ? error.message : error != null ? String(error) : "";
    if (!msg || msg.length > 250) return base;
    const lower = msg.toLowerCase();
    if (
      lower.includes("credential") ||
      lower.includes("secret") ||
      lower.includes("password") ||
      lower.includes("access_key") ||
      lower.includes("token")
    ) {
      return base;
    }
    return `${base}: ${msg}`;
  }

  /**
   * Generate a secure password setup token
   */
  private generatePasswordSetupToken(): string {
    // Generate a secure random token (32 bytes = 64 hex characters)
    return crypto.randomBytes(32).toString("hex");
  }

  /**
   * Send invitation email to a participant
   */
  private async sendInvitationEmail(data: {
    participantEmail: string;
    participantFirstName: string;
    participantLastName: string;
    courseName: string;
    companyName: string;
    teamName: string | null;
    loginUrl: string;
    passwordSetupUrl?: string | null;
    participantId?: number;
    companyId?: number;
    courseId?: number;
    learningGroupId?: number;
    learningGroupParticipantId?: number;
  }): Promise<void> {
    try {
      // Determine display values based on whether password setup is needed
      const needsPasswordSetup = !!data.passwordSetupUrl;
      const passwordSetupUrlDisplay = needsPasswordSetup ? "block" : "none";
      const loginOnlyDisplay = needsPasswordSetup ? "none" : "block";
      const buttonClass = needsPasswordSetup ? "button-secondary" : "button";
      // Brand purple #7c3aed (matches email-skeleton.template.ts BRAND_PURPLE).
      // Solid color instead of gradient — gradients render unpredictably in Outlook (Windows desktop).
      const buttonStyle = needsPasswordSetup
        ? "background:#ffffff;border:2px solid #7c3aed;color:#7c3aed"
        : "background:#7c3aed;color:#ffffff";

      await this.emailSender.sendTemplatedEmail({
        to: data.participantEmail,
        templateKey: "course.learning.group.invitation",
        variables: {
          "user.name": data.participantFirstName,
          "user.email": data.participantEmail,
          "course.name": data.courseName,
          "company.name": data.companyName,
          "team.name": data.teamName || "Not Assigned",
          "system.loginUrl": data.loginUrl,
          "system.passwordSetupUrl": data.passwordSetupUrl || "",
          "system.passwordSetupUrlDisplay": passwordSetupUrlDisplay,
          "system.loginOnlyDisplay": loginOnlyDisplay,
          "system.buttonClass": buttonClass,
          "system.buttonStyle": buttonStyle,
        },
        metadata: {
          participant_id: data.participantId,
          company_id: data.companyId,
          course_id: data.courseId,
          learning_group_id: data.learningGroupId,
          learning_group_participant_id: data.learningGroupParticipantId,
          companyName: data.companyName,
          courseName: data.courseName,
          triggeredBy: "learning_group_invitation",
        },
      });

      this.logger.log(`Invitation email sent to: ${data.participantEmail}`);
    } catch (emailError) {
      this.logger.error("Failed to send invitation email:", emailError);
      throw emailError;
    }
  }

  /**
   * Accept course invitation
   * @param participantId - Current participant ID
   * @param learningGroupParticipantId - LearningGroupParticipant ID
   * @returns Success message
   */
  async acceptCourseInvitation(
    participantId: number,
    learningGroupParticipantId: number,
  ): Promise<{ success: boolean; message: string }> {
    // Find the learning group participant
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        participant_id: participantId,
      },
      include: {
        participant: true,
        learningGroup: { select: { company_id: true } },
      },
    });

    if (!enrollment) {
      throw new BadRequestException("Course invitation not found or access denied");
    }

    await this.subscriptionCourseAccess.assertCompanySubscriptionActive(participantId);

    // Check if enrollment is cancelled
    if (enrollment.cancelled) {
      throw new ForbiddenException(
        "Course Access Unavailable\n\n" +
          "This course invitation is no longer available. The license for this course has been cancelled.\n\n" +
          "Don't worry! You can:\n" +
          "• Check your other available courses\n" +
          "• Contact your Participant Administrator if you believe this is an error\n" +
          "• They can help you with course access and license allocation",
      );
    }

    // Check if already accepted
    if (enrollment.accepted_at) {
      return {
        success: true,
        message: "Course invitation already accepted",
      };
    }

    // Check if status is INVITED
    if (enrollment.status !== "INVITED") {
      throw new BadRequestException("Course invitation is not in INVITED status");
    }

    // Check if participant has password set
    if (!enrollment.participant.password_hash) {
      throw new BadRequestException("Password must be set before accepting course invitation");
    }

    // Accept the invitation by updating status and accepted_at
    await this.prisma.client.learningGroupParticipant.update({
      where: {
        id: learningGroupParticipantId,
      },
      data: {
        accepted_at: new Date(),
        status: "ACCEPTED", // Update status to ACCEPTED when invitation is accepted
      },
    });

    // Check if this is the first participant to accept and update learning group status to ACTIVE
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: {
        id: enrollment.learning_group_id,
      },
    });

    if (learningGroup && learningGroup.status === "PENDING") {
      // Count how many participants have accepted
      const acceptedCount = await this.prisma.client.learningGroupParticipant.count({
        where: {
          learning_group_id: enrollment.learning_group_id,
          accepted_at: {
            not: null,
          },
        },
      });

      // If this is the first participant to accept, change learning group status to ACTIVE
      if (acceptedCount === 1) {
        await this.prisma.client.learningGroup.update({
          where: {
            id: enrollment.learning_group_id,
          },
          data: {
            status: "ACTIVE",
          },
        });

        this.logger.log(
          `Learning group ${enrollment.learning_group_id} status changed to ACTIVE (first participant accepted)`,
        );
      }
    }

    this.logger.log(
      `Course invitation accepted: Participant ${participantId}, LearningGroupParticipant ${learningGroupParticipantId}`,
    );

    // Keep subscription license counts in sync (accept doesn't change consumed, but ensures consistency)
    if (enrollment.learningGroup?.company_id) {
      await this.recalculateSubscriptionLicenseCounts(enrollment.learningGroup.company_id, {
        setLastLicenseRelease: false,
      });
    }

    return {
      success: true,
      message: "Course invitation accepted successfully",
    };
  }

  /**
   * Check cancellation eligibility and return progress information
   * @param learningGroupParticipantId - LearningGroupParticipant ID
   * @param companyId - Company ID for authorization
   * @returns Cancellation eligibility info with progress details
   */
  async checkCancellationEligibility(
    learningGroupParticipantId: number,
    companyId: number,
  ): Promise<{
    canCancel: boolean;
    enrollment: {
      id: number;
      participantId: number;
      participantName: string;
      participantEmail: string;
      courseId: number;
      courseName: string;
      status: string;
      completionPercentage: number;
    };
    progress: {
      preBatCompleted: boolean;
      eLearningStarted: boolean;
      modulesCompleted: number;
      totalModules: number;
      progressLevel:
        | "NONE"
        | "PRE_BAT_ONLY"
        | "E_LEARNING_STARTED"
        | "E_LEARNING_SIGNIFICANT"
        | "E_LEARNING_NEARLY_DONE";
    };
    warnings: string[];
  }> {
    // Get enrollment with related data
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        learningGroup: {
          company_id: companyId,
        },
      },
      include: {
        participant: {
          select: {
            id: true,
            first_name: true,
            last_name: true,
            email: true,
          },
        },
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
              },
            },
          },
        },
      },
    });

    if (!enrollment) {
      throw new BadRequestException("Enrollment not found or access denied");
    }

    // Check if already cancelled
    if (enrollment.cancelled) {
      throw new BadRequestException("License is already cancelled");
    }

    // Get e-learning progress if exists
    const eLearningProgress = await this.prisma.client.eLearningParticipant.findFirst({
      where: {
        participant_id: enrollment.participant_id,
        course_id: enrollment.course_id,
        learning_group_id: enrollment.learning_group_id,
      },
    });

    // Determine progress level
    let progressLevel:
      | "NONE"
      | "PRE_BAT_ONLY"
      | "E_LEARNING_STARTED"
      | "E_LEARNING_SIGNIFICANT"
      | "E_LEARNING_NEARLY_DONE" = "NONE";
    const warnings: string[] = [];
    const eLearningStarted = !!eLearningProgress?.start_date;

    if (!enrollment.pre_bat_completed_at) {
      progressLevel = "NONE";
    } else if (!eLearningProgress || !eLearningProgress.start_date) {
      progressLevel = "PRE_BAT_ONLY";
      warnings.push("Participant has completed Pre BAT assessment");
    } else {
      const completionRatio =
        eLearningProgress.total_course_modules > 0
          ? eLearningProgress.course_modules_completed / eLearningProgress.total_course_modules
          : 0;

      if (completionRatio < 0.25) {
        progressLevel = "E_LEARNING_STARTED";
        warnings.push(
          `Participant has started e-learning (${eLearningProgress.course_modules_completed}/${eLearningProgress.total_course_modules} modules)`,
        );
      } else if (completionRatio < 0.75) {
        progressLevel = "E_LEARNING_SIGNIFICANT";
        warnings.push(
          `WARNING: Participant has significant progress (${eLearningProgress.course_modules_completed}/${eLearningProgress.total_course_modules} modules, ${(completionRatio * 100).toFixed(0)}%)`,
        );
      } else {
        progressLevel = "E_LEARNING_NEARLY_DONE";
        warnings.push(
          `CRITICAL: Participant is nearly finished (${eLearningProgress.course_modules_completed}/${eLearningProgress.total_course_modules} modules, ${(completionRatio * 100).toFixed(0)}%)`,
        );
      }
    }

    // Cancellation is not allowed if eLearning has started
    const canCancel = !eLearningStarted;
    if (eLearningStarted && eLearningProgress) {
      warnings.unshift(
        `⚠️ Cancellation not allowed: Participant has started e-learning (${eLearningProgress.course_modules_completed}/${eLearningProgress.total_course_modules} modules completed)`,
      );
    }

    return {
      canCancel,
      enrollment: {
        id: enrollment.id,
        participantId: enrollment.participant_id,
        participantName: enrollment.participant
          ? `${enrollment.participant.first_name} ${enrollment.participant.last_name}`.trim()
          : "Unknown",
        participantEmail: enrollment.participant?.email || "",
        courseId: enrollment.course_id,
        courseName: enrollment.learningGroup.course.title,
        status: enrollment.status,
        completionPercentage: enrollment.completion_percentage ? Number(enrollment.completion_percentage) : 0,
      },
      progress: {
        preBatCompleted: !!enrollment.pre_bat_completed_at,
        eLearningStarted: !!eLearningProgress?.start_date,
        modulesCompleted: eLearningProgress?.course_modules_completed || 0,
        totalModules: eLearningProgress?.total_course_modules || 0,
        progressLevel,
      },
      warnings,
    };
  }

  /**
   * Cancel participant license allocation
   * @param learningGroupParticipantId - LearningGroupParticipant ID
   * @param companyId - Company ID
   * @param cancelledByParticipantId - Participant ID of admin who is cancelling
   * @param reason - Cancellation reason
   * @param notes - Optional cancellation notes
   * @returns Success message
   */
  /**
   * Cancel a participant license allocation
   * IMPORTANT: This immediately revokes access to the course. Once cancelled:
   * - Participant cannot start the course (startCourse endpoint checks cancelled field)
   * - Participant cannot access any pages (validatePageAccess, getAllCoursePages check cancelled field)
   * - Participant cannot see the course in their course list (getMyCoursesSequential filters out cancelled)
   * - All course access endpoints return ForbiddenException for cancelled enrollments
   *
   * The cancelled flag is set to true and status is preserved - participant can resume at the same level later.
   * The update is atomic and takes effect immediately - participant loses access on their next API call.
   *
   * @param learningGroupParticipantId - LearningGroupParticipant ID
   * @param companyId - Company ID
   * @param cancelledByParticipantId - Participant ID of admin who is cancelling
   * @param reason - Cancellation reason
   * @param notes - Optional cancellation notes
   * @returns Success message
   */
  async cancelParticipantLicense(
    learningGroupParticipantId: number,
    companyId: number,
    cancelledByParticipantId: number,
    reason: string,
    notes?: string,
  ): Promise<{ success: boolean; message: string }> {
    // Get enrollment with related data
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        learningGroup: {
          company_id: companyId,
        },
      },
      include: {
        participant: {
          select: {
            id: true,
            first_name: true,
            last_name: true,
            email: true,
          },
        },
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
              },
            },
          },
        },
      },
    });

    if (!enrollment) {
      throw new BadRequestException("Enrollment not found or access denied");
    }

    // Check if already cancelled
    if (enrollment.cancelled) {
      throw new BadRequestException("License is already cancelled");
    }

    // Check if eLearning has started - prevent cancellation
    const eLearningProgress = await this.prisma.client.eLearningParticipant.findFirst({
      where: {
        participant_id: enrollment.participant_id,
        course_id: enrollment.course_id,
        learning_group_id: enrollment.learning_group_id,
      },
    });

    if (eLearningProgress?.start_date) {
      throw new BadRequestException(
        "Cannot cancel license: Participant has started e-learning. Cancellation is not allowed once e-learning has begun.",
      );
    }

    // Update enrollment: set cancelled=true, preserve current status
    // IMPORTANT: This immediately revokes access - all course access endpoints check for cancelled field
    // Note: We preserve all progress data and status - just mark as cancelled
    // The cancelled update is atomic and takes effect immediately - participant will lose access on next API call
    const currentStatus = enrollment.status; // Preserve current status for resume
    const updatedEnrollment = await this.prisma.client.learningGroupParticipant.update({
      where: {
        id: learningGroupParticipantId,
      },
      data: {
        cancelled: true,
        // Status is preserved - participant can resume at the same level later
      },
      select: {
        id: true,
        status: true,
        cancelled: true,
        participant_id: true,
      },
    });

    // Verify the cancelled flag was updated correctly
    if (!updatedEnrollment.cancelled) {
      this.logger.error(
        `Failed to update cancelled flag for LearningGroupParticipant ${learningGroupParticipantId}.`,
      );
      throw new BadRequestException("Failed to cancel license. Please try again.");
    }

    this.logger.log(
      `License cancelled for LearningGroupParticipant ${learningGroupParticipantId} (status preserved: ${currentStatus}). Participant ${enrollment.participant_id} will lose access immediately.`,
    );

    // Update subscription license counts (recalculate based on current allocations)
    await this.recalculateSubscriptionLicenseCounts(companyId);

    // Emit license release event for immediate monitoring
    const subscription = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: "ACTIVE",
      },
    });

    if (subscription) {
      this.eventEmitter.emit(
        LICENSE_ALLOCATION_EVENTS.LICENSE_RELEASED,
        new LicenseReleasedEvent(
          companyId,
          1, // 1 license released
          subscription.license_count,
          learningGroupParticipantId,
        ),
      );
      this.logger.debug(`License release event emitted for company ${companyId}: 1 license released`);
    }

    // Log the cancellation with participant ID in metadata for easy retrieval
    const changedFields: Record<string, unknown> = {
      status: enrollment.status,
      cancellation_reason: reason,
      cancellation_notes: notes,
      learning_group_participant_id: learningGroupParticipantId,
      participant_id: enrollment.participant_id,
    };
    await this.systemLogService.logUpdate(
      SystemLogEntityType.LEARNING_GROUP,
      enrollment.learning_group_id,
      { cancelled: false, status: enrollment.status } as Record<string, unknown>,
      {
        cancelled: true,
        status: enrollment.status, // Preserve status for resume
        cancellation_reason: reason,
        cancellation_notes: notes,
        learning_group_participant_id: learningGroupParticipantId,
        participant_id: enrollment.participant_id,
        cancelled_by_participant_id: cancelledByParticipantId,
      } as Record<string, unknown>,
      changedFields,
      { participant_id: enrollment.participant_id, company_id: companyId },
    );

    this.logger.log(
      `License cancelled: Participant ${enrollment.participant_id}, Course ${enrollment.course_id}, LearningGroupParticipant ${learningGroupParticipantId}`,
    );

    return {
      success: true,
      message: `License cancelled for ${enrollment.participant.first_name} ${enrollment.participant.last_name}`,
    };
  }

  /**
   * Resume a cancelled participant license allocation
   * IMPORTANT: This restores access to the course. The participant resumes at the same level/status they were at before cancellation.
   *
   * @param learningGroupParticipantId - LearningGroupParticipant ID
   * @param companyId - Company ID
   * @param resumedByParticipantId - Participant ID of admin who is resuming
   * @returns Success message with restored status
   */
  async resumeParticipantLicense(
    learningGroupParticipantId: number,
    companyId: number,
    resumedByParticipantId: number,
  ): Promise<{ success: boolean; message: string; restoredStatus: string }> {
    // Get enrollment with all necessary relations
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        learningGroup: {
          company_id: companyId,
        },
      },
      include: {
        participant: {
          select: {
            id: true,
            first_name: true,
            last_name: true,
            email: true,
          },
        },
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                title: true,
              },
            },
          },
        },
      },
    });

    if (!enrollment) {
      throw new BadRequestException("Enrollment not found or access denied");
    }

    // Check if not cancelled
    if (!enrollment.cancelled) {
      throw new BadRequestException("License is not cancelled. Cannot resume an active license.");
    }

    // Resume the license: set cancelled=false, status is already preserved
    const restoredStatus = enrollment.status; // Status was preserved during cancellation
    const updatedEnrollment = await this.prisma.client.learningGroupParticipant.update({
      where: {
        id: learningGroupParticipantId,
      },
      data: {
        cancelled: false,
        // Status is already preserved - participant resumes at the same level
      },
      select: {
        id: true,
        status: true,
        cancelled: true,
        participant_id: true,
      },
    });

    // Verify the cancelled flag was updated correctly
    if (updatedEnrollment.cancelled) {
      this.logger.error(`Failed to resume license for LearningGroupParticipant ${learningGroupParticipantId}.`);
      throw new BadRequestException("Failed to resume license. Please try again.");
    }

    this.logger.log(
      `License resumed for LearningGroupParticipant ${learningGroupParticipantId} (restored status: ${restoredStatus}). Participant ${enrollment.participant_id} will regain access immediately.`,
    );

    // Update subscription license counts (recalculate based on current allocations)
    await this.recalculateSubscriptionLicenseCounts(companyId);

    // Log the resume action
    const changedFields: Record<string, unknown> = {
      status: restoredStatus,
      learning_group_participant_id: learningGroupParticipantId,
      participant_id: enrollment.participant_id,
    };
    await this.systemLogService.logUpdate(
      SystemLogEntityType.LEARNING_GROUP,
      enrollment.learning_group_id,
      { cancelled: true, status: restoredStatus } as Record<string, unknown>,
      {
        cancelled: false,
        status: restoredStatus, // Status preserved and restored
        resumed_by_participant_id: resumedByParticipantId,
      } as Record<string, unknown>,
      changedFields,
      { participant_id: enrollment.participant_id, company_id: companyId },
    );

    return {
      success: true,
      message: `License resumed for ${enrollment.participant.first_name} ${enrollment.participant.last_name}. Participant will resume at ${restoredStatus} level.`,
      restoredStatus: restoredStatus,
    };
  }

  /**
   * Recalculate and persist subscription license counts (allocated, consumed, available).
   * Call this whenever participant allocation or progress status changes so admin subscription view stays correct.
   * Consumed = allocated and not yet completed (INVITED, ACCEPTED, PRE_BAT, POST_BAT).
   * @param companyId - Company ID
   * @param options.setLastLicenseRelease - When true (default), updates last_license_release. Set false when recalculating due to progress-only changes (no release).
   */
  async recalculateSubscriptionLicenseCounts(
    companyId: number,
    options?: { setLastLicenseRelease?: boolean },
  ): Promise<void> {
    const subscription = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: "ACTIVE",
      },
      orderBy: {
        created_at: "desc",
      },
    });

    if (!subscription) {
      return;
    }

    // Count total allocated licenses (excluding completed and cancelled participants)
    const totalAllocated = await this.prisma.client.learningGroupParticipant.count({
      where: {
        learningGroup: {
          company_id: companyId,
        },
        cancelled: false, // Exclude cancelled
        status: {
          notIn: ["E_LEARNING", "COMPLETED"], // Exclude completed
        },
      },
    });

    // Count consumed licenses (allocated and not yet completed: INVITED, ACCEPTED, PRE_BAT, POST_BAT)
    const totalConsumed = await this.prisma.client.learningGroupParticipant.count({
      where: {
        learningGroup: {
          company_id: companyId,
        },
        cancelled: false, // Exclude cancelled
        status: {
          notIn: ["E_LEARNING", "COMPLETED"], // Only completed participants are not consumed
        },
      },
    });

    const data: {
      licenses_consumed: number;
      licenses_available: number;
      last_license_release?: Date;
    } = {
      licenses_consumed: totalConsumed,
      licenses_available: Math.max(0, subscription.license_count - totalAllocated),
    };
    if (options?.setLastLicenseRelease !== false) {
      data.last_license_release = new Date();
    }
    await this.prisma.client.subscription.update({
      where: { id: subscription.id },
      data,
    });
  }

  /**
   * Get license information for the company
   * @param companyId - Company ID
   * @returns License information including total, available, consumed, allocated, and inTraining counts
   */
  async getLicenseInfo(companyId: number): Promise<{
    total: number;
    available: number;
    consumed: number;
    allocated: number;
    inTraining: number;
  }> {
    // Get current active subscription for the company
    const subscription = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: "ACTIVE",
      },
      orderBy: {
        created_at: "desc",
      },
    });

    if (!subscription) {
      // No active subscription - return zeros
      return {
        total: 0,
        available: 0,
        consumed: 0,
        allocated: 0,
        inTraining: 0,
      };
    }

    const total = subscription.license_count;

    // Count allocated licenses (LearningGroupParticipant records that are NOT completed)
    // Completed participants (E_LEARNING or COMPLETED status) have their licenses released
    // This represents licenses currently allocated and not yet released
    const allocated = await this.prisma.client.learningGroupParticipant.count({
      where: {
        learningGroup: {
          company_id: companyId,
        },
        cancelled: false, // Exclude cancelled participants - their licenses are released
        status: {
          notIn: [ParticipantLearningProgressStatus.E_LEARNING, ParticipantLearningProgressStatus.COMPLETED], // Exclude completed participants
        },
      },
    });

    // Count consumed licenses (allocated and not yet completed: INVITED, ACCEPTED, PRE_BAT, POST_BAT)
    const consumed = await this.prisma.client.learningGroupParticipant.count({
      where: {
        learningGroup: {
          company_id: companyId,
        },
        cancelled: false, // Exclude cancelled from consumed
        status: {
          notIn: [ParticipantLearningProgressStatus.E_LEARNING, ParticipantLearningProgressStatus.COMPLETED], // Only completed participants are not consumed
        },
      },
    });

    // Count in-training licenses (participants with status IN_PROGRESS stages: PRE_BAT, POST_BAT)
    // Note: E_LEARNING is considered completed, so licenses are released at that stage
    const inTraining = await this.prisma.client.learningGroupParticipant.count({
      where: {
        learningGroup: {
          company_id: companyId,
        },
        status: {
          in: [ParticipantLearningProgressStatus.PRE_BAT, ParticipantLearningProgressStatus.POST_BAT], // E_LEARNING is excluded as it's considered completed
        },
      },
    });

    // Handle unlimited licenses (license_count === -1 means no limit)
    if (total === -1) {
      return {
        total: -1, // -1 indicates unlimited
        available: -1, // -1 indicates unlimited
        consumed,
        allocated,
        inTraining,
      };
    }

    // Available licenses = total - allocated
    // When participants complete (E_LEARNING/COMPLETED), they're excluded from allocated,
    // so their licenses are automatically released and added to available pool
    const available = Math.max(0, total - allocated);

    return {
      total,
      available,
      consumed,
      allocated,
      inTraining,
    };
  }

  /**
   * Get subscription billing information for the company
   * @param companyId - Company ID
   * @returns Subscription billing info including monthly total and next renewal date
   */
  async getSubscriptionBillingInfo(companyId: number): Promise<{
    monthlyTotal: number;
    nextRenewalDate: string | null;
    licenseCount: number;
    pricePerLicense: number | null;
    packageName: string | null;
    companyActive: boolean;
    isSubscriptionExpiry: boolean;
    stripeCancelAtPeriodEnd: boolean;
  }> {
    const companyActive = await this.subscriptionCourseAccess.isPortalCompanyActive(companyId);
    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: { is_subscription_expiry: true },
    });
    const isSubscriptionExpiry = company?.is_subscription_expiry === true;

    // Get current active subscription for the company
    const subscription = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: "ACTIVE",
      },
      include: {
        package: {
          select: {
            name: true,
            price_per_licence: true,
          },
        },
      },
      orderBy: {
        created_at: "desc",
      },
    });

    if (!subscription) {
      // No active subscription
      return {
        monthlyTotal: 0,
        nextRenewalDate: null,
        licenseCount: 0,
        pricePerLicense: null,
        packageName: null,
        companyActive,
        isSubscriptionExpiry,
        stripeCancelAtPeriodEnd: false,
      };
    }

    const licenseCount = subscription.license_count;
    const pricePerLicense = subscription.package.price_per_licence
      ? Number(subscription.package.price_per_licence)
      : null;

    // Calculate monthly total: license_count × price_per_license
    const monthlyTotal = pricePerLicense !== null ? licenseCount * pricePerLicense : 0;

    // Format next renewal date
    const nextRenewalDate = subscription.next_billing_date
      ? subscription.next_billing_date.toISOString().split("T")[0]
      : null;

    return {
      monthlyTotal: Math.round(monthlyTotal * 100) / 100, // Round to 2 decimal places
      nextRenewalDate,
      licenseCount,
      pricePerLicense,
      packageName: subscription.package.name || null,
      companyActive,
      isSubscriptionExpiry,
      stripeCancelAtPeriodEnd: subscription.stripe_cancel_at_period_end === true,
    };
  }

  /**
   * Get participant IDs that are already allocated to a specific course
   * @param companyId - Company ID
   * @param courseId - Course ID
   * @returns Array of participant IDs with completion status for the specific course
   */
  async getAllocatedParticipantsForCourse(
    companyId: number,
    courseId: number,
  ): Promise<{
    participants: Array<{
      participantId: number;
      participantName: string;
      participantEmail: string;
      isCompleted: boolean;
      cancelled: boolean;
    }>;
  }> {
    // Find all learning group participants for this course in this company
    // INCLUDE CANCELLED participants - one course one participant concept (cannot re-allocate)
    const allocatedParticipants = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        course_id: courseId,
        learningGroup: {
          company_id: companyId,
        },
        // Include both cancelled and non-cancelled - one participant can only have one enrollment per course
      },
      select: {
        participant_id: true,
        status: true,
        cancelled: true,
        participant: {
          select: {
            id: true,
            first_name: true,
            last_name: true,
            email: true,
          },
        },
      },
      distinct: ["participant_id"],
    });

    const participants = allocatedParticipants.map((p) => ({
      participantId: p.participant_id,
      participantName: p.participant
        ? `${p.participant.first_name || ""} ${p.participant.last_name || ""}`.trim() || "Unknown"
        : `Participant ${p.participant_id}`,
      participantEmail: p.participant?.email || "—",
      isCompleted: p.status === "E_LEARNING" || p.status === "COMPLETED",
      cancelled: p.cancelled,
    }));

    return { participants };
  }

  /**
   * Get participants currently in the enrollment cooling period for a given course.
   * Used by allocate-license dialog to disable those participants and show tooltip.
   */
  async getParticipantsInCoolingPeriod(
    companyId: number,
    excludeCourseId: number,
  ): Promise<{
    participants: Array<{
      participantId: number;
      nextEligibleDate: string;
      lastEnrollmentCourseName: string;
    }>;
    coolingPeriodDays: number;
  }> {
    const coolingPeriodDays = ENROLLMENT_COOLING_CONFIG.ENROLLMENT_COOLING_PERIOD_DAYS;
    const coolingPeriodMs = coolingPeriodDays * 24 * 60 * 60 * 1000;
    const nowMs = Date.now();

    const rows = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        cancelled: false,
        course_id: { not: excludeCourseId },
        learningGroup: { company_id: companyId },
        status: CLLearningGroupService.coolingEnrollmentStatusWhere,
      },
      include: {
        learningGroup: { include: { course: true } },
      },
    });

    const bestByParticipant = new Map<number, { anchorMs: number; row: (typeof rows)[number] }>();
    for (const row of rows) {
      const anchorMs = (row.invited_at ?? row.created_at).getTime();
      const prev = bestByParticipant.get(row.participant_id);
      if (!prev || anchorMs > prev.anchorMs) {
        bestByParticipant.set(row.participant_id, { anchorMs, row });
      }
    }

    const participants: Array<{
      participantId: number;
      nextEligibleDate: string;
      lastEnrollmentCourseName: string;
    }> = [];
    for (const { anchorMs, row } of bestByParticipant.values()) {
      if (anchorMs + coolingPeriodMs <= nowMs) {
        continue;
      }
      const nextEligibleDate = new Date(anchorMs + coolingPeriodMs);
      const lastCourseName = row.learningGroup?.course?.title ?? "another course";
      participants.push({
        participantId: row.participant_id,
        nextEligibleDate: nextEligibleDate.toISOString(),
        lastEnrollmentCourseName: lastCourseName,
      });
    }

    return { participants, coolingPeriodDays };
  }

  /**
   * Blocks allocating a participant to {@link targetCourseId} until `coolingPeriodDays`
   * have passed since their most recent non-cancelled, in-flight enrollment (not E_LEARNING/COMPLETED)
   * in any other course under the same company. Matches {@link getParticipantsInCoolingPeriod} logic.
   */
  private async assertParticipantPassesEnrollmentCooling(
    companyId: number,
    participantId: number,
    targetCourseId: number,
  ): Promise<void> {
    const coolingPeriodDays = ENROLLMENT_COOLING_CONFIG.ENROLLMENT_COOLING_PERIOD_DAYS;
    const coolingPeriodMs = coolingPeriodDays * 24 * 60 * 60 * 1000;
    const nowMs = Date.now();

    const rows = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        participant_id: participantId,
        cancelled: false,
        course_id: { not: targetCourseId },
        learningGroup: { company_id: companyId },
        status: CLLearningGroupService.coolingEnrollmentStatusWhere,
      },
      include: {
        learningGroup: { include: { course: true } },
      },
    });

    let latestMs = 0;
    let latestRow: (typeof rows)[number] | null = null;
    for (const row of rows) {
      const anchorMs = (row.invited_at ?? row.created_at).getTime();
      if (anchorMs > latestMs) {
        latestMs = anchorMs;
        latestRow = row;
      }
    }

    if (!latestRow || latestMs + coolingPeriodMs <= nowMs) {
      return;
    }

    const enrollmentDate = latestRow.invited_at ?? latestRow.created_at;
    const nextEligibleDate = new Date(latestMs + coolingPeriodMs);
    const lastCourseTitle = latestRow.learningGroup?.course?.title ?? "another course";

    throw new BadRequestException(
      `Participant has already been enrolled in "${lastCourseTitle}" on ${enrollmentDate.toLocaleDateString()}. A minimum gap of ${coolingPeriodDays} days between course enrollments is required. Next eligible enrollment date is ${nextEligibleDate.toLocaleDateString()}.`,
    );
  }

  /**
   * Get all participant-course allocations (non-cancelled only)
   * @param companyId - Company ID
   * @returns Array of participant-course pairs with completion status (one entry per allocation)
   */
  async getAllAllocatedParticipants(companyId: number): Promise<{
    participants: Array<{
      participantId: number;
      courseId: number;
      courseName: string;
      isCompleted: boolean;
    }>;
  }> {
    // Find all learning group participants for any course in this company
    // Include course information
    // EXCLUDE CANCELLED participants - they can be re-allocated
    const allocatedParticipants = await this.prisma.client.learningGroupParticipant.findMany({
      where: {
        learningGroup: {
          company_id: companyId,
        },
        cancelled: false, // Exclude cancelled - they can be re-allocated
      },
      select: {
        participant_id: true,
        status: true,
        course_id: true,
        course: {
          select: {
            title: true,
          },
        },
      },
    });

    // Group all courses by participant_id
    // Track all courses for each participant to check if ALL are completed
    const participantCoursesMap = new Map<
      number,
      Array<{ courseId: number; courseName: string; isCompleted: boolean }>
    >();

    allocatedParticipants.forEach((p) => {
      const isCompleted = p.status === "E_LEARNING" || p.status === "COMPLETED";
      const existing = participantCoursesMap.get(p.participant_id);

      if (!existing) {
        participantCoursesMap.set(p.participant_id, [
          {
            courseId: p.course_id,
            courseName: p.course.title,
            isCompleted: isCompleted,
          },
        ]);
      } else {
        // Check if this course is already in the list (avoid duplicates)
        const courseExists = existing.some((c) => c.courseId === p.course_id);
        if (!courseExists) {
          existing.push({
            courseId: p.course_id,
            courseName: p.course.title,
            isCompleted: isCompleted,
          });
        } else {
          // Update completion status if this one is completed
          const courseIndex = existing.findIndex((c) => c.courseId === p.course_id);
          if (courseIndex !== -1 && isCompleted) {
            existing[courseIndex].isCompleted = true;
          }
        }
      }
    });

    // Return one entry per participant-course so callers can distinguish in-progress vs completed
    // (e.g. resume is only blocked by other in-progress courses, not completed ones)
    const participants = Array.from(participantCoursesMap.entries()).flatMap(([participantId, courses]) =>
      courses.map((c) => ({
        participantId,
        courseId: c.courseId,
        courseName: c.courseName,
        isCompleted: c.isCompleted,
      })),
    );

    return { participants };
  }

  /**
   * Get course license utilization report
   * Returns utilization data for all courses in the company
   */
  async getCourseLicenseUtilization(
    companyId: number,
    filters?: {
      course?: string;
      learningGroup?: string;
      period?: string;
      utilizationStatus?: string;
    },
  ): Promise<CourseLicenseUtilizationRowDto[]> {
    // Get all learning groups for this company
    const where: any = {
      company_id: companyId,
    };

    if (filters?.course && filters.course !== "all") {
      where.course_id = parseInt(filters.course, 10);
    }

    if (filters?.learningGroup && filters.learningGroup !== "all") {
      where.id = parseInt(filters.learningGroup, 10);
    }

    const learningGroups = await this.prisma.client.learningGroup.findMany({
      where,
      include: {
        course: true,
        learningGroupParticipants: {
          where: {
            cancelled: false,
          },
        },
      },
    });

    // Group by course
    const courseMap = new Map<
      number,
      {
        courseId: number;
        courseName: string;
        totalLicensesAllocated: number;
        licensesCurrentlyUsed: number;
      }
    >();

    for (const lg of learningGroups) {
      const courseId = lg.course_id;
      if (!courseMap.has(courseId)) {
        const lgWithCourse = lg as typeof lg & { course: { title: string } };
        courseMap.set(courseId, {
          courseId: courseId,
          courseName: lgWithCourse.course.title,
          totalLicensesAllocated: 0,
          licensesCurrentlyUsed: 0,
        });
      }

      const courseData = courseMap.get(courseId)!;
      const lgWithParticipants = lg as typeof lg & {
        learningGroupParticipants: Array<{ completion_percentage: unknown }>;
      };
      const totalParticipants = lgWithParticipants.learningGroupParticipants.length;
      const activeParticipants = lgWithParticipants.learningGroupParticipants.filter((p: any) => {
        const progress = p.completion_percentage ? Number(p.completion_percentage) : 0;
        return progress > 0;
      }).length;

      courseData.totalLicensesAllocated += totalParticipants;
      courseData.licensesCurrentlyUsed += activeParticipants;
    }

    // Convert to array format
    const utilizationData: Array<{
      courseId: number;
      courseName: string;
      totalLicensesAllocated: number;
      licensesCurrentlyUsed: number;
      availableLicenses: number;
      utilizationPercentage: number;
    }> = [];

    for (const [courseId, data] of courseMap.entries()) {
      const availableLicenses = Math.max(0, data.totalLicensesAllocated - data.licensesCurrentlyUsed);
      const utilizationPercentage =
        data.totalLicensesAllocated > 0
          ? Math.round((data.licensesCurrentlyUsed / data.totalLicensesAllocated) * 100)
          : 0;

      // Filter by utilization status
      if (filters?.utilizationStatus && filters.utilizationStatus !== "all") {
        if (filters.utilizationStatus === "high" && utilizationPercentage < 80) continue;
        if (filters.utilizationStatus === "medium" && (utilizationPercentage < 50 || utilizationPercentage >= 80))
          continue;
        if (filters.utilizationStatus === "low" && utilizationPercentage >= 50) continue;
      }

      utilizationData.push({
        courseId: data.courseId,
        courseName: data.courseName,
        totalLicensesAllocated: data.totalLicensesAllocated,
        licensesCurrentlyUsed: data.licensesCurrentlyUsed,
        availableLicenses,
        utilizationPercentage,
      });
    }

    return this.toDtoArray(utilizationData, CourseLicenseUtilizationRowDto);
  }

  /**
   * Resolve enrollment for a participant in a learning group and verify company access.
   * Used by pre/post BAT report endpoints for license allocation details view.
   */
  private async getEnrollmentForParticipantReport(
    companyId: number,
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<{
    participantId: number;
    course: { id: number; assessment_id: number | null; title: string | null; course_code: string };
  }> {
    const lg = await this.prisma.client.learningGroup.findFirst({
      where: { id: learningGroupId, company_id: companyId },
      include: {
        course: true,
        learningGroupParticipants: {
          where: { id: learningGroupParticipantId },
          include: { participant: true },
        },
      },
    });
    if (!lg) {
      throw new NotFoundException(`Learning group ${learningGroupId} not found`);
    }
    const lgp = lg.learningGroupParticipants?.[0];
    if (!lgp) {
      throw new NotFoundException(`Enrollment ${learningGroupParticipantId} not found in this learning group`);
    }
    if (lgp.cancelled) {
      throw new ForbiddenException("This course license has been cancelled.");
    }
    const course = lg.course;
    if (!course?.id) {
      throw new NotFoundException("Course not found for learning group");
    }
    return {
      participantId: lgp.participant_id,
      course: {
        id: course.id,
        assessment_id: course.assessment_id ?? null,
        title: course.title ?? null,
        course_code: course.course_code ?? "",
      },
    };
  }

  /**
   * Generate pre-BAT HTML for a participant in a learning group (company-scoped, for allocation details view).
   */
  async generatePreBatHtmlForParticipant(
    companyId: number,
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<string> {
    const { participantId } = await this.getEnrollmentForParticipantReport(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
    );
    return this.integrationService.generatePreBatHtml(learningGroupId, participantId);
  }

  /**
   * Generate post-BAT HTML for a participant in a learning group (company-scoped).
   */
  async generatePostBatHtmlForParticipant(
    companyId: number,
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<string> {
    const { participantId, course } = await this.getEnrollmentForParticipantReport(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
    );
    return this.integrationService.generatePostBatHtml(learningGroupId, participantId, course.course_code);
  }

  /**
   * Get pre-BAT PDF download URL for a participant in a learning group (company-scoped).
   */
  async getPreBatPdfUrlForParticipant(
    companyId: number,
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<string> {
    const { participantId, course } = await this.getEnrollmentForParticipantReport(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
    );
    if (!course.assessment_id) {
      throw new NotFoundException("Course does not have an assessment assigned");
    }
    try {
      // Latest from S3 first; generates and uploads on-demand if missing.
      const s3Path = await this.integrationService.checkPreBatPdfExists(learningGroupId, participantId);
      return await this.mediaUtilService.getS3Url(s3Path, false);
    } catch (err) {
      const e = err instanceof Error ? err : new Error(String(err));
      const causeMsg = e.cause instanceof Error ? e.cause.message : e.cause != null ? String(e.cause) : "";
      this.logger.error(
        `Failed to generate pre-BAT PDF: ${e.message}${causeMsg ? ` (cause: ${causeMsg})` : ""}`,
        e.stack,
      );
      throw new InternalServerErrorException("Failed to generate PDF. Please try again.");
    }
  }

  /**
   * Get post-BAT PDF download URL for a participant in a learning group (company-scoped).
   */
  async getPostBatPdfUrlForParticipant(
    companyId: number,
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<string> {
    const { participantId, course } = await this.getEnrollmentForParticipantReport(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
    );
    if (!course.assessment_id) {
      throw new NotFoundException("Course does not have an assessment assigned");
    }
    try {
      // Latest from S3 first.
      // If missing, generate and upload once, then return the fresh file URL.
      let s3Path: string;
      try {
        s3Path = await this.integrationService.checkPostBatPdfExists(learningGroupId, participantId);
      } catch (error) {
        if (!(error instanceof NotFoundException)) {
          throw error;
        }
        s3Path = await this.integrationService.generateAndUploadPostBatPdf(learningGroupId, participantId, {
          id: course.id,
          assessment_id: course.assessment_id,
          title: course.title,
        });
      }
      return await this.mediaUtilService.getS3Url(s3Path, false);
    } catch (err) {
      const e = err instanceof Error ? err : new Error(String(err));
      const causeMsg = e.cause instanceof Error ? e.cause.message : e.cause != null ? String(e.cause) : "";
      this.logger.error(
        `Failed to generate post-BAT PDF: ${e.message}${causeMsg ? ` (cause: ${causeMsg})` : ""}`,
        e.stack,
      );
      throw new InternalServerErrorException("Failed to generate PDF. Please try again.");
    }
  }
}

results matching ""

    No results matching ""