File

apps/recallassess/recallassess-api/src/api/admin/course-module-page/course-module-page.service.ts

Extends

BNestBaseModuleService

Index

Properties
Methods

Methods

Async add
add(data: any)

Override add method to prevent creation when course has enrollments

Parameters :
Name Type Optional
data any No
Returns : Promise<any>
Private Async checkCourseEnrollments
checkCourseEnrollments(courseId: number)

Check if the associated course has enrollments

Parameters :
Name Type Optional
courseId number No
Returns : Promise<void>
Async delete
delete(id: number)

Override delete: block when the linked course has any LearningGroupParticipant rows (live count).

Parameters :
Name Type Optional
id number No
Returns : Promise<void>
Private Async deleteCourseModulePageDependentRows
deleteCourseModulePageDependentRows(courseModulePageId: number)

Remove rows that FK to this page before Prisma deletes course_module_page, otherwise delete fails with FOREIGN_KEY_CONSTRAINT_FAILED (progress, KR rows, system logs, etc.). Media files/rows are still removed by BNestBaseModuleService#deleteRelatedMediaFilesFromS3 + super.delete.

Parameters :
Name Type Optional
courseModulePageId number No
Returns : Promise<void>
Private flattenValidationErrors
flattenValidationErrors(errors: ValidationError[])
Parameters :
Name Type Optional
errors ValidationError[] No
Returns : ValidationError[]
Async getDetail
getDetail(id: number)

Override getDetail to ensure course and courseModule relations are loaded and enrollment_count is set for DTO transform decorators

Parameters :
Name Type Optional
id number No
Async getDetailData
getDetailData(id: number)

Override getDetailData to only include mediaPhotos relation (the only one that exists in Prisma schema) Other media types (videos, documents, etc.) are loaded separately

Parameters :
Name Type Optional
id number No
Returns : Promise<any>
Async getPagesByCourseModule
getPagesByCourseModule(courseModuleId: number)
Parameters :
Name Type Optional
courseModuleId number No
Returns : Promise<any>
Private mapSaveValidationErrors
mapSaveValidationErrors(errors: ValidationError[])
Parameters :
Name Type Optional
errors ValidationError[] No
Returns : Array<literal type>
Async save
save(id: number, data: any)

Override save: branch on live enrollment count for this course.

Parameters :
Name Type Optional
id number No
data any No
Returns : Promise<SaveResponseDataInterface<any>>
Async validateSavePayload
validateSavePayload(id: number, data: unknown)

Save validation must match #save: global BNestValidationService.validate('save') always uses CourseModulePageSaveDto, which requires content_type / relations — but disabled client fields omit them when the course has enrollments. Branch on live enrollment like save.

Parameters :
Name Type Optional
id number No
data unknown No
Returns : Promise<Array<literal type>>

Properties

Private utilService
Type : BNestUtilService
Decorators :
@Inject()
import { BNestBaseModuleService } from "@bish-nest/core/data/module-service/base-module.service";
import { DetailResponseDataInterface } from "@bish-nest/core/interfaces/detail-response-data.interface";
import { SaveResponseDataInterface } from "@bish-nest/core/interfaces/save-response-data.interface";
import { BNestUtilService } from "@bish-nest/core/services/util.service";
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { validate, type ValidationError } from "class-validator";
import { CourseModulePageSaveDto } from "./dto/course-module-page-save.dto";
import { CourseModulePageSaveWhenEnrolledDto } from "./dto/course-module-page-save-when-enrolled.dto";

/** Remove fields that must not affect transform when saving with enrollments (defense in depth). */
function stripLockedFieldsFromPayload(data: unknown): Record<string, unknown> {
  if (data == null || typeof data !== "object") {
    return {};
  }
  const o = { ...(data as Record<string, unknown>) };
  delete o["course_id"];
  delete o["course_module_id"];
  delete o["content_type"];
  delete o["course"];
  delete o["courseModule"];
  delete o["course_title"];
  delete o["course_module_title"];
  return o;
}

@Injectable()
export class CourseModulePageService extends BNestBaseModuleService {
  @Inject() private utilService!: BNestUtilService;

  /**
   * Save validation must match {@link #save}: global `BNestValidationService.validate('save')` always
   * uses {@link CourseModulePageSaveDto}, which requires `content_type` / relations — but disabled
   * client fields omit them when the course has enrollments. Branch on live enrollment like `save`.
   */
  async validateSavePayload(id: number, data: unknown): Promise<Array<{ colName: string; errorMessage: string }>> {
    const courseModulePage = await this.prisma.client.courseModulePage.findUnique({
      where: { id },
      select: { course_id: true },
    });

    if (!courseModulePage) {
      return [{ colName: "id", errorMessage: "Course module page not found" }];
    }

    const enrollmentCount = await this.prisma.client.learningGroupParticipant.count({
      where: { course_id: courseModulePage.course_id },
    });

    let dto: object;
    if (enrollmentCount > 0) {
      dto = plainToInstance(CourseModulePageSaveWhenEnrolledDto, stripLockedFieldsFromPayload(data), {
        excludeExtraneousValues: true,
      });
    } else {
      dto = plainToInstance(CourseModulePageSaveDto, data);
    }

    const errors = await validate(dto);
    return this.mapSaveValidationErrors(this.flattenValidationErrors(errors));
  }

