Merge branch 'develop' of bitbucket.org:ecropdev/ielts-ui into workflow-permissions

This commit is contained in:
Joao Correia
2025-02-07 13:06:59 +00:00
14 changed files with 312 additions and 227 deletions

View File

@@ -1,24 +1,14 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { Module } from "@/interfaces";
import { sessionOptions } from "@/lib/session";
import { requestUser } from "@/utils/api";
import { createApprovalWorkflow, getApprovalWorkflowByFormIntaker, getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { getApprovalWorkflows } from "@/utils/approval.workflows.be";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
export default withIronSessionApiRoute(handler, sessionOptions);
interface PostRequestBody {
examAuthor: string;
examEntities: string[];
examId: string;
examName: string;
examModule: Module;
}
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return await get(req, res);
if (req.method === "POST") return await post(req, res);
}
async function get(req: NextApiRequest, res: NextApiResponse) {
@@ -30,49 +20,4 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
}
return res.status(200).json(await getApprovalWorkflows("active-workflows"));
}
async function post(req: NextApiRequest, res: NextApiResponse) {
const user = await requestUser(req, res);
if (!user) return res.status(401).json({ ok: false });
if (!["admin", "developer", "teacher", "corporate", "mastercorporate"].includes(user.type)) {
return res.status(403).json({ ok: false });
}
const { examAuthor, examEntities, examId, examModule } = req.body as PostRequestBody;
const results = await Promise.all(
examEntities.map(async (entity) => {
const configuredWorkflow = await getApprovalWorkflowByFormIntaker(entity, examAuthor);
if (!configuredWorkflow) {
return { entity, created: false, error: "No configured workflow found for examAuthor." };
}
configuredWorkflow.modules.push(examModule);
configuredWorkflow.name = `${examId}`;
configuredWorkflow.examId = examId;
configuredWorkflow.entityId = entity;
configuredWorkflow.startDate = Date.now();
try {
const creationResponse = await createApprovalWorkflow("active-workflows", configuredWorkflow);
return { entity, created: true, creationResponse };
} catch (error) {
const err = error as Error;
return { entity, created: false, error: err.message };
}
})
);
const successCount = results.filter((r) => r.created).length;
const totalCount = examEntities.length;
if (successCount === totalCount) {
return res.status(200).json({ ok: true, results });
} else if (successCount > 0) {
return res.status(207).json({ ok: true, results });
} else {
return res.status(404).json({ ok: false, message: "No workflows were created", results });
}
}
}

View File

