File

apps/recallassess/recallassess-api/src/api/admin/assessment/services/assessment-question.service.ts

Index

Methods

Constructor

constructor(prisma: BNestPrismaService)
Parameters :
Name Type Optional
prisma BNestPrismaService No

Methods

Async addQuestion
addQuestion(addQuestionDto: AssessmentQuestionAddDto, assessmentId: number)
Parameters :
Name Type Optional
addQuestionDto AssessmentQuestionAddDto No
assessmentId number No
Private Async checkCourseEnrollments
checkCourseEnrollments(courseId: number)

Check if the associated course has enrollments

Parameters :
Name Type Optional
courseId number No
Returns : Promise<void>
Async deleteQuestion
deleteQuestion(assessment_question_id: number)
Parameters :
Name Type Optional
assessment_question_id number No
Returns : Promise<void>
Async getQuestionsByAssessment
getQuestionsByAssessment(assessmentId: number)
Parameters :
Name Type Optional
assessmentId number No
Private maxSortOrderToNext
maxSortOrderToNext(value: unknown)

Next sort index after max existing (sort_order is Decimal in DB).

Parameters :
Name Type Optional
value unknown No
Returns : number
Async reorderQuestions
reorderQuestions(questions: literal type[])
Parameters :
Name Type Optional
questions literal type[] No
Returns : Promise<void>
Async saveQuestion
saveQuestion(assessmentQuestionId: number, updateQuestionDto: AssessmentQuestionSaveDto)
Parameters :
Name Type Optional
assessmentQuestionId number No
updateQuestionDto AssessmentQuestionSaveDto No
import { BNestPrismaService } from "@bish-nest/core/services/database/prisma/prisma.service";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { AssessmentQuestionDto } from "../dto/quiz/assessment-question.dto";
import { AssessmentQuestionAddDto } from "../dto/quiz/assessment-question-add.dto";
import { AssessmentQuestionListDto } from "../dto/quiz/assessment-question-list.dto";
import { AssessmentQuestionSaveDto } from "../dto/quiz/assessment-question-save.dto";

@Injectable()
export class AssessmentQuestionService {
  constructor(private readonly prisma: BNestPrismaService) {}

  /** Next sort index after max existing (sort_order is Decimal in DB). */
  private maxSortOrderToNext(value: unknown): number {
    if (value == null) {
      return 0;
    }
    let n: number;
    if (typeof value === "object" && value !== null && typeof (value as { toNumber?: () => number }).toNumber === "function") {
      n = (value as { toNumber: () => number }).toNumber();
    } else if (typeof value === "number") {
      n = value;
    } else {
      n = Number(value);
    }
    const maxNum = Number.isFinite(n) ? n : -1;
    return maxNum + 1;
  }

