From 32cd8495d668ab741243f3f1ab0505ca22edf142 Mon Sep 17 00:00:00 2001 From: Joao Correia Date: Sun, 2 Mar 2025 00:10:57 +0000 Subject: [PATCH] add imutable ids to some exam arrays to detect and log changes between two exams. --- .../Exercises/Blanks/Letters/index.tsx | 6 + .../Exercises/Blanks/MultipleChoice/index.tsx | 7 + .../Exercises/MatchSentences/index.tsx | 2 + .../MultipleChoice/Underline/index.tsx | 2 + .../MultipleChoice/Vanilla/index.tsx | 2 + .../ExamEditor/Exercises/TrueFalse/index.tsx | 2 + .../Exercises/WriteBlanks/index.tsx | 3 + .../Exercises/WriteBlanksForm/index.tsx | 3 + src/interfaces/exam.ts | 7 + src/pages/api/exam/[module]/index.ts | 248 ++++++++++-------- src/stores/examEditor/reorder/global.ts | 4 +- src/utils/exam.differences.ts | 248 ++++++++++-------- 12 files changed, 309 insertions(+), 225 deletions(-) diff --git a/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx index 7ca7433e..84deb369 100644 --- a/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx +++ b/src/components/ExamEditor/Exercises/Blanks/Letters/index.tsx @@ -13,6 +13,7 @@ import validateBlanks from "../validateBlanks"; import { toast } from "react-toastify"; import setEditingAlert from "../../Shared/setEditingAlert"; import PromptEdit from "../../Shared/PromptEdit"; +import { uuidv4 } from "@firebase/util"; interface Word { letter: string; @@ -72,6 +73,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num ...local, text: blanksState.text, solutions: Array.from(answers.entries()).map(([id, solution]) => ({ + uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -145,6 +147,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num setLocal(prev => ({ ...prev, solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -189,6 +192,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num ...prev, words: newWords, solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -217,6 +221,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num ...prev, words: newWords, solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -234,6 +239,7 @@ const FillBlanksLetters: React.FC<{ exercise: FillBlanksExercise; sectionId: num setLocal(prev => ({ ...prev, solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) diff --git a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx index 15565c12..f954ddb2 100644 --- a/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx +++ b/src/components/ExamEditor/Exercises/Blanks/MultipleChoice/index.tsx @@ -11,6 +11,7 @@ import { toast } from "react-toastify"; import setEditingAlert from "../../Shared/setEditingAlert"; import { MdEdit, MdEditOff } from "react-icons/md"; import MCOption from "./MCOption"; +import { uuidv4 } from "@firebase/util"; const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number }> = ({ exercise, sectionId }) => { @@ -69,6 +70,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } ...local, text: blanksState.text, solutions: Array.from(answers.entries()).map(([id, solution]) => ({ + uuid: local.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -139,6 +141,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } setLocal(prev => ({ ...prev, solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -168,6 +171,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } ...prev, words: newWords, solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -217,6 +221,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } ...prev, words: (prev.words as FillBlanksMCOption[]).filter(w => w.id !== blankId.toString()), solutions: Array.from(newAnswers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) @@ -234,6 +239,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } blanksMissingWords.forEach(blank => { const newMCOption: FillBlanksMCOption = { + uuid: uuidv4(), id: blank.id.toString(), options: { A: 'Option A', @@ -249,6 +255,7 @@ const FillBlanksMC: React.FC<{ exercise: FillBlanksExercise; sectionId: number } ...prev, words: newWords, solutions: Array.from(answers.entries()).map(([id, solution]) => ({ + uuid: prev.solutions.find(sol => sol.id === id)?.uuid || uuidv4(), id, solution })) diff --git a/src/components/ExamEditor/Exercises/MatchSentences/index.tsx b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx index 9bb63867..a84f4655 100644 --- a/src/components/ExamEditor/Exercises/MatchSentences/index.tsx +++ b/src/components/ExamEditor/Exercises/MatchSentences/index.tsx @@ -18,6 +18,7 @@ import { toast } from 'react-toastify'; import { DragEndEvent } from '@dnd-kit/core'; import { handleMatchSentencesReorder } from '@/stores/examEditor/reorder/local'; import PromptEdit from '../Shared/PromptEdit'; +import { uuidv4 } from '@firebase/util'; const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: number }> = ({ exercise, sectionId }) => { const { currentModule, dispatch } = useExamEditorStore(); @@ -98,6 +99,7 @@ const MatchSentences: React.FC<{ exercise: MatchSentencesExercise, sectionId: nu sentences: [ ...local.sentences, { + uuid: uuidv4(), id: newId, sentence: "", solution: "" diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx index 8d6622ad..e483efba 100644 --- a/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Underline/index.tsx @@ -11,6 +11,7 @@ import { useCallback, useEffect, useState } from "react"; import { MdAdd } from "react-icons/md"; import Alert, { AlertItem } from "../../Shared/Alert"; import PromptEdit from "../../Shared/PromptEdit"; +import { uuidv4 } from "@firebase/util"; const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, sectionId: number}> = ({ @@ -57,6 +58,7 @@ const UnderlineMultipleChoice: React.FC<{exercise: MultipleChoiceExercise, secti { prompt: "", solution: "", + uuid: uuidv4(), id: newId, options, variant: "text" diff --git a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx index 91417628..418f14c9 100644 --- a/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx +++ b/src/components/ExamEditor/Exercises/MultipleChoice/Vanilla/index.tsx @@ -18,6 +18,7 @@ import SortableQuestion from '../../Shared/SortableQuestion'; import setEditingAlert from '../../Shared/setEditingAlert'; import { handleMultipleChoiceReorder } from '@/stores/examEditor/reorder/local'; import PromptEdit from '../../Shared/PromptEdit'; +import { uuidv4 } from '@firebase/util'; interface MultipleChoiceProps { exercise: MultipleChoiceExercise; @@ -120,6 +121,7 @@ const MultipleChoice: React.FC = ({ exercise, sectionId, op { prompt: "", solution: "", + uuid: uuidv4(), id: newId, options, variant: "text" diff --git a/src/components/ExamEditor/Exercises/TrueFalse/index.tsx b/src/components/ExamEditor/Exercises/TrueFalse/index.tsx index 068c3e42..02be1f5c 100644 --- a/src/components/ExamEditor/Exercises/TrueFalse/index.tsx +++ b/src/components/ExamEditor/Exercises/TrueFalse/index.tsx @@ -16,6 +16,7 @@ import setEditingAlert from '../Shared/setEditingAlert'; import { DragEndEvent } from '@dnd-kit/core'; import { handleTrueFalseReorder } from '@/stores/examEditor/reorder/local'; import PromptEdit from '../Shared/PromptEdit'; +import { uuidv4 } from '@firebase/util'; const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = ({ exercise, sectionId }) => { const { currentModule, dispatch } = useExamEditorStore(); @@ -50,6 +51,7 @@ const TrueFalse: React.FC<{ exercise: TrueFalseExercise, sectionId: number }> = { prompt: "", solution: undefined, + uuid: uuidv4(), id: newId } ] diff --git a/src/components/ExamEditor/Exercises/WriteBlanks/index.tsx b/src/components/ExamEditor/Exercises/WriteBlanks/index.tsx index 109bb56c..efe20346 100644 --- a/src/components/ExamEditor/Exercises/WriteBlanks/index.tsx +++ b/src/components/ExamEditor/Exercises/WriteBlanks/index.tsx @@ -22,6 +22,7 @@ import { validateEmptySolutions, validateQuestionText, validateWordCount } from import { handleWriteBlanksReorder } from '@/stores/examEditor/reorder/local'; import { ParsedQuestion, parseText, reconstructText } from './parsing'; import PromptEdit from '../Shared/PromptEdit'; +import { uuidv4 } from '@firebase/util'; const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; }> = ({ sectionId, exercise }) => { @@ -105,6 +106,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; const newId = (Math.max(...existingIds, 0) + 1).toString(); const newQuestion = { + uuid: uuidv4(), id: newId, questionText: "New question" }; @@ -113,6 +115,7 @@ const WriteBlanks: React.FC<{ sectionId: number; exercise: WriteBlanksExercise; const updatedText = reconstructText(updatedQuestions); const updatedSolutions = [...local.solutions, { + uuid: uuidv4(), id: newId, solution: [""] }]; diff --git a/src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx b/src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx index a8e8da53..eb884fbf 100644 --- a/src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx +++ b/src/components/ExamEditor/Exercises/WriteBlanksForm/index.tsx @@ -17,6 +17,7 @@ import { validateQuestions, validateEmptySolutions, validateWordCount } from "./ import Header from "../../Shared/Header"; import BlanksFormEditor from "./BlanksFormEditor"; import PromptEdit from "../Shared/PromptEdit"; +import { uuidv4 } from "@firebase/util"; const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExercise }> = ({ sectionId, exercise }) => { @@ -111,6 +112,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci const newLine = `New question with blank {{${newId}}}`; const updatedQuestions = [...parsedQuestions, { + uuid: uuidv4(), id: newId, parts: parseLine(newLine), editingPlaceholders: true @@ -121,6 +123,7 @@ const WriteBlanksForm: React.FC<{ sectionId: number; exercise: WriteBlanksExerci .join('\\n') + '\\n'; const updatedSolutions = [...local.solutions, { + uuid: uuidv4(), id: newId, solution: [""] }]; diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index 3fc363cc..7f838fc0 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -241,6 +241,7 @@ export interface InteractiveSpeakingExercise extends Section { } export interface FillBlanksMCOption { + uuid: string; // added later to fulfill the need for an immutable identifier. id: string; options: { A: string; @@ -258,6 +259,7 @@ export interface FillBlanksExercise { text: string; // *EXAMPLE: "They tried to {{1}} burning" allowRepetition?: boolean; solutions: { + uuid: string; // added later to fulfill the need for an immutable identifier. id: string; // *EXAMPLE: "1" solution: string; // *EXAMPLE: "preserve" }[]; @@ -281,6 +283,7 @@ export interface TrueFalseExercise { } export interface TrueFalseQuestion { + uuid: string; // added later to fulfill the need for an immutable identifier. id: string; // *EXAMPLE: "1" prompt: string; // *EXAMPLE: "What does her briefcase look like?" solution: "true" | "false" | "not_given" | undefined; // *EXAMPLE: "True" @@ -293,6 +296,7 @@ export interface WriteBlanksExercise { id: string; text: string; // *EXAMPLE: "The Government plans to give ${{14}}" solutions: { + uuid: string; // added later to fulfill the need for an immutable identifier. id: string; // *EXAMPLE: "14" solution: string[]; // *EXAMPLE: ["Prescott"] - All possible solutions (case sensitive) }[]; @@ -319,12 +323,14 @@ export interface MatchSentencesExercise { } export interface MatchSentenceExerciseSentence { + uuid: string; // added later to fulfill the need for an immutable identifier. id: string; sentence: string; solution: string; } export interface MatchSentenceExerciseOption { + uuid: string; // added later to fulfill the need for an immutable identifier. id: string; sentence: string; } @@ -346,6 +352,7 @@ export interface MultipleChoiceExercise { export interface MultipleChoiceQuestion { variant: "image" | "text"; + uuid: string; // added later to fulfill the need for an immutable identifier. id: string; // *EXAMPLE: "1" prompt: string; // *EXAMPLE: "What does her briefcase look like?" solution: string; // *EXAMPLE: "A" diff --git a/src/pages/api/exam/[module]/index.ts b/src/pages/api/exam/[module]/index.ts index 6643d81d..6b8d1c31 100644 --- a/src/pages/api/exam/[module]/index.ts +++ b/src/pages/api/exam/[module]/index.ts @@ -1,6 +1,6 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction import { Module } from "@/interfaces"; -import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; +import { Exam, ExamBase, InstructorGender, LevelExam, ListeningExam, ReadingExam, SpeakingExam, Variant } from "@/interfaces/exam"; import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation"; import client from "@/lib/mongodb"; import { sessionOptions } from "@/lib/session"; @@ -10,6 +10,7 @@ import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/a import { generateExamDifferences } from "@/utils/exam.differences"; import { getExams } from "@/utils/exams.be"; import { isAdmin } from "@/utils/users"; +import { uuidv4 } from "@firebase/util"; import { access } from "fs"; import { withIronSessionApiRoute } from "iron-session/next"; import type { NextApiRequest, NextApiResponse } from "next"; @@ -18,140 +19,161 @@ const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); -async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "GET") return await GET(req, res); - if (req.method === "POST") return await POST(req, res); +// Temporary: Adding UUID here but later move to backend. +function addUUIDs(exam: ReadingExam | ListeningExam | LevelExam): ExamBase { + const arraysToUpdate = ["solutions", "words", "questions", "sentences", "options"]; - res.status(404).json({ ok: false }); + exam.parts = exam.parts.map((part) => { + const updatedExercises = part.exercises.map((exercise: any) => { + arraysToUpdate.forEach((arrayName) => { + if (exercise[arrayName] && Array.isArray(exercise[arrayName])) { + exercise[arrayName] = exercise[arrayName].map((item: any) => (item.uuid ? item : { ...item, uuid: uuidv4() })); + } + }); + return exercise; + }); + return { ...part, exercises: updatedExercises }; + }); + return exam; +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await GET(req, res); + if (req.method === "POST") return await POST(req, res); + + res.status(404).json({ ok: false }); } async function GET(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } - const { module, avoidRepeated, variant, instructorGender } = req.query as { - module: Module; - avoidRepeated: string; - variant?: Variant; - instructorGender?: InstructorGender; - }; + const { module, avoidRepeated, variant, instructorGender } = req.query as { + module: Module; + avoidRepeated: string; + variant?: Variant; + instructorGender?: InstructorGender; + }; - const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender); - res.status(200).json(exams); + const exams: Exam[] = await getExams(db, module, avoidRepeated, req.session.user.id, variant, instructorGender); + res.status(200).json(exams); } async function POST(req: NextApiRequest, res: NextApiResponse) { - const user = await requestUser(req, res); - if (!user) return res.status(401).json({ ok: false }); + const user = await requestUser(req, res); + if (!user) return res.status(401).json({ ok: false }); - const { module } = req.query as { module: string }; + const { module } = req.query as { module: string }; - const session = client.startSession(); - const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); + const session = client.startSession(); + const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); - try { - const exam = { - access: "public", // default access is public - ...req.body, - module: module, - entities, - createdBy: user.id, - createdAt: new Date().toISOString(), - }; + try { + let exam = { + access: "public", // default access is public + ...req.body, + module: module, + entities, + createdBy: user.id, + createdAt: new Date().toISOString(), + }; - let responseStatus: number; - let responseMessage: string; + // Temporary: Adding UUID here but later move to backend. + exam = addUUIDs(exam); - await session.withTransaction(async () => { - const docSnap = await db.collection(module).findOne({ id: req.body.id }, { session }); + let responseStatus: number; + let responseMessage: string; - // Check whether the id of the exam matches another exam with different - // owners, throw exception if there is, else allow editing - const existingExamOwners = docSnap?.owners ?? []; - const newExamOwners = exam.owners ?? []; + await session.withTransaction(async () => { + const docSnap = await db.collection(module).findOne({ id: req.body.id }, { session }); - const ownersSet = new Set(existingExamOwners); + // Check whether the id of the exam matches another exam with different + // owners, throw exception if there is, else allow editing + const existingExamOwners = docSnap?.owners ?? []; + const newExamOwners = exam.owners ?? []; - if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) { - throw new Error("Name already exists"); - } + const ownersSet = new Set(existingExamOwners); - if (exam.requiresApproval === true) { - exam.access = "confidential"; - } + if (docSnap !== null && (existingExamOwners.length !== newExamOwners.length || !newExamOwners.every((e: string) => ownersSet.has(e)))) { + throw new Error("Name already exists"); + } - await db.collection(module).updateOne( - { id: req.body.id }, - { $set: { id: req.body.id, ...exam } }, - { - upsert: true, - session, - } - ); + if (exam.requiresApproval === true) { + exam.access = "confidential"; + } - // if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response. - responseStatus = 200; - responseMessage = `Successfully updated exam with ID: "${exam.id}"`; + await db.collection(module).updateOne( + { id: req.body.id }, + { $set: { id: req.body.id, ...exam } }, + { + upsert: true, + session, + } + ); - // create workflow only if exam is being created for the first time - if (docSnap === null) { - try { - if (exam.requiresApproval === false) { - responseStatus = 200; - responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`; - } else if (isAdmin(user)) { - responseStatus = 200; - responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`; - } else { - const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module); - - if (successCount === totalCount) { - responseStatus = 200; - responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`; - } else if (successCount > 0) { - responseStatus = 207; - responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`; - } else { - responseStatus = 207; - responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`; - } - } - } catch (error) { - console.error("Workflow creation error:", error); - responseStatus = 207; - responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`; - } - } else { - // if exam was updated, log the updates - const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id); + // if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response. + responseStatus = 200; + responseMessage = `Successfully updated exam with ID: "${exam.id}"`; - if (approvalWorkflows) { - const differences = generateExamDifferences(docSnap as Exam, exam as Exam); - if (differences) { - approvalWorkflows.forEach((workflow) => { - const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected); + // create workflow only if exam is being created for the first time + if (docSnap === null) { + try { + if (exam.requiresApproval === false) { + responseStatus = 200; + responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to user request.`; + } else if (isAdmin(user)) { + responseStatus = 200; + responseMessage = `Successfully created exam "${exam.id}" and skipped Approval Workflow due to admin rights.`; + } else { + const { successCount, totalCount } = await createApprovalWorkflowOnExamCreation(exam.createdBy, exam.entities, exam.id, module); - if (workflow.steps[currentStepIndex].examChanges === undefined) { - workflow.steps[currentStepIndex].examChanges = [...differences]; - } else { - workflow.steps[currentStepIndex].examChanges!.push(...differences); - } - }); - await updateApprovalWorkflows("active-workflows", approvalWorkflows); - } - } - } + if (successCount === totalCount) { + responseStatus = 200; + responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`; + } else if (successCount > 0) { + responseStatus = 207; + responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities.`; + } else { + responseStatus = 207; + responseMessage = `Successfully created exam with ID: "${exam.id}" but skipping approval process because no approval workflow was found configured for the exam author.`; + } + } + } catch (error) { + console.error("Workflow creation error:", error); + responseStatus = 207; + responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`; + } + } else { + // if exam was updated, log the updates + const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id); - res.status(responseStatus).json({ - message: responseMessage, - }); - }); - } catch (error) { - console.error("Transaction failed: ", error); - res.status(500).json({ ok: false, error: (error as any).message }); - } finally { - session.endSession(); - } + if (approvalWorkflows) { + const differences = generateExamDifferences(docSnap as Exam, exam as Exam); + if (differences) { + approvalWorkflows.forEach((workflow) => { + const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected); + + if (workflow.steps[currentStepIndex].examChanges === undefined) { + workflow.steps[currentStepIndex].examChanges = [...differences]; + } else { + workflow.steps[currentStepIndex].examChanges!.push(...differences); + } + }); + await updateApprovalWorkflows("active-workflows", approvalWorkflows); + } + } + } + + res.status(responseStatus).json({ + message: responseMessage, + }); + }); + } catch (error) { + console.error("Transaction failed: ", error); + res.status(500).json({ ok: false, error: (error as any).message }); + } finally { + session.endSession(); + } } diff --git a/src/stores/examEditor/reorder/global.ts b/src/stores/examEditor/reorder/global.ts index f2918bf0..017deed1 100644 --- a/src/stores/examEditor/reorder/global.ts +++ b/src/stores/examEditor/reorder/global.ts @@ -59,8 +59,8 @@ const reorderWriteBlanks = (exercise: WriteBlanksExercise, startId: number): Reo let newIds = oldIds.map((_, index) => (startId + index).toString()); let newSolutions = exercise.solutions.map((solution, index) => ({ - id: newIds[index], - solution: [...solution.solution] + ...solution, + id: newIds[index] })); let newText = exercise.text; diff --git a/src/utils/exam.differences.ts b/src/utils/exam.differences.ts index b860e11f..7d9fe40f 100644 --- a/src/utils/exam.differences.ts +++ b/src/utils/exam.differences.ts @@ -1,7 +1,6 @@ import { Exam } from "@/interfaces/exam"; -import { diff, Diff } from "deep-diff"; -const EXCLUDED_KEYS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]); +const EXCLUDED_KEYS = new Set(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]); const PATH_LABELS: Record = { access: "Access Type", @@ -24,124 +23,153 @@ const PATH_LABELS: Record = { allowRepetition: "Allow Repetition", maxWords: "Max Words", minTimer: "Timer", + section: "Section", + module: "Module", + type: "Type", + intro: "Intro", + category: "Category", + context: "Context", + instructions: "Instructions", + name: "Name", + gender: "Gender", + voice: "Voice", + enableNavigation: "Enable Navigation", + limit: "Limit", + instructorGender: "Instructor Gender", + wordCounter: "Word Counter", + attachment: "Attachment", + first_title: "First Title", + second_title: "Second Title", + first_topic: "First Topic", + second_topic: "Second Topic", + questions: "Questions", + sentences: "Sentences", + sentence: "Sentence", + solution: "Solution", + passage: "Passage", +}; + +const ARRAY_ITEM_LABELS: Record = { + exercises: "Exercise", + paths: "Path", + difficulties: "Difficulty", + solutions: "Solution", + options: "Option", + words: "Word", + questions: "Question", + userSolutions: "User Solution", + sentences: "Sentence", + parts: "Part", }; export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] { - const differences = diff(oldExam, newExam) || []; - return differences.map((change) => formatDifference(change)).filter(Boolean) as string[]; + const differences: string[] = []; + compareObjects(oldExam, newExam, [], differences); + return differences; } -function formatDifference(change: Diff): string | undefined { - if (!change.path) return; - - if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) { - return; - } - - const pathString = pathToHumanReadable(change.path); - - switch (change.kind) { - case "N": // New property/element - return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`; - case "D": // Deleted property/element - return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`; - case "E": // Edited property/element - return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`; - case "A": // Array change - return formatArrayChange(change); - default: - return; - } +function isObject(val: any): val is Record { + return val !== null && typeof val === "object" && !Array.isArray(val); } -function formatArrayChange(change: Diff): string | undefined { - if (!change.path) return; - - if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) { - return; - } - - const pathString = pathToHumanReadable(change.path); - const arrayChange = (change as any).item; - const idx = (change as any).index; - - if (!arrayChange) return; - - switch (arrayChange.kind) { - case "N": - return `• Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`; - case "D": - return `• Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`; - case "E": - return `• Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`; - case "A": - return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`; - default: - return; - } -} - -function formatValue(value: any): string { - if (value === null) return "null"; +function formatPrimitive(value: any): string { if (value === undefined) return "undefined"; - - if (typeof value === "object") { - try { - const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS); - - const renamed = renameKeysDeep(sanitized, PATH_LABELS); - - return JSON.stringify(renamed, null, 2); - } catch { - return String(value); - } - } + if (value === null) return "null"; return JSON.stringify(value); } -function removeExcludedKeysDeep(obj: any, excludedKeys: Set): any { - if (Array.isArray(obj)) { - return obj.map((item) => removeExcludedKeysDeep(item, excludedKeys)); - } else if (obj && typeof obj === "object") { - const newObj: any = {}; - for (const key of Object.keys(obj)) { - if (excludedKeys.has(key)) { - // Skip this key entirely - continue; - } - newObj[key] = removeExcludedKeysDeep(obj[key], excludedKeys); - } - return newObj; - } - return obj; -} - -function renameKeysDeep(obj: any, renameMap: Record): any { - if (Array.isArray(obj)) { - return obj.map((item) => renameKeysDeep(item, renameMap)); - } else if (obj && typeof obj === "object") { - const newObj: any = {}; - for (const key of Object.keys(obj)) { - const newKey = renameMap[key] ?? key; // Use friendly label if available - newObj[newKey] = renameKeysDeep(obj[key], renameMap); - } - return newObj; - } - return obj; -} - -/** - * Convert an array of path segments into a user-friendly string. - * e.g. ["parts", 0, "exercises", 1, "prompt"] - * → "Parts → [#1] → Exercises → [#2] → Prompt" - */ function pathToHumanReadable(pathSegments: Array): string { - return pathSegments - .map((seg) => { - if (typeof seg === "number") { - return `[#${seg + 1}]`; - } - return PATH_LABELS[seg] ?? seg; - }) - .join(" → "); + const mapped = pathSegments.map((seg) => { + if (typeof seg === "number") { + return `#${seg + 1}`; + } + return PATH_LABELS[seg] ?? seg; + }); + + let result = ""; + for (let i = 0; i < mapped.length; i++) { + result += mapped[i]; + if (mapped[i].startsWith("#") && i < mapped.length - 1) { + result += " - "; + } else if (i < mapped.length - 1) { + result += " "; + } + } + + return result.trim(); +} + +function getArrayItemLabel(path: (string | number)[]): string { + if (path.length === 0) return "item"; + const lastSegment = path[path.length - 1]; + if (typeof lastSegment === "string" && ARRAY_ITEM_LABELS[lastSegment]) { + return ARRAY_ITEM_LABELS[lastSegment]; + } + return "item"; +} + +function getIdentifier(item: any): string | number | undefined { + if (item?.uuid !== undefined) return item.uuid; + if (item?.id !== undefined) return item.id; + return undefined; +} + +function compareObjects(oldObj: any, newObj: any, path: (string | number)[], differences: string[]) { + if (Array.isArray(oldObj) && Array.isArray(newObj)) { + // Check if array elements are objects with an identifier (uuid or id). + if (oldObj.length > 0 && typeof oldObj[0] === "object" && getIdentifier(oldObj[0]) !== undefined) { + // Process removed items + const newIds = new Set(newObj.map((item: any) => getIdentifier(item))); + for (let i = 0; i < oldObj.length; i++) { + const oldItem = oldObj[i]; + const identifier = getIdentifier(oldItem); + if (identifier !== undefined && !newIds.has(identifier)) { + differences.push(`• Removed ${getArrayItemLabel(path)} #${i + 1} from ${pathToHumanReadable(path)}\n`); + } + } + + const oldIndexMap = new Map(oldObj.map((item: any, index: number) => [getIdentifier(item), index])); + let reorderDetected = false; + // Process items in the new array using their order. + for (let i = 0; i < newObj.length; i++) { + const newItem = newObj[i]; + const identifier = getIdentifier(newItem); + if (identifier !== undefined) { + if (oldIndexMap.has(identifier)) { + const oldIndex = oldIndexMap.get(identifier)!; + const oldItem = oldObj[oldIndex]; + if (oldIndex !== i) { + reorderDetected = true; + } + compareObjects(oldItem, newItem, path.concat(`#${i + 1}`), differences); + } else { + differences.push(`• Added new ${getArrayItemLabel(path)} #${i + 1} at ${pathToHumanReadable(path)}\n`); + } + } else { + // Fallback: if item does not have an identifier, compare by index. + compareObjects(oldObj[i], newItem, path.concat(`#${i + 1}`), differences); + } + } + if (reorderDetected) { + differences.push(`• Reordered Items at ${pathToHumanReadable(path)}\n`); + } + } else { + // For arrays that are not identifier-based, compare element by element. + const maxLength = Math.max(oldObj.length, newObj.length); + for (let i = 0; i < maxLength; i++) { + compareObjects(oldObj[i], newObj[i], path.concat(`#${i + 1}`), differences); + } + } + } else if (isObject(oldObj) && isObject(newObj)) { + // Compare objects by keys (ignoring excluded keys). + const keys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]); + for (const key of keys) { + if (EXCLUDED_KEYS.has(key)) continue; + compareObjects(oldObj[key], newObj[key], path.concat(key), differences); + } + } else { + if (oldObj !== newObj) { + differences.push(`• Changed ${pathToHumanReadable(path)} from:\n ${formatPrimitive(oldObj)}\n To:\n ${formatPrimitive(newObj)}\n`); + } + } }