Merged in develop (pull request #153)

Prod Update - 12/02/2025
This commit is contained in:
Tiago Ribeiro
2025-02-12 09:13:08 +00:00
41 changed files with 1636 additions and 1006 deletions

View File

@@ -0,0 +1,51 @@
import dotenv from "dotenv";
dotenv.config();
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI || "";
const options = {
maxPoolSize: 10,
};
const dbName = process.env.MONGODB_DB; // change this to prod db when needed
async function migrateData() {
const MODULE_ARRAY = ["reading", "listening", "writing", "speaking", "level"];
const client = new MongoClient(uri, options);
try {
await client.connect();
console.log("Connected to MongoDB");
if (!process.env.MONGODB_DB) {
throw new Error("Missing env var: MONGODB_DB");
}
const db = client.db(dbName);
for (const string of MODULE_ARRAY) {
const collection = db.collection(string);
const result = await collection.updateMany(
{ private: { $exists: false } },
{ $set: { access: "public" } }
);
const result2 = await collection.updateMany(
{ private: true },
{ $set: { access: "private" }, $unset: { private: "" } }
);
const result1 = await collection.updateMany(
{ private: { $exists: true } },
{ $set: { access: "public" } }
);
console.log(
`Updated ${
result.modifiedCount + result1.modifiedCount
} documents to "access: public" in ${string}`
);
console.log(
`Updated ${result2.modifiedCount} documents to "access: private" and removed private var in ${string}`
);
}
console.log("Migration completed successfully!");
} catch (error) {
console.error("Migration failed:", error);
} finally {
await client.close();
console.log("MongoDB connection closed.");
}
}
//migrateData(); // uncomment to run the migration

View File

@@ -1,17 +1,12 @@
import {infoButtonStyle} from "@/constants/buttonStyles";
import {Module} from "@/interfaces";
import {User} from "@/interfaces/user"; import {User} from "@/interfaces/user";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import {getExam, getExamById} from "@/utils/exams"; import {getExam} from "@/utils/exams";
import {MODULE_ARRAY} from "@/utils/moduleUtils"; import {MODULE_ARRAY} from "@/utils/moduleUtils";
import {writingMarking} from "@/utils/score";
import {Menu} from "@headlessui/react";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import {capitalize} from "lodash";
import {useRouter} from "next/router"; import {useRouter} from "next/router";
import {useEffect, useState} from "react"; import { useState} from "react";
import {BsBook, BsChevronDown, BsHeadphones, BsMegaphone, BsPen, BsQuestionSquare} from "react-icons/bs"; import { BsQuestionSquare} from "react-icons/bs";
import {toast} from "react-toastify"; import {toast} from "react-toastify";
import Button from "./Low/Button"; import Button from "./Low/Button";
import ModuleLevelSelector from "./Medium/ModuleLevelSelector"; import ModuleLevelSelector from "./Medium/ModuleLevelSelector";

View File

@@ -19,7 +19,7 @@ interface SettingsEditorProps {
children?: ReactNode; children?: ReactNode;
canPreview: boolean; canPreview: boolean;
canSubmit: boolean; canSubmit: boolean;
submitModule: () => void; submitModule: (requiresApproval: boolean) => void;
preview: () => void; preview: () => void;
} }
@@ -148,18 +148,33 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
</div> </div>
</Dropdown> </Dropdown>
{children} {children}
<div className="flex flex-row justify-between mt-4"> <div className="flex flex-col gap-3 mt-4">
<button <button
className={clsx( className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300", "flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`, `bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200" "disabled:cursor-not-allowed disabled:text-gray-200"
)} )}
onClick={submitModule} onClick={() => submitModule(true)}
disabled={!canSubmit} disabled={!canSubmit}
> >
<FaFileUpload className="mr-2" size={18} /> <FaFileUpload className="mr-2" size={18} />
Submit Module as Exam Submit module as exam for approval
</button>
<button
className={clsx(
"flex items-center justify-center px-4 py-2 text-white rounded-xl transition-colors duration-300",
`bg-ielts-${module}/70 border border-ielts-${module} hover:bg-ielts-${module} disabled:bg-ielts-${module}/30`,
"disabled:cursor-not-allowed disabled:text-gray-200"
)}
onClick={() => {
if (!confirm(`Are you sure you want to skip the approval process for this exam?`)) return;
submitModule(false);
}}
disabled={!canSubmit}
>
<FaFileUpload className="mr-2" size={18} />
Submit module as exam and skip approval process
</button> </button>
<button <button
className={clsx( className={clsx(
@@ -171,7 +186,7 @@ const SettingsEditor: React.FC<SettingsEditorProps> = ({
disabled={!canPreview} disabled={!canPreview}
> >
<FaEye className="mr-2" size={18} /> <FaEye className="mr-2" size={18} />
Preview Module Preview module
</button> </button>
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@ const LevelSettings: React.FC = () => {
difficulty, difficulty,
sections, sections,
minTimer, minTimer,
isPrivate, access,
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<LevelSectionSettings>(
@@ -76,7 +76,7 @@ const LevelSettings: React.FC = () => {
}); });
}); });
const submitLevel = async () => { const submitLevel = async (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -195,12 +195,13 @@ const LevelSettings: React.FC = () => {
category: s.settings.category category: s.settings.category
}; };
}).filter(part => part.exercises.length > 0), }).filter(part => part.exercises.length > 0),
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer, minTimer,
module: "level", module: "level",
id: title, id: title,
difficulty, difficulty,
private: isPrivate, access,
}; };
const result = await axios.post('/api/exam/level', exam); const result = await axios.post('/api/exam/level', exam);
@@ -243,7 +244,7 @@ const LevelSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
} as LevelExam); } as LevelExam);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);

View File

@@ -27,7 +27,7 @@ const ListeningSettings: React.FC = () => {
difficulty, difficulty,
sections, sections,
minTimer, minTimer,
isPrivate, access,
instructionsState instructionsState
} = useExamEditorStore(state => state.modules[currentModule]); } = useExamEditorStore(state => state.modules[currentModule]);
@@ -65,7 +65,7 @@ const ListeningSettings: React.FC = () => {
} }
]; ];
const submitListening = async () => { const submitListening = async (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -138,13 +138,14 @@ const ListeningSettings: React.FC = () => {
category: s.settings.category category: s.settings.category
}; };
}), }),
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer, minTimer,
module: "listening", module: "listening",
id: title, id: title,
variant: sections.length === 4 ? "full" : "partial", variant: sections.length === 4 ? "full" : "partial",
difficulty, difficulty,
private: isPrivate, access,
instructions: instructionsURL instructions: instructionsURL
}; };
@@ -191,7 +192,7 @@ const ListeningSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: sections.length === 4 ? "full" : "partial", variant: sections.length === 4 ? "full" : "partial",
difficulty, difficulty,
private: isPrivate, access,
instructions: instructionsState.currentInstructionsURL instructions: instructionsState.currentInstructionsURL
} as ListeningExam); } as ListeningExam);
setExerciseIndex(0); setExerciseIndex(0);

View File

@@ -26,43 +26,41 @@ const ReadingSettings: React.FC = () => {
} = usePersistentExamStore(); } = usePersistentExamStore();
const { currentModule, title } = useExamEditorStore(); const { currentModule, title } = useExamEditorStore();
const { const { focusedSection, difficulty, sections, minTimer, access, type } =
focusedSection, useExamEditorStore((state) => state.modules[currentModule]);
difficulty,
sections,
minTimer,
isPrivate,
type,
} = useExamEditorStore(state => state.modules[currentModule]);
const { localSettings, updateLocalAndScheduleGlobal } = useSettingsState<ReadingSectionSettings>( const { localSettings, updateLocalAndScheduleGlobal } =
currentModule, useSettingsState<ReadingSectionSettings>(currentModule, focusedSection);
focusedSection
);
const currentSection = sections.find((section) => section.sectionId == focusedSection)?.state as ReadingPart;
const currentSection = sections.find(
(section) => section.sectionId == focusedSection
)?.state as ReadingPart;
const defaultPresets: Option[] = [ const defaultPresets: Option[] = [
{ {
label: "Preset: Reading Passage 1", label: "Preset: Reading Passage 1",
value: "Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas." value:
"Welcome to {part} of the {label}. You will read texts relating to everyday topics and situations. These may include advertisements, brochures, manuals, or official documents. Answer questions that test your ability to locate specific information and understand main ideas.",
}, },
{ {
label: "Preset: Reading Passage 2", label: "Preset: Reading Passage 2",
value: "Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views." value:
"Welcome to {part} of the {label}. You will read texts dealing with general interest topics that may include news articles, company policies, or workplace documents. Answer questions testing your understanding of main ideas, specific details, and the author's views.",
}, },
{ {
label: "Preset: Reading Passage 3", label: "Preset: Reading Passage 3",
value: "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas." value:
} "Welcome to {part} of the {label}. You will read longer academic texts that may include journal articles, academic essays, or research papers. Answer questions testing your ability to understand complex arguments, identify key points, and follow the development of ideas.",
},
]; ];
const canPreviewOrSubmit = sections.some( const canPreviewOrSubmit = sections.some(
(s) => (s.state as ReadingPart).exercises && (s.state as ReadingPart).exercises.length > 0 (s) =>
(s.state as ReadingPart).exercises &&
(s.state as ReadingPart).exercises.length > 0
); );
const submitReading = () => { const submitReading = (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -73,20 +71,22 @@ const ReadingSettings: React.FC = () => {
return { return {
...exercise, ...exercise,
intro: localSettings.currentIntro, intro: localSettings.currentIntro,
category: localSettings.category category: localSettings.category,
}; };
}), }),
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
minTimer, minTimer,
module: "reading", module: "reading",
id: title, id: title,
variant: sections.length === 3 ? "full" : "partial", variant: sections.length === 3 ? "full" : "partial",
difficulty, difficulty,
private: isPrivate, access,
type: type! type: type!,
}; };
axios.post(`/api/exam/reading`, exam) axios
.post(`/api/exam/reading`, exam)
.then((result) => { .then((result) => {
playSound("sent"); playSound("sent");
// Successfully submitted exam // Successfully submitted exam
@@ -98,9 +98,12 @@ const ReadingSettings: React.FC = () => {
}) })
.catch((error) => { .catch((error) => {
console.log(error); console.log(error);
toast.error(error.response.data.error || "Something went wrong while submitting, please try again later."); toast.error(
}) error.response.data.error ||
} "Something went wrong while submitting, please try again later."
);
});
};
const preview = () => { const preview = () => {
setExam({ setExam({
@@ -109,7 +112,7 @@ const ReadingSettings: React.FC = () => {
return { return {
...exercises, ...exercises,
intro: s.settings.currentIntro, intro: s.settings.currentIntro,
category: s.settings.category category: s.settings.category,
}; };
}), }),
minTimer, minTimer,
@@ -118,15 +121,15 @@ const ReadingSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access: access,
type: type! type: type!,
} as ReadingExam); } as ReadingExam);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);
setPartIndex(0); setPartIndex(0);
setBgColor("bg-white"); setBgColor("bg-white");
openDetachedTab("popout?type=Exam&module=reading", router) openDetachedTab("popout?type=Exam&module=reading", router);
} };
return ( return (
<SettingsEditor <SettingsEditor

View File

@@ -30,7 +30,7 @@ const SpeakingSettings: React.FC = () => {
} = usePersistentExamStore(); } = usePersistentExamStore();
const { title, currentModule } = useExamEditorStore(); const { title, currentModule } = useExamEditorStore();
const { focusedSection, difficulty, sections, minTimer, isPrivate } = useExamEditorStore((store) => store.modules[currentModule]) const { focusedSection, difficulty, sections, minTimer, access } = useExamEditorStore((store) => store.modules[currentModule])
const section = sections.find((section) => section.sectionId == focusedSection)?.state; const section = sections.find((section) => section.sectionId == focusedSection)?.state;
@@ -84,7 +84,7 @@ const SpeakingSettings: React.FC = () => {
}); });
})(); })();
const submitSpeaking = async () => { const submitSpeaking = async (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -181,11 +181,12 @@ const SpeakingSettings: React.FC = () => {
minTimer, minTimer,
module: "speaking", module: "speaking",
id: title, id: title,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
instructorGender: "varied", instructorGender: "varied",
private: isPrivate, access,
}; };
const result = await axios.post('/api/exam/speaking', exam); const result = await axios.post('/api/exam/speaking', exam);
@@ -238,7 +239,7 @@ const SpeakingSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
} as SpeakingExam); } as SpeakingExam);
setExerciseIndex(0); setExerciseIndex(0);
setQuestionIndex(0); setQuestionIndex(0);

