File

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

Index

Properties

Properties

message
message: string
Type : string
postBatInviteEmailDate
postBatInviteEmailDate: string | null
Type : string | null
success
success: boolean
Type : boolean
import { CLLearningGroupService } from "@api/client/learning-group/learning-group.service";
import { SystemLogService } from "@api/shared/services";
import { BNestBaseModuleService } from "@bish-nest/core/data/module-service/base-module.service";
import { DetailResponseDataInterface } from "@bish-nest/core/interfaces/detail-response-data.interface";
import { BNestPrismaService } from "@bish-nest/core/services";
import { BadRequestException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common";

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

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

import { SystemLogEntityType } from "@prisma/client";
import { plainToInstance } from "class-transformer";
import { EmailReminderService } from "@api/shared/email/services/email-reminder.service";

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

  /**
   * 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
    const addResponse = await super.add(prismaData);
    const learningGroup = addResponse.data;

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

    return addResponse;
  }

  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`);
    }

    // Calculate changed fields and log the update
    const changedFields = SystemLogService.calculateChangedFields(
      oldLearningGroup as Record<string, unknown>,
      updatedLearningGroup as Record<string, unknown>,
    );

    await this.systemLogService.logUpdate(
      SystemLogEntityType.LEARNING_GROUP,
      id,
      oldLearningGroup as Record<string, unknown>,
      updatedLearningGroup as Record<string, unknown>,
      changedFields,
      { company_id: updatedLearningGroup.company_id ?? oldLearningGroup.company_id },
    );

    return await this.getDetail(id);
  }

  /**
   * Override delete method to log deletion
   */
  async delete(id: number): Promise<void> {
    // Get learning group data before deletion
    const learningGroup = await this.prisma.client.learningGroup.findUnique({
      where: { id },
    });

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

    // Call parent delete method
    await super.delete(id);

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

  /**
   * 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;

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

    const sanitizedData: Record<string, unknown> = {
      ...learningGroup,
      completion_percentage: completionPercentage,
      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,
      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,
  ): Promise<string> {
    const companyId = await this.getLearningGroupCompanyId(learningGroupId);
    return this.clLearningGroupService.getPreBatPdfUrlForParticipant(
      companyId,
      learningGroupId,
      learningGroupParticipantId,
    );
  }

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

results matching ""

    No results matching ""