  private flattenValidationErrors(errors: ValidationError[]): ValidationError[] {
    const out: ValidationError[] = [];
    for (const e of errors) {
      if (e.constraints && Object.keys(e.constraints).length > 0) {
        out.push(e);
      }
      if (e.children?.length) {
        out.push(...this.flattenValidationErrors(e.children));
      }
    }
    return out;
  }

  private mapSaveValidationErrors(errors: ValidationError[]): Array<{ colName: string; errorMessage: string }> {
    const columns = this.gVars.moduleCurrentCfg.columns;
    const returnErrors: Array<{ colName: string; errorMessage: string }> = [];
    for (const error of errors) {
      const colName = error.property;
      const colObj = this.utilService.getItemByName(columns, colName);
      const colTitle = colObj?.title ?? colName;
      const errorMsgArr: string[] = [];
      for (const [_key, errMsg] of Object.entries(error.constraints || {})) {
        errorMsgArr.push(String(errMsg).replace(colName, colTitle));
      }
      if (errorMsgArr.length > 0) {
        returnErrors.push({
          colName,
          errorMessage: errorMsgArr.join(", "),
        });
      }
    }
    return returnErrors;
  }

  /**
   * Check if the associated course has enrollments
   */
  private async checkCourseEnrollments(courseId: number): Promise<void> {
    const enrollmentCount = await this.prisma.client.learningGroupParticipant.count({
      where: {
        course_id: courseId,
      },
    });

    if (enrollmentCount > 0) {
      throw new BadRequestException(
        `Cannot modify course module page. The associated course has ${enrollmentCount} enrolled participant(s). Course module pages for courses with enrollments cannot be modified or deleted.`,
      );
    }
  }

  /**
   * Override add method to prevent creation when course has enrollments
   */
  async add(data: any): Promise<any> {
    // Validate that the course module belongs to the selected course
    // CourseModule no longer has course_id - validate through CourseModulePage
    if (data.courseModule?.id && data.course?.id) {
      const courseModulePage = await this.prisma.client.courseModulePage.findFirst({
        where: {
          course_module_id: data.courseModule.id,
          course_id: data.course.id,
        },
      });

      if (!courseModulePage) {
        throw new BadRequestException('Selected course module does not belong to the selected course');
      }
    }

    // Check if course_id is provided in the data
    const courseId = data.course_id || data.course?.id;
    
    if (courseId) {
      await this.checkCourseEnrollments(courseId);
    }

    // BNestBaseModuleService.add() calls prisma create() with the DTO output.
    // In Prisma, the checked create input for this model expects the relation
    // `knowledgeReview`, not the scalar foreign key `knowledge_review_id`.
    // So we translate `knowledge_review_id` -> `knowledgeReview.connect`.
    const repo = this.commonMethods.getRepo(this.gVars.moduleCurrentCfg.repoName);

    let dataAdd = plainToInstance(this.gVars.moduleCurrentCfg.addDto, data, { excludeExtraneousValues: true }) as unknown as Record<
      string,
      unknown
    >;
    dataAdd = (await this.dbUtilService.addCreatedBy(dataAdd)) as Record<string, unknown>;

    if (dataAdd && typeof dataAdd === "object" && "knowledge_review_id" in dataAdd) {
      const knowledgeReviewId = (dataAdd as Record<string, unknown>)["knowledge_review_id"];

      // Remove the direct scalar field assignment (Prisma create input doesn't accept it)
      delete (dataAdd as Record<string, unknown>)["knowledge_review_id"];

      // For create: only connect when a real numeric id is provided. If null/undefined, omit relation.
      if (typeof knowledgeReviewId === "number") {
        (dataAdd as Record<string, unknown>)["knowledgeReview"] = { connect: { id: knowledgeReviewId } };
      }
    }

    const newRow = await repo.create({
      data: dataAdd,
    });

    return this.moduleMethods.getReturnDataForAdd(newRow);
  }

