implement logging of exam edits on workflow's current step

This commit is contained in:
Joao Correia
2025-02-06 19:12:18 +00:00
parent 8711802b97
commit cf12a4ed4f
7 changed files with 201 additions and 6 deletions

View File

@@ -37,6 +37,7 @@ export interface WorkflowStep {
finalStep?: boolean,
selected?: boolean,
comments?: string,
examChanges?: string[],
onClick?: React.MouseEventHandler<HTMLDivElement>
}

View File

@@ -6,6 +6,8 @@ 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";
@@ -108,8 +110,26 @@ async function POST(req: NextApiRequest, res: NextApiResponse) {
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,
});

View File

@@ -29,7 +29,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";
@@ -89,6 +89,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);
@@ -517,11 +518,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

View File

@@ -34,6 +34,16 @@ export const getApprovalWorkflowByFormIntaker = async (entityId: string, formInt
});
};
export const getApprovalWorkflowsByExamId = async (examId: string) => {
return await db
.collection<ApprovalWorkflow>("active-workflows")
.find({
examId,
status: { $in: ["pending"] }
})
.toArray();
};
export const getFormIntakersByEntity = async (entityId: string) => {
const results = await db
.collection<ApprovalWorkflow>("configured-workflows")

View File

@@ -0,0 +1,84 @@
import { Exam } from "@/interfaces/exam";
import { diff, Diff } from "deep-diff";
const EXCLUDED_FIELDS = new Set(["_id", "id", "createdAt", "createdBy", "entities", "isDiagnostic", "private"]);
export function generateExamDifferences(oldExam: Exam, newExam: Exam): string[] {
const differences = diff(oldExam, newExam) || [];
return differences.map((change) => formatDifference(change)).filter(Boolean) as string[];
}
function formatDifference(change: Diff<any, any>): string | undefined {
if (!change.path) {
return;
}
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
return;
}
// Convert path array to something human-readable
const pathString = change.path.join(" \u2192 "); // e.g. "parts → 0 → exercises → 1 → prompt"
switch (change.kind) {
case "N":
// A new property/element was added
return `\u{2022} Added \`${pathString}\` with value: ${formatValue(change.rhs)}`;
case "D":
// A property/element was deleted
return `\u{2022} Removed \`${pathString}\` which had value: ${formatValue(change.lhs)}`;
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);
default:
return;
}
}
function formatArrayChange(change: Diff<any, any>): string | undefined {
if (!change.path) return;
if (change.path.some((segment) => EXCLUDED_FIELDS.has(segment))) {
return;
}
const pathString = change.path.join(" \u2192 ");
const arrayChange = (change as any).item;
const idx = (change as any).index;
if (!arrayChange) return;
switch (arrayChange.kind) {
case "N":
return `\u{2022} Added an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.rhs)}`;
case "D":
return `\u{2022} Removed an item at index [${idx}] in \`${pathString}\`: ${formatValue(arrayChange.lhs)}`;
case "E":
return `\u{2022} Edited an item at index [${idx}] in \`${pathString}\` from ${formatValue(arrayChange.lhs)} to ${formatValue(arrayChange.rhs)}`;
case "A":
// Nested array changes could happen theoretically; handle or ignore similarly
return `\u{2022} Complex array change at index [${idx}] in \`${pathString}\`: ${JSON.stringify(arrayChange)}`;
default:
return;
}
}
function formatValue(value: any): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return JSON.stringify(value);
}