@@ -1,15 +1,17 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import client from "@/lib/mongodb";
import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { getExams } from "@/utils/exams.be";
import { Module } from "@/interfaces";
import { getUserCorporate } from "@/utils/groups.be";
import { requestUser } from "@/utils/api";
import { isAdmin } from "@/utils/users";
import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam";
import { createApprovalWorkflowsOnExamCreation } from "@/lib/createWorkflowsOnExamCreation";
import client from "@/lib/mongodb";
import { sessionOptions } from "@/lib/session";
import { mapBy } from "@/utils";
import { requestUser } from "@/utils/api";
import { getApprovalWorkflowsByExamId, updateApprovalWorkflows } from "@/utils/approval.workflows.be";
import { generateExamDifferences } from "@/utils/exam.differences";
import { getExams } from "@/utils/exams.be";
import { isAdmin } from "@/utils/users";
import { withIronSessionApiRoute } from "iron-session/next";
import type { NextApiRequest, NextApiResponse } from "next";
const db = client.db(process.env.MONGODB_DB);
@@ -46,7 +48,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
const { module } = req.query as { module: string };
const session = client.startSession();
const entities = isAdmin(user) ? [] : mapBy(user.entities, "id");
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.
try {
const exam = {
@@ -57,6 +59,9 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
createdAt: new Date().toISOString(),
};
let responseStatus: number;
let responseMessage: string;
await session.withTransaction(async () => {
const docSnap = await db.collection(module).findOne<ExamBase>({ id: req.body.id }, { session });
@@ -79,9 +84,56 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
session,
}
);
});
res.status(200).json(exam);
// if it doesn't enter the next if condition it means the exam was updated and not created, so we can send this response.
responseStatus = 200;
responseMessage = `Successfully updated exam with ID: "${exam.id}"`;
// 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
if (docSnap === null) {
try {
const { successCount, totalCount } = await createApprovalWorkflowsOnExamCreation(exam.createdBy, exam.entities, exam.id, module);
if (successCount === totalCount) {
responseStatus = 200;
responseMessage = `Successfully created exam "${exam.id}" and started its Approval Workflow(s)`;
} else if (successCount > 0) {
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to start/find an Approval Workflow for all the author's entities`;
} else {
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but was not able to find any configured Approval Workflow for the author.`;
}
} catch (error) {
console.error("Workflow creation error:", error);
responseStatus = 207;
responseMessage = `Successfully created exam with ID: "${exam.id}" but something went wrong while creating the Approval Workflow(s).`;
}
} else { // if exam was updated, log the updates
const approvalWorkflows = await getApprovalWorkflowsByExamId(exam.id);
if (approvalWorkflows) {
const differences = generateExamDifferences(docSnap as Exam, exam as Exam);
if (differences) {
approvalWorkflows.forEach((workflow) => {
const currentStepIndex = workflow.steps.findIndex(step => !step.completed || step.rejected);
if (workflow.steps[currentStepIndex].examChanges === undefined) {
workflow.steps[currentStepIndex].examChanges = [...differences];
} else {
workflow.steps[currentStepIndex].examChanges!.push(...differences);
}
});
await updateApprovalWorkflows("active-workflows", approvalWorkflows);
}
}
}
res.status(responseStatus).json({
message: responseMessage,
});
});
} catch (error) {
console.error("Transaction failed: ", error);
res.status(500).json({ ok: false, error: (error as any).message });

View File

@@ -31,7 +31,7 @@ import { FaSpinner, FaWpforms } from "react-icons/fa6";
import { FiSave } from "react-icons/fi";
import { IoMdCheckmarkCircleOutline } from "react-icons/io";
import { IoDocumentTextOutline } from "react-icons/io5";
import { MdOutlineDoubleArrow } from "react-icons/md";
import { MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineDoubleArrow } from "react-icons/md";
import { RiThumbUpLine } from "react-icons/ri";
import { RxCrossCircled } from "react-icons/rx";
import { TiEdit } from "react-icons/ti";
@@ -95,6 +95,7 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
const [selectedStepIndex, setSelectedStepIndex] = useState<number>(currentStepIndex);
const [selectedStep, setSelectedStep] = useState<WorkflowStep>(currentWorkflow.steps[selectedStepIndex]);
const [isPanelOpen, setIsPanelOpen] = useState(true);
const [isAccordionOpen, setIsAccordionOpen] = useState(false);
const [comments, setComments] = useState<string>(selectedStep.comments || "");
const [viewExamIsLoading, setViewExamIsLoading] = useState<boolean>(false);
const [editExamIsLoading, setEditExamIsLoading] = useState<boolean>(false);
@@ -523,11 +524,53 @@ export default function Home({ user, initialWorkflow, id, workflowAssignees, wor
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
{/* Accordion for Exam Changes */}
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer p-2 rounded-lg"
onClick={() => setIsAccordionOpen((prev) => !prev)}
>
<h2 className="font-medium text-gray-500">
Changes ({currentWorkflow.steps[selectedStepIndex].examChanges?.length || "0"})
</h2>
{isAccordionOpen ? (
<MdKeyboardArrowUp size={24} />
) : (
<MdKeyboardArrowDown size={24} />
)}
</div>
<AnimatePresence>
{isAccordionOpen && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden mt-2"
>
<div className="p-3 border border-gray-300 rounded-xl bg-white bg-opacity-80 overflow-y-auto max-h-40">
{currentWorkflow.steps[selectedStepIndex].examChanges?.length ? (
currentWorkflow.steps[selectedStepIndex].examChanges!.map((change, index) => (
<p key={index} className="text-sm text-gray-500 mb-2">
{change}
</p>
))
) : (
<p className="text-normal text-opacity-70 text-gray-500">No changes made so far.</p>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<hr className="my-4 h-[4px] bg-mti-purple-ultralight rounded-full w-full" />
<textarea
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder="Input comments here"
className="w-full h-64 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
className="w-full h-40 p-2 border-2 rounded-xl shadow-lg focus:border-mti-purple focus:outline-none mt-3 resize-none"
/>
<Button