apps/recallassess/recallassess-api/src/api/client/participant/participant.service.ts
Properties |
|
Methods |
|
constructor(eventEmitter: EventEmitter2, systemLogService: SystemLogService, participantGroupService: CLParticipantGroupService, emailSender: BNestEmailSenderService)
|
|||||||||||||||
|
Parameters :
|
| Async addParticipant | ||||||||||||||||
addParticipant(data: AddParticipantDto, companyId: number, participantIdAddedBy: number)
|
||||||||||||||||
|
Add a new participant
Parameters :
Returns :
Promise<CLParticipantDto>
Added participant data |
| Private Async adjustDueDatesOnReactivation | |||||||||
adjustDueDatesOnReactivation(participantId: number, deactivatedAt: Date | null)
|
|||||||||
|
Adjust due dates for courses when participant is reactivated Extends due dates by the duration of deactivation
Parameters :
Returns :
Promise<void>
|
| Private Async assignParticipantToGroup | ||||||||||||||||||||
assignParticipantToGroup(participantId: number, groupName: string, companyId: number, participantIdAddedBy: number)
|
||||||||||||||||||||
|
Assign a participant to a participant group (department)
Parameters :
Returns :
Promise<void>
|
| Async checkParticipantActiveWork | ||||||||||||
checkParticipantActiveWork(participantId: number, companyId: number)
|
||||||||||||
|
Check if participant has active work (incomplete courses, pending assessments)
Parameters :
Returns :
Promise<ParticipantActiveWorkDto>
Active work information |
| Async deleteParticipantForParticipantAdmin | |||||||||||||||
deleteParticipantForParticipantAdmin(targetParticipantId: number, companyId: number, performedByParticipantId: number, performedByRole: string)
|
|||||||||||||||
|
Participant administrators may remove a company contact only when no active course
allocation exists (same rule as directory
Parameters :
Returns :
Promise<void>
|
| Private Async enrichParticipantData | ||||||||
enrichParticipantData(participant: Record<string | unknown>)
|
||||||||
|
Enrich participant data with additional fields for frontend
Parameters :
Returns :
Promise<CLParticipantDto>
Enriched participant DTO |
| Async getFilteredParticipants | |||||||||||||||||||||||||||||||||||||||||||||
getFilteredParticipants(companyId: number, page: number, limit: number, sq?: string, status?: string, participantGroup?: string, callerParticipantId?: number, callerRole?: string)
|
|||||||||||||||||||||||||||||||||||||||||||||
|
Get filtered participants for client consumption with pagination. When caller role is PARTICIPANT (not PARTICIPANT_ADMIN), returns only that participant's details.
Parameters :
Returns :
Promise<CLParticipantListResponse>
Paginated list response with participants and metadata |
| Async getParticipantAdmin | ||||||||||||
getParticipantAdmin(participantId: number, companyId: number)
|
||||||||||||
|
Get the participant administrator (User who created/manages this participant)
Parameters :
Returns :
Promise<literal type | null>
Admin user information (first_name, last_name, email, phone) or null if not found |
| Async getParticipantById | ||||||||||||
getParticipantById(id: number, companyId: number)
|
||||||||||||
|
Get a single participant by ID
Parameters :
Returns :
Promise<CLParticipantDto | null>
Enriched participant data |
| Async getParticipantLicenseAvailability | ||||||||
getParticipantLicenseAvailability(companyId: number)
|
||||||||
|
Get license availability information for adding participants
Parameters :
Returns :
Promise<literal type>
License availability info including total licenses, current active participants, and available slots |
| Private Async pauseScheduledEmails | ||||||
pauseScheduledEmails(participantId: number)
|
||||||
|
Pause scheduled emails for a deactivated participant Moves scheduled_date far into the future to prevent sending
Parameters :
Returns :
Promise<void>
|
| Private Async resumeScheduledEmails | |||||||||
resumeScheduledEmails(participantId: number, deactivatedAt: Date | null)
|
|||||||||
|
Resume scheduled emails for a reactivated participant Adjusts scheduled_date by the deactivation period
Parameters :
Returns :
Promise<void>
|
| Async saveParticipant | ||||||||||||||||||||
saveParticipant(id: number, companyId: number, data: SaveParticipantDto, participantIdUpdatedBy: number)
|
||||||||||||||||||||
|
Save (update) a participant
Parameters :
Returns :
Promise<CLParticipantDto | null>
Saved participant data |
| Async verifyProgressIntegrity | ||||||
verifyProgressIntegrity(participantId: number)
|
||||||
|
Verify progress integrity for a participant Checks for missing or inconsistent progress data
Parameters :
Returns :
Promise<literal type>
|
| Protected buildCompanyWhere | ||||||||||||
buildCompanyWhere(companyId: number, additionalWhere?: Record
|
||||||||||||
|
Inherited from
CLBaseService
|
||||||||||||
|
Defined in
CLBaseService:82
|
||||||||||||
|
Build base WHERE clause with company scope Ensures all queries are scoped to the user's company
Parameters :
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 |
| Protected Readonly prisma |
Type : BNestPrismaService
|
Decorators :
@Inject()
|
|
Inherited from
CLBaseService
|
|
Defined in
CLBaseService:12
|
import { CLBaseService, SystemLogService } from "@api/shared/services";
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
UnprocessableEntityException,
} from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { ParticipantLearningProgressStatus, ParticipantRole, SystemLogEntityType } from "@prisma/client";
import { CLParticipantGroupService } from "../participant-group/participant-group.service";
import { AddParticipantDto, CLParticipantDto, SaveParticipantDto } from "./dto";
import { ParticipantActiveWorkDto } from "./dto/participant-active-work.dto";
import { CLParticipantListResponse } from "./dto/participant-list-response.dto";
import {
PARTICIPANT_EVENTS,
ParticipantCreatedEvent,
ParticipantDeletedEvent,
ParticipantReactivatedEvent,
ParticipantStatusUpdatedEvent,
} from "./events/participant.events";
@Injectable()
export class CLParticipantService extends CLBaseService {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly systemLogService: SystemLogService,
private readonly participantGroupService: CLParticipantGroupService,
private readonly emailSender: BNestEmailSenderService,
) {
super();
}
/**
* Get filtered participants for client consumption with pagination.
* When caller role is PARTICIPANT (not PARTICIPANT_ADMIN), returns only that participant's details.
*
* @param companyId - Company ID to filter participants by (required)
* @param page - Page number (1-based)
* @param limit - Number of items per page
* @param sq - Search query to filter by name, email, or participant group
* @param status - Filter by status (all, active, inactive, pending)
* @param participantGroup - Filter by participant group
* @param callerParticipantId - When callerRole is PARTICIPANT, restrict list to this id
* @param callerRole - PARTICIPANT => only own details; PARTICIPANT_ADMIN => all in company
* @returns Paginated list response with participants and metadata
*/
async getFilteredParticipants(
companyId: number,
page = 1,
limit = 20,
sq?: string,
status?: string,
participantGroup?: string,
callerParticipantId?: number,
callerRole?: string,
): Promise<CLParticipantListResponse> {
// Build where clause based on filters
// Using flexible typing since the actual DB schema may differ from strict Prisma types
const where: Record<string, unknown> = {
// Always filter by company_id - participants belong to a specific company
company_id: companyId,
};
// When caller is PARTICIPANT (not PARTICIPANT_ADMIN), show only their own details (hide-data / UAT expectation)
if (callerRole === "PARTICIPANT" && callerParticipantId != null) {
where["id"] = callerParticipantId;
}
// Status filter - convert status string to is_active boolean
if (status && status !== "all") {
// "active" -> is_active = true
// "inactive" or "pending" -> is_active = false
where["is_active"] = status === "active";
}
// Participant group filter (department)
if (participantGroup && participantGroup !== "all") {
where["department"] = participantGroup;
}
// Search query filter (name, email, or department)
if (sq?.trim()) {
where["OR"] = [
{ first_name: { contains: sq, mode: "insensitive" } },
{ last_name: { contains: sq, mode: "insensitive" } },
{ email: { contains: sq, mode: "insensitive" } },
// { department: { contains: sq, mode: "insensitive" } },
];
}
// Get total count for pagination metadata
const totalCount = await this.prisma.client.participant.count({
where,
});
// Base filter for status counts (same scope as list: company or single participant when caller is PARTICIPANT)
const countBaseWhere: Record<string, unknown> = { company_id: companyId };
if (callerRole === "PARTICIPANT" && callerParticipantId != null) {
countBaseWhere["id"] = callerParticipantId;
}
// Calculate status counts directly from the participants table to avoid stale denormalized totals
const [activeCount, inactiveCount, replacedCount] = await Promise.all([
this.prisma.client.participant.count({
where: {
...countBaseWhere,
is_active: true,
is_replaced: false,
},
}),
this.prisma.client.participant.count({
where: {
...countBaseWhere,
is_active: false,
is_replaced: false,
},
}),
this.prisma.client.participant.count({
where: {
...countBaseWhere,
is_replaced: true,
},
}),
]);
// Calculate pagination
const skip = (page - 1) * limit;
// Fetch participants from database with pagination
const participants = await this.prisma.client.participant.findMany({
where,
orderBy: [
{ created_at: "desc" }, // Newest first
],
skip,
take: limit,
});
// Transform database participants to client-facing format
// Load all group histories in one query for efficiency
const participantIds = participants.map((p) => p.id as number);
const groupHistories = await this.prisma.client.participantGroupHistory.findMany({
where: {
participant_id: {
in: participantIds,
},
},
include: {
participantGroup: {
select: {
name: true,
},
},
},
orderBy: {
created_at: "desc",
},
});
// Create a map of participant_id -> group name (most recent)
const participantGroupMap = new Map<number, string>();
for (const history of groupHistories) {
if (!participantGroupMap.has(history.participant_id)) {
participantGroupMap.set(history.participant_id, history.participantGroup.name);
}
}
// Get course enrollment data for all participants to calculate progress (exclude cancelled licenses)
const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: {
in: participantIds,
},
cancelled: false,
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
title: true,
},
},
eLearningParticipants: {
where: {
participant_id: {
in: participantIds,
},
},
take: 1,
},
},
},
},
});
// Create maps for efficient lookup
const enrollmentMap = new Map<number, typeof enrollments>();
enrollments.forEach((enrollment) => {
const participantId = enrollment.participant_id;
if (!enrollmentMap.has(participantId)) {
enrollmentMap.set(participantId, []);
}
enrollmentMap.get(participantId)!.push(enrollment);
});
// Get email preferences for all participants (for email subscription status)
// Use the participantIds already declared above
const emailPreferences = await (this.prisma.client as any).emailPreference.findMany({
where: {
participant_id: { in: participantIds },
},
select: {
participant_id: true,
all_reminders_enabled: true,
unsubscribed_at: true,
unsubscribed_reason: true,
resubscribed_at: true,
resubscribe_count: true,
},
});
// Create email status map
const emailStatusMap = new Map();
emailPreferences.forEach((pref: any) => {
emailStatusMap.set(pref.participant_id, {
isUnsubscribed: !pref.all_reminders_enabled,
unsubscribedAt: pref.unsubscribed_at ? new Date(pref.unsubscribed_at).toISOString() : null,
unsubscribedReason: pref.unsubscribed_reason,
resubscribedAt: pref.resubscribed_at ? new Date(pref.resubscribed_at).toISOString() : null,
resubscribeCount: pref.resubscribe_count || 0,
});
});
// Transform participants with department and progress data
const data = await Promise.all(
participants.map(async (participant) => {
const participantData = this.toDto(participant, CLParticipantDto);
const groupName = participantGroupMap.get(participant.id as number);
participantData.department = groupName || "Not Assigned";
// Add email subscription status
const emailStatus = emailStatusMap.get(participant.id as number);
participantData.email_subscription_status = emailStatus || {
isUnsubscribed: false,
unsubscribedAt: null,
unsubscribedReason: null,
resubscribedAt: null,
resubscribeCount: 0,
};
// Calculate progress metrics
const participantEnrollments = enrollmentMap.get(participant.id as number) || [];
const totalCourses = participantEnrollments.length;
const completedCourses = participantEnrollments.filter(
(e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
// Calculate progress from current/active course (not average of all courses)
// Use completion_percentage from LearningGroupParticipant (authoritative source)
let participantProgress = 0;
let lastActivity: Date | null = null as Date | null;
let currentCourse: string | undefined;
// Find current course (in progress, or most recent if all completed)
const inProgressEnrollments = participantEnrollments.filter(
(e) => e.status !== ParticipantLearningProgressStatus.COMPLETED,
);
if (inProgressEnrollments.length > 0) {
// Get the most recent in-progress course
const mostRecent = inProgressEnrollments.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0];
currentCourse = mostRecent.learningGroup?.course?.title;
// Use completion_percentage from the current course
participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
} else if (participantEnrollments.length > 0) {
// All completed, get the most recent completed course
const mostRecent = participantEnrollments.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0];
currentCourse = mostRecent.learningGroup?.course?.title;
// Use completion_percentage from the most recent completed course
participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
}
// Get last activity from all enrollments
participantEnrollments.forEach((enrollment) => {
const progressData = enrollment.learningGroup?.eLearningParticipants?.find(
(ep) => ep.participant_id === participant.id,
);
if (progressData?.last_activity) {
const activityDate = new Date(progressData.last_activity);
if (!lastActivity || activityDate > lastActivity) {
lastActivity = activityDate;
}
}
});
// Set calculated fields
participantData.total_courses = totalCourses;
participantData.courses_completed = completedCourses;
participantData.progress = participantProgress; // Use current course progress, not average
participantData.current_course = currentCourse;
if (lastActivity) {
participantData.last_active = lastActivity.toISOString();
} else if (participant.last_login) {
participantData.last_active = new Date(participant.last_login).toISOString();
} else {
participantData.last_active = "";
}
// === Invitation tracking fields ===
// current_stage = status of the most-recently allocated enrollment (drives the row's stage pill).
// has_password = whether users.password_hash is set (distinguishes Invited vs Pending email templates).
// eligible_for_next = most-recent enrollment has cleared e-Learning (drives "Allocate next" menu item).
// enrolled_courses[] = full per-course breakdown for the View Details dialog.
if (participantEnrollments.length > 0) {
const sortedByAllocated = [...participantEnrollments].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
);
const mostRecentEnrollment = sortedByAllocated[0];
participantData.current_stage = mostRecentEnrollment.status;
// Eligible for next allocation when the most-recent course has cleared e-Learning.
// The Prisma enum maxes out at COMPLETED; we treat anything past E_LEARNING (i.e.
// POST_BAT or COMPLETED) plus an explicit e_learning_completed_at timestamp as eligible.
participantData.eligible_for_next =
mostRecentEnrollment.status === ParticipantLearningProgressStatus.POST_BAT ||
mostRecentEnrollment.status === ParticipantLearningProgressStatus.COMPLETED ||
(mostRecentEnrollment.status === ParticipantLearningProgressStatus.E_LEARNING &&
!!mostRecentEnrollment.e_learning_completed_at);
// Per-course breakdown for View Details dialog.
// The eLearningParticipants relation is filtered by `participant_id IN
// [batchIds]` and limited to take:1, which can return a row for a
// *different* participant in the same learning group when multiple
// batched participants are enrolled. We must .find() the entry that
// matches THIS participant — same pattern as the lastActivity loop above.
participantData.enrolled_courses = sortedByAllocated.map((e) => {
const ownEpRecord = e.learningGroup?.eLearningParticipants?.find(
(ep) => ep.participant_id === participant.id,
);
return {
enrollment_id: e.id,
learning_group_id: e.learning_group_id,
course_name: e.learningGroup?.course?.title ?? 'Unknown course',
stage: e.status,
progress: e.completion_percentage ? Number(e.completion_percentage) : 0,
allocated_at: (e.invited_at ?? e.created_at).toISOString(),
last_active_at: ownEpRecord?.last_activity?.toISOString() ?? null,
};
});
} else {
participantData.current_stage = undefined;
participantData.eligible_for_next = false;
participantData.enrolled_courses = [];
}
participantData.has_password = !!participant.password_hash;
return participantData;
}),
);
// Return paginated response with metadata including status counts
return new CLParticipantListResponse(data, page, limit, totalCount, activeCount, inactiveCount, replacedCount);
}
/**
* Add a new participant
* @param data Participant data
* @param companyId Company ID from authenticated user
* @param participantIdAddedBy Participant ID of the user adding this participant (from auth token)
* @returns Added participant data
* @throws ConflictException if email already exists
*/
async addParticipant(
data: AddParticipantDto,
companyId: number,
participantIdAddedBy: number,
): Promise<CLParticipantDto> {
// Check if email already exists
const normalizedEmail = data.email.trim().toLowerCase();
const existingParticipant = await this.prisma.client.participant.findFirst({
where: {
email: normalizedEmail,
company_id: companyId,
},
});
if (existingParticipant) {
throw new ConflictException(
`A contact with the email "${data.email}" already exists. Please use a different email address.`,
);
}
// No license check needed when adding participants (company contacts)
// License validation only applies when allocating licenses (assigning participants to courses)
// Participants can be added unlimited - they are just company contacts
const participant = await this.prisma.client.participant.create({
data: {
company_id: companyId,
first_name: data.first_name.trim(),
last_name: data.last_name.trim(),
email: normalizedEmail,
is_active: true, // New participants start as active
is_replaced: false,
},
});
// Emit event for participant creation (listeners will update counts)
this.eventEmitter.emit(
PARTICIPANT_EVENTS.CREATED,
new ParticipantCreatedEvent(participant.id, companyId, true, false),
);
// Handle department assignment via ParticipantGroupHistory
// Skip if department is empty, undefined, or "Not Assigned" (treat "Not Assigned" as no department)
const trimmedDepartment = data.department?.trim();
if (trimmedDepartment && trimmedDepartment.toLowerCase() !== "not assigned") {
await this.assignParticipantToGroup(participant.id, trimmedDepartment, companyId, participantIdAddedBy);
}
// Prepare participant data for logging (include department if assigned)
const participantDataForLog: Record<string, unknown> = {
...participant,
};
// Add department to the log data if it was assigned
// Note: Department is stored in ParticipantGroupHistory, not directly on participant
// So we need to explicitly add it to the log data
if (data.department?.trim()) {
participantDataForLog["department"] = data.department.trim();
} else {
// Even if no department was provided, check if one exists from the assignment
// (in case it was assigned in a previous step)
const groupHistory = await this.prisma.client.participantGroupHistory.findFirst({
where: { participant_id: participant.id },
include: { participantGroup: { select: { name: true } } },
orderBy: { created_at: "desc" },
});
if (groupHistory?.participantGroup?.name) {
participantDataForLog["department"] = groupHistory.participantGroup.name;
}
}
// Log the creation
await this.systemLogService.logInsert(
SystemLogEntityType.PARTICIPANT,
participant.id,
participantDataForLog,
{ company_id: participant.company_id },
);
return await this.enrichParticipantData(participant);
}
/**
* Get license availability information for adding participants
* @param companyId Company ID
* @returns License availability info including total licenses, current active participants, and available slots
*/
async getParticipantLicenseAvailability(companyId: number): Promise<{
totalLicenses: number;
currentActiveParticipants: number;
availableSlots: number;
canAddMore: boolean;
}> {
// Get current active subscription
const subscription = await this.prisma.client.subscription.findFirst({
where: {
company_id: companyId,
is_current: true,
status: "ACTIVE",
},
orderBy: {
created_at: "desc",
},
});
if (!subscription) {
return {
totalLicenses: 0,
currentActiveParticipants: 0,
availableSlots: 0,
canAddMore: false,
};
}
// Count current active participants (not replaced)
const currentActiveCount = await this.prisma.client.participant.count({
where: {
company_id: companyId,
is_active: true,
is_replaced: false,
},
});
const totalLicenses = subscription.license_count;
// Handle unlimited licenses (license_count === -1 means no limit)
if (totalLicenses === -1) {
return {
totalLicenses: -1, // -1 indicates unlimited
currentActiveParticipants: currentActiveCount,
availableSlots: -1, // -1 indicates unlimited
canAddMore: true, // Always can add more when unlimited
};
}
const availableSlots = Math.max(0, totalLicenses - currentActiveCount);
const canAddMore = currentActiveCount < totalLicenses;
return {
totalLicenses,
currentActiveParticipants: currentActiveCount,
availableSlots,
canAddMore,
};
}
/**
* Get a single participant by ID
* @param id Participant ID
* @param companyId Company ID to ensure participant belongs to the company
* @returns Enriched participant data
*/
async getParticipantById(id: number, companyId: number): Promise<CLParticipantDto | null> {
const participant = await this.findByIdWithCompanyScope("participant", id, companyId);
if (!participant) {
return null;
}
return this.enrichParticipantData(participant);
}
/**
* Get the participant administrator (User who created/manages this participant)
* @param participantId Participant ID (from auth)
* @param companyId Company ID to ensure participant belongs to the company
* @returns Admin user information (first_name, last_name, email, phone) or null if not found
*/
async getParticipantAdmin(
participantId: number,
companyId: number,
): Promise<{
id: number;
first_name: string;
last_name: string;
email: string;
phone: string | null;
} | null> {
// Get the participant to find the admin user ID
const participant = await this.findByIdWithCompanyScope("participant", participantId, companyId);
if (!participant) {
return null;
}
// Get the user_id_created_by (admin who created/manages this participant)
const adminUserId = participant["user_id_created_by"] as number | null | undefined;
if (!adminUserId) {
// No admin assigned - return null
return null;
}
// Fetch the admin user
const adminUser = await this.prisma.client.user.findUnique({
where: { id: adminUserId },
select: {
id: true,
first_name: true,
last_name: true,
email: true,
phone: true,
},
});
return adminUser;
}
/**
* Check if participant has active work (incomplete courses, pending assessments)
* @param participantId Participant ID
* @param companyId Company ID to ensure participant belongs to the company
* @returns Active work information
*/
async checkParticipantActiveWork(participantId: number, companyId: number): Promise<ParticipantActiveWorkDto> {
// First verify the participant belongs to the company
const participant = await this.findByIdWithCompanyScope("participant", participantId, companyId);
if (!participant) {
throw new BadRequestException(`Participant with ID ${participantId} not found`);
}
// Get all enrollments for this participant
const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: participantId,
cancelled: false, // Only non-cancelled enrollments
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
title: true,
},
},
},
},
},
});
// Filter incomplete courses (status is not COMPLETED)
const incompleteCourses = enrollments
.filter((e) => e.status !== ParticipantLearningProgressStatus.COMPLETED)
.map((e) => ({
courseName: e.learningGroup.course.title,
courseId: e.learningGroup.course.id,
progress: e.completion_percentage ? Number(e.completion_percentage) : 0,
status: e.status.toString(),
enrollmentId: e.id,
}));
// Get pending assessments (assessments that haven't been completed)
const assessmentParticipants = await this.prisma.client.assessmentParticipant.findMany({
where: {
participant_id: participantId,
assessment_completion_date: null, // Not completed
learning_group_id: {
not: null, // Only assessments assigned through learning groups
},
},
include: {
course: {
select: {
id: true,
title: true,
},
},
learningGroup: {
select: {
id: true,
},
},
},
});
const pendingAssessments = assessmentParticipants.map((ap) => ({
assessmentType: ap.assessment_type === "PRE_BAT" ? "Pre-BAT" : "Post-BAT",
courseName: ap.course.title,
courseId: ap.course.id,
enrollmentId: ap.learning_group_id!,
}));
const activeEnrollments = enrollments.filter(
(e) => e.status !== ParticipantLearningProgressStatus.COMPLETED,
).length;
const hasActiveWork = incompleteCourses.length > 0 || pendingAssessments.length > 0 || activeEnrollments > 0;
return this.toDto(
{
hasActiveWork,
incompleteCourses,
pendingAssessments,
activeEnrollments,
},
ParticipantActiveWorkDto,
);
}
/**
* Save (update) a participant
* @param id Participant ID
* @param companyId Company ID to ensure participant belongs to the company
* @param data Participant data to save
* @param participantIdUpdatedBy Participant ID of the user updating this participant (from auth token)
* @returns Saved participant data
*/
async saveParticipant(
id: number,
companyId: number,
data: SaveParticipantDto,
participantIdUpdatedBy: number,
): Promise<CLParticipantDto | null> {
const updateData: Record<string, unknown> = {};
if (data.first_name !== undefined) {
updateData["first_name"] = data.first_name.trim();
}
if (data.last_name !== undefined) {
updateData["last_name"] = data.last_name.trim();
}
// Email should not be updated via this endpoint (readonly)
// if (data.email !== undefined) {
// updateData["email"] = data.email.trim().toLowerCase();
// }
if (data.is_active !== undefined) {
updateData["is_active"] = data.is_active;
}
// First verify the participant belongs to the company
const existingParticipant = await this.findByIdWithCompanyScope("participant", id, companyId);
if (!existingParticipant) {
return null;
}
// Track old status for count updates
const oldIsActive = existingParticipant["is_active"] as boolean;
const oldIsReplaced = (existingParticipant["is_replaced"] as boolean) || false;
// Get old data for logging
const oldParticipant = await this.prisma.client.participant.findUnique({
where: { id },
});
const participant = await this.prisma.client.participant.update({
where: { id },
data: updateData,
});
if (!participant) {
return null;
}
// Emit event if status changed (listeners will update counts)
const newIsActive = (participant["is_active"] as boolean) || false;
const newIsReplaced = (participant["is_replaced"] as boolean) || false;
if (oldIsActive !== newIsActive || oldIsReplaced !== newIsReplaced) {
this.eventEmitter.emit(
PARTICIPANT_EVENTS.STATUS_UPDATED,
new ParticipantStatusUpdatedEvent(id, companyId, oldIsActive, newIsActive, oldIsReplaced, newIsReplaced),
);
// Log deactivation/reactivation history
if (oldIsActive !== newIsActive) {
const actionType = newIsActive ? "REACTIVATED" : "DEACTIVATED";
const oldData = {
is_active: oldIsActive,
participant_id: id,
participant_name: `${oldParticipant?.first_name || ""} ${oldParticipant?.last_name || ""}`.trim(),
participant_email: oldParticipant?.email || "",
};
const newData = {
is_active: newIsActive,
participant_id: id,
participant_name: `${participant.first_name} ${participant.last_name}`,
participant_email: participant.email,
action: actionType,
company_id: companyId,
};
await this.systemLogService.logUpdate(
SystemLogEntityType.PARTICIPANT,
id,
oldData,
newData,
{ is_active: actionType },
{ company_id: companyId },
);
}
// Handle scheduled email pause/resume
if (oldIsActive !== newIsActive) {
if (newIsActive === false) {
// Pause scheduled emails when deactivating
await this.pauseScheduledEmails(id);
} else {
// Resume scheduled emails when reactivating
const deactivatedAt = oldParticipant?.updated_at ? new Date(oldParticipant.updated_at as Date) : null;
await this.resumeScheduledEmails(id, deactivatedAt);
}
}
// If participant was reactivated, emit reactivation event and send email
if (oldIsActive === false && newIsActive === true) {
// Get active work info for email
const activeWork = await this.checkParticipantActiveWork(id, companyId);
// Adjust due dates if participant was deactivated for a period
// Use updated_at from oldParticipant as proxy for deactivation time
const deactivatedAt = oldParticipant?.updated_at ? new Date(oldParticipant.updated_at as Date) : null;
await this.adjustDueDatesOnReactivation(id, deactivatedAt);
// Verify progress integrity
const integrityCheck = await this.verifyProgressIntegrity(id);
if (!integrityCheck.isValid && integrityCheck.issues.length > 0) {
// Log integrity issues but don't fail reactivation
console.warn(`Progress integrity issues found for participant ${id}:`, integrityCheck.issues);
}
this.eventEmitter.emit(
PARTICIPANT_EVENTS.REACTIVATED,
new ParticipantReactivatedEvent(
id,
companyId,
activeWork.incompleteCourses.length,
activeWork.pendingAssessments.length,
new Date(),
),
);
// Send reactivation welcome email
try {
const frontendUrl = requireEnv("FRONTEND_URL");
await this.emailSender.sendTemplatedEmail({
to: participant.email,
templateKey: "PARTICIPANT_REACTIVATED",
variables: {
"user.name": participant.first_name,
"user.email": participant.email,
"user.first_name": participant.first_name,
"incomplete_courses.count": activeWork.incompleteCourses.length.toString(),
"pending_assessments.count": activeWork.pendingAssessments.length.toString(),
"system.loginUrl": `${frontendUrl}/portal/login`,
"system.myCoursesUrl": `${frontendUrl}/portal/my-courses`,
},
metadata: {
participant_id: id,
company_id: companyId,
triggeredBy: "participant_reactivation",
incompleteCourses: activeWork.incompleteCourses.length,
pendingAssessments: activeWork.pendingAssessments.length,
},
});
} catch (emailError) {
// Log error but don't fail the request
console.error("Failed to send reactivation email:", emailError);
}
}
}
// Get old department for logging
const oldGroupHistory = await this.prisma.client.participantGroupHistory.findFirst({
where: { participant_id: id },
include: { participantGroup: { select: { name: true } } },
});
const oldDepartment = oldGroupHistory?.participantGroup?.name || null;
// Handle department assignment via ParticipantGroupHistory
if (data.department !== undefined) {
// Delete existing group assignments (we'll create a new one)
// Note: In a production system, you might want to soft-delete by setting removed_at
// For now, we'll delete existing assignments and create a new one
await this.prisma.client.participantGroupHistory.deleteMany({
where: {
participant_id: id,
},
});
// Assign to new group if department is provided
if (data.department.trim()) {
await this.assignParticipantToGroup(id, data.department.trim(), companyId, participantIdUpdatedBy);
}
}
// Get new department for logging
const newGroupHistory = await this.prisma.client.participantGroupHistory.findFirst({
where: { participant_id: id },
include: { participantGroup: { select: { name: true } } },
});
const newDepartment = newGroupHistory?.participantGroup?.name || null;
// Prepare participant data for logging (include department)
const oldParticipantData: Record<string, unknown> = {
...oldParticipant,
};
if (oldDepartment) {
oldParticipantData["department"] = oldDepartment;
}
const updatedParticipantData: Record<string, unknown> = {
...participant,
};
if (newDepartment) {
updatedParticipantData["department"] = newDepartment;
}
// Calculate changed fields and log the update
if (oldParticipant) {
const changedFields = SystemLogService.calculateChangedFields(oldParticipantData, updatedParticipantData);
await this.systemLogService.logUpdate(
SystemLogEntityType.PARTICIPANT,
id,
oldParticipantData,
updatedParticipantData,
changedFields,
{ company_id: participant.company_id },
);
}
return await this.enrichParticipantData(participant);
}
/**
* Assign a participant to a participant group (department)
* @param participantId Participant ID
* @param groupName Participant group name
* @param companyId Company ID
* @param participantIdAddedBy Participant ID who is making this assignment
* @throws BadRequestException if the group doesn't exist
*/
private async assignParticipantToGroup(
participantId: number,
groupName: string,
companyId: number,
participantIdAddedBy: number,
): Promise<void> {
// Skip if groupName is empty or "Not Assigned" (treat as no department)
const trimmedGroupName = groupName.trim();
if (!trimmedGroupName || trimmedGroupName.toLowerCase() === "not assigned") {
return; // No department assignment needed
}
// Find the participant group by name and company
const participantGroup = await this.prisma.client.participantGroup.findFirst({
where: {
name: trimmedGroupName,
company_id: companyId,
},
});
if (!participantGroup) {
// Group not found - throw error instead of auto-creating
// Get available groups for error message
const availableGroups = await this.prisma.client.participantGroup.findMany({
where: { company_id: companyId },
select: { name: true },
orderBy: { name: "asc" },
});
const availableGroupNames = availableGroups.map((g) => g.name);
const errorMessage =
availableGroupNames.length > 0
? `Invalid department "${trimmedGroupName}". Available departments: ${availableGroupNames.join(", ")}`
: `Invalid department "${trimmedGroupName}". No departments are available. Leave empty for "Not Assigned".`;
throw new BadRequestException(errorMessage);
}
// Check if there's already an assignment to this group
const existingAssignment = await this.prisma.client.participantGroupHistory.findFirst({
where: {
participant_id: participantId,
participant_group_id: participantGroup.id,
},
});
if (existingAssignment) {
// Already assigned to this group
return;
}
// Create new group assignment
await this.prisma.client.participantGroupHistory.create({
data: {
participant_id: participantId,
participant_group_id: participantGroup.id,
participant_id_added_by: participantIdAddedBy,
},
});
// Update participant count in the group
await this.prisma.client.participantGroup.update({
where: { id: participantGroup.id },
data: {
participant_count: {
increment: 1,
},
},
});
}
/**
* Enrich participant data with additional fields for frontend
* @param participant Database participant object
* @returns Enriched participant DTO
*/
private async enrichParticipantData(participant: Record<string, unknown>): Promise<CLParticipantDto> {
const participantData = this.toDto(participant, CLParticipantDto);
const participantId = participant["id"] as number;
// Load department from ParticipantGroupHistory
const groupHistory = await this.prisma.client.participantGroupHistory.findFirst({
where: {
participant_id: participantId,
},
include: {
participantGroup: {
select: {
name: true,
},
},
},
orderBy: {
created_at: "desc", // Get the most recent assignment
},
});
// Set department from the group name
if (groupHistory?.participantGroup) {
participantData.department = groupHistory.participantGroup.name;
} else {
participantData.department = "Not Assigned";
}
// Calculate progress metrics (exclude cancelled licenses)
const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: participantId,
cancelled: false,
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
title: true,
},
},
eLearningParticipants: {
where: {
participant_id: participantId,
},
take: 1,
},
},
},
},
});
const totalCourses = enrollments.length;
const completedCourses = enrollments.filter(
(e) => e.status === ParticipantLearningProgressStatus.COMPLETED,
).length;
// Calculate progress from current/active course (not average of all courses)
// Use completion_percentage from LearningGroupParticipant (authoritative source)
// This is set by event listeners and updated incrementally as modules are completed
let participantProgress = 0;
let lastActivity: Date | null = null as Date | null;
let currentCourse: string | undefined;
let currentEnrollment: (typeof enrollments)[0] | undefined;
// Find current course (in progress, or most recent if all completed)
const inProgressEnrollments = enrollments.filter(
(e) => e.status !== ParticipantLearningProgressStatus.COMPLETED,
);
if (inProgressEnrollments.length > 0) {
// Get the most recent in-progress course
const mostRecent = inProgressEnrollments.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0];
currentEnrollment = mostRecent;
currentCourse = mostRecent.learningGroup?.course?.title;
// Use completion_percentage from the current course
participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
} else if (enrollments.length > 0) {
// All completed, get the most recent completed course
const mostRecent = enrollments.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0];
currentEnrollment = mostRecent;
currentCourse = mostRecent.learningGroup?.course?.title;
// Use completion_percentage from the most recent completed course
participantProgress = mostRecent.completion_percentage ? Number(mostRecent.completion_percentage) : 0;
}
// Get last activity from all enrollments
enrollments.forEach((enrollment) => {
const progressData = enrollment.learningGroup?.eLearningParticipants?.[0];
if (progressData?.last_activity) {
const activityDate = new Date(progressData.last_activity);
if (!lastActivity || activityDate > lastActivity) {
lastActivity = activityDate;
}
}
});
// Set calculated fields
participantData.total_courses = totalCourses;
participantData.courses_completed = completedCourses;
participantData.progress = participantProgress; // Use current course progress, not average
participantData.current_course = currentCourse;
if (lastActivity) {
participantData.last_active = lastActivity.toISOString();
} else if (participant["last_login"]) {
participantData.last_active = new Date(participant["last_login"] as Date).toISOString();
} else {
participantData.last_active = "";
}
return participantData;
}
/**
* Adjust due dates for courses when participant is reactivated
* Extends due dates by the duration of deactivation
*/
private async adjustDueDatesOnReactivation(participantId: number, deactivatedAt: Date | null): Promise<void> {
if (!deactivatedAt) {
return; // Can't adjust if we don't know when they were deactivated
}
const reactivatedAt = new Date();
const daysDeactivated = Math.floor(
(reactivatedAt.getTime() - deactivatedAt.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysDeactivated <= 0) {
return; // No adjustment needed
}
// Get all active enrollments with due dates
const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: participantId,
cancelled: false,
status: { not: ParticipantLearningProgressStatus.COMPLETED },
},
include: {
learningGroup: {
select: {
id: true,
due_date: true,
},
},
},
});
// Update due dates for learning groups
for (const enrollment of enrollments) {
if (enrollment.learningGroup.due_date) {
const newDueDate = new Date(enrollment.learningGroup.due_date);
newDueDate.setDate(newDueDate.getDate() + daysDeactivated);
await this.prisma.client.learningGroup.update({
where: { id: enrollment.learning_group_id },
data: { due_date: newDueDate },
});
}
}
}
/**
* Verify progress integrity for a participant
* Checks for missing or inconsistent progress data
*/
async verifyProgressIntegrity(participantId: number): Promise<{
isValid: boolean;
issues: Array<{
type: "missing_progress" | "orphaned_data" | "inconsistent_status";
description: string;
courseId?: number;
}>;
}> {
const issues: Array<{
type: "missing_progress" | "orphaned_data" | "inconsistent_status";
description: string;
courseId?: number;
}> = [];
// Get all enrollments
const enrollments = await this.prisma.client.learningGroupParticipant.findMany({
where: {
participant_id: participantId,
cancelled: false,
},
include: {
learningGroup: {
include: {
course: {
select: {
id: true,
title: true,
},
},
},
},
},
});
for (const enrollment of enrollments) {
const courseId = enrollment.learningGroup.course.id;
// Check for inconsistent status vs completion percentage
if (
enrollment.status === ParticipantLearningProgressStatus.COMPLETED &&
enrollment.completion_percentage &&
Number(enrollment.completion_percentage) < 100
) {
issues.push({
type: "inconsistent_status",
description: `Course "${enrollment.learningGroup.course.title}" marked as COMPLETED but completion percentage is ${enrollment.completion_percentage}%`,
courseId,
});
}
// Check for missing e-learning progress when status suggests it should exist
if (
enrollment.status === ParticipantLearningProgressStatus.E_LEARNING ||
enrollment.status === ParticipantLearningProgressStatus.POST_BAT ||
enrollment.status === ParticipantLearningProgressStatus.COMPLETED
) {
const eLearningProgress = await this.prisma.client.eLearningParticipant.findFirst({
where: {
participant_id: participantId,
course_id: courseId,
learning_group_id: enrollment.learning_group_id,
},
});
if (!eLearningProgress) {
issues.push({
type: "missing_progress",
description: `Course "${enrollment.learningGroup.course.title}" has status ${enrollment.status} but no e-learning progress record found`,
courseId,
});
}
}
// Check for orphaned assessment participants
const assessmentParticipants = await this.prisma.client.assessmentParticipant.findMany({
where: {
participant_id: participantId,
course_id: courseId,
learning_group_id: enrollment.learning_group_id,
},
});
for (const ap of assessmentParticipants) {
// Check if assessment participant has results but enrollment doesn't reflect completion
const hasResults = await this.prisma.client.assessmentResult.findFirst({
where: {
assessment_participant_id: ap.id,
},
});
if (hasResults && !ap.assessment_completion_date) {
issues.push({
type: "orphaned_data",
description: `Assessment "${ap.assessment_type}" for course "${enrollment.learningGroup.course.title}" has results but no completion date`,
courseId,
});
}
}
}
return {
isValid: issues.length === 0,
issues,
};
}
/**
* Pause scheduled emails for a deactivated participant
* Moves scheduled_date far into the future to prevent sending
*/
private async pauseScheduledEmails(participantId: number): Promise<void> {
// Find all PENDING scheduled emails for this participant
const pendingEmails = await this.prisma.client.emailLog.findMany({
where: {
participant_id: participantId,
status: "PENDING",
scheduled_date: {
gte: new Date(), // Only pause future emails
},
},
});
if (pendingEmails.length === 0) {
return;
}
// Move scheduled_date far into the future (1 year from now) to effectively pause them
const farFutureDate = new Date();
farFutureDate.setFullYear(farFutureDate.getFullYear() + 1);
await this.prisma.client.emailLog.updateMany({
where: {
participant_id: participantId,
status: "PENDING",
scheduled_date: {
gte: new Date(),
},
},
data: {
scheduled_date: farFutureDate,
},
});
}
/**
* Participant administrators may remove a company contact only when no active course
* allocation exists (same rule as directory `total_courses`: non-cancelled enrollments).
*/
async deleteParticipantForParticipantAdmin(
targetParticipantId: number,
companyId: number,
performedByParticipantId: number,
performedByRole: string,
): Promise<void> {
if (performedByRole !== ParticipantRole.PARTICIPANT_ADMIN) {
throw new ForbiddenException("Only participant administrators can delete contacts.");
}
if (targetParticipantId === performedByParticipantId) {
throw new BadRequestException("You cannot delete your own account from the directory.");
}
const participant = await this.prisma.client.participant.findFirst({
where: { id: targetParticipantId, company_id: companyId },
});
if (!participant) {
throw new NotFoundException(`Participant with ID ${targetParticipantId} not found`);
}
const activeEnrollmentCount = await this.prisma.client.learningGroupParticipant.count({
where: {
participant_id: targetParticipantId,
cancelled: false,
},
});
if (activeEnrollmentCount > 0) {
throw new UnprocessableEntityException(
"This contact has an active course allocation. Remove the license allocation before deleting this contact.",
);
}
await this.systemLogService.logDelete(
SystemLogEntityType.PARTICIPANT,
targetParticipantId,
participant as unknown as Record<string, unknown>,
{ company_id: participant.company_id },
);
await this.prisma.client.$transaction(async (tx) => {
await tx.systemLog.updateMany({
where: { participant_id: targetParticipantId },
data: { participant_id: null },
});
await tx.participant.updateMany({
where: { replaced_by_participant_id: targetParticipantId },
data: { replaced_by_participant_id: null, replaced_at: null },
});
await tx.participantGroupHistory.updateMany({
where: { participant_id_added_by: targetParticipantId },
data: { participant_id_added_by: performedByParticipantId },
});
await tx.participantGroupHistory.deleteMany({
where: { participant_id: targetParticipantId },
});
await tx.participantGroup.updateMany({
where: { participant_id_created_by: targetParticipantId },
data: { participant_id_created_by: performedByParticipantId },
});
await tx.learningGroup.updateMany({
where: { participant_id_created_by: targetParticipantId },
data: { participant_id_created_by: performedByParticipantId },
});
await tx.learningGroupParticipant.updateMany({
where: { participant_id_invited_by: targetParticipantId },
data: { participant_id_invited_by: performedByParticipantId },
});
await tx.reportLog.deleteMany({
where: { participant_id: targetParticipantId },
});
await tx.emailLog.updateMany({
where: { participant_id: targetParticipantId },
data: { participant_id: null },
});
await tx.media.updateMany({
where: { participant_id: targetParticipantId },
data: { participant_id: null },
});
await tx.emailVerificationToken.deleteMany({
where: { participant_id: targetParticipantId },
});
await tx.passwordSetupToken.deleteMany({
where: { participant_id: targetParticipantId },
});
await tx.participant.delete({
where: { id: targetParticipantId },
});
});
this.eventEmitter.emit(
PARTICIPANT_EVENTS.DELETED,
new ParticipantDeletedEvent(targetParticipantId, companyId, participant.is_active, participant.is_replaced),
);
}
/**
* Resume scheduled emails for a reactivated participant
* Adjusts scheduled_date by the deactivation period
*/
private async resumeScheduledEmails(participantId: number, deactivatedAt: Date | null): Promise<void> {
if (!deactivatedAt) {
return; // Can't adjust if we don't know when they were deactivated
}
const reactivatedAt = new Date();
const daysDeactivated = Math.floor(
(reactivatedAt.getTime() - deactivatedAt.getTime()) / (1000 * 60 * 60 * 24),
);
if (daysDeactivated <= 0) {
return; // No adjustment needed
}
// Find all PENDING emails that were paused (scheduled_date far in future)
// We'll identify paused emails as those scheduled more than 6 months from now
const sixMonthsFromNow = new Date();
sixMonthsFromNow.setMonth(sixMonthsFromNow.getMonth() + 6);
const pausedEmails = await this.prisma.client.emailLog.findMany({
where: {
participant_id: participantId,
status: "PENDING",
scheduled_date: {
gte: sixMonthsFromNow, // Likely paused emails
},
},
});
if (pausedEmails.length === 0) {
return;
}
// Adjust scheduled dates by adding the deactivation period
for (const email of pausedEmails) {
// Skip if scheduled_date is null
if (!email.scheduled_date) {
continue;
}
const newScheduledDate = new Date(email.scheduled_date);
newScheduledDate.setDate(newScheduledDate.getDate() + daysDeactivated);
await this.prisma.client.emailLog.update({
where: { id: email.id },
data: { scheduled_date: newScheduledDate },
});
}
}
}