backend_adaptive_learning/controllers/contentControllers/exercise.js

848 lines
24 KiB
JavaScript

import response from "../../response.js";
import models from "../../models/index.js";
import fs from "fs";
import path from "path";
import {
clearFileBuffers,
saveFileToDisk,
} from "../../middlewares/uploadExercise.js";
export const getExercises = async (req, res) => {
try {
const exercises = await models.Exercise.findAll({
where: { IS_DELETED: 0 },
include: [
{
model: models.MultipleChoices,
as: "multipleChoices",
},
{
model: models.MatchingPairs,
as: "matchingPairs",
},
{
model: models.TrueFalse,
as: "trueFalse",
},
],
});
if (exercises.length === 0) {
return response(404, null, "No exercises found", res);
}
const result = exercises.map((exercise) => {
const exerciseData = { ...exercise.dataValues };
const questionType = exercise.QUESTION_TYPE;
if (questionType === "MCQ") {
delete exerciseData.matchingPairs;
delete exerciseData.trueFalse;
} else if (questionType === "MPQ") {
delete exerciseData.multipleChoices;
delete exerciseData.trueFalse;
} else if (questionType === "TFQ") {
delete exerciseData.multipleChoices;
delete exerciseData.matchingPairs;
} else {
delete exerciseData.multipleChoices;
delete exerciseData.matchingPairs;
delete exerciseData.trueFalse;
}
return exerciseData;
});
response(200, result, "Success", res);
} catch (error) {
console.log(error);
res.status(500).json({ message: "Internal Server Error" });
}
};
export const getExerciseById = async (req, res) => {
try {
const { id } = req.params;
const exercise = await models.Exercise.findOne({
where: { ID_ADMIN_EXERCISE: id, IS_DELETED: 0 },
include: [
{
model: models.MultipleChoices,
as: "multipleChoices",
},
{
model: models.MatchingPairs,
as: "matchingPairs",
},
{
model: models.TrueFalse,
as: "trueFalse",
},
],
});
if (!exercise) {
return response(404, null, "Exercise not found", res);
}
const exerciseData = { ...exercise.dataValues };
const questionType = exercise.QUESTION_TYPE;
if (questionType === "MCQ") {
if (exerciseData.multipleChoices) {
exerciseData.multipleChoices = exerciseData.multipleChoices.map(
(choice) => choice.dataValues
);
}
delete exerciseData.matchingPairs;
delete exerciseData.trueFalse;
} else if (questionType === "MPQ") {
if (exerciseData.matchingPairs) {
exerciseData.matchingPairs = exerciseData.matchingPairs.map(
(pair) => pair.dataValues
);
}
delete exerciseData.multipleChoices;
delete exerciseData.trueFalse;
} else if (questionType === "TFQ") {
if (exerciseData.trueFalse) {
exerciseData.trueFalse = exerciseData.trueFalse.map(
(tf) => tf.dataValues
);
}
delete exerciseData.multipleChoices;
delete exerciseData.matchingPairs;
} else {
delete exerciseData.multipleChoices;
delete exerciseData.matchingPairs;
delete exerciseData.trueFalse;
}
response(200, exerciseData, "Success", res);
} catch (error) {
console.log(error);
res.status(500).json({ message: "Internal Server Error" });
}
};
export const getExercisesForAdmin = async (req, res) => {
const { page = 1, limit = 10, search = "", sort = "time" } = req.query;
try {
const { rows: exercises } = await models.Exercise.findAndCountAll({
where: {
IS_DELETED: 0,
...(search && {
[models.Op.or]: [
{
"$levelExercise->levelTopic->topicSection.NAME_SECTION$": {
[models.Op.like]: `%${search}%`,
},
},
{
"$levelExercise->levelTopic.NAME_TOPIC$": {
[models.Op.like]: `%${search}%`,
},
},
{
"$levelExercise.NAME_LEVEL$": { [models.Op.like]: `%${search}%` },
},
{ QUESTION: { [models.Op.like]: `%${search}%` } },
],
}),
},
distinct: true,
include: [
{
model: models.Level,
as: "levelExercise",
attributes: ["NAME_LEVEL"],
include: [
{
model: models.Topic,
as: "levelTopic",
attributes: ["NAME_TOPIC"],
include: [
{
model: models.Section,
as: "topicSection",
attributes: ["NAME_SECTION"],
},
],
},
],
},
{
model: models.MultipleChoices,
as: "multipleChoices",
attributes: ["ANSWER_KEY"],
},
{
model: models.MatchingPairs,
as: "matchingPairs",
attributes: ["LEFT_PAIR", "RIGHT_PAIR"],
},
{
model: models.TrueFalse,
as: "trueFalse",
attributes: ["IS_TRUE"],
},
],
});
const formattedExercises = exercises.map((exercise) => {
const questionType = exercise.QUESTION_TYPE;
let answerKey = null;
const nameSection =
exercise.levelExercise?.levelTopic?.topicSection?.NAME_SECTION ||
"Unknown Section";
const nameTopic =
exercise.levelExercise?.levelTopic?.NAME_TOPIC || "Unknown Topic";
if (questionType === "MCQ" && exercise.multipleChoices.length > 0) {
answerKey = exercise.multipleChoices[0].ANSWER_KEY;
} else if (questionType === "MPQ" && exercise.matchingPairs.length > 0) {
answerKey = exercise.matchingPairs
.map((pair) => `${pair.LEFT_PAIR}-${pair.RIGHT_PAIR}`)
.join(", ");
} else if (questionType === "TFQ" && exercise.trueFalse.length > 0) {
answerKey = exercise.trueFalse[0].IS_TRUE === 1 ? "true" : "false";
}
return {
ID_ADMIN_EXERCISE: exercise.ID_ADMIN_EXERCISE,
NAME_SECTION: nameSection,
NAME_TOPIC: nameTopic,
NAME_LEVEL: exercise.levelExercise.NAME_LEVEL,
TITLE: exercise.TITLE,
QUESTION: exercise.QUESTION,
QUESTION_TYPE: questionType,
ANSWER_KEY: answerKey,
TIME_ADMIN_EXC: exercise.TIME_ADMIN_EXC,
};
});
const filteredExercises = formattedExercises.filter(
(exercise) =>
exercise.NAME_SECTION !== "Unknown Section" &&
exercise.NAME_TOPIC !== "Unknown Topic"
);
if (sort === "section") {
filteredExercises.sort((a, b) => {
return a.NAME_SECTION.localeCompare(b.NAME_SECTION);
});
} else if (sort === "topic") {
filteredExercises.sort((a, b) => {
return a.NAME_TOPIC.localeCompare(b.NAME_TOPIC);
});
} else if (sort === "level") {
filteredExercises.sort((a, b) => {
return a.NAME_LEVEL.localeCompare(b.NAME_LEVEL);
});
} else if (sort === "question") {
filteredExercises.sort((a, b) => {
return a.QUESTION.localeCompare(b.QUESTION);
});
} else if (sort === "answer") {
filteredExercises.sort((a, b) => {
return (a.ANSWER_KEY || "").localeCompare(b.ANSWER_KEY || "");
});
} else {
filteredExercises.sort((a, b) => {
return new Date(b.TIME_ADMIN_EXC) - new Date(a.TIME_ADMIN_EXC);
});
}
const formattedSortedExercises = filteredExercises.map((exercise) => {
const formattedTimeAdminExc = new Date(exercise.TIME_ADMIN_EXC)
.toLocaleString("en-GB", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
day: "2-digit",
month: "2-digit",
year: "numeric",
})
.replace(/(\d{2}\/\d{2}\/\d{4}), (\d{2}:\d{2})/, "$2 $1");
return {
...exercise,
TIME_ADMIN_EXC: formattedTimeAdminExc,
};
});
const paginatedExercises = formattedSortedExercises.slice(
(page - 1) * limit,
page * limit
);
const totalPages = Math.ceil(formattedSortedExercises.length / limit);
response(
200,
{
exercises: paginatedExercises,
currentPage: parseInt(page),
totalPages: totalPages,
totalExercises: formattedSortedExercises.length,
},
"Success",
res
);
} catch (error) {
console.log(error);
response(500, null, "Error retrieving exercises data!", res);
}
};
export const getExerciseByLevelId = async (req, res) => {
try {
const { idLevel } = req.params;
const levelExists = await models.Level.findByPk(idLevel, {
include: [
{
model: models.Topic,
as: "levelTopic",
attributes: ["NAME_TOPIC"],
},
],
attributes: ["NAME_LEVEL"],
});
if (!levelExists) {
return response(404, null, "Level not found", res);
}
const exercises = await models.Exercise.findAll({
where: { ID_LEVEL: idLevel, IS_DELETED: 0 },
include: [
{
model: models.MultipleChoices,
as: "multipleChoices",
},
{
model: models.MatchingPairs,
as: "matchingPairs",
},
{
model: models.TrueFalse,
as: "trueFalse",
},
],
});
if (!exercises || exercises.length === 0) {
return response(404, null, "No exercises found for this level", res);
}
const formattedExercises = exercises.map((exercise) => {
const exerciseData = { ...exercise.dataValues };
const questionType = exercise.QUESTION_TYPE;
if (questionType === "MCQ") {
if (exerciseData.multipleChoices) {
exerciseData.multipleChoices = exerciseData.multipleChoices.map(
(choice) => {
const { ANSWER_KEY, ...rest } = choice.dataValues;
return rest;
}
);
}
delete exerciseData.matchingPairs;
delete exerciseData.trueFalse;
} else if (questionType === "MPQ") {
if (exerciseData.matchingPairs) {
exerciseData.matchingPairs = exerciseData.matchingPairs.map(
(pair) => pair.dataValues
);
}
delete exerciseData.multipleChoices;
delete exerciseData.trueFalse;
} else if (questionType === "TFQ") {
if (exerciseData.trueFalse) {
exerciseData.trueFalse = exerciseData.trueFalse.map((tf) => {
const { IS_TRUE, ...rest } = tf.dataValues;
return rest;
});
}
delete exerciseData.multipleChoices;
delete exerciseData.matchingPairs;
} else {
delete exerciseData.multipleChoices;
delete exerciseData.matchingPairs;
delete exerciseData.trueFalse;
}
return exerciseData;
});
const responsePayload = {
NAME_TOPIC: levelExists.levelTopic.NAME_TOPIC,
NAME_LEVEL: levelExists.NAME_LEVEL,
EXERCISES: formattedExercises,
};
response(200, responsePayload, "Success", res);
} catch (error) {
console.log(error);
res.status(500).json({ message: "Internal Server Error" });
}
};
export const createExercises = async (req, res) => {
const { exercises } = req.body;
if (!exercises || !Array.isArray(exercises) || exercises.length === 0) {
return response(400, null, "Exercises array is required", res);
}
const transaction = await models.db.transaction();
try {
const createdExercises = [];
const levelId = exercises[0]?.ID_LEVEL;
let lastExercise = await models.Exercise.findOne({
where: { ID_LEVEL: levelId },
order: [["TITLE", "DESC"]],
limit: 1,
});
let lastTitleNumber = 0;
if (lastExercise && lastExercise.TITLE) {
const lastTitleParts = lastExercise.TITLE.split(" ");
lastTitleNumber = parseInt(lastTitleParts[1], 10) || 0;
}
for (let i = 0; i < exercises.length; i++) {
const exerciseData = exercises[i];
const { ID_LEVEL, TITLE, QUESTION, SCORE_WEIGHT, VIDEO, QUESTION_TYPE } =
exerciseData;
if (!ID_LEVEL) throw new Error("Level ID is required");
if (!QUESTION) throw new Error("Question is required");
if (!SCORE_WEIGHT) throw new Error("Score weight is required");
const level = await models.Level.findByPk(ID_LEVEL);
if (!level) throw new Error("Level not found");
let generatedTitle = TITLE;
if (!TITLE) {
lastTitleNumber++;
generatedTitle = `Soal ${lastTitleNumber}`;
}
const existingExercise = await models.Exercise.findOne({
where: { ID_LEVEL, TITLE: generatedTitle },
});
if (existingExercise) {
throw new Error(
`An exercise with the title ${generatedTitle} already exists`
);
}
const newExercise = await models.Exercise.create(
{
ID_LEVEL,
TITLE: generatedTitle,
QUESTION,
SCORE_WEIGHT,
QUESTION_TYPE,
AUDIO: null,
VIDEO: VIDEO || null,
IMAGE: null,
},
{ transaction }
);
const AUDIO = req.filesToSave[`AUDIO[${i}]`] || null;
const IMAGE = req.filesToSave[`IMAGE[${i}]`] || null;
const audioFilename = AUDIO
? saveFileToDisk(
AUDIO,
"AUDIO",
ID_LEVEL,
newExercise.ID_ADMIN_EXERCISE
)
: null;
const imageFilename = IMAGE
? saveFileToDisk(
IMAGE,
"IMAGE",
ID_LEVEL,
newExercise.ID_ADMIN_EXERCISE
)
: null;
newExercise.AUDIO = audioFilename;
newExercise.IMAGE = imageFilename;
await newExercise.save({ transaction });
let questionDetails = null;
switch (QUESTION_TYPE) {
case "MCQ":
const {
OPTION_A,
OPTION_B,
OPTION_C,
OPTION_D,
OPTION_E,
ANSWER_KEY,
} = exerciseData;
if (
!OPTION_A ||
!OPTION_B ||
!OPTION_C ||
!OPTION_D ||
!OPTION_E ||
!ANSWER_KEY
) {
throw new Error(
"All options and answer key are required for Multiple Choice"
);
}
questionDetails = await models.MultipleChoices.create(
{
ID_ADMIN_EXERCISE: newExercise.ID_ADMIN_EXERCISE,
OPTION_A,
OPTION_B,
OPTION_C,
OPTION_D,
OPTION_E,
ANSWER_KEY,
},
{ transaction }
);
break;
case "MPQ":
const { PAIRS } = exerciseData;
if (!PAIRS || !Array.isArray(PAIRS) || PAIRS.length === 0) {
throw new Error("At least one pair is required for Matching Pairs");
}
const matchingPairsPromises = PAIRS.map((pair) =>
models.MatchingPairs.create(
{
ID_ADMIN_EXERCISE: newExercise.ID_ADMIN_EXERCISE,
LEFT_PAIR: pair.LEFT_PAIR,
RIGHT_PAIR: pair.RIGHT_PAIR,
},
{ transaction }
)
);
await Promise.all(matchingPairsPromises);
questionDetails = PAIRS;
break;
case "TFQ":
const { IS_TRUE } = exerciseData;
if (typeof IS_TRUE === "undefined") {
throw new Error("IS_TRUE is required for True/False");
}
questionDetails = await models.TrueFalse.create(
{
ID_ADMIN_EXERCISE: newExercise.ID_ADMIN_EXERCISE,
IS_TRUE,
},
{ transaction }
);
break;
default:
throw new Error("Unsupported question type");
}
newExercise.dataValues.questionDetails = questionDetails;
createdExercises.push(newExercise);
}
await transaction.commit();
response(201, createdExercises, "Exercises created successfully", res);
} catch (error) {
console.error(error);
await transaction.rollback();
response(500, null, error.message || "Internal Server Error", res);
}
};
export const updateExerciseById = async (req, res) => {
const { id } = req.params;
const { ID_LEVEL, QUESTION, SCORE_WEIGHT, VIDEO, PAIRS } = req.body;
const { IMAGE, AUDIO } = req.filesToSave || {};
const transaction = await models.db.transaction();
try {
const exercise = await models.Exercise.findByPk(id, { transaction });
if (!exercise) {
clearFileBuffers({ IMAGE, AUDIO });
await transaction.rollback();
return response(404, null, "Exercise not found", res);
}
const { QUESTION_TYPE } = exercise;
if (ID_LEVEL) {
const level = await models.Level.findByPk(ID_LEVEL, { transaction });
if (!level) {
clearFileBuffers({ IMAGE, AUDIO });
await transaction.rollback();
return response(404, null, "Level not found", res);
}
exercise.ID_LEVEL = ID_LEVEL;
}
if (QUESTION) exercise.QUESTION = QUESTION;
if (SCORE_WEIGHT) exercise.SCORE_WEIGHT = SCORE_WEIGHT;
if (VIDEO) exercise.VIDEO = VIDEO;
if (AUDIO) {
if (exercise.AUDIO) {
const oldAudioPath = path.join(
"public/uploads/exercise/audio",
exercise.AUDIO
);
if (fs.existsSync(oldAudioPath)) {
fs.unlinkSync(oldAudioPath);
}
}
exercise.AUDIO = saveFileToDisk(
AUDIO,
"AUDIO",
ID_LEVEL || exercise.ID_LEVEL,
exercise.ID_ADMIN_EXERCISE
);
}
if (IMAGE) {
if (exercise.IMAGE) {
const oldImagePath = path.join(
"public/uploads/exercise/image",
exercise.IMAGE
);
if (fs.existsSync(oldImagePath)) {
fs.unlinkSync(oldImagePath);
}
}
exercise.IMAGE = saveFileToDisk(
IMAGE,
"IMAGE",
ID_LEVEL || exercise.ID_LEVEL,
exercise.ID_ADMIN_EXERCISE
);
}
await exercise.save({ transaction });
let payload = {
ID_ADMIN_EXERCISE: exercise.ID_ADMIN_EXERCISE,
ID_LEVEL: exercise.ID_LEVEL,
QUESTION: exercise.QUESTION,
SCORE_WEIGHT: exercise.SCORE_WEIGHT,
QUESTION_TYPE: exercise.QUESTION_TYPE,
AUDIO: exercise.AUDIO,
VIDEO: exercise.VIDEO,
IMAGE: exercise.IMAGE,
IS_DELETED: exercise.IS_DELETED,
TIME_ADMIN_EXC: exercise.TIME_ADMIN_EXC,
};
if (QUESTION_TYPE === "MPQ") {
if (!Array.isArray(PAIRS) || PAIRS.length === 0) {
await transaction.rollback();
return response(
400,
null,
"At least one pair is required for Matching Pairs",
res
);
}
const existingPairs = await models.MatchingPairs.findAll({
where: { ID_ADMIN_EXERCISE: exercise.ID_ADMIN_EXERCISE },
transaction,
});
const pairsToDelete = new Set(
existingPairs.map((pair) => pair.ID_MATCHING_PAIRS)
);
for (const pair of PAIRS) {
if (pair.ID_MATCHING_PAIRS) {
const existingPair = existingPairs.find(
(p) => p.ID_MATCHING_PAIRS === pair.ID_MATCHING_PAIRS
);
if (existingPair) {
existingPair.LEFT_PAIR = pair.LEFT_PAIR;
existingPair.RIGHT_PAIR = pair.RIGHT_PAIR;
await existingPair.save({ transaction });
pairsToDelete.delete(existingPair.ID_MATCHING_PAIRS);
}
} else {
await models.MatchingPairs.create(
{
ID_ADMIN_EXERCISE: exercise.ID_ADMIN_EXERCISE,
LEFT_PAIR: pair.LEFT_PAIR,
RIGHT_PAIR: pair.RIGHT_PAIR,
},
{ transaction }
);
}
}
for (const pairId of pairsToDelete) {
await models.MatchingPairs.destroy({
where: { ID_MATCHING_PAIRS: pairId },
transaction,
});
}
const updatedPairs = await models.MatchingPairs.findAll({
where: { ID_ADMIN_EXERCISE: exercise.ID_ADMIN_EXERCISE },
});
payload.matchingPairs = updatedPairs;
} else if (QUESTION_TYPE === "MCQ") {
const { OPTION_A, OPTION_B, OPTION_C, OPTION_D, OPTION_E, ANSWER_KEY } =
req.body;
const multipleChoices = await models.MultipleChoices.findOne({
where: { ID_ADMIN_EXERCISE: exercise.ID_ADMIN_EXERCISE },
transaction,
});
if (!multipleChoices) {
await transaction.rollback();
return response(404, null, "Multiple Choices not found", res);
}
if (OPTION_A) multipleChoices.OPTION_A = OPTION_A;
if (OPTION_B) multipleChoices.OPTION_B = OPTION_B;
if (OPTION_C) multipleChoices.OPTION_C = OPTION_C;
if (OPTION_D) multipleChoices.OPTION_D = OPTION_D;
if (OPTION_E) multipleChoices.OPTION_E = OPTION_E;
if (ANSWER_KEY) multipleChoices.ANSWER_KEY = ANSWER_KEY;
await multipleChoices.save({ transaction });
payload.multipleChoices = multipleChoices;
} else if (QUESTION_TYPE === "TFQ") {
const { IS_TRUE } = req.body;
const trueFalse = await models.TrueFalse.findOne({
where: { ID_ADMIN_EXERCISE: exercise.ID_ADMIN_EXERCISE },
transaction,
});
if (!trueFalse) {
await transaction.rollback();
return response(404, null, "True/False exercise not found", res);
}
if (typeof IS_TRUE !== "undefined") {
trueFalse.IS_TRUE = IS_TRUE;
}
await trueFalse.save({ transaction });
payload.trueFalse = trueFalse;
}
await transaction.commit();
response(200, payload, "Exercise updated successfully", res);
} catch (error) {
console.error(error);
clearFileBuffers({ IMAGE, AUDIO });
await transaction.rollback();
response(500, null, "Internal Server Error", res);
}
};
export const deleteExerciseById = async (req, res) => {
const { id } = req.params;
const transaction = await models.db.transaction();
try {
const exercise = await models.Exercise.findByPk(id);
if (!exercise) {
await transaction.rollback();
return response(404, null, "Exercise not found", res);
}
await exercise.update({ IS_DELETED: 1 }, { transaction });
await transaction.commit();
response(200, null, "Exercise soft-deleted successfully", res);
} catch (error) {
console.log(error);
await transaction.rollback();
response(500, null, "Internal Server Error", res);
}
};
export const deleteExerciseFileById = async (req, res) => {
const { id } = req.params;
const { fileType } = req.body;
if (!["audio", "image", "video"].includes(fileType)) {
return response(400, null, "Invalid file type specified", res);
}
try {
const exercise = await models.Exercise.findByPk(id);
if (!exercise) {
return response(404, null, "Exercise not found", res);
}
let filePath;
let fileName;
if (fileType === "audio" && exercise.AUDIO) {
fileName = exercise.AUDIO;
filePath = path.join("public/uploads/exercise/audio", fileName);
exercise.AUDIO = null;
} else if (fileType === "image" && exercise.IMAGE) {
fileName = exercise.IMAGE;
filePath = path.join("public/uploads/exercise/image", fileName);
exercise.IMAGE = null;
} else if (fileType === "video" && exercise.VIDEO) {
exercise.VIDEO = null;
} else {
return response(
404,
null,
`${
fileType.charAt(0).toUpperCase() + fileType.slice(1)
} file not found`,
res
);
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
await exercise.save();
response(
200,
exercise,
`${
fileType.charAt(0).toUpperCase() + fileType.slice(1)
} file deleted successfully`,
res
);
} catch (error) {
console.log(error);
response(500, null, "Internal Server Error", res);
}
};