  /**
   * Override save: branch on live enrollment count for this course.
   * - No enrollments → {@link CourseModulePageSaveDto} (course / module / content type may change).
   * - Has enrollments → {@link CourseModulePageSaveWhenEnrolledDto} (those fields are not accepted).
   */
  async save(id: number, data: any): Promise<SaveResponseDataInterface<any>> {
    const courseModulePage = await this.prisma.client.courseModulePage.findUnique({
      where: { id },
      select: { course_id: true },
    });

    if (!courseModulePage) {
      throw new BadRequestException(`Course module page with ID ${id} not found`);
    }

    const enrollmentCount = await this.prisma.client.learningGroupParticipant.count({
      where: { course_id: courseModulePage.course_id },
    });

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

    let dataSave: any;
    if (enrollmentCount > 0) {
      const payload = stripLockedFieldsFromPayload(data);
      dataSave = plainToInstance(CourseModulePageSaveWhenEnrolledDto, payload, {
        excludeExtraneousValues: true,
      });
    } else {
      dataSave = plainToInstance(CourseModulePageSaveDto, data);
    }
    dataSave = await this.dbUtilService.addUpdatedBy(dataSave);

    // Build update data object
    const updateData: any = { ...dataSave };

    // Handle knowledge_review_id using relation syntax for Prisma
    if (dataSave && typeof dataSave === 'object' && 'knowledge_review_id' in dataSave) {
      const knowledgeReviewId = (dataSave as any).knowledge_review_id;

      // Remove the direct field assignment
      delete updateData.knowledge_review_id;

      // Use relation syntax instead
      if (knowledgeReviewId !== undefined) {
        if (knowledgeReviewId !== null) {
          updateData.knowledgeReview = { connect: { id: knowledgeReviewId } };
        } else {
          updateData.knowledgeReview = { disconnect: true };
        }
      }
    }

    // Save the record data
    await repo.update({
      where: { id },
      data: updateData,
    });

    return await this.getDetail(id);
  }

  /**
   * Override delete: block when the linked course has any LearningGroupParticipant rows (live count).
   */
  async delete(id: number): Promise<void> {
    const courseModulePage = await this.prisma.client.courseModulePage.findUnique({
      where: { id },
      select: { course_id: true },
    });

    if (!courseModulePage) {
      throw new BadRequestException(`Course module page with ID ${id} not found`);
    }

    const enrollmentCount = await this.prisma.client.learningGroupParticipant.count({
      where: { course_id: courseModulePage.course_id },
    });

    if (enrollmentCount > 0) {
      throw new BadRequestException(
        `Cannot delete this e-learning content while the associated course has ${enrollmentCount} enrolled participant(s).`,
      );
    }

    await this.deleteCourseModulePageDependentRows(id);
    return super.delete(id);
  }

  /**
   * Remove rows that FK to this page before Prisma deletes `course_module_page`, otherwise delete fails
   * with FOREIGN_KEY_CONSTRAINT_FAILED (progress, KR rows, system logs, etc.).
   * Media files/rows are still removed by {@link BNestBaseModuleService#deleteRelatedMediaFilesFromS3} + `super.delete`.
   */
  private async deleteCourseModulePageDependentRows(courseModulePageId: number): Promise<void> {
    const db = this.prisma.client;

    await db.$transaction(async (tx) => {
      const progressIds = (
        await tx.eLearningCourseModulePageProgress.findMany({
          where: { course_module_page_id: courseModulePageId },
          select: { id: true },
        })
      ).map((r) => r.id);

      if (progressIds.length > 0) {
        await tx.systemLog.deleteMany({
          where: { course_module_page_progress_id: { in: progressIds } },
        });
      }

      const krrIds = (
        await tx.knowledgeReviewResult.findMany({
          where: { course_module_page_id: courseModulePageId },
          select: { id: true },
        })
      ).map((r) => r.id);
      if (krrIds.length > 0) {
        await tx.systemLog.deleteMany({
          where: { knowledge_review_result_id: { in: krrIds } },
        });
      }

      const krpIds = (
        await tx.knowledgeReviewParticipant.findMany({
          where: { course_module_page_id: courseModulePageId },
          select: { id: true },
        })
      ).map((r) => r.id);
      if (krpIds.length > 0) {
        await tx.systemLog.deleteMany({
          where: { knowledge_review_participant_id: { in: krpIds } },
        });
      }

      await tx.systemLog.deleteMany({
        where: { course_module_page_id: courseModulePageId },
      });

      await tx.eLearningCourseModulePageProgress.deleteMany({
        where: { course_module_page_id: courseModulePageId },
      });

      await tx.knowledgeReviewResult.deleteMany({
        where: { course_module_page_id: courseModulePageId },
      });

      await tx.knowledgeReviewParticipant.deleteMany({
        where: { course_module_page_id: courseModulePageId },
      });
    });
  }

  async getPagesByCourseModule(courseModuleId: number): Promise<any> {
    return this.prisma.client.courseModulePage.findMany({
      where: {
        course_module_id: courseModuleId,
      },
      include: {
        course: true,
        courseModule: true,
      },
      orderBy: {
        sort_order: 'asc',
      },
    });
  }

