Merged in approval-workflows (pull request #156)
Approval workflows Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<MultipleChoiceProps> = ({ exercise, sectionId, op
|
||||
{
|
||||
prompt: "",
|
||||
solution: "",
|
||||
uuid: uuidv4(),
|
||||
id: newId,
|
||||
options,
|
||||
variant: "text"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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: [""]
|
||||
}];
|
||||
|
||||
@@ -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: [""]
|
||||
}];
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -68,14 +68,14 @@ export async function createApprovalWorkflowOnExamCreation(examAuthor: string, e
|
||||
}
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
|
||||
// commented because they asked for every exam to stay confidential
|
||||
/* if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
|
||||
await db.collection(examModule).updateOne(
|
||||
{ id: examId },
|
||||
{ $set: { id: examId, access: "private" }},
|
||||
{ upsert: true }
|
||||
);
|
||||
}
|
||||
} */
|
||||
|
||||
return {
|
||||
successCount,
|
||||
|
||||
@@ -23,5 +23,10 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const entityIdsArray = entityIdsString.split(",");
|
||||
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
|
||||
if (!["admin", "developer"].includes(user.type)) {
|
||||
// filtering workflows that have user as assignee in at least one of the steps
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray, undefined, user.id));
|
||||
} else {
|
||||
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ExamBase>({ 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<ExamBase>({ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
const handleApproveStep = () => {
|
||||
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
|
||||
if (isLastStep) {
|
||||
if (!confirm(`Are you sure you want to approve the last step? Doing so will change the access type of the exam from confidential to private.`)) return;
|
||||
if (!confirm(`Are you sure you want to approve the last step and complete the approval process?`)) return;
|
||||
}
|
||||
|
||||
const updatedWorkflow: ApprovalWorkflow = {
|
||||
@@ -188,7 +188,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
|
||||
if (isLastStep) {
|
||||
setIsPanelOpen(false);
|
||||
const examModule = currentWorkflow.modules[0];
|
||||
/* const examModule = currentWorkflow.modules[0];
|
||||
const examId = currentWorkflow.examId;
|
||||
|
||||
axios
|
||||
@@ -207,7 +207,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
|
||||
toast.error("Something went wrong, please try again later.");
|
||||
})
|
||||
.finally(reload);
|
||||
.finally(reload); */
|
||||
} else {
|
||||
handleStepClick(selectedStepIndex + 1, currentWorkflow.steps[selectedStepIndex + 1]);
|
||||
}
|
||||
@@ -386,7 +386,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
{/* Side panel */}
|
||||
<AnimatePresence mode="wait">
|
||||
<LayoutGroup key="sidePanel">
|
||||
<section className={`absolute inset-y-0 right-0 h-full bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
|
||||
<section className={`absolute inset-y-0 right-0 h-full overflow-y-auto bg-mti-purple-ultralight bg-opacity-50 shadow-xl shadow-mti-purple transition-all duration-300 overflow-hidden ${isPanelOpen ? 'w-[500px]' : 'w-0'}`}>
|
||||
{isPanelOpen && selectedStep && (
|
||||
<motion.div
|
||||
className="p-6"
|
||||
@@ -551,12 +551,16 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
transition={{ duration: 0.3 }}
|
||||
className="overflow-hidden mt-2"
|
||||
>
|
||||
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-40">
|
||||
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-[300px]">
|
||||
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
|
||||
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
||||
{change}
|
||||
</p>
|
||||
<>
|
||||
<p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
|
||||
<span className="text-mti-purple-light text-lg">{change.charAt(0)}</span>
|
||||
{change.slice(1)}
|
||||
</p>
|
||||
<hr className="my-3 h-[3px] bg-mti-purple-light rounded-full w-full" />
|
||||
</>
|
||||
))
|
||||
) : (
|
||||
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
|
||||
@@ -573,7 +577,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
|
||||
value={comments}
|
||||
onChange={(e) => setComments(e.target.value)}
|
||||
placeholder="Input comments here"
|
||||
className="w-full h-40 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
|
||||
className="w-full h-[200px] p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -63,26 +63,26 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
|
||||
const [users, groups] = await Promise.all([
|
||||
isAdmin(user)
|
||||
? getUsers(
|
||||
{},
|
||||
0,
|
||||
{},
|
||||
{
|
||||
_id: 0,
|
||||
id: 1,
|
||||
type: 1,
|
||||
name: 1,
|
||||
email: 1,
|
||||
levels: 1,
|
||||
}
|
||||
)
|
||||
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
|
||||
{},
|
||||
0,
|
||||
{},
|
||||
{
|
||||
_id: 0,
|
||||
id: 1,
|
||||
type: 1,
|
||||
name: 1,
|
||||
email: 1,
|
||||
levels: 1,
|
||||
}),
|
||||
}
|
||||
)
|
||||
: getEntitiesUsers(mapBy(allowedEntities, "id"), {}, 0, {
|
||||
_id: 0,
|
||||
id: 1,
|
||||
type: 1,
|
||||
name: 1,
|
||||
email: 1,
|
||||
levels: 1,
|
||||
}),
|
||||
isAdmin(user)
|
||||
? getGroups()
|
||||
: getGroupsByEntities(mapBy(allowedEntities, "id")),
|
||||
@@ -143,6 +143,9 @@ export default function AssignmentsPage({
|
||||
const [useRandomExams, setUseRandomExams] = useState(true);
|
||||
const [examIDs, setExamIDs] = useState<{ id: string; module: Module }[]>([]);
|
||||
|
||||
const [showApprovedExams, setShowApprovedExams] = useState<boolean>(true);
|
||||
const [showNonApprovedExams, setShowNonApprovedExams] = useState<boolean>(true);
|
||||
|
||||
const { exams } = useExams();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -326,7 +329,7 @@ export default function AssignmentsPage({
|
||||
onClick={
|
||||
(!selectedModules.includes("level") &&
|
||||
selectedModules.length === 0) ||
|
||||
selectedModules.includes("level")
|
||||
selectedModules.includes("level")
|
||||
? () => toggleModule("level")
|
||||
: undefined
|
||||
}
|
||||
@@ -501,37 +504,64 @@ export default function AssignmentsPage({
|
||||
Random Exams
|
||||
</Checkbox>
|
||||
{!useRandomExams && (
|
||||
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||
{selectedModules.map((module) => (
|
||||
<div key={module} className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{capitalize(module)} Exam
|
||||
</label>
|
||||
<Select
|
||||
value={{
|
||||
value:
|
||||
examIDs.find((e) => e.module === module)?.id ||
|
||||
null,
|
||||
label:
|
||||
examIDs.find((e) => e.module === module)?.id || "",
|
||||
}}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setExamIDs((prev) => [
|
||||
<>
|
||||
<Checkbox
|
||||
isChecked={showApprovedExams}
|
||||
onChange={() => {
|
||||
setShowApprovedExams((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
Show approved exams
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
isChecked={showNonApprovedExams}
|
||||
onChange={() => {
|
||||
setShowNonApprovedExams((prev) => !prev)
|
||||
}}
|
||||
>
|
||||
Show non-approved exams
|
||||
</Checkbox>
|
||||
<div className="grid md:grid-cols-2 w-full gap-4">
|
||||
{selectedModules.map((module) => (
|
||||
<div key={module} className="flex flex-col gap-3 w-full">
|
||||
<label className="font-normal text-base text-mti-gray-dim">
|
||||
{capitalize(module)} Exam
|
||||
</label>
|
||||
<Select
|
||||
isClearable
|
||||
value={{
|
||||
value:
|
||||
examIDs.find((e) => e.module === module)?.id ||
|
||||
null,
|
||||
label:
|
||||
examIDs.find((e) => e.module === module)?.id || "",
|
||||
}}
|
||||
onChange={(value) =>
|
||||
value
|
||||
? setExamIDs((prev) => [
|
||||
...prev.filter((x) => x.module !== module),
|
||||
{ id: value.value!, module },
|
||||
])
|
||||
: setExamIDs((prev) =>
|
||||
: setExamIDs((prev) =>
|
||||
prev.filter((x) => x.module !== module)
|
||||
)
|
||||
}
|
||||
options={exams
|
||||
.filter((x) => !x.isDiagnostic && x.module === module)
|
||||
.map((x) => ({ value: x.id, label: x.id }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
options={exams
|
||||
.filter((x) =>
|
||||
!x.isDiagnostic &&
|
||||
x.module === module &&
|
||||
x.access !== "confidential" &&
|
||||
(
|
||||
(x.requiresApproval && showApprovedExams) ||
|
||||
(!x.requiresApproval && showNonApprovedExams)
|
||||
)
|
||||
)
|
||||
.map((x) => ({ value: x.id, label: x.id }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -568,7 +598,7 @@ export default function AssignmentsPage({
|
||||
users
|
||||
.filter((u) => g.participants.includes(u.id))
|
||||
.every((u) => assignees.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{g.name}
|
||||
@@ -653,7 +683,7 @@ export default function AssignmentsPage({
|
||||
users
|
||||
.filter((u) => g.participants.includes(u.id))
|
||||
.every((u) => teachers.includes(u.id)) &&
|
||||
"!bg-mti-purple-light !text-white"
|
||||
"!bg-mti-purple-light !text-white"
|
||||
)}
|
||||
>
|
||||
{g.name}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ObjectId } from "mongodb";
|
||||
|
||||
const db = client.db(process.env.MONGODB_DB);
|
||||
|
||||
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[]) => {
|
||||
export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[], assignee?: string) => {
|
||||
const filters: any = {};
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
@@ -15,7 +15,15 @@ export const getApprovalWorkflows = async (collection: string, entityIds?: strin
|
||||
filters.entityId = { $in: entityIds };
|
||||
}
|
||||
|
||||
return await db.collection<ApprovalWorkflow>(collection).find(filters).toArray();
|
||||
if (assignee) {
|
||||
filters["steps.assignees"] = assignee;
|
||||
}
|
||||
|
||||
return await db
|
||||
.collection<ApprovalWorkflow>(collection)
|
||||
.find(filters)
|
||||
.sort({ startDate: -1 })
|
||||
.toArray();
|
||||
};
|
||||
|
||||
export const getApprovalWorkflow = async (collection: string, id: string) => {
|
||||
@@ -26,6 +34,7 @@ export const getApprovalWorkflowsByEntities = async (collection: string, ids: st
|
||||
return await db
|
||||
.collection<ApprovalWorkflow>(collection)
|
||||
.find({ entityId: { $in: ids } })
|
||||
.sort({ startDate: -1 })
|
||||
.toArray();
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Exam } from "@/interfaces/exam";
|
||||
import { diff, Diff } from "deep-diff";
|
||||
|
||||
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]);
|
||||
const EXCLUDED_KEYS = new Set<string>(["_id", "id", "uuid", "isDiagnostic", "owners", "entities", "createdAt", "createdBy", "access", "requiresApproval", "exerciseID", "questionID", "sectionId", "userSolutions"]);
|
||||
|
||||
const PATH_LABELS: Record<string, string> = {
|
||||
access: "Access Type",
|
||||
@@ -24,124 +23,146 @@ const PATH_LABELS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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<any, any>): 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<string, any> {
|
||||
return val !== null && typeof val === "object" && !Array.isArray(val);
|
||||
}
|
||||
|
||||
function formatArrayChange(change: Diff<any, any>): 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<string>): 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<string, string>): 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 | number>): 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]));
|
||||
// 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];
|
||||
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);
|
||||
}
|
||||
}
|
||||
} 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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user