apps/recallassess/recallassess-api/src/api/client/knowledge-review/knowledge-review.service.ts
Methods |
|
constructor(prisma: BNestPrismaService, eventEmitter: EventEmitter2, subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService)
|
||||||||||||
|
Parameters :
|
| Async getKnowledgeReviewByLearningGroupParticipantId | ||||||||||||
getKnowledgeReviewByLearningGroupParticipantId(learningGroupParticipantId: number, participantId: number)
|
||||||||||||
|
Get knowledge review quiz for a course
Parameters :
Returns :
Promise<literal type>
Knowledge review with questions and answers |
| Async getKnowledgeReviewSubmissionStatus | ||||||||||||||||
getKnowledgeReviewSubmissionStatus(knowledgeReviewId: number, participantId: number, courseModulePageId?: number | null)
|
||||||||||||||||
|
Get knowledge review submission status and submitted answers
Parameters :
Returns :
Promise<literal type>
Submission status and submitted answers if completed |
| Async submitKnowledgeReview | ||||||||||||
submitKnowledgeReview(submitDto: SubmitKnowledgeReviewDto, participantId: number)
|
||||||||||||
|
Submit knowledge review answers
Parameters :
Returns :
Promise<literal type>
Created knowledge review participant record |
import { ParticipantSubscriptionCourseAccessService } from "@api/client/shared/participant-subscription-course-access.service";
import { bnestPlainToDto, bnestPlainToDtoArray } from "@bish-nest/core";
import { BNestPrismaService } from "@bish-nest/core/services";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { EventEmitter2 } from "@nestjs/event-emitter";
import { ContentType, KnowledgeReviewQuestionType } from "@prisma/client";
// Temporary: Use string literals until TypeScript picks up the regenerated Prisma client
// After restarting TypeScript server, this should work: import { KnowledgeReviewSubmissionType } from "@prisma/client";
type KnowledgeReviewSubmissionType = "COURSE_LEVEL" | "EMBEDDED_QUIZ" | "EMBEDDED_ASSIGNMENT";
const KnowledgeReviewSubmissionType = {
COURSE_LEVEL: "COURSE_LEVEL" as const,
EMBEDDED_QUIZ: "EMBEDDED_QUIZ" as const,
EMBEDDED_ASSIGNMENT: "EMBEDDED_ASSIGNMENT" as const,
};
import {
COURSE_PROGRESS_EVENTS,
KnowledgeReviewCompletedEvent,
} from "../learning-group/events/course-progress.events";
import {
CLKnowledgeReviewAnswerDto,
CLKnowledgeReviewDto,
CLKnowledgeReviewQuestionDto,
SubmitKnowledgeReviewDto,
} from "./dto";
@Injectable()
export class CLKnowledgeReviewService {
constructor(
private readonly prisma: BNestPrismaService,
private readonly eventEmitter: EventEmitter2,
private readonly subscriptionCourseAccess: ParticipantSubscriptionCourseAccessService,
) {}
/**
* Shuffle array using Fisher-Yates algorithm
* @param array Array to shuffle
* @returns New shuffled array
*/
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
/**
* Get knowledge review quiz for a course
* @param learningGroupParticipantId LearningGroupParticipant ID
* @param participantId Participant ID (for validation)
* @returns Knowledge review with questions and answers
*/
async getKnowledgeReviewByLearningGroupParticipantId(
learningGroupParticipantId: number,
participantId: number,
): Promise<{
knowledgeReview: CLKnowledgeReviewDto;
questions: CLKnowledgeReviewQuestionDto[];
isCompleted: boolean;
}> {
// Get enrollment using LearningGroupParticipant ID
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
id: learningGroupParticipantId,
participant_id: participantId,
},
});
if (!enrollment) {
throw new NotFoundException("Course enrollment not found or access denied");
}
if (enrollment.cancelled) {
throw new ForbiddenException("This course license has been cancelled. You no longer have access to this knowledge review.");
}
// Type assertion to access course_id
const enrollmentWithCourseId = enrollment as typeof enrollment & { course_id: number };
const courseId = enrollmentWithCourseId.course_id;
// Get course to access knowledge_review_id
const course = await this.prisma.client.course.findUnique({
where: {
id: courseId,
},
});
if (!course) {
throw new NotFoundException("Course not found");
}
// Type assertion to access knowledge_review_id
const courseWithKnowledgeReviewId = course as typeof course & { knowledge_review_id: number | null };
if (!courseWithKnowledgeReviewId.knowledge_review_id) {
throw new NotFoundException(
`Knowledge review quiz not found for course ID ${courseId}. Please ensure a knowledge review is assigned to this course.`,
);
}
// Get knowledge review
const knowledgeReview = await this.prisma.client.knowledgeReview.findUnique({
where: {
id: courseWithKnowledgeReviewId.knowledge_review_id,
},
});
if (!knowledgeReview) {
throw new NotFoundException("Knowledge review not found");
}
await this.subscriptionCourseAccess.assertAllowsKnowledgeReviewRead(
participantId,
learningGroupParticipantId,
enrollment.status,
courseId,
knowledgeReview.id,
);
// Get questions with answers
const questions = await this.prisma.client.knowledgeReviewQuestion.findMany({
where: {
knowledge_review_id: knowledgeReview.id,
},
include: {
knowledgeReviewAnswers: {
orderBy: {
sort_order: "asc",
},
},
},
orderBy: {
sort_order: "asc",
},
});
// Check if already completed and get submitted answers
// For standalone KR, course_module_page_id must be NULL
// This distinguishes it from embedded quizzes which have course_module_page_id set
const existingParticipant = await this.prisma.client.knowledgeReviewParticipant.findFirst({
where: {
knowledge_review_id: knowledgeReview.id,
participant_id: participantId,
course_module_page_id: null, // Only check standalone KR, not embedded quizzes
} as any, // Type assertion needed until TypeScript server picks up regenerated Prisma types
});
const isCompleted = !!existingParticipant;
// Get submitted answers if completed
const submittedAnswers: Map<number, { answerId?: number; answerText?: string }> = new Map();
if (isCompleted) {
const results = await this.prisma.client.knowledgeReviewResult.findMany({
where: {
knowledge_review_id: knowledgeReview.id,
participant_id: participantId,
course_module_page_id: null, // Only get results for standalone KR, not embedded quizzes
} as any, // Type assertion needed until TypeScript server picks up regenerated Prisma types
include: {
knowledgeReviewQuestion: true,
},
});
results.forEach((result) => {
const questionId = result.knowledge_review_question_id;
const selectedAnswers = result.selected_answers as any;
if (selectedAnswers) {
// Handle text input answers from JSON (check first, as it's mutually exclusive with multiple choice)
if (selectedAnswers.answer_text !== undefined && selectedAnswers.answer_text !== null) {
submittedAnswers.set(questionId, { answerText: selectedAnswers.answer_text });
}
// Handle multiple choice answers from JSON
else if (selectedAnswers.knowledge_review_answer_id) {
submittedAnswers.set(questionId, { answerId: selectedAnswers.knowledge_review_answer_id });
}
} else if (result.knowledge_review_answer_id) {
// Handle multiple choice answers from direct field (fallback)
submittedAnswers.set(questionId, { answerId: result.knowledge_review_answer_id });
}
});
}
// Map questions to include answers in the correct format
// Prisma returns knowledgeReviewAnswers, but DTO expects answers
// Preserve the order from the document (answers are already ordered by sort_order from DB query)
const questionsWithAnswers = questions.map((question) => {
const { knowledgeReviewAnswers, ...questionData } = question;
const submittedAnswer = submittedAnswers.get(question.id);
// Convert to DTOs - preserve order from database (already sorted by sort_order ASC)
// This ensures "None of the above" and "All of the above" appear in the 4th position as per document
const answerDtos = knowledgeReviewAnswers
? bnestPlainToDtoArray(knowledgeReviewAnswers, CLKnowledgeReviewAnswerDto)
: [];
return {
...questionData,
answers: answerDtos, // Use answers in original order (preserves document order)
submittedAnswer: submittedAnswer || null,
};
});
return {
knowledgeReview: bnestPlainToDto(knowledgeReview, CLKnowledgeReviewDto),
questions: bnestPlainToDtoArray(questionsWithAnswers, CLKnowledgeReviewQuestionDto),
isCompleted,
};
}
/**
* Get knowledge review submission status and submitted answers
* @param knowledgeReviewId Knowledge Review ID
* @param participantId Participant ID
* @param courseModulePageId Optional course module page ID (for embedded quizzes). If null, checks standalone KR.
* @returns Submission status and submitted answers if completed
*/
async getKnowledgeReviewSubmissionStatus(
knowledgeReviewId: number,
participantId: number,
courseModulePageId?: number | null,
): Promise<{
isCompleted: boolean;
submittedAnswers?: Record<number, { answerId?: number; answerText?: string }>;
}> {
// Check if already completed
// For embedded quizzes: filter by course_module_page_id
// For standalone KR: course_module_page_id should be NULL
const whereClause: any = {
knowledge_review_id: knowledgeReviewId,
participant_id: participantId,
};
if (courseModulePageId !== undefined) {
whereClause.course_module_page_id = courseModulePageId;
} else {
whereClause.course_module_page_id = null;
}
const existingParticipant = await this.prisma.client.knowledgeReviewParticipant.findFirst({
where: whereClause,
});
const isCompleted = !!existingParticipant;
if (!isCompleted) {
return { isCompleted: false };
}
// Get submitted answers
// Filter by course_module_page_id to distinguish between embedded quizzes and standalone KR
const resultsWhereClause: any = {
knowledge_review_id: knowledgeReviewId,
participant_id: participantId,
};
if (courseModulePageId !== undefined) {
resultsWhereClause.course_module_page_id = courseModulePageId;
} else {
resultsWhereClause.course_module_page_id = null;
}
const results = await this.prisma.client.knowledgeReviewResult.findMany({
where: resultsWhereClause,
});
const submittedAnswers = new Map<number, { answerId?: number; answerText?: string }>();
results.forEach((result) => {
const questionId = result.knowledge_review_question_id;
const selectedAnswers = result.selected_answers as any;
if (selectedAnswers) {
// Handle text input answers from JSON
if (selectedAnswers.answer_text !== undefined && selectedAnswers.answer_text !== null) {
submittedAnswers.set(questionId, { answerText: selectedAnswers.answer_text });
}
// Handle multiple choice answers from JSON
else if (selectedAnswers.knowledge_review_answer_id) {
submittedAnswers.set(questionId, { answerId: selectedAnswers.knowledge_review_answer_id });
}
} else if (result.knowledge_review_answer_id) {
// Handle multiple choice answers from direct field (fallback)
submittedAnswers.set(questionId, { answerId: result.knowledge_review_answer_id });
}
});
// Convert Map to object for JSON serialization
const submittedAnswersObj: Record<number, { answerId?: number; answerText?: string }> = {};
submittedAnswers.forEach((value, key) => {
submittedAnswersObj[key] = value;
});
return {
isCompleted: true,
submittedAnswers: submittedAnswersObj,
};
}
/**
* Submit knowledge review answers
* @param submitDto Submission data
* @param participantId Participant ID
* @returns Created knowledge review participant record
*/
async submitKnowledgeReview(
submitDto: SubmitKnowledgeReviewDto,
participantId: number,
): Promise<{
success: boolean;
knowledgeReviewParticipantId: number | null;
passed: boolean;
score: number;
passPercentage: number | null;
questionResults: Array<{ questionId: number; isCorrect: boolean }>;
}> {
// Verify participant is enrolled in this course
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
participant_id: participantId,
learningGroup: {
course_id: submitDto.course_id,
},
},
});
if (!enrollment) {
throw new NotFoundException("Course not found or you are not enrolled");
}
if (enrollment.cancelled) {
throw new ForbiddenException("This course license has been cancelled. You can no longer submit this knowledge review.");
}
await this.subscriptionCourseAccess.assertAllowsContinuingCourseAccess(
participantId,
enrollment.id,
enrollment.status,
);
// Verify knowledge review exists
const knowledgeReview = await this.prisma.client.knowledgeReview.findUnique({
where: {
id: submitDto.knowledge_review_id,
},
});
if (!knowledgeReview) {
throw new NotFoundException("Knowledge review not found");
}
// Verify knowledge review belongs to the course
// Check if it's a course-level knowledge review OR if it's used in a page within this course
const course = await this.prisma.client.course.findUnique({
where: {
id: submitDto.course_id,
},
});
if (!course) {
throw new NotFoundException("Course not found");
}
const courseWithKnowledgeReviewId = course as typeof course & { knowledge_review_id: number | null };
// Check if it's a course-level knowledge review
const isCourseLevelReview = courseWithKnowledgeReviewId.knowledge_review_id === submitDto.knowledge_review_id;
// Determine course_module_page_id
// If provided in DTO (for embedded quizzes), use it directly
// If not provided and it's a course-level review, set to null for standalone KR
// If not provided and it's NOT a course-level review, it must be an embedded quiz - find the page
let courseModulePageId: number | null = null;
let pageWithKnowledgeReview: { id: number; course_module_id: number } | null = null;
if (submitDto.course_module_page_id !== undefined && submitDto.course_module_page_id !== null) {
// Page ID explicitly provided (embedded quiz)
courseModulePageId = submitDto.course_module_page_id;
// Verify the page exists and belongs to this course and has this knowledge review
pageWithKnowledgeReview = await this.prisma.client.courseModulePage.findFirst({
where: {
id: courseModulePageId,
course_id: submitDto.course_id,
knowledge_review_id: submitDto.knowledge_review_id,
},
});
if (!pageWithKnowledgeReview) {
throw new NotFoundException("Course module page not found or does not match the knowledge review");
}
} else {
// No page ID provided
if (isCourseLevelReview) {
// It's a course-level knowledge review - this is a standalone KR
// courseModulePageId remains null
pageWithKnowledgeReview = null;
} else {
// Not a course-level review, so it must be an embedded quiz
// Find the page that contains this knowledge review
pageWithKnowledgeReview = await this.prisma.client.courseModulePage.findFirst({
where: {
course_id: submitDto.course_id,
knowledge_review_id: submitDto.knowledge_review_id,
},
});
if (pageWithKnowledgeReview) {
courseModulePageId = pageWithKnowledgeReview.id;
} else {
throw new NotFoundException(
"Knowledge review not found as embedded quiz in any page. If this is a standalone knowledge review, ensure it is assigned to the course.",
);
}
}
}
if (!isCourseLevelReview && !pageWithKnowledgeReview) {
throw new NotFoundException("Knowledge review does not belong to this course");
}
// Check if already submitted
// For embedded quizzes: filter by course_module_page_id
// For standalone KR: course_module_page_id should be NULL
const submitWhereClause: any = {
knowledge_review_id: submitDto.knowledge_review_id,
participant_id: participantId,
course_module_page_id: courseModulePageId,
};
const existingParticipant = await this.prisma.client.knowledgeReviewParticipant.findFirst({
where: submitWhereClause,
});
if (existingParticipant) {
throw new NotFoundException("Knowledge review quiz has already been submitted");
}
// Get questions to validate answers
const questions = await this.prisma.client.knowledgeReviewQuestion.findMany({
where: {
knowledge_review_id: submitDto.knowledge_review_id,
},
include: {
knowledgeReviewAnswers: {
orderBy: {
sort_order: "asc",
},
},
},
});
// Determine submission_type based on course_module_page_id and page content_type
let submissionType: KnowledgeReviewSubmissionType;
if (courseModulePageId === null) {
// Course-level knowledge review (not embedded)
submissionType = KnowledgeReviewSubmissionType.COURSE_LEVEL;
} else {
// Embedded quiz/assignment - use content_type from the page
// Get the page to check its content_type
const page = pageWithKnowledgeReview
? await this.prisma.client.courseModulePage.findUnique({
where: { id: courseModulePageId },
select: { content_type: true },
})
: null;
// Use content_type from page, or fallback to question types if not set
// Using string literal for ASSIGNMENT until TypeScript picks up the enum
if (page?.content_type === ("ASSIGNMENT" as ContentType)) {
submissionType = KnowledgeReviewSubmissionType.EMBEDDED_ASSIGNMENT;
} else if (page?.content_type === ContentType.QUIZ) {
submissionType = KnowledgeReviewSubmissionType.EMBEDDED_QUIZ;
} else {
// Fallback: determine type based on question types (for backward compatibility)
const hasTextInput = questions.some((q) => q.question_type === KnowledgeReviewQuestionType.TEXT_INPUT);
const hasMultipleChoice = questions.some(
(q) => q.question_type === KnowledgeReviewQuestionType.MULTIPLE_CHOICE,
);
if (hasTextInput && !hasMultipleChoice) {
submissionType = KnowledgeReviewSubmissionType.EMBEDDED_ASSIGNMENT;
} else {
submissionType = KnowledgeReviewSubmissionType.EMBEDDED_QUIZ;
}
}
}
// Start transaction
return await this.prisma.client
.$transaction(async (tx) => {
// Get course_module_id
// For page-level quizzes, use the module from the page
// For course-level quizzes, use the first module
let courseModuleId: number;
if (pageWithKnowledgeReview) {
// Use the module from the page that contains this knowledge review
courseModuleId = pageWithKnowledgeReview.course_module_id;
} else {
// For course-level knowledge reviews, use the first module
// CourseModule no longer has course_id - find through CourseModulePage
const firstPage = await tx.courseModulePage.findFirst({
where: {
course_id: submitDto.course_id,
courseModule: {
is_published: true,
},
},
select: {
courseModule: {
select: {
id: true,
sort_order: true,
},
},
},
orderBy: {
courseModule: {
sort_order: "asc",
},
},
});
if (!firstPage?.courseModule) {
throw new NotFoundException("Course must have at least one published module");
}
courseModuleId = firstPage.courseModule.id;
}
const results = [];
const questionResults: Array<{ questionId: number; isCorrect: boolean }> = [];
let correctCount = 0;
let incorrectCount = 0;
// Create results for each answer
for (const answer of submitDto.answers) {
const question = questions.find((q) => q.id === answer.knowledge_review_question_id);
if (!question) {
throw new NotFoundException(`Question ${answer.knowledge_review_question_id} not found`);
}
let isCorrect = false;
let knowledgeReviewAnswerId: number | null = null;
if (question.question_type === KnowledgeReviewQuestionType.MULTIPLE_CHOICE) {
// For multiple choice, validate that answer_id is provided
if (!answer.knowledge_review_answer_id) {
throw new NotFoundException(
`Answer ID is required for multiple choice question ${answer.knowledge_review_question_id}`,
);
}
// Check if the selected answer is correct
// IMPORTANT: Find the answer by ID from the database
const selectedAnswer = question.knowledgeReviewAnswers.find(
(a) => a.id === answer.knowledge_review_answer_id,
);
if (!selectedAnswer) {
console.error(`[Quiz Debug ERROR] Answer ${answer.knowledge_review_answer_id} not found in database for question ${answer.knowledge_review_question_id}`);
console.error(`[Quiz Debug ERROR] Available answer IDs for this question:`, question.knowledgeReviewAnswers.map(a => a.id));
throw new NotFoundException(
`Answer ${answer.knowledge_review_answer_id} not found for question ${answer.knowledge_review_question_id}`,
);
}
isCorrect = selectedAnswer.is_correct;
knowledgeReviewAnswerId = selectedAnswer.id;
} else if (question.question_type === KnowledgeReviewQuestionType.TEXT_INPUT) {
// For text input, check if answer_text is provided
if (!answer.answer_text || answer.answer_text.trim() === "") {
throw new NotFoundException(
`Answer text is required for text input question ${answer.knowledge_review_question_id}`,
);
}
// For text input, we can't automatically determine correctness
// You might want to implement manual review or keyword matching here
// For now, we'll store the text answer but mark as not correct by default
isCorrect = false;
}
// Store question result for response (before deletion if failed)
questionResults.push({
questionId: answer.knowledge_review_question_id,
isCorrect: isCorrect,
});
// Store selected answer in JSON format for both multiple choice and text input
const selectedAnswersJson: any = {};
if (question.question_type === KnowledgeReviewQuestionType.MULTIPLE_CHOICE && knowledgeReviewAnswerId) {
selectedAnswersJson.knowledge_review_answer_id = knowledgeReviewAnswerId;
} else if (question.question_type === KnowledgeReviewQuestionType.TEXT_INPUT && answer.answer_text) {
selectedAnswersJson.answer_text = answer.answer_text;
}
const resultData: any = {
course_id: submitDto.course_id,
participant_id: participantId,
knowledge_review_id: submitDto.knowledge_review_id,
knowledge_review_question_id: answer.knowledge_review_question_id,
knowledge_review_answer_id: knowledgeReviewAnswerId,
course_module_id: courseModuleId,
course_module_page_id: courseModulePageId, // NULL for course-level KR, page ID for embedded quizzes/assignments
submission_type: submissionType, // COURSE_LEVEL, EMBEDDED_QUIZ, or EMBEDDED_ASSIGNMENT
is_correct: isCorrect,
selected_answers: selectedAnswersJson,
};
const result = await tx.knowledgeReviewResult.create({
data: resultData,
});
results.push(result);
if (isCorrect) {
correctCount++;
} else {
incorrectCount++;
}
}
// Calculate score
const totalQuestions = questions.length;
const score = totalQuestions > 0 ? (correctCount / totalQuestions) * 100 : 0;
// Get pass percentage from knowledge review
const passPercentage = knowledgeReview.pass_percentage ?? null;
// Check if participant passed
// Assignments always pass (no pass percentage validation) - only quizzes validate pass percentage
const passed =
submissionType === KnowledgeReviewSubmissionType.EMBEDDED_ASSIGNMENT ||
passPercentage === null ||
score >= passPercentage;
let knowledgeReviewParticipantId: number | null = null;
if (passed) {
// Create knowledge review participant record only if passed
const participantData: any = {
course_id: submitDto.course_id,
knowledge_review_id: submitDto.knowledge_review_id,
participant_id: participantId,
course_module_page_id: courseModulePageId,
submission_type: submissionType, // COURSE_LEVEL, EMBEDDED_QUIZ, or EMBEDDED_ASSIGNMENT
review_completion_date: new Date(),
review_score: score,
correct_answers_count: correctCount,
incorrect_answers_count: incorrectCount,
total_answers_count: totalQuestions,
};
const knowledgeReviewParticipant = await tx.knowledgeReviewParticipant.create({
data: participantData,
});
knowledgeReviewParticipantId = knowledgeReviewParticipant.id;
} else {
// Failed - delete all results that were just created
await tx.knowledgeReviewResult.deleteMany({
where: {
knowledge_review_id: submitDto.knowledge_review_id,
participant_id: participantId,
course_module_page_id: courseModulePageId,
},
});
}
const result = {
success: true,
knowledgeReviewParticipantId: knowledgeReviewParticipantId,
passed: passed,
score: score,
passPercentage: passPercentage,
questionResults: questionResults, // Include question results for review display
};
return result;
})
.then(async (result) => {
// Only emit event if passed (participant record was created)
if (result.passed && result.knowledgeReviewParticipantId) {
// Emit event outside transaction
// Get learningGroupParticipantId from enrollment
const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
where: {
participant_id: participantId,
learningGroup: {
course_id: submitDto.course_id,
},
},
});
if (enrollment) {
// Only emit KNOWLEDGE_REVIEW_COMPLETED event for standalone knowledge reviews
// (course_module_page_id is NULL for standalone KR)
// Embedded page quizzes should NOT trigger this event
if (courseModulePageId === null) {
this.eventEmitter.emit(
COURSE_PROGRESS_EVENTS.KNOWLEDGE_REVIEW_COMPLETED,
new KnowledgeReviewCompletedEvent(
enrollment.id,
participantId,
submitDto.course_id,
result.knowledgeReviewParticipantId,
),
);
}
}
}
return result;
});
}
}