apps/recallassess/recallassess-api/src/api/admin/course-module-page/course-module-page.service.ts
BNestBaseModuleService
Properties |
|
Methods |
|
| Async add | ||||||
add(data: any)
|
||||||
|
Override add method to prevent creation when course has enrollments
Parameters :
Returns :
Promise<any>
|
| Private Async checkCourseEnrollments | ||||||
checkCourseEnrollments(courseId: number)
|
||||||
|
Check if the associated course has enrollments
Parameters :
Returns :
Promise<void>
|
| Async delete | ||||||
delete(id: number)
|
||||||
|
Override delete: block when the linked course has any LearningGroupParticipant rows (live count).
Parameters :
Returns :
Promise<void>
|
| Private Async deleteCourseModulePageDependentRows | ||||||
deleteCourseModulePageDependentRows(courseModulePageId: number)
|
||||||
|
Remove rows that FK to this page before Prisma deletes
Parameters :
Returns :
Promise<void>
|
| Private flattenValidationErrors | ||||||
flattenValidationErrors(errors: ValidationError[])
|
||||||
|
Parameters :
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 :
|
| 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 :
Returns :
Promise<any>
|
| Async getPagesByCourseModule | ||||||
getPagesByCourseModule(courseModuleId: number)
|
||||||
|
Parameters :
Returns :
Promise<any>
|
| Private mapSaveValidationErrors | ||||||
mapSaveValidationErrors(errors: ValidationError[])
|
||||||
|
Parameters :
Returns :
Array<literal type>
|
| Async save |
save(id: number, data: any)
|
|
Override save: branch on live enrollment count for this course.
Returns :
Promise<SaveResponseDataInterface<any>>
|
| Async validateSavePayload |
validateSavePayload(id: number, data: unknown)
|
|
Save validation must match #save: global
Returns :
Promise<Array<literal type>>
|
| 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;
}
}
}