feat: normalize question sort_order to prevent gaps and duplicates in quiz questions

Add normalizeQuestionSortOrder method to ensure sequential 1-based sort_order values. Update reorderQuestion to normalize before reordering and validate new position against question count. Call normalization after question deletion to fill gaps in remaining questions.
This commit is contained in:
JakkrapartXD 2026-02-02 15:42:24 +07:00
parent 4461c1d119
commit 69e4806831

View file

@ -1032,11 +1032,23 @@ export class ChaptersLessonService {
throw new NotFoundError('Question not found in this quiz'); throw new NotFoundError('Question not found in this quiz');
} }
const oldSortOrder = existingQuestion.sort_order;
const quizId = lesson.quiz.id; const quizId = lesson.quiz.id;
// First, normalize sort_order to fix any gaps or duplicates
await this.normalizeQuestionSortOrder(quizId);
// Re-fetch the question to get updated sort_order after normalization
const normalizedQuestion = await prisma.question.findUnique({ where: { id: question_id } });
if (!normalizedQuestion) throw new NotFoundError('Question not found');
const oldSortOrder = normalizedQuestion.sort_order;
// Get total question count to validate sort_order (1-based)
const questionCount = await prisma.question.count({ where: { quiz_id: quizId } });
const newSortOrder = Math.max(1, Math.min(sort_order, questionCount));
// If same position, no need to reorder // If same position, no need to reorder
if (oldSortOrder === sort_order) { if (oldSortOrder === newSortOrder) {
const questions = await prisma.question.findMany({ const questions = await prisma.question.findMany({
where: { quiz_id: quizId }, where: { quiz_id: quizId },
orderBy: { sort_order: 'asc' }, orderBy: { sort_order: 'asc' },
@ -1046,12 +1058,12 @@ export class ChaptersLessonService {
} }
// Shift other questions to make room for the insert // Shift other questions to make room for the insert
if (oldSortOrder > sort_order) { if (oldSortOrder > newSortOrder) {
// Moving up: shift questions between newSortOrder and oldSortOrder-1 down by 1 // Moving up: shift questions between newSortOrder and oldSortOrder-1 down by 1
await prisma.question.updateMany({ await prisma.question.updateMany({
where: { where: {
quiz_id: quizId, quiz_id: quizId,
sort_order: { gte: sort_order, lt: oldSortOrder } sort_order: { gte: newSortOrder, lt: oldSortOrder }
}, },
data: { sort_order: { increment: 1 } } data: { sort_order: { increment: 1 } }
}); });
@ -1060,7 +1072,7 @@ export class ChaptersLessonService {
await prisma.question.updateMany({ await prisma.question.updateMany({
where: { where: {
quiz_id: quizId, quiz_id: quizId,
sort_order: { gt: oldSortOrder, lte: sort_order } sort_order: { gt: oldSortOrder, lte: newSortOrder }
}, },
data: { sort_order: { decrement: 1 } } data: { sort_order: { decrement: 1 } }
}); });
@ -1069,7 +1081,7 @@ export class ChaptersLessonService {
// Update the question's sort order // Update the question's sort order
await prisma.question.update({ await prisma.question.update({
where: { id: question_id }, where: { id: question_id },
data: { sort_order } data: { sort_order: newSortOrder }
}); });
// Fetch all questions with updated order // Fetch all questions with updated order
@ -1121,6 +1133,9 @@ export class ChaptersLessonService {
// Delete the question (CASCADE will delete choices) // Delete the question (CASCADE will delete choices)
await prisma.question.delete({ where: { id: question_id } }); await prisma.question.delete({ where: { id: question_id } });
// Normalize sort_order for remaining questions (fill gaps)
await this.normalizeQuestionSortOrder(lesson.quiz.id);
// Recalculate scores for remaining questions // Recalculate scores for remaining questions
await this.recalculateQuestionScores(lesson.quiz.id); await this.recalculateQuestionScores(lesson.quiz.id);
@ -1176,6 +1191,34 @@ export class ChaptersLessonService {
logger.info(`Recalculated quiz ${quizId}: ${questionCount} questions. Base: ${baseScore}, Remainder: ${remainder}`); logger.info(`Recalculated quiz ${quizId}: ${questionCount} questions. Base: ${baseScore}, Remainder: ${remainder}`);
} }
/**
* Normalize sort_order for all questions in a quiz
* Ensures sort_order is sequential starting from 0 with no gaps or duplicates
*
* @param quizId Quiz ID to normalize
*/
private async normalizeQuestionSortOrder(quizId: number): Promise<void> {
// Get all questions ordered by current sort_order
const questions = await prisma.question.findMany({
where: { quiz_id: quizId },
orderBy: { sort_order: 'asc' },
select: { id: true, sort_order: true }
});
if (questions.length === 0) return;
// Update each question with sequential sort_order starting from 1
const updates = questions.map((question, index) =>
prisma.question.update({
where: { id: question.id },
data: { sort_order: index + 1 }
})
);
await prisma.$transaction(updates);
logger.info(`Normalized sort_order for quiz ${quizId}: ${questions.length} questions`);
}
/** /**
* Quiz * Quiz
* Update quiz settings (title, passing_score, time_limit, etc.) * Update quiz settings (title, passing_score, time_limit, etc.)