  /**
   * Override getDetailData to only include mediaPhotos relation (the only one that exists in Prisma schema)
   * Other media types (videos, documents, etc.) are loaded separately
   */
  async getDetailData(id: number): Promise<any> {
    const moduleCurrentCfg = this.gVars.moduleCurrentCfg;
    const repoName = moduleCurrentCfg.repoName;
    const repo = this.commonMethods.getRepo(repoName);

    const findParams: Record<string, unknown> = {
      where: { id },
    };

    // get the main row
    const rowMain = await repo.findUnique(findParams);
    if (!rowMain) {
      const msg = "The record you are looking for is not found.";
      throw new BadRequestException(msg);
    }

    // include base table relations
    if (moduleCurrentCfg.relationObjToIncludeForDetail) {
      findParams["include"] = moduleCurrentCfg.relationObjToIncludeForDetail;
    }

    // Only include mediaPhotos relation (the only one that exists in Prisma schema)
    // Other media types will be loaded separately below
    const mediaPhotosConfig = moduleCurrentCfg.mediaArr.find(
      (m) => m.mediaRelationName === "mediaPhotos"
    );
    if (mediaPhotosConfig) {
      const existingInclude = (findParams["include"] as Record<string, unknown>) || {};
      findParams["include"] = {
        ...existingInclude,
        ...mediaPhotosConfig.mediaRelationObj,
      } as Record<string, unknown>;
    }

    const data: any = await repo.findUnique(findParams);

    // Load other media types separately from Media table
    if (data && moduleCurrentCfg.mediaArr.length > 0) {
      for (const mediaObj of moduleCurrentCfg.mediaArr) {
        if (mediaObj.mediaRelationName !== "mediaPhotos") {
          // Load from Media table using the where clause from mediaRelationObj
          const whereClause = mediaObj.mediaRelationObj[mediaObj.mediaRelationName]?.where || {};
          data[mediaObj.mediaRelationName] = await this.prisma.client.media.findMany({
            where: {
              course_module_page_id: id,
              ...whereClause,
            },
          });
        }
      }
    }

    // Process photos for S3 URL prefix -- update data object
    await Promise.all(
      moduleCurrentCfg.mediaArr.map(async (mediaObj) => {
        const relationName = mediaObj.mediaRelationName;
        await this.mediaUtilService.addS3UrlPrefixToMediaArray(data[relationName]);
      }),
    );

    return data;
  }

  /**
   * Override getDetail to ensure course and courseModule relations are loaded
   * and enrollment_count is set for DTO transform decorators
   */
  async getDetail(id: number): Promise<DetailResponseDataInterface<unknown>> {
    try {
      const detailData = await this.getDetailData(id);

      // Ensure course relation is loaded and enrollment_count is set
      if (!detailData.course) {
        // If course is missing, fetch it
        const courseModulePage = await this.prisma.client.courseModulePage.findUnique({
          where: { id },
          select: { course_id: true },
        });
        if (courseModulePage?.course_id) {
          detailData.course = await this.prisma.client.course.findUnique({
            where: { id: courseModulePage.course_id },
          });
        }
      }

      // Always set live participant count on course for DTO transforms (course_has_enrollments, etc.).
      // Raw Course.enrollment_count is not kept in sync when people enroll — same as course getDetail.
      if (detailData.course?.id != null) {
        const enrollmentCount = await this.prisma.client.learningGroupParticipant.count({
          where: {
            course_id: detailData.course.id,
          },
        });
        detailData.course.enrollment_count = enrollmentCount;
      }

      // Ensure courseModule relation is loaded
      if (!detailData.courseModule) {
        const courseModulePage = await this.prisma.client.courseModulePage.findUnique({
          where: { id },
          select: { course_module_id: true },
        });
        if (courseModulePage?.course_module_id) {
          detailData.courseModule = await this.prisma.client.courseModule.findUnique({
            where: { id: courseModulePage.course_module_id },
          });
        }
      }

      // Ensure media arrays are initialized
      const moduleCurrentCfg = this.gVars.moduleCurrentCfg;
      if (moduleCurrentCfg.mediaArr.length > 0) {
        moduleCurrentCfg.mediaArr.forEach((mediaObj) => {
          const relationName = mediaObj.mediaRelationName;
          if (!detailData[relationName]) {
            detailData[relationName] = [];
          }
        });
      }

      // Transform using DTO (same as base implementation - no options)
      const transformedData = plainToInstance(moduleCurrentCfg.detailDto, detailData);

      return this.moduleMethods.getReturnDataForDetail(transformedData);
    } catch (error) {
      // Log the error for debugging
      console.error('Error in getDetail for course-module-page:', error);
      throw error;
    }
  }
}

results matching ""

    No results matching ""