  /**
   * 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 assessment question. The associated course has ${enrollmentCount} enrolled participant(s). Assessment questions for courses with enrollments cannot be modified or deleted.`,
      );
    }
  }

  async getQuestionsByAssessment(assessmentId: number): Promise<AssessmentQuestionListDto[]> {
    const questions = await this.prisma.client.assessmentQuestion.findMany({
      where: { assessment_id: assessmentId },
      include: {
        answers: {
          orderBy: { sort_order: "asc" },
        },
      },
      orderBy: [{ sort_order: "asc" }, { created_at: "asc" }, { id: "asc" }],
    });

    // Prisma Decimal fields can serialize as Decimal instances; normalize to primitives
    // to avoid runtime DecimalError during response serialization/transforms.
    const normalized = questions.map((q) => ({
      ...q,
      sort_order: q.sort_order == null ? 0 : Number(q.sort_order),
    }));

    return plainToInstance(AssessmentQuestionListDto, normalized, { excludeExtraneousValues: true });
  }

  async addQuestion(
    addQuestionDto: AssessmentQuestionAddDto,
    assessmentId: number,
  ): Promise<AssessmentQuestionDto> {
    // Check if course has enrollments before adding question
    if (addQuestionDto.course_id) {
      await this.checkCourseEnrollments(addQuestionDto.course_id);
    }

    // Compute next sort_order = (max sort_order for this assessment) + 1
    const agg = await this.prisma.client.assessmentQuestion.aggregate({
      where: { assessment_id: assessmentId },
      _max: { sort_order: true },
    });
    const nextSortOrder = this.maxSortOrderToNext(agg._max.sort_order);

    const resolvedOrder = addQuestionDto.sort_order ?? nextSortOrder;
    const sortOrder = Number.isFinite(Number(resolvedOrder)) ? Number(resolvedOrder) : nextSortOrder;

    const question = await this.prisma.client.assessmentQuestion.create({
      data: {
        course_id: addQuestionDto.course_id,
        assessment_id: assessmentId,
        question_text: addQuestionDto.question_text,
        sort_order: sortOrder,
      },
    });

    const normalized = {
      ...question,
      sort_order: question.sort_order == null ? 0 : Number(question.sort_order),
    };
    return plainToInstance(AssessmentQuestionDto, normalized, { excludeExtraneousValues: true });
  }

  async saveQuestion(
    assessmentQuestionId: number,
    updateQuestionDto: AssessmentQuestionSaveDto,
  ): Promise<AssessmentQuestionDto> {
    const existingQuestion = await this.prisma.client.assessmentQuestion.findUnique({
      where: { id: assessmentQuestionId },
    });

    if (!existingQuestion) {
      throw new NotFoundException(`Assessment question with ID ${assessmentQuestionId} not found`);
    }

    const requestedSortOrder =
      updateQuestionDto.sort_order == null
        ? null
        : Number.isFinite(updateQuestionDto.sort_order)
          ? updateQuestionDto.sort_order
          : null;

    const nextQuestionText = updateQuestionDto.question_text ?? existingQuestion.question_text ?? "";
    const nextCourseModuleId =
      updateQuestionDto.course_module_id !== undefined
        ? (updateQuestionDto.course_module_id ?? null)
        : (existingQuestion.course_module_id ?? null);
    const nextSortOrder = requestedSortOrder ?? Number(existingQuestion.sort_order);

    const hasQuestionTextChanged = (nextQuestionText ?? "") !== (existingQuestion.question_text ?? "");
    const hasCourseModuleChanged =
      (nextCourseModuleId ?? null) !== (existingQuestion.course_module_id ?? null);
    const hasSortOrderChanged = nextSortOrder !== Number(existingQuestion.sort_order);

    const enrollmentCount = await this.prisma.client.learningGroupParticipant.count({
      where: { course_id: existingQuestion.course_id },
    });
    const hasEnrollments = enrollmentCount > 0;

    if (hasEnrollments && hasCourseModuleChanged) {
      throw new BadRequestException(
        `Cannot modify module for assessment question. The associated course has ${enrollmentCount} enrolled participant(s). Module cannot be changed after participants are enrolled.`,
      );
    }

    // For enrolled courses, allow question text and sort_order edits; only module changes remain blocked.
    if (!hasEnrollments && !hasQuestionTextChanged && !hasCourseModuleChanged && !hasSortOrderChanged) {
      throw new BadRequestException("No fields provided to update.");
    }

    const data: {
      question_text?: string;
      course_module_id?: number | null;
      sort_order?: number;
    } = {};

    if (updateQuestionDto.question_text !== undefined) {
      data.question_text = updateQuestionDto.question_text;
    }
    if (updateQuestionDto.course_module_id !== undefined) {
      data.course_module_id = updateQuestionDto.course_module_id;
    }
    if (requestedSortOrder != null) {
      data.sort_order = requestedSortOrder;
    }

    const safeData = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)) as typeof data;

    if (Object.keys(safeData).length === 0) {
      throw new BadRequestException("No fields provided to update.");
    }

    const question = await this.prisma.client.assessmentQuestion.update({
      where: { id: assessmentQuestionId },
      data: safeData,
      include: {
        answers: true,
      },
    });

    // Prisma Decimal fields can serialize as Decimal instances; normalize to primitives
    // to avoid runtime DecimalError during response serialization/transforms.
    const normalizedQuestion = {
      ...question,
      sort_order: question.sort_order == null ? 0 : Number(question.sort_order),
      answers: (question.answers ?? []).map((a) => ({
        ...a,
        sort_order: a.sort_order == null ? 0 : Number(a.sort_order),
      })),
    };

    return plainToInstance(AssessmentQuestionDto, normalizedQuestion, { excludeExtraneousValues: true });
  }

  async deleteQuestion(assessment_question_id: number): Promise<void> {
    const existingQuestion = await this.prisma.client.assessmentQuestion.findUnique({
      where: { id: assessment_question_id },
    });

    if (!existingQuestion) {
      throw new NotFoundException(`Assessment question with ID ${assessment_question_id} not found`);
    }

    // Check if course has enrollments before deleting
    await this.checkCourseEnrollments(existingQuestion.course_id);

    // Delete associated assessment results first
    await this.prisma.client.assessmentResult.deleteMany({
      where: { assessment_question_id: assessment_question_id },
    });

    // Delete associated answers
    await this.prisma.client.assessmentAnswer.deleteMany({
      where: { assessment_question_id: assessment_question_id },
    });

    // Delete the question
    await this.prisma.client.assessmentQuestion.delete({
      where: { id: assessment_question_id },
    });
  }

  async reorderQuestions(questions: { id: number; sort_order: number }[]): Promise<void> {
    // Reordering (sort_order updates) is allowed even when the course has enrollments.

    const updatePromises = questions.map((question) =>
      this.prisma.client.assessmentQuestion.update({
        where: { id: question.id },
        data: { sort_order: Number.isFinite(question.sort_order) ? question.sort_order : 0 },
      }),
    );

    await Promise.all(updatePromises);
  }
}

results matching ""

    No results matching ""