File

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

Index

Properties

Properties

knowledgeReviewEmailDate
knowledgeReviewEmailDate: string | null
Type : string | null
message
message: string
Type : string
success
success: boolean
Type : boolean
import { CLLearningGroupService } from "@api/client/learning-group/learning-group.service";
import { CraiBatRegenerationService } from "@api/integration/crai-bat-regeneration.service";
import { EmailReminderService } from "@api/shared/email/services/email-reminder.service";
import { SystemLogService } from "@api/shared/services";
import { BNestBaseModuleService, moduleNameToAuditEntityType, type PaginationOptions } from "@bish-nest/data";
import { DetailResponseDataInterface, ListResponseDataInterface } from "@bish-nest/http";
import { BNestPrismaService } from "@bish-nest/prisma";
import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
import { ParticipantLearningProgressStatus, SystemLogEntityType } from "@prisma/client";
import { plainToInstance } from "class-transformer";

export interface UpdateKnowledgeReviewEmailDateResponse {
  success: boolean;
  message: string;
  knowledgeReviewEmailDate: string | null;
}

export interface UpdatePostBatInviteEmailDateResponse {
  success: boolean;
  message: string;
  postBatInviteEmailDate: string | null;
}

type ProgressParticipant = {
  cancelled: boolean;
  status: string;
  completion_percentage: unknown;
};

/**
 * LearningGroupService
 *
 * Admin API — Learning Group (Learning Group). Business Logic Service for RecallAssess.
 */


@Injectable()
export class LearningGroupService extends BNestBaseModuleService {
  constructor(
    protected prisma: BNestPrismaService,
    private readonly systemLogService: SystemLogService,
    private readonly emailReminderService: EmailReminderService,
    private readonly clLearningGroupService: CLLearningGroupService,
    private readonly craiBatRegenerationService: CraiBatRegenerationService,
  ) {
    super();
  }

  /**
   * Same rules as client portal: E_LEARNING/COMPLETED = completed seat;
   * all cancelled → CANCELLED; all active seats completed → COMPLETED.
   */
  private deriveProgressFields(
    participants: ProgressParticipant[],
    fallback: {
      participants_completed?: number;
      completion_percentage?: number | null;
      status?: string;
      is_completed?: boolean;
    },
  ): {
    participants_completed: number;
    completion_percentage: number | null;
    status: string;
    is_completed: boolean;
  } {
    if (participants.length === 0) {
      return {
        participants_completed: fallback.participants_completed ?? 0,
        completion_percentage:
          fallback.completion_percentage != null ? Number(fallback.completion_percentage) : null,
        status: fallback.status ?? "PENDING",
        is_completed: fallback.is_completed ?? false,
      };
    }

    const totalProgress = participants.reduce((sum, p) => {
      const n = p.completion_percentage != null ? Number(p.completion_percentage) : 0;
      return sum + (Number.isNaN(n) ? 0 : n);
    }, 0);
    const completion_percentage = Math.round(totalProgress / participants.length);

    const participants_completed = participants.filter(
      (p) =>
        p.status === ParticipantLearningProgressStatus.E_LEARNING ||
        p.status === ParticipantLearningProgressStatus.COMPLETED,
    ).length;

    let status = fallback.status ?? "PENDING";
    if (participants.every((p) => p.cancelled)) {
      status = "CANCELLED";
    } else {
      const active = participants.filter((p) => !p.cancelled);
      if (
        active.length > 0 &&
        active.every(
          (p) =>
            p.status === ParticipantLearningProgressStatus.E_LEARNING ||
            p.status === ParticipantLearningProgressStatus.COMPLETED,
        )
      ) {
        status = "COMPLETED";
      }
    }

    return {
      participants_completed,
      completion_percentage,
      status,
      is_completed: status === "COMPLETED" || participants_completed === participants.length,
    };
  }