View File

@@ -23,7 +23,7 @@ const WritingSettings: React.FC = () => {
const { const {
minTimer, minTimer,
difficulty, difficulty,
isPrivate, access,
sections, sections,
focusedSection, focusedSection,
type, type,
@@ -81,14 +81,14 @@ const WritingSettings: React.FC = () => {
isDiagnostic: false, isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
type: type! type: type!
}); });
setExerciseIndex(0); setExerciseIndex(0);
openDetachedTab("popout?type=Exam&module=writing", router) openDetachedTab("popout?type=Exam&module=writing", router)
} }
const submitWriting = async () => { const submitWriting = async (requiresApproval: boolean) => {
if (title === "") { if (title === "") {
toast.error("Enter a title for the exam!"); toast.error("Enter a title for the exam!");
return; return;
@@ -131,10 +131,11 @@ const WritingSettings: React.FC = () => {
minTimer, minTimer,
module: "writing", module: "writing",
id: title, id: title,
isDiagnostic: true, // using isDiagnostic to keep exam hidden until the respective approval workflow is completed. requiresApproval: requiresApproval,
isDiagnostic: false,
variant: undefined, variant: undefined,
difficulty, difficulty,
private: isPrivate, access,
type: type! type: type!
}; };

View File

@@ -4,8 +4,8 @@ import Checkbox from "../Low/Checkbox";
import Input from "../Low/Input"; import Input from "../Low/Input";
import Select from "../Low/Select"; import Select from "../Low/Select";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import {Difficulty} from "@/interfaces/exam"; import { AccessType, ACCESSTYPE, Difficulty } from "@/interfaces/exam";
import {useCallback, useEffect, useMemo, useState} from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { ModuleState, SectionState } from "@/stores/examEditor/types"; import { ModuleState, SectionState } from "@/stores/examEditor/types";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
@@ -24,16 +24,25 @@ import {EntityWithRoles} from "@/interfaces/entity";
const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"]; const DIFFICULTIES: Difficulty[] = ["A1", "A2", "B1", "B2", "C1", "C2"];
const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: EntityWithRoles[]}> = ({ const ExamEditor: React.FC<{
levelParts = 0, levelParts?: number;
entitiesAllowEditPrivacy = [], entitiesAllowEditPrivacy: EntityWithRoles[];
}) => { }> = ({ levelParts = 0, entitiesAllowEditPrivacy = [] }) => {
const { currentModule, dispatch } = useExamEditorStore(); const { currentModule, dispatch } = useExamEditorStore();
const {sections, minTimer, expandedSections, examLabel, isPrivate, difficulty, sectionLabels, importModule} = useExamEditorStore( const {
(state) => state.modules[currentModule], sections,
); minTimer,
expandedSections,
examLabel,
access,
difficulty,
sectionLabels,
importModule,
} = useExamEditorStore((state) => state.modules[currentModule]);
const [numberOfLevelParts, setNumberOfLevelParts] = useState(levelParts !== 0 ? levelParts : 1); const [numberOfLevelParts, setNumberOfLevelParts] = useState(
levelParts !== 0 ? levelParts : 1
);
const [isResetModuleOpen, setIsResetModuleOpen] = useState(false); const [isResetModuleOpen, setIsResetModuleOpen] = useState(false);
// For exam edits // For exam edits
@@ -61,11 +70,16 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
const currentLabels = sectionLabels; const currentLabels = sectionLabels;
let updatedSections: SectionState[]; let updatedSections: SectionState[];
let updatedLabels: any; let updatedLabels: any;
if ((currentModule === "level" && currentSections.length !== currentLabels.length) || numberOfLevelParts !== currentSections.length) { if (
(currentModule === "level" &&
currentSections.length !== currentLabels.length) ||
numberOfLevelParts !== currentSections.length
) {
const newSections = [...currentSections]; const newSections = [...currentSections];
const newLabels = [...currentLabels]; const newLabels = [...currentLabels];
for (let i = currentLabels.length; i < numberOfLevelParts; i++) { for (let i = currentLabels.length; i < numberOfLevelParts; i++) {
if (currentSections.length !== numberOfLevelParts) newSections.push(defaultSectionSettings(currentModule, i + 1)); if (currentSections.length !== numberOfLevelParts)
newSections.push(defaultSectionSettings(currentModule, i + 1));
newLabels.push({ newLabels.push({
id: i + 1, id: i + 1,
label: `Part ${i + 1}`, label: `Part ${i + 1}`,
@@ -80,7 +94,9 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
return; return;
} }
const updatedExpandedSections = expandedSections.filter((sectionId) => updatedSections.some((section) => section.sectionId === sectionId)); const updatedExpandedSections = expandedSections.filter((sectionId) =>
updatedSections.some((section) => section.sectionId === sectionId)
);
dispatch({ dispatch({
type: "UPDATE_MODULE", type: "UPDATE_MODULE",
@@ -101,7 +117,7 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
(updates: Partial<ModuleState>) => { (updates: Partial<ModuleState>) => {
dispatch({ type: "UPDATE_MODULE", payload: { updates } }); dispatch({ type: "UPDATE_MODULE", payload: { updates } });
}, },
[dispatch], [dispatch]
); );
const toggleSection = (sectionId: number) => { const toggleSection = (sectionId: number) => {
@@ -121,7 +137,8 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
}; };
const Settings = ModuleSettings[currentModule]; const Settings = ModuleSettings[currentModule];
const showImport = importModule && ["reading", "listening", "level"].includes(currentModule); const showImport =
importModule && ["reading", "listening", "level"].includes(currentModule);
const updateLevelParts = (parts: number) => { const updateLevelParts = (parts: number) => {
setNumberOfLevelParts(parts); setNumberOfLevelParts(parts);
@@ -130,7 +147,10 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
return ( return (
<> <>
{showImport ? ( {showImport ? (
<ImportOrStartFromScratch module={currentModule} setNumberOfLevelParts={updateLevelParts} /> <ImportOrStartFromScratch
module={currentModule}
setNumberOfLevelParts={updateLevelParts}
/>
) : ( ) : (
<> <>
{isResetModuleOpen && ( {isResetModuleOpen && (
@@ -144,7 +164,9 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
<div className="flex gap-4 w-full items-center -xl:flex-col"> <div className="flex gap-4 w-full items-center -xl:flex-col">
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="font-normal text-base text-mti-gray-dim">Timer</label> <label className="font-normal text-base text-mti-gray-dim">
Timer
</label>
<Input <Input
type="number" type="number"
name="minTimer" name="minTimer"
@@ -158,7 +180,9 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
/> />
</div> </div>
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">Difficulty</label> <label className="font-normal text-base text-mti-gray-dim">
Difficulty
</label>
<Select <Select
isMulti={true} isMulti={true}
options={DIFFICULTIES.map((x) => ({ options={DIFFICULTIES.map((x) => ({
@@ -166,12 +190,17 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
label: capitalize(x), label: capitalize(x),
}))} }))}
onChange={(values) => { onChange={(values) => {
const selectedDifficulties = values ? values.map((v) => v.value as Difficulty) : []; const selectedDifficulties = values
? values.map((v) => v.value as Difficulty)
: [];
updateModule({ difficulty: selectedDifficulties }); updateModule({ difficulty: selectedDifficulties });
}} }}
value={ value={
difficulty difficulty
? difficulty.map((d) => ({ ? (Array.isArray(difficulty)
? difficulty
: [difficulty]
).map((d) => ({
value: d, value: d,
label: capitalize(d), label: capitalize(d),
})) }))
@@ -182,7 +211,9 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
</div> </div>
{sectionLabels.length != 0 && currentModule !== "level" ? ( {sectionLabels.length != 0 && currentModule !== "level" ? (
<div className="flex flex-col gap-3 -xl:w-full"> <div className="flex flex-col gap-3 -xl:w-full">
<label className="font-normal text-base text-mti-gray-dim">{sectionLabels[0].label.split(" ")[0]}</label> <label className="font-normal text-base text-mti-gray-dim">
{sectionLabels[0].label.split(" ")[0]}
</label>
<div className="flex flex-row gap-8"> <div className="flex flex-row gap-8">
{sectionLabels.map(({ id, label }) => ( {sectionLabels.map(({ id, label }) => (
<span <span
@@ -192,9 +223,10 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
sectionIds.includes(id) sectionIds.includes(id)
? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white` ? `bg-ielts-${currentModule}/70 border-ielts-${currentModule} text-white`
: "bg-white border-mti-gray-platinum", : "bg-white border-mti-gray-platinum"
)} )}
onClick={() => toggleSection(id)}> onClick={() => toggleSection(id)}
>
{label} {label}
</span> </span>
))} ))}
@@ -202,7 +234,9 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3 w-1/3"> <div className="flex flex-col gap-3 w-1/3">
<label className="font-normal text-base text-mti-gray-dim">Number of Parts</label> <label className="font-normal text-base text-mti-gray-dim">
Number of Parts
</label>
<Input <Input
type="number" type="number"
name="Number of Parts" name="Number of Parts"
@@ -212,19 +246,27 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
/> />
</div> </div>
)} )}
<div className="flex flex-col gap-3 w-fit h-fit">
<div className="h-6" />
<Checkbox
isChecked={isPrivate}
onChange={(checked) => updateModule({isPrivate: checked})}
disabled={entitiesAllowEditPrivacy.length === 0}>
Privacy (Only available for Assignments)
</Checkbox>
</div> </div>
<div className="flex flex-row gap-3 w-64">
<Select
label="Access Type"
options={ACCESSTYPE.map((item) => ({
value: item,
label: capitalize(item),
}))}
onChange={(value) => {
if (value?.value) {
updateModule({ access: value.value! as AccessType });
}
}}
value={{ value: access, label: capitalize(access) }}
/>
</div> </div>
<div className="flex flex-row gap-3 w-full"> <div className="flex flex-row gap-3 w-full">
<div className="flex flex-col gap-3 flex-grow"> <div className="flex flex-col gap-3 flex-grow">
<label className="font-normal text-base text-mti-gray-dim">Exam Label *</label> <label className="font-normal text-base text-mti-gray-dim">
Exam Label *
</label>
<Input <Input
type="text" type="text"
placeholder="Exam Label" placeholder="Exam Label"
@@ -239,7 +281,8 @@ const ExamEditor: React.FC<{levelParts?: number; entitiesAllowEditPrivacy: Entit
<Button <Button
onClick={() => setIsResetModuleOpen(true)} onClick={() => setIsResetModuleOpen(true)}
customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`} customColor={`bg-ielts-${currentModule}/70 hover:bg-ielts-${currentModule} border-ielts-${currentModule}`}
className={`text-white self-end`}> className={`text-white self-end`}
>
Reset Module Reset Module
</Button> </Button>
</div> </div>

View File

@@ -149,10 +149,16 @@ export default function Table<T>({
))} ))}
</tbody> </tbody>
</table> </table>
{isLoading && ( {isLoading ? (
<div className="min-h-screen flex justify-center items-start"> <div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" /> <span className="loading loading-infinity w-32" />
</div> </div>
) : (
rows.length === 0 && (
<div className="w-full flex justify-center items-start">
<span className="text-xl text-gray-500">No data found...</span>
</div>
)
)} )}
</div> </div>
); );

View File

@@ -2,7 +2,7 @@ import { ApprovalWorkflow } from "@/interfaces/approval.workflow";
import axios from "axios"; import axios from "axios";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
export default function useApprovalWorkflows() { export default function useApprovalWorkflows(entitiesString?: string) {
const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]); const [workflows, setWorkflows] = useState<ApprovalWorkflow[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false); const [isError, setIsError] = useState(false);
@@ -10,7 +10,7 @@ export default function useApprovalWorkflows() {
const getData = useCallback(() => { const getData = useCallback(() => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<ApprovalWorkflow[]>(`/api/approval-workflows`) .get<ApprovalWorkflow[]>(`/api/approval-workflows`, {params: { entityIds: entitiesString }})
.then((response) => setWorkflows(response.data)) .then((response) => setWorkflows(response.data))
.catch((error) => { .catch((error) => {
setIsError(true); setIsError(true);

View File

@@ -10,7 +10,7 @@ export default function useExams() {
const getData = () => { const getData = () => {
setIsLoading(true); setIsLoading(true);
axios axios
.get<Exam[]>("/api/exam") .get<Exam[]>(`/api/exam`)
.then((response) => setExams(response.data)) .then((response) => setExams(response.data))
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}; };

View File

@@ -1,25 +1,74 @@
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import {useMemo, useState} from "react"; import { useEffect, useMemo, useState } from "react";
import {BiChevronLeft} from "react-icons/bi"; import {
import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs"; BsChevronDoubleLeft,
BsChevronDoubleRight,
BsChevronLeft,
BsChevronRight,
} from "react-icons/bs";
import Select from "../components/Low/Select";
export default function usePagination<T>(list: T[], size = 25) { export default function usePagination<T>(list: T[], size = 25) {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState(size);
const items = useMemo(() => list.slice(page * size, (page + 1) * size), [page, size, list]); const items = useMemo(
() => list.slice(page * itemsPerPage, (page + 1) * itemsPerPage),
[list, page, itemsPerPage]
);
useEffect(() => {
if (page * itemsPerPage >= list.length) setPage(0);
}, [items, itemsPerPage, list.length, page]);
const itemsPerPageOptions = [25, 50, 100, 200];
const render = () => ( const render = () => (
<div className="w-full flex gap-2 justify-between items-center"> <div className="w-full flex gap-2 justify-between items-center">
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<Button className="w-[200px] h-fit" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}> <Button
className="w-[200px] h-fit"
disabled={page === 0}
onClick={() => setPage((prev) => prev - 1)}
>
Previous Page Previous Page
</Button> </Button>
</div> </div>
<div className="flex items-center gap-4 w-fit"> <div className="flex items-center gap-4 w-fit">
<span className="opacity-80"> <div className="flex flex-row items-center gap-1 w-56">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} <Select
value={{
value: itemsPerPage.toString(),
label: (itemsPerPage * page > items.length
? items.length
: itemsPerPage * page
).toString(),
}}
onChange={(value) =>
setItemsPerPage(parseInt(value!.value ?? "25"))
}
options={itemsPerPageOptions.map((size) => ({
label: size.toString(),
value: size.toString(),
}))}
isClearable={false}
styles={{
control: (styles) => ({ ...styles, width: "100px" }),
container: (styles) => ({ ...styles, width: "100px" }),
}}
/>
<span className="opacity-80 w-32 text-center">
{page * itemsPerPage + 1} -{" "}
{itemsPerPage * (page + 1) > list.length
? list.length
: itemsPerPage * (page + 1)}
{list.length}
</span> </span>
<Button className="w-[200px]" disabled={(page + 1) * size >= list.length} onClick={() => setPage((prev) => prev + 1)}> </div>
<Button
className="w-[200px]"
disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage((prev) => prev + 1)}
>
Next Page Next Page
</Button> </Button>
</div> </div>
@@ -29,27 +78,59 @@ export default function usePagination<T>(list: T[], size = 25) {
const renderMinimal = () => ( const renderMinimal = () => (
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<button disabled={page === 0} onClick={() => setPage(0)} className="disabled:opacity-60 disabled:cursor-not-allowed"> <button
disabled={page === 0}
onClick={() => setPage(0)}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronDoubleLeft /> <BsChevronDoubleLeft />
</button> </button>
<button disabled={page === 0} onClick={() => setPage((prev) => prev - 1)} className="disabled:opacity-60 disabled:cursor-not-allowed"> <button
disabled={page === 0}
onClick={() => setPage((prev) => prev - 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronLeft /> <BsChevronLeft />
</button> </button>
</div> </div>
<div className="flex flex-row items-center gap-1 w-56">
<Select
value={{
value: itemsPerPage.toString(),
label: itemsPerPage.toString(),
}}
onChange={(value) => setItemsPerPage(parseInt(value!.value ?? "25"))}
options={itemsPerPageOptions.map((size) => ({
label: size.toString(),
value: size.toString(),
}))}
isClearable={false}
styles={{
control: (styles) => ({ ...styles, width: "100px" }),
container: (styles) => ({ ...styles, width: "100px" }),
}}
/>
<span className="opacity-80 w-32 text-center"> <span className="opacity-80 w-32 text-center">
{page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} {page * itemsPerPage + 1} -{" "}
{itemsPerPage * (page + 1) > list.length
? list.length
: itemsPerPage * (page + 1)}
/ {list.length}
</span> </span>
</div>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<button <button
disabled={(page + 1) * size >= list.length} disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage((prev) => prev + 1)} onClick={() => setPage((prev) => prev + 1)}
className="disabled:opacity-60 disabled:cursor-not-allowed"> className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronRight /> <BsChevronRight />
</button> </button>
<button <button
disabled={(page + 1) * size >= list.length} disabled={(page + 1) * itemsPerPage >= list.length}
onClick={() => setPage(Math.floor(list.length / size))} onClick={() => setPage(Math.floor(list.length / itemsPerPage))}
className="disabled:opacity-60 disabled:cursor-not-allowed"> className="disabled:opacity-60 disabled:cursor-not-allowed"
>
<BsChevronDoubleRight /> <BsChevronDoubleRight />
</button> </button>
</div> </div>

View File

@@ -1,4 +1,3 @@
import instructions from "@/pages/api/exam/media/instructions";
import { Module } from "."; import { Module } from ".";
export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam;
@@ -10,6 +9,9 @@ export type Difficulty = BasicDifficulty | CEFRLevels;
// Left easy, medium and hard to support older exam versions // Left easy, medium and hard to support older exam versions
export type BasicDifficulty = "easy" | "medium" | "hard"; export type BasicDifficulty = "easy" | "medium" | "hard";
export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2"; export type CEFRLevels = "A1" | "A2" | "B1" | "B2" | "C1" | "C2";
export const ACCESSTYPE = ["public", "private", "confidential"] as const;
export type AccessType = typeof ACCESSTYPE[number];
export interface ExamBase { export interface ExamBase {
@@ -24,8 +26,9 @@ export interface ExamBase {
shuffle?: boolean; shuffle?: boolean;
createdBy?: string; // option as it has been added later createdBy?: string; // option as it has been added later
createdAt?: string; // option as it has been added later createdAt?: string; // option as it has been added later
private?: boolean; access: AccessType;
label?: string; label?: string;
requiresApproval?: boolean;
} }
export interface ReadingExam extends ExamBase { export interface ReadingExam extends ExamBase {
module: "reading"; module: "reading";

View File

@@ -1,7 +1,10 @@
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be"; import { getApprovalWorkflowByFormIntaker, createApprovalWorkflow } from "@/utils/approval.workflows.be";
import client from "@/lib/mongodb";
export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) { const db = client.db(process.env.MONGODB_DB);
/* export async function createApprovalWorkflowsOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) {
const results = await Promise.all( const results = await Promise.all(
examEntities.map(async (entity) => { examEntities.map(async (entity) => {
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor); const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
@@ -27,6 +30,53 @@ export async function createApprovalWorkflowsOnExamCreation(examAuthor: string,
const successCount = results.filter((r) => r.created).length; const successCount = results.filter((r) => r.created).length;
const totalCount = examEntities.length; const totalCount = examEntities.length;
return {
successCount,
totalCount,
};
} */
// TEMPORARY BEHAVIOUR! ONLY THE FIRST CONFIGURED WORKFLOW FOUND IS STARTED
export async function createApprovalWorkflowOnExamCreation(examAuthor: string, examEntities: string[], examId: string, examModule: string) {
let successCount = 0;
let totalCount = 0;
for (const entity of examEntities) {
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
if (!configuredWorkflow) {
continue;
}
totalCount = 1; // a workflow was found
configuredWorkflow.modules.push(examModule as Module);
configuredWorkflow.name = examId;
configuredWorkflow.examId = examId;
configuredWorkflow.entityId = entity;
configuredWorkflow.startDate = Date.now();
configuredWorkflow.steps[0].completed = true;
configuredWorkflow.steps[0].completedBy = examAuthor;
configuredWorkflow.steps[0].completedDate = Date.now();
try {
await createApprovalWorkflow("active-workflows", configuredWorkflow);
successCount = 1;
break; // Stop after the first success
} catch (error: any) {
break;
}
}
// prettier-ignore
if (totalCount === 0) { // current behaviour: if no workflow was found skip approval process
await db.collection(examModule).updateOne(
{ id: examId },
{ $set: { id: examId, isDiagnostic: false }},
{ upsert: true }
);
}
return { return {
successCount, successCount,
totalCount, totalCount,

View File

@@ -265,7 +265,7 @@ export default function CorporateGradingSystem({
<> <>
<Separator /> <Separator />
<label className="font-normal text-base text-mti-gray-dim"> <label className="font-normal text-base text-mti-gray-dim">
Apply this grading system to other entities Copy this grading system to other entities
</label> </label>
<Select <Select
options={entities.map((e) => ({ value: e.id, label: e.label }))} options={entities.map((e) => ({ value: e.id, label: e.label }))}
@@ -282,7 +282,7 @@ export default function CorporateGradingSystem({
disabled={isLoading || otherEntities.length === 0} disabled={isLoading || otherEntities.length === 0}
variant="outline" variant="outline"
> >
Apply to {otherEntities.length} other entities Copy to {otherEntities.length} other entities
</Button> </Button>
<Separator /> <Separator />
</> </>
@@ -326,7 +326,7 @@ export default function CorporateGradingSystem({
disabled={isLoading} disabled={isLoading}
className="mt-8" className="mt-8"
> >
Save Grading System Save Changes to entities
</Button> </Button>
</div> </div>
); );

View File

@@ -15,26 +15,41 @@ import { findBy, mapBy } from "@/utils";
import useEntitiesCodes from "@/hooks/useEntitiesCodes"; import useEntitiesCodes from "@/hooks/useEntitiesCodes";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
type TableData = Code & { entity?: EntityWithRoles, creator?: User } type TableData = Code & { entity?: EntityWithRoles; creator?: User };
const columnHelper = createColumnHelper<TableData>(); const columnHelper = createColumnHelper<TableData>();
export default function CodeList({ user, entities, canDeleteCodes } export default function CodeList({
: { user: User, entities: EntityWithRoles[], canDeleteCodes?: boolean }) { user,
entities,
canDeleteCodes,
}: {
user: User;
entities: EntityWithRoles[];
canDeleteCodes?: boolean;
}) {
const [selectedCodes, setSelectedCodes] = useState<string[]>([]); const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
const entityIDs = useMemo(() => mapBy(entities, 'id'), [entities]) const entityIDs = useMemo(() => mapBy(entities, "id"), [entities]);
const { users } = useUsers(); const { users } = useUsers();
const { codes, reload } = useEntitiesCodes(isAdmin(user) ? undefined : entityIDs) const { codes, reload, isLoading } = useEntitiesCodes(
isAdmin(user) ? undefined : entityIDs
);
const data: TableData[] = useMemo(() => codes.map((code) => ({ const data: TableData[] = useMemo(
() =>
codes.map((code) => ({
...code, ...code,
entity: findBy(entities, 'id', code.entity), entity: findBy(entities, "id", code.entity),
creator: findBy(users, 'id', code.creator) creator: findBy(users, "id", code.creator),
})) as TableData[], [codes, entities, users]) })) as TableData[],
[codes, entities, users]
);
const toggleCode = (id: string) => { const toggleCode = (id: string) => {
setSelectedCodes((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); setSelectedCodes((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
}; };
// const toggleAllCodes = (checked: boolean) => { // const toggleAllCodes = (checked: boolean) => {
@@ -44,8 +59,11 @@ export default function CodeList({ user, entities, canDeleteCodes }
// }; // };
const deleteCodes = async (codes: string[]) => { const deleteCodes = async (codes: string[]) => {
if (!canDeleteCodes) return if (!canDeleteCodes) return;
if (!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)) return; if (
!confirm(`Are you sure you want to delete these ${codes.length} code(s)?`)
)
return;
const params = new URLSearchParams(); const params = new URLSearchParams();
codes.forEach((code) => params.append("code", code)); codes.forEach((code) => params.append("code", code));
@@ -73,8 +91,9 @@ export default function CodeList({ user, entities, canDeleteCodes }
}; };
const deleteCode = async (code: Code) => { const deleteCode = async (code: Code) => {
if (!canDeleteCodes) return if (!canDeleteCodes) return;
if (!confirm(`Are you sure you want to delete this "${code.code}" code?`)) return; if (!confirm(`Are you sure you want to delete this "${code.code}" code?`))
return;
axios axios
.delete(`/api/code/${code.code}`) .delete(`/api/code/${code.code}`)
@@ -99,10 +118,13 @@ export default function CodeList({ user, entities, canDeleteCodes }
columnHelper.accessor("code", { columnHelper.accessor("code", {
id: "codeCheckbox", id: "codeCheckbox",
enableSorting: false, enableSorting: false,
header: () => (""), header: () => "",
cell: (info) => cell: (info) =>
!info.row.original.userId ? ( !info.row.original.userId ? (
<Checkbox isChecked={selectedCodes.includes(info.getValue())} onChange={() => toggleCode(info.getValue())}> <Checkbox
isChecked={selectedCodes.includes(info.getValue())}
onChange={() => toggleCode(info.getValue())}
>
{""} {""}
</Checkbox> </Checkbox>
) : null, ) : null,
@@ -113,7 +135,8 @@ export default function CodeList({ user, entities, canDeleteCodes }
}), }),
columnHelper.accessor("creationDate", { columnHelper.accessor("creationDate", {
header: "Creation Date", header: "Creation Date",
cell: (info) => (info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A"), cell: (info) =>
info.getValue() ? moment(info.getValue()).format("DD/MM/YYYY") : "N/A",
}), }),
columnHelper.accessor("email", { columnHelper.accessor("email", {
header: "E-mail", header: "E-mail",
@@ -121,7 +144,12 @@ export default function CodeList({ user, entities, canDeleteCodes }
}), }),
columnHelper.accessor("creator", { columnHelper.accessor("creator", {
header: "Creator", header: "Creator",
cell: (info) => info.getValue() ? `${info.getValue().name} (${USER_TYPE_LABELS[info.getValue().type]})` : "N/A", cell: (info) =>
info.getValue()
? `${info.getValue().name} (${
USER_TYPE_LABELS[info.getValue().type]
})`
: "N/A",
}), }),
columnHelper.accessor("entity", { columnHelper.accessor("entity", {
header: "Entity", header: "Entity",
@@ -147,7 +175,11 @@ export default function CodeList({ user, entities, canDeleteCodes }
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{canDeleteCodes && !row.original.userId && ( {canDeleteCodes && !row.original.userId && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteCode(row.original)}> <div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteCode(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -168,7 +200,8 @@ export default function CodeList({ user, entities, canDeleteCodes }
variant="outline" variant="outline"
color="red" color="red"
className="!py-1 px-10" className="!py-1 px-10"
onClick={() => deleteCodes(selectedCodes)}> onClick={() => deleteCodes(selectedCodes)}
>
Delete Delete
</Button> </Button>
</div> </div>
@@ -177,7 +210,14 @@ export default function CodeList({ user, entities, canDeleteCodes }
<Table<TableData> <Table<TableData>
data={data} data={data}
columns={defaultColumns} columns={defaultColumns}
searchFields={[["code"], ["email"], ["entity", "label"], ["creator", "name"], ['creator', 'type']]} isLoading={isLoading}
searchFields={[
["code"],
["email"],
["entity", "label"],
["creator", "name"],
["creator", "type"],
]}
/> />
</> </>
); );

View File

@@ -8,21 +8,24 @@ import {User} from "@/interfaces/user";
import useExamStore from "@/stores/exam"; import useExamStore from "@/stores/exam";
import { getExamById } from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import { countExercises } from "@/utils/moduleUtils"; import { countExercises } from "@/utils/moduleUtils";
import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import {capitalize, uniq} from "lodash"; import { capitalize } from "lodash";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import {BsBan, BsCheck, BsCircle, BsPencil, BsTrash, BsUpload, BsX} from "react-icons/bs"; import { BsCheck, BsPencil, BsTrash, BsUpload, BsX } from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
import { useListSearch } from "@/hooks/useListSearch"; import { useListSearch } from "@/hooks/useListSearch";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import {checkAccess} from "@/utils/permissions"; import { checkAccess, findAllowedEntities } from "@/utils/permissions";
import useGroups from "@/hooks/useGroups";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import { EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { BiEdit } from "react-icons/bi"; import { BiEdit } from "react-icons/bi";
import { findBy, mapBy } from "@/utils"; import { findBy, mapBy } from "@/utils";
import {getUserName} from "@/utils/users";
const searchFields = [["module"], ["id"], ["createdBy"]]; const searchFields = [["module"], ["id"], ["createdBy"]];
@@ -33,22 +36,44 @@ const CLASSES: {[key in Module]: string} = {
writing: "text-ielts-writing", writing: "text-ielts-writing",
level: "text-ielts-level", level: "text-ielts-level",
}; };
const columnHelper = createColumnHelper<Exam>(); const columnHelper = createColumnHelper<Exam>();
export default function ExamList({user, entities}: {user: User; entities: EntityWithRoles[]}) { export default function ExamList({
user,
entities,
}: {
user: User;
entities: EntityWithRoles[];
}) {
const [selectedExam, setSelectedExam] = useState<Exam>(); const [selectedExam, setSelectedExam] = useState<Exam>();
const {exams, reload} = useExams(); const canViewConfidentialEntities = useMemo(
const {users} = useUsers(); () =>
mapBy(
findAllowedEntities(user, entities, "view_confidential_exams"),
"id"
),
[user, entities]
);
const { exams, reload, isLoading } = useExams();
const { users } = useUsers();
// Pass this permission filter to the backend later
const filteredExams = useMemo( const filteredExams = useMemo(
() => () =>
exams.filter((e) => { ["admin", "developer"].includes(user?.type)
if (!e.private) return true; ? exams
return (e.entities || []).some((ent) => mapBy(user.entities, "id").includes(ent)); : exams.filter((item) => {
if (
item.access === "confidential" &&
!canViewConfidentialEntities.find((x) =>
(item.entities ?? []).includes(x)
)
)
return false;
return true;
}), }),
[exams, user?.entities], [canViewConfidentialEntities, exams, user?.type]
); );
const parsedExams = useMemo(() => { const parsedExams = useMemo(() => {
@@ -67,7 +92,10 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}); });
}, [filteredExams, users]); }, [filteredExams, users]);
const {rows: filteredRows, renderSearch} = useListSearch<Exam>(searchFields, parsedExams); const { rows: filteredRows, renderSearch } = useListSearch<Exam>(
searchFields,
parsedExams
);
const dispatch = useExamStore((state) => state.dispatch); const dispatch = useExamStore((state) => state.dispatch);
@@ -76,19 +104,33 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
const loadExam = async (module: Module, examId: string) => { const loadExam = async (module: Module, examId: string) => {
const exam = await getExamById(module, examId.trim()); const exam = await getExamById(module, examId.trim());
if (!exam) { if (!exam) {
toast.error("Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID", { toast.error(
"Unknown Exam ID! Please make sure you selected the right module and entered the right exam ID",
{
toastId: "invalid-exam-id", toastId: "invalid-exam-id",
}); }
);
return; return;
} }
dispatch({type: "INIT_EXAM", payload: {exams: [exam], modules: [module]}}); dispatch({
type: "INIT_EXAM",
payload: { exams: [exam], modules: [module] },
});
router.push("/exam"); router.push("/exam");
}; };
/*
const privatizeExam = async (exam: Exam) => { const privatizeExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to make this ${capitalize(exam.module)} exam ${exam.private ? "public" : "private"}?`)) return; if (
!confirm(
`Are you sure you want to make this ${capitalize(exam.module)} exam ${
exam.access
}?`
)
)
return;
axios axios
.patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private }) .patch(`/api/exam/${exam.module}/${exam.id}`, { private: !exam.private })
@@ -108,9 +150,15 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}) })
.finally(reload); .finally(reload);
}; };
*/
const deleteExam = async (exam: Exam) => { const deleteExam = async (exam: Exam) => {
if (!confirm(`Are you sure you want to delete this ${capitalize(exam.module)} exam?`)) return; if (
!confirm(
`Are you sure you want to delete this ${capitalize(exam.module)} exam?`
)
)
return;
axios axios
.delete(`/api/exam/${exam.module}/${exam.id}`) .delete(`/api/exam/${exam.module}/${exam.id}`)
@@ -132,8 +180,12 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}; };
const getTotalExercises = (exam: Exam) => { const getTotalExercises = (exam: Exam) => {
if (exam.module === "reading" || exam.module === "listening" || exam.module === "level") { if (
return countExercises(exam.parts.flatMap((x) => x.exercises)); exam.module === "reading" ||
exam.module === "listening" ||
exam.module === "level"
) {
return countExercises((exam.parts ?? []).flatMap((x) => x.exercises));
} }
return countExercises(exam.exercises); return countExercises(exam.exercises);
@@ -146,7 +198,11 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}), }),
columnHelper.accessor("module", { columnHelper.accessor("module", {
header: "Module", header: "Module",
cell: (info) => <span className={CLASSES[info.getValue()]}>{capitalize(info.getValue())}</span>, cell: (info) => (
<span className={CLASSES[info.getValue()]}>
{capitalize(info.getValue())}
</span>
),
}), }),
columnHelper.accessor((x) => getTotalExercises(x), { columnHelper.accessor((x) => getTotalExercises(x), {
header: "Exercises", header: "Exercises",
@@ -156,9 +212,9 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
header: "Timer", header: "Timer",
cell: (info) => <>{info.getValue()} minute(s)</>, cell: (info) => <>{info.getValue()} minute(s)</>,
}), }),
columnHelper.accessor("private", { columnHelper.accessor("access", {
header: "Private", header: "Access",
cell: (info) => <span className="w-full flex items-center justify-center">{!info.getValue() ? <BsX /> : <BsCheck />}</span>, cell: (info) => <span>{capitalize(info.getValue())}</span>,
}), }),
columnHelper.accessor("createdAt", { columnHelper.accessor("createdAt", {
header: "Created At", header: "Created At",
@@ -173,7 +229,10 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}), }),
columnHelper.accessor("createdBy", { columnHelper.accessor("createdBy", {
header: "Created By", header: "Created By",
cell: (info) => (!info.getValue() ? "System" : findBy(users, "id", info.getValue())?.name || "N/A"), cell: (info) =>
!info.getValue()
? "System"
: findBy(users, "id", info.getValue())?.name || "N/A",
}), }),
{ {
header: "", header: "",
@@ -181,16 +240,19 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
cell: ({ row }: { row: { original: Exam } }) => { cell: ({ row }: { row: { original: Exam } }) => {
return ( return (
<div className="flex gap-4"> <div className="flex gap-4">
{(row.original.owners?.includes(user.id) || checkAccess(user, ["admin", "developer"])) && ( {(row.original.owners?.includes(user.id) ||
checkAccess(user, ["admin", "developer"])) && (
<> <>
{checkAccess(user, [
"admin",
"developer",
"mastercorporate",
]) && (
<button <button
data-tip={row.original.private ? "Set as public" : "Set as private"} data-tip="Edit exam"
onClick={async () => await privatizeExam(row.original)} onClick={() => setSelectedExam(row.original)}
className="cursor-pointer tooltip"> className="cursor-pointer tooltip"
{row.original.private ? <BsCircle /> : <BsBan />} >
</button>
{checkAccess(user, ["admin", "developer", "mastercorporate"]) && (
<button data-tip="Edit exam" onClick={() => setSelectedExam(row.original)} className="cursor-pointer tooltip">
<BsPencil /> <BsPencil />
</button> </button>
)} )}
@@ -199,11 +261,18 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
<button <button
data-tip="Load exam" data-tip="Load exam"
className="cursor-pointer tooltip" className="cursor-pointer tooltip"
onClick={async () => await loadExam(row.original.module, row.original.id)}> onClick={async () =>
await loadExam(row.original.module, row.original.id)
}
>
<BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsUpload className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</button> </button>
{PERMISSIONS.examManagement.delete.includes(user.type) && ( {PERMISSIONS.examManagement.delete.includes(user.type) && (
<div data-tip="Delete" className="cursor-pointer tooltip" onClick={() => deleteExam(row.original)}> <div
data-tip="Delete"
className="cursor-pointer tooltip"
onClick={() => deleteExam(row.original)}
>
<BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" /> <BsTrash className="hover:text-mti-purple-light transition ease-in-out duration-300" />
</div> </div>
)} )}
@@ -220,34 +289,53 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
}); });
const handleExamEdit = () => { const handleExamEdit = () => {
router.push(`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`); router.push(
`/generation?id=${selectedExam!.id}&module=${selectedExam!.module}`
);
}; };
return ( return (
<div className="flex flex-col gap-4 w-full h-full"> <div className="flex flex-col gap-4 w-full h-full">
{renderSearch()} {renderSearch()}
<Modal isOpen={!!selectedExam} onClose={() => setSelectedExam(undefined)} maxWidth="max-w-xl"> <Modal
isOpen={!!selectedExam}
onClose={() => setSelectedExam(undefined)}
maxWidth="max-w-xl"
>
{!!selectedExam ? ( {!!selectedExam ? (
<> <>
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<BiEdit className="w-5 h-5 text-gray-600" /> <BiEdit className="w-5 h-5 text-gray-600" />
<span className="text-gray-600 font-medium">Ready to Edit</span> <span className="text-gray-600 font-medium">
Ready to Edit
</span>
</div> </div>
<div className="bg-gray-50 rounded-lg p-4 mb-3"> <div className="bg-gray-50 rounded-lg p-4 mb-3">
<p className="font-medium mb-1">Exam ID: {selectedExam.id}</p> <p className="font-medium mb-1">Exam ID: {selectedExam.id}</p>
</div> </div>
<p className="text-gray-500 text-sm">Click &apos;Next&apos; to proceed to the exam editor.</p> <p className="text-gray-500 text-sm">
Click &apos;Next&apos; to proceed to the exam editor.
</p>
</div> </div>
<div className="flex justify-between gap-4 mt-8"> <div className="flex justify-between gap-4 mt-8">
<Button color="purple" variant="outline" onClick={() => setSelectedExam(undefined)} className="w-32"> <Button
color="purple"
variant="outline"
onClick={() => setSelectedExam(undefined)}
className="w-32"
>
Cancel Cancel
</Button> </Button>
<Button color="purple" onClick={handleExamEdit} className="w-32 text-white flex items-center justify-center gap-2"> <Button
color="purple"
onClick={handleExamEdit}
className="w-32 text-white flex items-center justify-center gap-2"
>
Proceed Proceed
</Button> </Button>
</div> </div>
@@ -264,7 +352,12 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
<tr key={headerGroup.id}> <tr key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<th className="p-4 text-left" key={header.id}> <th className="p-4 text-left" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} {header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th> </th>
))} ))}
</tr> </tr>
@@ -272,7 +365,10 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
</thead> </thead>
<tbody className="px-2"> <tbody className="px-2">
{table.getRowModel().rows.map((row) => ( {table.getRowModel().rows.map((row) => (
<tr className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2" key={row.id}> <tr
className="odd:bg-white even:bg-mti-purple-ultralight/40 rounded-lg py-2"
key={row.id}
>
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td className="px-4 py-2" key={cell.id}> <td className="px-4 py-2" key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -282,6 +378,17 @@ export default function ExamList({user, entities}: {user: User; entities: Entity
))} ))}
</tbody> </tbody>
</table> </table>
{isLoading ? (
<div className="min-h-screen flex justify-center items-start">
<span className="loading loading-infinity w-32" />
</div>
) : (
filteredRows.length === 0 && (
<div className="w-full flex justify-center items-start">
<span className="text-xl text-gray-500">No data found...</span>
</div>
)
)}
</div> </div>
); );
} }

View File

@@ -22,8 +22,6 @@ import useFilterStore from "@/stores/listFilterStore";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
import { exportListToExcel } from "@/utils/users"; import { exportListToExcel } from "@/utils/users";
import usePermissions from "@/hooks/usePermissions";
import useUserBalance from "@/hooks/useUserBalance";
import useEntitiesUsers from "@/hooks/useEntitiesUsers"; import useEntitiesUsers from "@/hooks/useEntitiesUsers";
import { WithLabeledEntities } from "@/interfaces/entity"; import { WithLabeledEntities } from "@/interfaces/entity";
import Table from "@/components/High/Table"; import Table from "@/components/High/Table";
@@ -494,21 +492,19 @@ export default function UserList({
}, },
]; ];
const downloadExcel = (rows: WithLabeledEntities<User>[]) => { const downloadExcel = async (rows: WithLabeledEntities<User>[]) => {
if (entitiesDownloadUsers.length === 0) if (entitiesDownloadUsers.length === 0)
return toast.error("You are not allowed to download the user list."); return toast.error("You are not allowed to download the user list.");
const allowedRows = rows.filter((r) => const allowedRows = rows;
mapBy(r.entities, "id").some((e) => const csv = await exportListToExcel(allowedRows);
mapBy(entitiesDownloadUsers, "id").includes(e)
)
);
const csv = exportListToExcel(allowedRows);
const element = document.createElement("a"); const element = document.createElement("a");
const file = new Blob([csv], { type: "text/csv" }); const file = new Blob([csv], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
element.href = URL.createObjectURL(file); element.href = URL.createObjectURL(file);
element.download = "users.csv"; element.download = "users.xlsx";
document.body.appendChild(element); document.body.appendChild(element);
element.click(); element.click();
document.body.removeChild(element); document.body.removeChild(element);

View File

@@ -4,7 +4,6 @@ import clsx from "clsx";
import CodeList from "./CodeList"; import CodeList from "./CodeList";
import DiscountList from "./DiscountList"; import DiscountList from "./DiscountList";
import ExamList from "./ExamList"; import ExamList from "./ExamList";
import GroupList from "./GroupList";
import PackageList from "./PackageList"; import PackageList from "./PackageList";
import UserList from "./UserList"; import UserList from "./UserList";
import { checkAccess } from "@/utils/permissions"; import { checkAccess } from "@/utils/permissions";

View File

@@ -19,5 +19,9 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
return res.status(403).json({ ok: false }); return res.status(403).json({ ok: false });
} }
return res.status(200).json(await getApprovalWorkflows("active-workflows")); const entityIdsString = req.query.entityIds as string;
const entityIdsArray = entityIdsString.split(",");
return res.status(200).json(await getApprovalWorkflows("active-workflows", entityIdsArray));
} }

View File

@@ -3,16 +3,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { Code, Group, Type } from "@/interfaces/user"; import { Code, } from "@/interfaces/user";
import { PERMISSIONS } from "@/constants/userPermissions";
import { prepareMailer, prepareMailOptions } from "@/email";
import { isAdmin } from "@/utils/users";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { doesEntityAllow } from "@/utils/permissions";
import { getEntity, getEntityWithRoles } from "@/utils/entities.be";
import { findBy } from "@/utils";
import { EntityWithRoles } from "@/interfaces/entity";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export default withIronSessionApiRoute(handler, sessionOptions); export default withIronSessionApiRoute(handler, sessionOptions);
@@ -30,7 +22,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
const { entities } = req.query as { entities?: string[] }; const { entities } = req.query as { entities?: string[] };
if (entities) if (entities)
return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: entities } }).toArray()); return res.status(200).json(await db.collection("codes").find<Code>({ entity: { $in: Array.isArray(entities) ? entities : [entities] } }).toArray());
return res.status(200).json(await db.collection("codes").find<Code>({}).toArray()); return res.status(200).json(await db.collection("codes").find<Code>({}).toArray());
} }

View File

@@ -32,6 +32,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
} }
const { entity } = req.query as { entity?: string }; const { entity } = req.query as { entity?: string };
const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray(); const snapshot = await db.collection("codes").find(entity ? { entity } : {}).toArray();
res.status(200).json(snapshot); res.status(200).json(snapshot);

View File

@@ -1,7 +1,7 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction // Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { createApprovalWorkflowsOnExamCreation } from "@/lib/createWorkflowsOnExamCreation"; import { createApprovalWorkflowOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { mapBy } from "@/utils"; import { mapBy } from "@/utils";
@@ -10,6 +10,7 @@ import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/a
import { generateExamDifferences } from "@/utils/exam.differences"; import { generateExamDifferences } from "@/utils/exam.differences";
import { getExams } from "@/utils/exams.be"; import { getExams } from "@/utils/exams.be";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { access } from "fs";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
@@ -48,10 +49,11 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
const { module } = req.query as { module: string }; const { module } = req.query as { module: string };
const session = client.startSession(); const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id"); // might need to change this with new approval workflows logic.. if an admin creates an exam no workflow is started because workflows must have entities configured. const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
try { try {
const exam = { const exam = {
access: "public", // default access is public
...req.body, ...req.body,
module: module, module: module,
entities, entities,
@@ -76,6 +78,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
throw new Error("Name already exists"); throw new Error("Name already exists");
} }
if (exam.requiresApproval === true) {
exam.access = "confidential";
}
await db.collection(module).updateOne( await db.collection(module).updateOne(
{ id: req.body.id }, { id: req.body.id },
{ $set: { id: req.body.id, ...exam } }, { $set: { id: req.body.id, ...exam } },
@@ -88,36 +94,44 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response. // 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; responseStatus = 200;
responseMessage = `Successfully updated exam with ID: "${exam.id}"`; responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
// TODO maybe find a way to start missing approval workflows in case they were only configured after exam creation.
// create workflow only if exam is being created for the first time // create workflow only if exam is being created for the first time
if (docSnap === null) { if (docSnap === null) {
try { try {
const { successCount, totalCount } = await createApprovalWorkflowsOnExamCreation(exam.createdBy, exam.entities, exam.id, module); 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) { if (successCount === totalCount) {
responseStatus = 200; responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s)`; responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow.`;
} else if (successCount > 0) { } else if (successCount > 0) {
responseStatus = 207; 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`; 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 { } else {
responseStatus = 207; responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to find any configured Approval Workflow for the author.`; 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) { } catch (error) {
console.error("Workflow creation error:", error); console.error("Workflow creation error:", error);
responseStatus = 207; responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`; 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 } else {
// if exam was updated, log the updates
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id); const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
if (approvalWorkflows) { if (approvalWorkflows) {
const differences = generateExamDifferences(docSnap as Exam, exam as Exam); const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
if (differences) { if (differences) {
approvalWorkflows.forEach((workflow) => { approvalWorkflows.forEach((workflow) => {
const currentStepIndex = workflow.steps.findIndex(step => !step.completed || step.rejected); const currentStepIndex = workflow.steps.findIndex((step) => !step.completed || step.rejected);
if (workflow.steps[currentStepIndex].examChanges === undefined) { if (workflow.steps[currentStepIndex].examChanges === undefined) {
workflow.steps[currentStepIndex].examChanges = [...differences]; workflow.steps[currentStepIndex].examChanges = [...differences];

View File

@@ -3,9 +3,11 @@ import type {NextApiRequest, NextApiResponse} from "next";
import client from "@/lib/mongodb"; import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next"; import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import {flatten} from "lodash"; import { flatten, map } from "lodash";
import {Exam} from "@/interfaces/exam"; import { AccessType, Exam } from "@/interfaces/exam";
import { MODULE_ARRAY } from "@/utils/moduleUtils"; import { MODULE_ARRAY } from "@/utils/moduleUtils";
import { requestUser } from "../../../utils/api";
import { mapBy } from "../../../utils";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
@@ -22,9 +24,29 @@ async function GET(req: NextApiRequest, res: NextApiResponse) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; return;
} }
const user = await requestUser(req, res)
if (!user)
return res.status(401).json({ ok: false, reason: "You must be logged in!" })
const isAdmin = ["admin", "developer"].includes(user.type)
const { entities = [] } = req.query as { access?: AccessType, entities?: string[] | string };
let entitiesToFetch = Array.isArray(entities) ? entities : entities ? [entities] : []
if (!isAdmin) {
const userEntitiesIDs = mapBy(user.entities || [], 'id')
entitiesToFetch = entities ? entitiesToFetch.filter((entity): entity is string => entity ? userEntitiesIDs.includes(entity) : false) : userEntitiesIDs
if ((entitiesToFetch.length ?? 0) === 0) {
res.status(200).json([])
return
}
}
const moduleExamsPromises = MODULE_ARRAY.map(async (module) => { const moduleExamsPromises = MODULE_ARRAY.map(async (module) => {
const snapshot = await db.collection(module).find<Exam>({ isDiagnostic: false }).toArray(); const snapshot = await db.collection(module).find<Exam>({
isDiagnostic: false, ...(isAdmin && (entitiesToFetch.length ?? 0) === 0 ? {
} : {
entity: { $in: entitiesToFetch }
})
}).toArray();
return snapshot.map((doc) => ({ return snapshot.map((doc) => ({
...doc, ...doc,

View File

@@ -58,7 +58,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res, params }
const allAssigneeIds: string[] = [ const allAssigneeIds: string[] = [
...new Set( ...new Set(
workflow.steps workflow.steps
.map(step => step.assignees) .map((step) => {
const assignees = step.assignees;
if (step.completedBy) {
assignees.push(step.completedBy);
}
return assignees;
})
.flat() .flat()
) )
]; ];
@@ -144,7 +150,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const handleApproveStep = () => { const handleApproveStep = () => {
const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length); const isLastStep = (selectedStepIndex + 1 === currentWorkflow.steps.length);
if (isLastStep) { if (isLastStep) {
if (!confirm(`Are you sure you want to approve the last step? Doing so will approve the exam.`)) return; 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;
} }
const updatedWorkflow: ApprovalWorkflow = { const updatedWorkflow: ApprovalWorkflow = {
@@ -186,7 +192,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const examId = currentWorkflow.examId; const examId = currentWorkflow.examId;
axios axios
.patch(`/api/exam/${examModule}/${examId}`, { isDiagnostic: false }) .patch(`/api/exam/${examModule}/${examId}`, { access: "private" })
.then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`)) .then(() => toast.success(`The exam was successfuly approved and this workflow has been completed.`))
.catch((reason) => { .catch((reason) => {
if (reason.response.status === 404) { if (reason.response.status === 404) {
@@ -551,7 +557,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
<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-40">
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? ( {currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => ( currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
<p key={index} className="text-sm text-gray-500 mb-2"> <p key={index} className="whitespace-pre-wrap text-sm text-gray-500 mb-2">
{change} {change}
</p> </p>
)) ))

View File

@@ -1,24 +1,22 @@
import Tip from "@/components/ApprovalWorkflows/Tip"; import Tip from "@/components/ApprovalWorkflows/Tip";
import Layout from "@/components/High/Layout";
import Button from "@/components/Low/Button"; import Button from "@/components/Low/Button";
import Input from "@/components/Low/Input"; import Input from "@/components/Low/Input";
import Select from "@/components/Low/Select"; import Select from "@/components/Low/Select";
import useApprovalWorkflows from "@/hooks/useApprovalWorkflows"; import useApprovalWorkflows from "@/hooks/useApprovalWorkflows";
import { useAllowedEntities, useAllowedEntitiesSomePermissions, useEntityPermission } from "@/hooks/useEntityPermissions";
import { Module, ModuleTypeLabels } from "@/interfaces"; import { Module, ModuleTypeLabels } from "@/interfaces";
import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow"; import { ApprovalWorkflow, ApprovalWorkflowStatus, ApprovalWorkflowStatusLabel, StepTypeLabel } from "@/interfaces/approval.workflow";
import { Entity, EntityWithRoles } from "@/interfaces/entity"; import { EntityWithRoles } from "@/interfaces/entity";
import { User } from "@/interfaces/user"; import { User } from "@/interfaces/user";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { mapBy, redirect, serialize } from "@/utils"; import { mapBy, redirect, serialize } from "@/utils";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be"; import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { getEntitiesWithRoles } from "@/utils/entities.be";
import { shouldRedirectHome } from "@/utils/navigation.disabled"; import { shouldRedirectHome } from "@/utils/navigation.disabled";
import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions"; import { doesEntityAllow, findAllowedEntities } from "@/utils/permissions";
import { isAdmin } from "@/utils/users"; import { isAdmin } from "@/utils/users";
import { getSpecificUsers } from "@/utils/users.be"; import { getSpecificUsers } from "@/utils/users.be";
import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, flexRender, getCoreRowModel, useReactTable, getPaginationRowModel } from "@tanstack/react-table";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
import { withIronSessionSsr } from "iron-session/next"; import { withIronSessionSsr } from "iron-session/next";
@@ -69,7 +67,11 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) return redirect("/"); if (shouldRedirectHome(user) || !["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) return redirect("/");
const workflows = await getApprovalWorkflows("active-workflows"); const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
const workflows = await getApprovalWorkflows("active-workflows", allowedEntities.map(entity => entity.id));
const allAssigneeIds: string[] = [ const allAssigneeIds: string[] = [
...new Set( ...new Set(
@@ -81,10 +83,6 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
) )
]; ];
const entityIDS = mapBy(user.entities, "id");
const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS);
const allowedEntities = findAllowedEntities(user, entities, "view_workflows");
return { return {
props: serialize({ props: serialize({
user, user,
@@ -103,7 +101,8 @@ interface Props {
} }
export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) { export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAssignees, userEntitiesWithLabel }: Props) {
const { workflows, reload } = useApprovalWorkflows(); const entitiesString = userEntitiesWithLabel.map(entity => entity.id).join(",");
const { workflows, reload } = useApprovalWorkflows(entitiesString);
const currentWorkflows = workflows || initialWorkflows; const currentWorkflows = workflows || initialWorkflows;
const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]); const [filteredWorkflows, setFilteredWorkflows] = useState<ApprovalWorkflow[]>([]);
@@ -191,7 +190,15 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
{info.getValue().map((module: Module, index: number) => ( {info.getValue().map((module: Module, index: number) => (
<span <span
key={index} key={index}
className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900"> /* className="inline-block rounded-full px-3 py-1 text-sm font-medium bg-indigo-100 border border-indigo-300 text-indigo-900"> */
className={clsx("inline-block rounded-full px-3 py-1 text-sm font-medium text-white",
module === "speaking" ? "bg-ielts-speaking" :
module === "reading" ? "bg-ielts-reading" :
module === "writing" ? "bg-ielts-writing" :
module === "listening" ? "bg-ielts-listening" :
module === "level" ? "bg-ielts-level" :
"bg-slate-700"
)}>
{ModuleTypeLabels[module]} {ModuleTypeLabels[module]}
</span> </span>
))} ))}
@@ -296,10 +303,20 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
}), }),
]; ];
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({ const table = useReactTable({
data: filteredWorkflows, data: filteredWorkflows,
columns: columns, columns: columns,
state: {
pagination,
},
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
}); });
return ( return (
@@ -395,6 +412,43 @@ export default function ApprovalWorkflows({ user, initialWorkflows, workflowsAss
))} ))}
</tbody> </tbody>
</table> </table>
<div className="mt-2 flex flex-row gap-2 w-full justify-end items-center">
<button
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{"<<"}
</button>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{"<"}
</button>
<span className="px-4 text-sm font-medium">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{">"}
</button>
<button
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
className="px-3 py-2 rounded-md text-sm font-semibold text-mti-purple-ultradark border border-mti-purple-light
bg-white hover:bg-mti-purple-light hover:text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{">>"}
</button>
</div>
</div> </div>
</> </>
); );

View File

@@ -33,7 +33,6 @@ import moment from "moment";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { generate } from "random-words";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import ReactDatePicker from "react-datepicker"; import ReactDatePicker from "react-datepicker";
import { import {

View File

@@ -159,6 +159,21 @@ export default function Home({ user, group, users, entity }: Props) {
prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id] prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id]
); );
const toggleAllUsersInList = () =>
setSelectedUsers((prev) =>
prev.length === rows.length
? []
: [
...prev,
...items.reduce((acc, i) => {
if (!prev.find((item) => item === i.id)) {
(acc as string[]).push(i.id);
}
return acc;
}, [] as string[]),
]
);
const removeParticipants = () => { const removeParticipants = () => {
if (selectedUsers.length === 0) return; if (selectedUsers.length === 0) return;
if (!canRemoveParticipants) return; if (!canRemoveParticipants) return;
@@ -428,6 +443,25 @@ export default function Home({ user, group, users, entity }: Props) {
{capitalize(type)} {capitalize(type)}
</button> </button>
))} ))}
<button
onClick={() => {
toggleAllUsersInList();
}}
disabled={rows.length === 0}
className={clsx(
"bg-mti-purple-ultralight text-mti-purple px-4 py-2 rounded-full hover:text-white hover:bg-mti-purple-light",
"transition duration-300 ease-in-out",
"disabled:grayscale disabled:hover:bg-mti-purple-ultralight disabled:hover:text-mti-purple disabled:cursor-not-allowed",
(isAdding ? nonParticipantUsers : group.participants)
.length === selectedUsers.length &&
"!bg-mti-purple-light !text-white"
)}
>
{"De/Select All"}
</button>
<span className="opacity-80">
{selectedUsers.length} selected
</span>
</div> </div>
</section> </section>

View File

@@ -63,6 +63,7 @@ const EXAM_MANAGEMENT: PermissionLayout[] = [
{label: "Generate Level", key: "generate_level"}, {label: "Generate Level", key: "generate_level"},
{label: "Delete Level", key: "delete_level"}, {label: "Delete Level", key: "delete_level"},
{label: "Set as Private/Public", key: "update_exam_privacy"}, {label: "Set as Private/Public", key: "update_exam_privacy"},
{label: "View Confidential Exams", key: "view_confidential_exams"},
{label: "View Statistics", key: "view_statistics"}, {label: "View Statistics", key: "view_statistics"},
]; ];

View File

@@ -10,7 +10,7 @@ import BatchCodeGenerator from "./(admin)/BatchCodeGenerator";
import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {shouldRedirectHome} from "@/utils/navigation.disabled";
import BatchCreateUser from "./(admin)/Lists/BatchCreateUser"; import BatchCreateUser from "./(admin)/Lists/BatchCreateUser";
import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import {checkAccess, getTypesOfUser} from "@/utils/permissions";
import {useEffect, useState} from "react"; import { useState} from "react";
import Modal from "@/components/Modal"; import Modal from "@/components/Modal";
import IconCard from "@/components/IconCard"; import IconCard from "@/components/IconCard";
import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs"; import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs";

View File

@@ -71,7 +71,8 @@ export type RolePermission =
| "view_workflows" | "view_workflows"
| "configure_workflows" | "configure_workflows"
| "edit_workflow" | "edit_workflow"
| "delete_workflow"; | "delete_workflow"
| "view_confidential_exams";
export const DEFAULT_PERMISSIONS: RolePermission[] = [ export const DEFAULT_PERMISSIONS: RolePermission[] = [
"view_students", "view_students",

View File

@@ -158,7 +158,7 @@ const defaultModuleSettings = (module: Module, minTimer: number, reset: boolean
examLabel: defaultExamLabel(module), examLabel: defaultExamLabel(module),
minTimer, minTimer,
difficulty: [sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!], difficulty: [sample(["A1", "A2", "B1", "B2", "C1", "C2"] as Difficulty[])!],
isPrivate: true, access: "private",
sectionLabels: sectionLabels(module), sectionLabels: sectionLabels(module),
expandedSections: [(reset && (module === "writing" || module === "speaking")) ? 0 : 1], expandedSections: [(reset && (module === "writing" || module === "speaking")) ? 0 : 1],
focusedSection: 1, focusedSection: 1,

View File

@@ -6,6 +6,7 @@ import { SECTION_ACTIONS, SectionActions, sectionReducer } from "./sectionReduce
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import { updateExamWithUserSolutions } from "@/stores/exam/utils"; import { updateExamWithUserSolutions } from "@/stores/exam/utils";
import { defaultExamUserSolutions } from "@/utils/exams"; import { defaultExamUserSolutions } from "@/utils/exams";
import { access } from "fs";
type RootActions = { type: 'FULL_RESET' } | type RootActions = { type: 'FULL_RESET' } |
{ type: 'INIT_EXAM_EDIT', payload: { exam: Exam; examModule: Module; id: string } } | { type: 'INIT_EXAM_EDIT', payload: { exam: Exam; examModule: Module; id: string } } |
@@ -121,7 +122,7 @@ export const rootReducer = (
...defaultModuleSettings(examModule, exam.minTimer), ...defaultModuleSettings(examModule, exam.minTimer),
examLabel: exam.label, examLabel: exam.label,
difficulty: exam.difficulty, difficulty: exam.difficulty,
isPrivate: exam.private, access: exam.access,
sections: examState, sections: examState,
importModule: false, importModule: false,
sectionLabels: sectionLabels:

View File

@@ -1,4 +1,4 @@
import { Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam"; import { AccessType, Difficulty, InteractiveSpeakingExercise, LevelPart, ListeningPart, ReadingPart, Script, SpeakingExercise, WritingExercise } from "@/interfaces/exam";
import { Module } from "@/interfaces"; import { Module } from "@/interfaces";
import Option from "@/interfaces/option"; import Option from "@/interfaces/option";
@@ -126,7 +126,7 @@ export interface ModuleState {
sections: SectionState[]; sections: SectionState[];
minTimer: number; minTimer: number;
difficulty: Difficulty[]; difficulty: Difficulty[];
isPrivate: boolean; access: AccessType;
sectionLabels: { id: number; label: string; }[]; sectionLabels: { id: number; label: string; }[];
expandedSections: number[]; expandedSections: number[];
focusedSection: number; focusedSection: number;

View File

@@ -4,11 +4,18 @@ import { ObjectId } from "mongodb";
const db = client.db(process.env.MONGODB_DB); const db = client.db(process.env.MONGODB_DB);
export const getApprovalWorkflows = async (collection: string, ids?: string[]) => { export const getApprovalWorkflows = async (collection: string, entityIds?: string[], ids?: string[]) => {
return await db const filters: any = {};
.collection<ApprovalWorkflow>(collection)
.find(ids ? { _id: { $in: ids.map((id) => new ObjectId(id)) } } : {}) if (ids && ids.length > 0) {
.toArray(); filters.id = { $in: ids };
}
if (entityIds && entityIds.length > 0) {
filters.entityId = { $in: entityIds };
}
return await db.collection<ApprovalWorkflow>(collection).find(filters).toArray();
}; };
export const getApprovalWorkflow = async (collection: string, id: string) => { export const getApprovalWorkflow = async (collection: string, id: string) => {
@@ -39,7 +46,7 @@ export const getApprovalWorkflowsByExamId = async (examId: string) => {
.collection<ApprovalWorkflow>("active-workflows") .collection<ApprovalWorkflow>("active-workflows")
.find({ .find({
examId, examId,
status: { $in: ["pending"] } status: { $in: ["pending"] },
}) })
.toArray(); .toArray();
}; };

View File

@@ -1,7 +1,30 @@
import { Exam } from "@/interfaces/exam"; import { Exam } from "@/interfaces/exam";
import { diff, Diff } from "deep-diff"; import { diff, Diff } from "deep-diff";
const EXCLUDED_FIELDS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private"]); const EXCLUDED_KEYS = new Set<string>(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private", "requiresApproval", "exerciseID", "questionID"]);
const PATH_LABELS: Record<string, string> = {
access: "Access Type",
parts: "Parts",
exercises: "Exercises",
userSolutions: "User Solutions",
words: "Words",
options: "Options",
prompt: "Prompt",
text: "Text",
audio: "Audio",
script: "Script",
difficulty: "Difficulty",
shuffle: "Shuffle",
solutions: "Solutions",
variant: "Variant",
prefix: "Prefix",
suffix: "Suffix",
topic: "Topic",
allowRepetition: "Allow Repetition",
maxWords: "Max Words",
minTimer: "Timer",
};
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] { export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
const differences = diff(oldExam, newExam) || []; const differences = diff(oldExam, newExam) || [];
@@ -9,34 +32,23 @@ export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[]
} }
function formatDifference(change: Diff<any, any>): string | undefined { function formatDifference(change: Diff<any, any>): string | undefined {
if (!change.path) { if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return; return;
} }
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) { const pathString = pathToHumanReadable(change.path);
return;
}
// Convert path array to something human-readable
const pathString = change.path.join(" \u2192 "); // e.g. "parts → 0 → exercises → 1 → prompt"
switch (change.kind) { switch (change.kind) {
case "N": case "N": // New property/element
// A new property/element was added return `• Added ${pathString} with value: ${formatValue(change.rhs)}\n`;
return `\u{2022} Added \`${pathString}\` with value: ${formatValue(change.rhs)}`; case "D": // Deleted property/element
return `• Removed ${pathString} which had value: ${formatValue(change.lhs)}\n`;
case "D": case "E": // Edited property/element
// A property/element was deleted return `• Changed ${pathString} from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}\n`;
return `\u{2022} Removed \`${pathString}\` which had value: ${formatValue(change.lhs)}`; case "A": // Array change
case "E":
// A property/element was edited
return `\u{2022} Changed \`${pathString}\` from ${formatValue(change.lhs)} to ${formatValue(change.rhs)}`;
case "A":
// An array change; change.item describes what happened at array index change.index
return formatArrayChange(change); return formatArrayChange(change);
default: default:
return; return;
} }
@@ -44,12 +56,12 @@ function formatDifference(change: Diff<any, any>): string | undefined {
function formatArrayChange(change: Diff<any, any>): string | undefined { function formatArrayChange(change: Diff<any, any>): string | undefined {
if (!change.path) return; if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
if (change.path.some((segment) => EXCLUDED_KEYS.has(segment))) {
return; return;
} }
const pathString = change.path.join(" \u2192 "); const pathString = pathToHumanReadable(change.path);
const arrayChange = (change as any).item; const arrayChange = (change as any).item;
const idx = (change as any).index; const idx = (change as any).index;
@@ -57,14 +69,13 @@ function formatArrayChange(change: Diff<any, any>): string | undefined {
switch (arrayChange.kind) { switch (arrayChange.kind) {
case "N": case "N":
return `\u{2022} Added an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.rhs)}`; return ` Added an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.rhs)}\n`;
case "D": case "D":
return `\u{2022} Removed an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.lhs)}`; return ` Removed an item at [#${idx + 1}] in ${pathString}: ${formatValue(arrayChange.lhs)}\n`;
case "E": case "E":
return `\u{2022} Edited an item at index [${idx}] in \`${pathString}\` from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}`; return ` Edited an item at [#${idx + 1}] in ${pathString} from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}\n`;
case "A": case "A":
// Nested array changes could happen theoretically; handle or ignore similarly return `• Complex array change at [#${idx + 1}] in ${pathString}: ${JSON.stringify(arrayChange)}\n`;
return `\u{2022} Complex array change at index [${idx}] in \`${pathString}\`: ${JSON.stringify(arrayChange)}`;
default: default:
return; return;
} }
@@ -73,12 +84,64 @@ function formatArrayChange(change: Diff<any, any>): string | undefined {
function formatValue(value: any): string { function formatValue(value: any): string {
if (value === null) return "null"; if (value === null) return "null";
if (value === undefined) return "undefined"; if (value === undefined) return "undefined";
if (typeof value === "object") { if (typeof value === "object") {
try { try {
return JSON.stringify(value); const sanitized = removeExcludedKeysDeep(value, EXCLUDED_KEYS);
const renamed = renameKeysDeep(sanitized, PATH_LABELS);
return JSON.stringify(renamed, null, 2);
} catch { } catch {
return String(value); return String(value);
} }
} }
return JSON.stringify(value); 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(" → ");
}

View File

@@ -64,6 +64,7 @@ export const getExams = async (
.collection(module) .collection(module)
.find<Exam>({ .find<Exam>({
isDiagnostic: false, isDiagnostic: false,
access: "public",
}) })
.toArray(); .toArray();
@@ -72,7 +73,7 @@ export const getExams = async (
...doc, ...doc,
module, module,
})) as Exam[], })) as Exam[],
).filter((x) => !x.private); )
let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId); let exams: Exam[] = await filterByEntities(shuffledPublicExams, userId);
exams = filterByVariant(exams, variant); exams = filterByVariant(exams, variant);

View File

@@ -23,7 +23,7 @@ export const sortByModuleName = (a: string, b: string) => {
}; };
export const countExercises = (exercises: Exercise[]) => { export const countExercises = (exercises: Exercise[]) => {
const lengthMap = exercises.map((e) => { const lengthMap = (exercises ?? []).map((e) => {
if (e.type === "multipleChoice") return e.questions.length; if (e.type === "multipleChoice") return e.questions.length;
if (e.type === "interactiveSpeaking") return e.prompts.length; if (e.type === "interactiveSpeaking") return e.prompts.length;
if (e.type === "fillBlanks") return e.solutions.length; if (e.type === "fillBlanks") return e.solutions.length;

View File

@@ -2,7 +2,6 @@ import { EntityWithRoles, Role } from "@/interfaces/entity";
import { PermissionType } from "@/interfaces/permissions"; import { PermissionType } from "@/interfaces/permissions";
import { User, Type, userTypes } from "@/interfaces/user"; import { User, Type, userTypes } from "@/interfaces/user";
import { RolePermission } from "@/resources/entityPermissions"; import { RolePermission } from "@/resources/entityPermissions";
import axios from "axios";
import { findBy, mapBy } from "."; import { findBy, mapBy } from ".";
import { isAdmin } from "./users"; import { isAdmin } from "./users";
@@ -76,7 +75,7 @@ export function groupAllowedEntitiesByPermissions(
export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) { export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) {
if (["admin", "developer"].includes(user?.type)) return entities if (["admin", "developer"].includes(user?.type)) return entities
const allowedEntities = entities.filter((e) => doesEntityAllow(user, e, permission)) const allowedEntities = (entities ?? []).filter((e) => doesEntityAllow(user, e, permission))
return allowedEntities return allowedEntities
} }

View File

@@ -3,6 +3,7 @@ import {User} from "@/interfaces/user";
import { USER_TYPE_LABELS } from "@/resources/user"; import { USER_TYPE_LABELS } from "@/resources/user";
import { capitalize } from "lodash"; import { capitalize } from "lodash";
import moment from "moment"; import moment from "moment";
import ExcelJS from "exceljs";
export interface UserListRow { export interface UserListRow {
name: string; name: string;
@@ -17,6 +18,22 @@ export interface UserListRow {
gender: string; gender: string;
} }
const indexToLetter = (index: number): string => {
// Base case: if the index is less than 0, return an empty string
if (index < 0) {
return '';
}
// Calculate the quotient for recursion (number of times the letter sequence repeats)
const quotient = Math.floor(index / 26);
// Calculate the remainder for the current letter
const remainder = index % 26;
// Recursively call indexToLetter for the quotient and append the current letter
return indexToLetter(quotient - 1) + String.fromCharCode(65 + remainder);
};
export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => { export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
const rows: UserListRow[] = rowUsers.map((user) => ({ const rows: UserListRow[] = rowUsers.map((user) => ({
name: user.name, name: user.name,
@@ -33,10 +50,31 @@ export const exportListToExcel = (rowUsers: WithLabeledEntities<User>[]) => {
gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A", gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A",
verified: user.isVerified?.toString() || "FALSE", verified: user.isVerified?.toString() || "FALSE",
})); }));
const header = "Name,Email,Type,Entities,Expiry Date,Country,Phone,Employment/Department,Gender,Verification"; const workbook = new ExcelJS.Workbook();
const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n"); const worksheet = workbook.addWorksheet("User Data");
const border: Partial<ExcelJS.Borders> = { top: { style: 'thin' as ExcelJS.BorderStyle }, left: { style: 'thin' as ExcelJS.BorderStyle }, bottom: { style: 'thin' as ExcelJS.BorderStyle }, right: { style: 'thin' as ExcelJS.BorderStyle } }
const header = ['Name', 'Email', 'Type', 'Entities', 'Expiry Date', 'Country', 'Phone', 'Employment/Department', 'Gender', 'Verification'].forEach((item, index) => {
const cell = worksheet.getCell(`${indexToLetter(index)}1`);
const column = worksheet.getColumn(index + 1);
column.width = item.length * 2;
cell.value = item;
cell.font = { bold: true, size: 16 };
cell.border = border;
return `${header}\n${rowsString}`; });
rows.forEach((x, index) => {
(Object.keys(x) as (keyof UserListRow)[]).forEach((key, i) => {
const cell = worksheet.getCell(`${indexToLetter(i)}${index + 2}`);
cell.value = x[key];
if (index === 0) {
const column = worksheet.getColumn(i + 1);
column.width = Math.max(column.width ?? 0, x[key].toString().length * 2);
}
cell.border = border;
});
})
return workbook.xlsx.writeBuffer();
}; };
export const getUserName = (user?: User) => { export const getUserName = (user?: User) => {