apps/recallassess/recallassess-api/src/api/client/learning-group/learning-group.service.ts
constructor(emailSender: BNestEmailSenderService, systemLogService: SystemLogService, eventEmitter: EventEmitter2, integrationService: IntegrationService, configService: ConfigService, mediaUtilService: BNestMediaUtilService, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
|
||||||||||||||||||||||||
|
Parameters :
|
| Async acceptCourseInvitation | ||||||||||||
acceptCourseInvitation(participantId: number, learningGroupParticipantId: number)
|
||||||||||||
|
Accept course invitation
Parameters :
Returns :
Promise<literal type>
Success message |
| Private Async assertParticipantPassesEnrollmentCooling | ||||||||||||
assertParticipantPassesEnrollmentCooling(companyId: number, participantId: number, targetCourseId: number)
|
||||||||||||
|
Blocks allocating a participant to targetCourseId until
Parameters :
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:
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 :
Returns :
Promise<literal type>
Success message |
| Async checkCancellationEligibility | ||||||||||||
checkCancellationEligibility(learningGroupParticipantId: number, companyId: number)
|
||||||||||||
|
Check cancellation eligibility and return progress information
Parameters :
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 :
Returns :
Promise<CLLearningGroupDto>
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 :
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 :
Returns :
Promise<string>
|
| Async getAllAllocatedParticipants | ||||||||
getAllAllocatedParticipants(companyId: number)
|
||||||||
|
Get all participant-course allocations (non-cancelled only)
Parameters :
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 :
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 :
|
| 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 :
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 :
Returns :
Promise<CLLearningGroupListResponse>
Paginated list response with learning groups |
| Async getLearningGroupDetail | ||||||||||||
getLearningGroupDetail(companyId: number, learningGroupId: number)
|
||||||||||||
|
Get a single learning group (license allocation) with participants
Parameters :
Returns :
Promise<unknown>
Learning group detail with participants |
| Async getLicenseInfo | ||||||||
getLicenseInfo(companyId: number)
|
||||||||
|
Get license information for the company
Parameters :
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.
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 :
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 :
Returns :
Promise<string>
|
| Private getSafeEmailErrorMessage | ||||||
getSafeEmailErrorMessage(error: unknown)
|
||||||
|
Build a user-safe error message for email send failures (no credentials or internals).
Parameters :
Returns :
string
|
| Async getSubscriptionBillingInfo | ||||||||
getSubscriptionBillingInfo(companyId: number)
|
||||||||
|
Get subscription billing information for the company
Parameters :
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 :
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 :
Returns :
Promise<void>
|
| Async resendInvitation | ||||||||||||||||||||
resendInvitation(companyId: number, learningGroupId: number, participantId: number, participantIdInvitedBy: number)
|
||||||||||||||||||||
|
Resend invitation to a specific participant
Parameters :
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 :
Returns :
Promise<literal type>
Success message with restored status |
| Private Async sendInvitationEmail | ||||||
sendInvitationEmail(data: literal type)
|
||||||
|
Send invitation email to a participant
Parameters :
Returns :
Promise<void>
|
| Async sendInvitationsToAll | ||||||||||||||||
sendInvitationsToAll(companyId: number, learningGroupId: number, participantIdInvitedBy: number)
|
||||||||||||||||
|
Send invitations to all participants in a learning group
Parameters :
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 :
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 :
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 :
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 :
Returns :
any
The Prisma repository instance |
| Protected toDto | ||||||||||||
toDto(entity: any, dtoClass: unknown)
|
||||||||||||
|
Inherited from
CLBaseService
|
||||||||||||
|
Defined in
CLBaseService:20
|
||||||||||||
Type parameters :
|
||||||||||||
|
Transform database entity to DTO using class-transformer
Parameters :
Returns :
TDto
Transformed DTO instance |
| Protected toDtoArray | ||||||||||||
toDtoArray(entities: any[], dtoClass: unknown)
|
||||||||||||
|
Inherited from
CLBaseService
|
||||||||||||
|
Defined in
CLBaseService:30
|
||||||||||||
Type parameters :
|
||||||||||||
|
Transform array of database entities to DTOs
Parameters :
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 :
Returns :
Promise<boolean>
True if entity belongs to company, false otherwise |
| 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.");
}
}
}