  /**
   * Recalculate progress fields so admin list/company tab match client portal (avoid stale DB columns).
   */
  override async getList(paginationOptions: PaginationOptions): Promise<ListResponseDataInterface<any>> {
    const result = await super.getList(paginationOptions);
    const rows = result.data ?? [];
    const ids = rows
      .map((row: { id?: number }) => row.id)
      .filter((id: number | undefined): id is number => typeof id === "number");

    if (ids.length === 0) {
      return result;
    }

    const participants = await this.prisma.client.learningGroupParticipant.findMany({
      where: { learning_group_id: { in: ids } },
      select: {
        learning_group_id: true,
        cancelled: true,
        status: true,
        completion_percentage: true,
      },
    });

    const byGroup = new Map<number, ProgressParticipant[]>();
    for (const p of participants) {
      const list = byGroup.get(p.learning_group_id) ?? [];
      list.push({
        cancelled: p.cancelled,
        status: p.status,
        completion_percentage: p.completion_percentage,
      });
      byGroup.set(p.learning_group_id, list);
    }

    result.data = rows.map((row: Record<string, unknown>) => {
      const id = row["id"] as number;
      const derived = this.deriveProgressFields(byGroup.get(id) ?? [], {
        participants_completed: row["participants_completed"] as number | undefined,
        completion_percentage: row["completion_percentage"] as number | null | undefined,
        status: row["status"] as string | undefined,
        is_completed: row["is_completed"] as boolean | undefined,
      });
      return {
        ...row,
        ...derived,
      };
    });

    return result;
  }

  /**
   * Override add method to remove foreign key fields before passing to Prisma
   * Prisma only accepts relation objects (company, course, createdBy), not raw foreign keys (company_id, course_id, participant_id_created_by)
   */
  async add(data: any): Promise<any> {
    // Create a copy of data without the foreign key fields
    // The transformed relation fields (company, course, createdBy) are already present
    const { company_id, course_id, participant_id_created_by, ...prismaData } = data;

    // Call parent add method with cleaned data
    return super.add(prismaData);
  }

