apps/recallassess/recallassess-api/src/api/admin/assessment/services/assessment-question.service.ts
Methods |
|
constructor(prisma: BNestPrismaService)
|
||||||
|
Parameters :
|
| Async addQuestion | |||||||||
addQuestion(addQuestionDto: AssessmentQuestionAddDto, assessmentId: number)
|
|||||||||
|
Parameters :
Returns :
Promise<AssessmentQuestionDto>
|
| Private Async checkCourseEnrollments | ||||||
checkCourseEnrollments(courseId: number)
|
||||||
|
Check if the associated course has enrollments
Parameters :
Returns :
Promise<void>
|
| Async deleteQuestion | ||||||
deleteQuestion(assessment_question_id: number)
|
||||||
|
Parameters :
Returns :
Promise<void>
|
| Async getQuestionsByAssessment | ||||||
getQuestionsByAssessment(assessmentId: number)
|
||||||
|
Parameters :
Returns :
Promise<AssessmentQuestionListDto[]>
|
| Private maxSortOrderToNext | ||||||
maxSortOrderToNext(value: unknown)
|
||||||
|
Next sort index after max existing (sort_order is Decimal in DB).
Parameters :
Returns :
number
|
| Async reorderQuestions | ||||||
reorderQuestions(questions: literal type[])
|
||||||
|
Parameters :
Returns :
Promise<void>
|
| Async saveQuestion | |||||||||
saveQuestion(assessmentQuestionId: number, updateQuestionDto: AssessmentQuestionSaveDto)
|
|||||||||
|
Parameters :
Returns :
Promise<AssessmentQuestionDto>
|
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);
}
}