  async save(id: number, data: any) {
    // Get old data before update
    const oldLearningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id },
    });

    if (!oldLearningGroup) {
      throw new NotFoundException(`Learning group with ID ${id} not found`);
    }

    const repo = this.commonMethods.getRepo(this.gVars.moduleCurrentCfg.repoName);

    let dataSave = plainToInstance(this.gVars.moduleCurrentCfg.saveDto, data);
    // Use scalar audit columns to avoid relation update errors
    dataSave = await this.dbUtilService.addUpdatedById(dataSave);

    // Remove relation objects from dataSave - only keep scalar fields
    const updateData: any = {};
    const scalarFields = [
      "company_id",
      "course_id",
      "participant_id_created_by",
      "name",
      "description",
      "start_date",
      "completion_percentage",
      "participants_completed",
      "total_participants",
      "is_completed",
      "completion_date",
      "user_id_updated",
      "updated_at",
    ];

    for (const field of scalarFields) {
      if (dataSave && field in dataSave) {
        updateData[field] = (dataSave as any)[field];
      }
    }

    await repo.update({ where: { id }, data: updateData });

    // Get updated data directly from database for logging
    const updatedLearningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id },
    });

    if (!updatedLearningGroup) {
      throw new NotFoundException(`Learning group with ID ${id} not found after update`);
    }

    const detailedResponse = await this.getDetail(id);
    this.emitModuleAuditEvent("updated", {
      entityId: id,
      entityType: moduleNameToAuditEntityType(this.gVars.moduleCurrentCfg.name),
      oldData: oldLearningGroup as Record<string, unknown>,
      newData: detailedResponse.data[0] as Record<string, unknown>,
    });

    return detailedResponse;
  }

  /**
   * Override getDetail so that admin learning-group detail (and the Participants tab)
   * receive the same rich participant tracking data that the portal sees.
   *
   * This mirrors the client learning-group detail include, ensuring that
   * learningGroupParticipants with their progress fields are loaded.
   */
  async getDetail(id: number): Promise<DetailResponseDataInterface<unknown>> {
    const moduleCurrentCfg = this.gVars.moduleCurrentCfg;

    // Load learning group with all relations needed for participant tracking
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id },
      include: {
        // Include completion_percentage so we can normalize it before sending to DTO
        // Prisma returns this as Decimal | null
        company: {
          select: {
            id: true,
            name: true,
          },
        },
        course: {
          select: {
            id: true,
            title: true,
          },
        },
        participantGroup: {
          select: {
            id: true,
            name: true,
          },
        },
        learningGroupParticipants: {
          include: {
            participant: {
              select: {
                id: true,
                first_name: true,
                last_name: true,
                email: true,
              },
            },
          },
          orderBy: {
            created_at: "asc",
          },
        },
        userCreatedBy: {
          select: {
            id: true,
            first_name: true,
            last_name: true,
          },
        },
        userUpdatedBy: {
          select: {
            id: true,
            first_name: true,
            last_name: true,
          },
        },
      },
    });

    if (!learningGroup) {
      const msg = "The record you are looking for is not found.";
      throw new UnprocessableEntityException(msg);
    }

    // Keep the raw Prisma object shape (including learningGroupParticipants)
    // and let the DTO handle only the scalar fields it cares about.
    // Normalize completion_percentage to a plain number (or null) to avoid Decimal runtime errors.
    const completionPercentage =
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (learningGroup as any).completion_percentage != null
        ? Number((learningGroup as any).completion_percentage)
        : null;

    // e_learning_started_at is only set after the first completed module, but e-learning counts
    // as started (and cancellation is blocked) as soon as the course is opened.
    const eLearningStarts = await this.prisma.client.eLearningParticipant.findMany({
      where: { learning_group_id: id, course_id: learningGroup.course_id, start_date: { not: null } },
      select: { participant_id: true, start_date: true },
    });
    const eLearningStartByParticipant = new Map(eLearningStarts.map((e) => [e.participant_id, e.start_date]));

    // Normalize completion_percentage in learningGroupParticipants to avoid Decimal errors
    const normalizedParticipants = (learningGroup.learningGroupParticipants || []).map((participant: any) => ({
      ...participant,
      completion_percentage:
        participant.completion_percentage != null ? Number(participant.completion_percentage) : null,
      e_learning_started_at:
        participant.e_learning_started_at ?? eLearningStartByParticipant.get(participant.participant_id) ?? null,
    }));

    const derived = this.deriveProgressFields(normalizedParticipants, {
      participants_completed: learningGroup.participants_completed,
      completion_percentage: completionPercentage,
      status: learningGroup.status,
      is_completed: learningGroup.is_completed,
    });

    const sanitizedData: Record<string, unknown> = {
      ...learningGroup,
      ...derived,
      learningGroupParticipants: normalizedParticipants,
    };

    // Transform to DTO for scalar fields (id, name, dates, etc.).
    // Then re-attach participants relation so admin participants tab can render tracking data.
    const data = plainToInstance(moduleCurrentCfg.detailDto, sanitizedData) as Record<string, unknown>;
    const detailData: Record<string, unknown> = {
      ...data,
      ...derived,
      learningGroupParticipants: normalizedParticipants,
    };

    return this.moduleMethods.getReturnDataForDetail(detailData);
  }

  /**
   * Update knowledge_review_email_date to NOW() for a learning group participant.
   * Mirrors update-knowledge-review-email-date.sh for use from the admin UI.
   */
  async updateKnowledgeReviewEmailDate(
    learningGroupParticipantId: number,
  ): Promise<UpdateKnowledgeReviewEmailDateResponse> {
    const existing = await this.prisma.client.learningGroupParticipant.findUnique({
      where: { id: learningGroupParticipantId },
      include: {
        learningGroup: { select: { company_id: true } },
      },
    });

    if (!existing) {
      throw new NotFoundException(`Learning group participant with ID ${learningGroupParticipantId} not found`);
    }

    const newDate = new Date();
    const updated = await this.prisma.client.learningGroupParticipant.update({
      where: { id: learningGroupParticipantId },
      data: { knowledge_review_email_date: newDate },
    });

    // Log the update (mirrors other learning group participant changes)
    const oldDate = existing.knowledge_review_email_date;
    const changedFields: Record<string, unknown> = {
      learning_group_participant_id: learningGroupParticipantId,
      participant_id: existing.participant_id,
      knowledge_review_email_date: {
        from: oldDate?.toISOString() ?? null,
        to: newDate.toISOString(),
      },
    };
    await this.systemLogService.logUpdate(
      SystemLogEntityType.LEARNING_GROUP,
      existing.learning_group_id,
      {
        learning_group_participant_id: learningGroupParticipantId,
        knowledge_review_email_date: oldDate?.toISOString() ?? null,
      } as Record<string, unknown>,
      {
        learning_group_participant_id: learningGroupParticipantId,
        knowledge_review_email_date: newDate.toISOString(),
      } as Record<string, unknown>,
      changedFields,
      {
        participant_id: existing.participant_id,
        company_id: existing.learningGroup.company_id,
      },
    );

    return {
      success: true,
      message: "Knowledge review email date updated successfully",
      knowledgeReviewEmailDate: updated.knowledge_review_email_date?.toISOString() ?? null,
    };
  }

  /**
   * Send post BAT invite email now and ensure post_bat_email_date is set (and not in the future)
   * for a learning group participant. Used from admin UI "Update" (send now) for post BAT invite.
   */
  async updatePostBatInviteEmailDate(
    learningGroupParticipantId: number,
  ): Promise<UpdatePostBatInviteEmailDateResponse> {
    const existing = await this.prisma.client.learningGroupParticipant.findUnique({
      where: { id: learningGroupParticipantId },
      include: {
        learningGroup: { select: { company_id: true } },
      },
    });

    if (!existing) {
      throw new NotFoundException(`Learning group participant with ID ${learningGroupParticipantId} not found`);
    }

    if (!existing.knowledge_review_completed_at) {
      throw new BadRequestException(
        "Knowledge Review must be completed before sending the Post BAT invite email.",
      );
    }

    const newDate =
      await this.emailReminderService.sendPostBatInviteForLearningGroupParticipant(learningGroupParticipantId);

    if (!newDate) {
      throw new NotFoundException(`Learning group participant with ID ${learningGroupParticipantId} not found`);
    }

    const oldDate = existing.post_bat_email_date;
    const changedFields: Record<string, unknown> = {
      learning_group_participant_id: learningGroupParticipantId,
      participant_id: existing.participant_id,
      post_bat_email_date: {
        from: oldDate?.toISOString() ?? null,
        to: newDate.toISOString(),
      },
    };
    await this.systemLogService.logUpdate(
      SystemLogEntityType.LEARNING_GROUP,
      existing.learning_group_id,
      {
        learning_group_participant_id: learningGroupParticipantId,
        post_bat_email_date: oldDate?.toISOString() ?? null,
      } as Record<string, unknown>,
      {
        learning_group_participant_id: learningGroupParticipantId,
        post_bat_email_date: newDate.toISOString(),
      } as Record<string, unknown>,
      changedFields,
      {
        participant_id: existing.participant_id,
        company_id: existing.learningGroup.company_id,
      },
    );

    return {
      success: true,
      message: "Post BAT invite email sent and date updated successfully",
      postBatInviteEmailDate: newDate.toISOString(),
    };
  }

  /**
   * Resend invitation to a specific participant (admin). Delegates to client learning-group
   * service with company_id from the learning group; uses 0 for "invited by" (admin action).
   */
  async resendInvitation(learningGroupId: number, participantId: number): Promise<{ message: string }> {
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
      select: { company_id: true },
    });
    if (!learningGroup) {
      throw new NotFoundException(`Learning group with ID ${learningGroupId} not found`);
    }
    return this.clLearningGroupService.resendInvitation(
      learningGroup.company_id,
      learningGroupId,
      participantId,
      0,
    );
  }

  private async getLearningGroupCompanyId(learningGroupId: number): Promise<number> {
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id: learningGroupId },
      select: { company_id: true },
    });
    if (!learningGroup) {
      throw new NotFoundException(`Learning group with ID ${learningGroupId} not found`);
    }
    return learningGroup.company_id;
  }

  async generatePreBatHtmlForParticipant(
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<string> {
    const companyId = await this.getLearningGroupCompanyId(learningGroupId);
    return this.clLearningGroupService.generatePreBatHtmlForParticipant(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
    );
  }

  async generatePostBatHtmlForParticipant(
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<string> {
    const companyId = await this.getLearningGroupCompanyId(learningGroupId);
    return this.clLearningGroupService.generatePostBatHtmlForParticipant(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
    );
  }

  async getPreBatPdfUrlForParticipant(
    learningGroupId: number,
    learningGroupParticipantId: number,
    forceRegenerate = false,
  ): Promise<string> {
    const companyId = await this.getLearningGroupCompanyId(learningGroupId);
    return this.clLearningGroupService.getPreBatPdfUrlForParticipant(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
      forceRegenerate,
    );
  }

  async getPostBatPdfUrlForParticipant(
    learningGroupId: number,
    learningGroupParticipantId: number,
    forceRegenerate = false,
  ): Promise<string> {
    const companyId = await this.getLearningGroupCompanyId(learningGroupId);
    return this.clLearningGroupService.getPostBatPdfUrlForParticipant(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
      forceRegenerate,
    );
  }

  /**
   * Regenerate a participant's PRE-BAT AI analysis by re-submitting their data to the
   * external CR(AI) API.
   *
   * Mirrors the participant portal "Regenerate" action so admins can refresh the
   * underlying analysis that the PRE-BAT PDF is rendered from. CRAI regenerates
   * asynchronously and posts the result back to `/api/integration/cr/*`.
   */
  async regeneratePreBatAnalysis(
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<{ success: boolean; message: string }> {
    const enrollment = await this.resolveBatEnrollment(learningGroupId, learningGroupParticipantId);

    if (!enrollment.pre_bat_completed_at) {
      throw new UnprocessableEntityException(
        "PRE-BAT assessment must be completed before regenerating analysis",
      );
    }

    return this.craiBatRegenerationService.regenerateBatAnalysis(
      learningGroupId,
      enrollment.participant_id,
      "PRE_BAT",
    );
  }

  /**
   * Regenerate a participant's POST-BAT AI analysis by re-submitting their data to the
   * external CR(AI) API (same behaviour as {@link regeneratePreBatAnalysis}).
   */
  async regeneratePostBatAnalysis(
    learningGroupId: number,
    learningGroupParticipantId: number,
  ): Promise<{ success: boolean; message: string }> {
    const enrollment = await this.resolveBatEnrollment(learningGroupId, learningGroupParticipantId);

    const course = enrollment.learningGroup?.course;
    if (!course?.assessment_id) {
      throw new UnprocessableEntityException("Course does not have an assessment configured");
    }

    const postBatParticipant = await this.prisma.client.assessmentParticipant.findFirst({
      where: {
        assessment_id: course.assessment_id,
        participant_id: enrollment.participant_id,
        assessment_type: "POST_BAT",
      },
    });

    if (!postBatParticipant) {
      throw new UnprocessableEntityException(
        "POST-BAT assessment must be completed before regenerating analysis",
      );
    }

    return this.craiBatRegenerationService.regenerateBatAnalysis(
      learningGroupId,
      enrollment.participant_id,
      "POST_BAT",
    );
  }

  /**
   * Resolve the learning-group enrollment (participant + course) for a BAT regeneration
   * request, ensuring it belongs to the given learning group.
   */
  private async resolveBatEnrollment(learningGroupId: number, learningGroupParticipantId: number) {
    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: {
        id: learningGroupParticipantId,
        learning_group_id: learningGroupId,
      },
      include: {
        learningGroup: {
          include: {
            course: {
              select: {
                id: true,
                assessment_id: true,
              },
            },
          },
        },
      },
    });

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

    return enrollment;
  }
}

results matching ""

    No results matching ""