diff --git a/src/hooks/useEvaluationPolling.tsx b/src/hooks/useEvaluationPolling.tsx index fe54da8f..603c2b17 100644 --- a/src/hooks/useEvaluationPolling.tsx +++ b/src/hooks/useEvaluationPolling.tsx @@ -1,95 +1,114 @@ -import { UserSolution } from '@/interfaces/exam'; -import useExamStore from '@/stores/exam'; -import { StateFlags } from '@/stores/exam/types'; -import axios from 'axios'; -import { SetStateAction, useEffect, useRef } from 'react'; +import { UserSolution } from "@/interfaces/exam"; +import useExamStore from "@/stores/exam"; +import axios from "axios"; +import { useEffect, useRef } from "react"; +import { useRouter } from "next/router"; -type UseEvaluationPolling = (props: { - pendingExercises: string[], - setPendingExercises: React.Dispatch>, -}) => void; +const useEvaluationPolling = (sessionIds: string[], mode: "exam" | "records", userId: string) => { + const { setUserSolutions, userSolutions } = useExamStore(); + const pollingTimeoutsRef = useRef>(new Map()); + const router = useRouter(); -const useEvaluationPolling: UseEvaluationPolling = ({ - pendingExercises, - setPendingExercises, -}) => { - const { - flags, sessionId, user, - userSolutions, evaluated, - setEvaluated, setFlags - } = useExamStore(); + const poll = async (sessionId: string) => { + try { + const { data: statusData } = await axios.get('/api/evaluate/status', { + params: { op: 'pending', userId, sessionId } + }); - const pollingTimeoutRef = useRef(); + if (!statusData.hasPendingEvaluation) { - useEffect(() => { - return () => { - if (pollingTimeoutRef.current) { - clearTimeout(pollingTimeoutRef.current); - } - }; - }, []); + let solutionsOrStats = userSolutions; - useEffect(() => { - if (!flags.pendingEvaluation || pendingExercises.length === 0) { - - if (pollingTimeoutRef.current) { - clearTimeout(pollingTimeoutRef.current); - } - return; + if (mode === "records") { + const res = await axios.get(`/api/stats/session/${sessionId}`) + solutionsOrStats = res.data; } + const { data: completedSolutions } = await axios.post('/api/evaluate/fetchSolutions?op=session', { + sessionId, + userId, + stats: solutionsOrStats, + }); - const pollStatus = async () => { - try { - const { data } = await axios.get('/api/evaluate/status', { - params: { - sessionId, - userId: user, - exerciseIds: pendingExercises.join(',') - } - }); + await axios.post('/api/stats/disabled', { + sessionId, + userId, + solutions: completedSolutions, + }); - if (data.finishedExerciseIds.length > 0) { - const remainingExercises = pendingExercises.filter( - id => !data.finishedExerciseIds.includes(id) - ); + const timeout = pollingTimeoutsRef.current.get(sessionId); + if (timeout) clearTimeout(timeout); + pollingTimeoutsRef.current.delete(sessionId); - setPendingExercises(remainingExercises); + if (mode === "exam") { + const updatedSolutions = userSolutions.map(solution => { + const completed = completedSolutions.find( + (c: UserSolution) => c.exercise === solution.exercise + ); + return completed || solution; + }); - if (remainingExercises.length === 0) { - const evaluatedData = await axios.post('/api/evaluate/fetchSolutions', { - sessionId, - userId: user, - userSolutions - }); + setUserSolutions(updatedSolutions); + } else { + router.reload(); + } + } else { + if (pollingTimeoutsRef.current.has(sessionId)) { + clearTimeout(pollingTimeoutsRef.current.get(sessionId)); + } + pollingTimeoutsRef.current.set( + sessionId, + setTimeout(() => poll(sessionId), 5000) + ); + } + } catch (error) { + if (pollingTimeoutsRef.current.has(sessionId)) { + clearTimeout(pollingTimeoutsRef.current.get(sessionId)); + } + pollingTimeoutsRef.current.set( + sessionId, + setTimeout(() => poll(sessionId), 5000) + ); + } + }; - const newEvaluations = evaluatedData.data.filter( - (newEval: UserSolution) => - !evaluated.some(existingEval => existingEval.exercise === newEval.exercise) - ); + useEffect(() => { + if (mode === "exam") { + const hasDisabledSolutions = userSolutions.some(s => s.isDisabled); - setEvaluated([...evaluated, ...newEvaluations]); - setFlags({ pendingEvaluation: false }); - return; - } - } + if (hasDisabledSolutions && sessionIds.length > 0) { + poll(sessionIds[0]); + } else { + pollingTimeoutsRef.current.forEach((timeout) => { + clearTimeout(timeout); + }); + pollingTimeoutsRef.current.clear(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, sessionIds, userSolutions]); - if (pendingExercises.length > 0) { - pollingTimeoutRef.current = setTimeout(pollStatus, 5000); - } - } catch (error) { - console.error('Evaluation polling error:', error); - pollingTimeoutRef.current = setTimeout(pollStatus, 5000); - } - }; + useEffect(() => { + if (mode === "records" && sessionIds.length > 0) { + sessionIds.forEach(sessionId => { + poll(sessionId); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode, sessionIds]); - pollStatus(); + useEffect(() => { + const timeouts = pollingTimeoutsRef.current; + return () => { + timeouts.forEach((timeout) => { + clearTimeout(timeout); + }); + timeouts.clear(); + }; + }, []); - return () => { - if (pollingTimeoutRef.current) { - clearTimeout(pollingTimeoutRef.current); - } - }; - }); + return { + isPolling: pollingTimeoutsRef.current.size > 0 + }; }; export default useEvaluationPolling; diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index faad8317..bfb491b5 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -43,7 +43,7 @@ export default function ExamPage({ const [variant, setVariant] = useState("full"); const [avoidRepeated, setAvoidRepeated] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false); - const [pendingExercises, setPendingExercises] = useState([]); + const [moduleLock, setModuleLock] = useState(false); const { exam, @@ -74,7 +74,6 @@ export default function ExamPage({ saveSession, setFlags, setShuffles, - evaluated, } = useExamStore(); const [isFetchingExams, setIsFetchingExams] = useState(false); @@ -139,96 +138,107 @@ export default function ExamPage({ setShowAbandonPopup(false); }; + useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id); + useEffect(() => { - if (flags.finalizeModule && !showSolutions && flags.pendingEvaluation) { + setModuleLock(true); + }, [flags.finalizeModule]); + + useEffect(() => { + if (flags.finalizeModule && !showSolutions) { if ( exam && (exam.module === "writing" || exam.module === "speaking") && - userSolutions.length > 0 && - !showSolutions + userSolutions.length > 0 ) { - const exercisesToEvaluate = exam.exercises.map( - (exercise) => exercise.id - ); - - setPendingExercises(exercisesToEvaluate); (async () => { - await Promise.all( - exam.exercises.map(async (exercise, index) => { - if (exercise.type === "writing") - await evaluateWritingAnswer( - user.id, - sessionId, - exercise, - index + 1, - userSolutions.find((x) => x.exercise === exercise.id)!, - exercise.attachment?.url - ); - - if ( - exercise.type === "interactiveSpeaking" || - exercise.type === "speaking" - ) { - await evaluateSpeakingAnswer( - user.id, - sessionId, - exercise, - userSolutions.find((x) => x.exercise === exercise.id)!, - index + 1 - ); - } - }) - ); + try { + const results = await Promise.all( + exam.exercises.map(async (exercise, index) => { + if (exercise.type === "writing") { + const sol = await evaluateWritingAnswer( + user.id, + sessionId, + exercise, + index + 1, + userSolutions.find((x) => x.exercise === exercise.id)!, + exercise.attachment?.url + ); + return sol; + } + if ( + exercise.type === "interactiveSpeaking" || + exercise.type === "speaking" + ) { + const sol = await evaluateSpeakingAnswer( + user.id, + sessionId, + exercise, + userSolutions.find((x) => x.exercise === exercise.id)!, + index + 1 + ); + return sol; + } + return null; + }) + ); + const updatedSolutions = userSolutions.map((solution) => { + const completed = results + .filter((r) => r !== null) + .find((c: any) => c.exercise === solution.exercise); + return completed || solution; + }); + setUserSolutions(updatedSolutions); + } catch (error) { + console.error("Error during module evaluation:", error); + } finally { + setModuleLock(false); + } })(); + } else { + setModuleLock(false); } } - }, [exam, showSolutions, userSolutions, sessionId, user?.id, flags]); - - useEvaluationPolling({ pendingExercises, setPendingExercises }); + }, [ + exam, + showSolutions, + userSolutions, + sessionId, + user.id, + flags.finalizeModule, + setUserSolutions, + ]); useEffect(() => { - if (flags.finalizeExam && moduleIndex !== -1) { - setModuleIndex(-1); + if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) { + (async () => { + setModuleIndex(-1); + await saveStats(); + await axios.get("/api/stats/update"); + })(); } - }, [flags.finalizeExam, moduleIndex, setModuleIndex]); + }, [ + flags.finalizeExam, + moduleIndex, + saveStats, + setModuleIndex, + userSolutions, + moduleLock, + flags.finalizeModule, + ]); useEffect(() => { if ( flags.finalizeExam && - !flags.pendingEvaluation && - pendingExercises.length === 0 + !userSolutions.some((s) => s.isDisabled) && + !moduleLock ) { - (async () => { - if (evaluated.length !== 0) { - setUserSolutions( - userSolutions.map((solution) => { - const evaluatedSolution = evaluated.find( - (e) => e.exercise === solution.exercise - ); - if (evaluatedSolution) { - return { ...solution, ...evaluatedSolution }; - } - return solution; - }) - ); - } - await saveStats(); - await axios.get("/api/stats/update"); - setShowSolutions(true); - setFlags({ finalizeExam: false }); - dispatch({ type: "UPDATE_EXAMS" }); - })(); + setShowSolutions(true); + setFlags({ finalizeExam: false }); + dispatch({ type: "UPDATE_EXAMS" }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - saveStats, - setFlags, - setModuleIndex, - evaluated, - pendingExercises, - setUserSolutions, - flags, - ]); + }, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]); const aggregateScoresByModule = ( isPractice?: boolean @@ -269,7 +279,7 @@ export default function ExamPage({ }; userSolutions.forEach((x) => { - if ((isPractice && x.isPractice) || (!isPractice && !x.isPractice)) { + if (x.isPractice === isPractice) { const examModule = x.module || (x.type === "writing" @@ -286,12 +296,13 @@ export default function ExamPage({ } }); - return Object.keys(scores).reduce((acc, x) => { - if (scores[x as Module].total > 0) { - acc.push({ module: x as Module, ...scores[x as Module] }); - } - return acc; - }, [] as any[]); + return Object.keys(scores).reduce< + { module: Module; total: number; missing: number; correct: number }[] + >((accm, x) => { + if (scores[x as Module].total > 0) + accm.push({ module: x as Module, ...scores[x as Module] }); + return accm; + }, []); }; const ModuleExamMap: Record>> = { @@ -318,8 +329,7 @@ export default function ExamPage({ useEffect(() => { setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true)); - }, [ - ]); + }, []); useEffect(() => { setBgColor(bgColor); @@ -339,111 +349,112 @@ export default function ExamPage({ setHideSidebar, showSolutions, ]); + return ( <> {user && ( - <> - {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} - {selectedModules.length === 0 && ( - { - setModuleIndex(0); - setAvoidRepeated(avoid); - setSelectedModules(modules); - setVariant(variant); - }} - /> - )} - {isFetchingExams && ( -
- - - Loading Exam ... - -
- )} - {moduleIndex === -1 && selectedModules.length !== 0 && ( - { - if (exams[0].module === "level") { - const levelExam = exams[0] as LevelExam; - const allExercises = levelExam.parts.flatMap( - (part) => part.exercises - ); - const exerciseOrderMap = new Map( - allExercises.map((ex, index) => [ex.id, index]) - ); - const orderedSolutions = userSolutions - .slice() - .sort((a, b) => { - const indexA = - exerciseOrderMap.get(a.exercise) ?? Infinity; - const indexB = - exerciseOrderMap.get(b.exercise) ?? Infinity; - return indexA - indexB; - }); - setUserSolutions(orderedSolutions); - } else { - setUserSolutions(userSolutions); - } - setShuffles([]); - if (index === undefined) { - setFlags({ reviewAll: true }); + <> + {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} + {selectedModules.length === 0 && ( + { setModuleIndex(0); - setExam(exams[0]); - } else { - setModuleIndex(index); - setExam(exams[index]); - } - setShowSolutions(true); - setQuestionIndex(0); - setExerciseIndex(0); - setPartIndex(0); - }} - scores={aggregateScoresByModule()} - practiceScores={aggregateScoresByModule(true)} - /> - )} - {/* Exam is on going, display it and the abandon modal */} - {isExamLoaded && moduleIndex !== -1 && ( - <> - {exam && CurrentExam && ( - - )} - {!showSolutions && ( - setShowAbandonPopup(false)} + setAvoidRepeated(avoid); + setSelectedModules(modules); + setVariant(variant); + }} + /> + )} + {isFetchingExams && ( +
+ - )} - - )} - + + Loading Exam ... + +
+ )} + {moduleIndex === -1 && selectedModules.length !== 0 && ( + s.isDisabled)} + user={user!} + modules={selectedModules} + solutions={userSolutions} + assignment={assignment} + information={{ + timeSpent, + inactivity, + }} + destination={destination} + onViewResults={(index?: number) => { + if (exams[0].module === "level") { + const levelExam = exams[0] as LevelExam; + const allExercises = levelExam.parts.flatMap( + (part) => part.exercises + ); + const exerciseOrderMap = new Map( + allExercises.map((ex, index) => [ex.id, index]) + ); + const orderedSolutions = userSolutions + .slice() + .sort((a, b) => { + const indexA = + exerciseOrderMap.get(a.exercise) ?? Infinity; + const indexB = + exerciseOrderMap.get(b.exercise) ?? Infinity; + return indexA - indexB; + }); + setUserSolutions(orderedSolutions); + } else { + setUserSolutions(userSolutions); + } + setShuffles([]); + if (index === undefined) { + setFlags({ reviewAll: true }); + setModuleIndex(0); + setExam(exams[0]); + } else { + setModuleIndex(index); + setExam(exams[index]); + } + setShowSolutions(true); + setQuestionIndex(0); + setExerciseIndex(0); + setPartIndex(0); + }} + scores={aggregateScoresByModule()} + practiceScores={aggregateScoresByModule(true)} + /> + )} + {/* Exam is on going, display it and the abandon modal */} + {isExamLoaded && moduleIndex !== -1 && ( + <> + {exam && CurrentExam && ( + + )} + {!showSolutions && ( + setShowAbandonPopup(false)} + /> + )} + + )} + )} ); diff --git a/src/pages/api/evaluate/fetchSolutions.ts b/src/pages/api/evaluate/fetchSolutions.ts index 05045bca..18d9a4c5 100644 --- a/src/pages/api/evaluate/fetchSolutions.ts +++ b/src/pages/api/evaluate/fetchSolutions.ts @@ -4,21 +4,71 @@ import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import { UserSolution } from "@/interfaces/exam"; import { speakingReverseMarking, writingReverseMarking } from "@/utils/score"; +import { Stat } from "@/interfaces/user"; const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); -} - -async function post(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { res.status(401).json({ ok: false }); return; } - const { sessionId, userId, userSolutions } = req.body; + try { + return await getSessionEvals(req, res); + } catch (error) { + console.error(error); + res.status(500).json({ ok: false }); + } +} + +function formatSolutionWithEval(userSolution: UserSolution | Stat, evaluation: any) { + if (userSolution.type === 'writing') { + return { + ...userSolution, + solutions: [{ + ...userSolution.solutions[0], + evaluation: evaluation.result + }], + score: { + correct: writingReverseMarking[evaluation.result.overall], + total: 100, + missing: 0 + }, + isDisabled: false + }; + } + + if (userSolution.type === 'speaking' || userSolution.type === 'interactiveSpeaking') { + return { + ...userSolution, + solutions: [{ + ...userSolution.solutions[0], + ...( + userSolution.type === 'speaking' + ? { fullPath: evaluation.result.fullPath } + : { answer: evaluation.result.answer } + ), + evaluation: evaluation.result + }], + score: { + correct: speakingReverseMarking[evaluation.result.overall || 0] || 0, + total: 100, + missing: 0 + }, + isDisabled: false + }; + } + + return { + solution: userSolution, + evaluation + }; +} + +async function getSessionEvals(req: NextApiRequest, res: NextApiResponse) { + const { sessionId, userId, stats } = req.body; const completedEvals = await db.collection("evaluation").find({ session_id: sessionId, user: userId, @@ -29,52 +79,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) { completedEvals.map(e => [e.exercise_id, e]) ); - const solutionsWithEvals = userSolutions.filter((solution: UserSolution) => - evalsByExercise.has(solution.exercise) - ).map((solution: any) => { - const evaluation = evalsByExercise.get(solution.exercise)!; + const statsWithEvals = stats + .filter((solution: UserSolution | Stat) => evalsByExercise.has(solution.exercise)) + .map((solution: UserSolution | Stat) => + formatSolutionWithEval(solution, evalsByExercise.get(solution.exercise)!) + ); - if (solution.type === 'writing') { - return { - ...solution, - solutions: [{ - ...solution.solutions[0], - evaluation: evaluation.result - }], - score: { - correct: writingReverseMarking[evaluation.result.overall], - total: 100, - missing: 0 - }, - isDisabled: false - }; - } - - if (solution.type === 'speaking' || solution.type === 'interactiveSpeaking') { - return { - ...solution, - solutions: [{ - ...solution.solutions[0], - ...( - solution.type === 'speaking' - ? { fullPath: evaluation.result.fullPath } - : { answer: evaluation.result.answer } - ), - evaluation: evaluation.result - }], - score: { - correct: speakingReverseMarking[evaluation.result.overall || 0] || 0, - total: 100, - missing: 0 - }, - isDisabled: false - }; - } - return { - solution, - evaluation - }; - }); - - res.status(200).json(solutionsWithEvals) + res.status(200).json(statsWithEvals); } diff --git a/src/pages/api/evaluate/status.ts b/src/pages/api/evaluate/status.ts index 9679a545..dbc089bf 100644 --- a/src/pages/api/evaluate/status.ts +++ b/src/pages/api/evaluate/status.ts @@ -11,19 +11,100 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method === "GET") return get(req, res); } +type Query = { + op: string; + sessionId: string; + userId: string; +} + async function get(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ ok: false }); - return; + return res.status(401).json({ ok: false }); } - const { sessionId, userId } = req.query; + const { sessionId, userId, op } = req.query as Query; + switch (op) { + case 'pending': + return getPendingEvaluation(userId, sessionId, res); + case 'disabled': + return getSessionsWIthDisabledWithPending(userId, res); + default: + return res.status(400).json({ + ok: false, + }); + } +} + +async function getPendingEvaluation( + userId: string, + sessionId: string, + res: NextApiResponse +) { const singleEval = await db.collection("evaluation").findOne({ session_id: sessionId, user: userId, status: "pending", }); - - res.status(200).json({ hasPendingEvaluation: singleEval !== null}); + return res.status(200).json({ hasPendingEvaluation: singleEval !== null }); +} + +async function getSessionsWIthDisabledWithPending( + userId: string, + res: NextApiResponse +) { + const sessions = await db.collection("stats") + .aggregate([ + { + $match: { + user: userId, + disabled: true + } + }, + { + $project: { + _id: 0, + session: 1 + } + }, + { + $lookup: { + from: "evaluation", + let: { sessionId: "$session" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$session", "$$sessionId"] }, + { $eq: ["$user", userId] }, + { $eq: ["$status", "pending"] } + ] + } + } + }, + { + $project: { + _id: 1 + } + } + ], + as: "pendingEvals" + } + }, + { + $match: { + "pendingEvals.0": { $exists: true } + } + }, + { + $group: { + id: "$session" + } + } + ]).toArray(); + + return res.status(200).json({ + sessions: sessions.map(s => s.id) + }); } diff --git a/src/pages/api/stats/updateDisabled.ts b/src/pages/api/stats/disabled.ts similarity index 74% rename from src/pages/api/stats/updateDisabled.ts rename to src/pages/api/stats/disabled.ts index ba7e688c..3fa6b756 100644 --- a/src/pages/api/stats/updateDisabled.ts +++ b/src/pages/api/stats/disabled.ts @@ -3,37 +3,41 @@ import type { NextApiRequest, NextApiResponse } from "next"; import client from "@/lib/mongodb"; import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; -import { Stat } from "@/interfaces/user"; import { requestUser } from "@/utils/api"; import { UserSolution } from "@/interfaces/exam"; +import { WithId } from "mongodb"; const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); -} - -interface Body { - solutions: UserSolution[]; - sessionID: string; -} - -async function post(req: NextApiRequest, res: NextApiResponse) { const user = await requestUser(req, res) if (!user) return res.status(401).json({ ok: false }); - const { solutions, sessionID } = req.body as Body; + if (req.method === "POST") return post(req, res); +} - const disabledStats = await db.collection("stats").find({ user: user.id, session: sessionID, disabled: true }).toArray(); + +interface Body { + solutions: UserSolution[]; + sessionId: string; + userId: string; +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const { userId, solutions, sessionId } = req.body as Body; + const disabledStats = await db.collection("stats").find( + { user: userId, session: sessionId, isDisabled: true } + ).toArray(); await Promise.all(disabledStats.map(async (stat) => { const matchingSolution = solutions.find(s => s.exercise === stat.exercise); if (matchingSolution) { + const { _id, ...updateFields } = matchingSolution as WithId; await db.collection("stats").updateOne( { id: stat.id }, - { $set: { ...matchingSolution } } + { $set: { ...updateFields } } ); } })); diff --git a/src/pages/api/stats/session/[session].ts b/src/pages/api/stats/session/[session].ts new file mode 100644 index 00000000..2d3ff16e --- /dev/null +++ b/src/pages/api/stats/session/[session].ts @@ -0,0 +1,21 @@ +// 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"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {session} = req.query; + const snapshot = await db.collection("stats").find({ user: req.session.user.id, session }).toArray(); + + res.status(200).json(snapshot); +} \ No newline at end of file diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 9adf3065..a026c362 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -29,6 +29,8 @@ import { EntityWithRoles } from "@/interfaces/entity"; import CardList from "@/components/High/CardList"; import { requestUser } from "@/utils/api"; import { useAllowedEntities } from "@/hooks/useEntityPermissions"; +import getPendingEvals from "@/utils/disabled.be"; +import useEvaluationPolling from "@/hooks/useEvaluationPolling"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) @@ -44,9 +46,10 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds)) const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds)) const gradingSystems = await getGradingSystemByEntities(entitiesIds) + const pendingSessionIds = await getPendingEvals(user.id); return { - props: serialize({ user, users, assignments, entities, gradingSystems,isAdmin }), + props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }), }; }, sessionOptions); @@ -58,12 +61,13 @@ interface Props { assignments: Assignment[]; entities: EntityWithRoles[] gradingSystems: Grading[] + pendingSessionIds: string[]; isAdmin:boolean } const MAX_TRAINING_EXAMS = 10; -export default function History({ user, users, assignments, entities, gradingSystems,isAdmin }: Props) { +export default function History({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }: Props) { const router = useRouter(); const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ state.selectedUser, @@ -85,9 +89,11 @@ export default function History({ user, users, assignments, entities, gradingSys const groupedStats = useMemo(() => groupByDate( stats.filter((x) => { if ( - (x.module === "writing" || x.module === "speaking") && - !x.isDisabled && - !x.solutions.every((y) => Object.keys(y).includes("evaluation")) + ( + x.module === "writing" || x.module === "speaking") && + !x.isDisabled && Array.isArray(x.solutions) && + !x.solutions.every((y) => Object.keys(y).includes("evaluation") + ) ) return false; return true; @@ -179,6 +185,8 @@ export default function History({ user, users, assignments, entities, gradingSys ); }; + useEvaluationPolling(pendingSessionIds ? pendingSessionIds : [], "records", user.id); + return ( <> diff --git a/src/stores/exam/index.ts b/src/stores/exam/index.ts index 24292a89..e832503c 100644 --- a/src/stores/exam/index.ts +++ b/src/stores/exam/index.ts @@ -26,7 +26,6 @@ export const initialState: ExamState = { inactivity: 0, shuffles: [], bgColor: "bg-white", - evaluated: [], user: undefined, navigation: { previousDisabled: false, @@ -39,7 +38,6 @@ export const initialState: ExamState = { reviewAll: false, finalizeModule: false, finalizeExam: false, - pendingEvaluation: false, }, }; @@ -62,8 +60,6 @@ const useExamStore = create((set, get) => ({ setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })), setBgColor: (bgColor: string) => set(() => ({ bgColor })), - setEvaluated: (evaluated: UserSolution[]) => set(() => ({ evaluated })), - setNavigation: (updates: Partial) => set((state) => ({ navigation: { ...state.navigation, @@ -166,7 +162,7 @@ export const usePersistentExamStore = create()( saveStats: async () => { }, saveSession: async () => { }, - setEvaluated: (evaluated: UserSolution[]) => {}, + setEvalSolutions: (evaluated: UserSolution[]) => {}, reset: () => set(() => initialState), dispatch: (action) => set((state) => rootReducer(state, action)) diff --git a/src/stores/exam/reducers/index.ts b/src/stores/exam/reducers/index.ts index d56212df..3eed8587 100644 --- a/src/stores/exam/reducers/index.ts +++ b/src/stores/exam/reducers/index.ts @@ -93,20 +93,11 @@ export const rootReducer = ( }; case 'FINALIZE_MODULE': { const { updateTimers } = action.payload; - const solutions = state.userSolutions; - const evaluated = state.evaluated; - - const hasUnevaluatedSolutions = solutions.some(solution => - (solution.type === 'speaking' || - solution.type === 'writing' || - solution.type === 'interactiveSpeaking') && - !evaluated.some(evaluation => evaluation.exercise === solution.exercise) - ); // To finalize a module first flag the timers to be updated if (updateTimers) { return { - flags: { ...state.flags, finalizeModule: true, pendingEvaluation: hasUnevaluatedSolutions } + flags: { ...state.flags, finalizeModule: true } } } else { // then check whether there are more modules in the exam, if there are @@ -118,7 +109,6 @@ export const rootReducer = ( ...state.flags, finalizeModule: false, finalizeExam: true, - pendingEvaluation: hasUnevaluatedSolutions, } } } else if (state.moduleIndex < state.selectedModules.length - 1) { diff --git a/src/stores/exam/types.ts b/src/stores/exam/types.ts index eadf8b0d..df9d64f2 100644 --- a/src/stores/exam/types.ts +++ b/src/stores/exam/types.ts @@ -16,7 +16,6 @@ export interface StateFlags { reviewAll: boolean; finalizeModule: boolean; finalizeExam: boolean; - pendingEvaluation: boolean; } export interface ExamState { @@ -39,8 +38,7 @@ export interface ExamState { user: undefined | string; currentSolution?: UserSolution | undefined; navigation: Navigation; - flags: StateFlags, - evaluated: UserSolution[]; + flags: StateFlags; } @@ -65,8 +63,6 @@ export interface ExamFunctions { setTimeIsUp: (timeIsUp: boolean) => void; - setEvaluated: (evaluated: UserSolution[]) => void, - saveSession: () => Promise; saveStats: () => Promise; diff --git a/src/utils/disabled.be.ts b/src/utils/disabled.be.ts new file mode 100644 index 00000000..058b53d8 --- /dev/null +++ b/src/utils/disabled.be.ts @@ -0,0 +1,27 @@ +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); + +async function getPendingEvals(userId: string): Promise { + try { + const disabledStatsSessions = await db.collection("stats") + .distinct("session", { + "isDisabled": true, + "user": userId + }); + + const sessionsWithEvals = await db.collection("evaluation") + .distinct("session_id", { + "session_id": { $in: disabledStatsSessions }, + "user": userId + }); + + + return sessionsWithEvals; + } catch (error) { + console.error('Error fetching session IDs:', error); + throw error; + } +} + +export default getPendingEvals; \ No newline at end of file diff --git a/src/utils/evaluation.ts b/src/utils/evaluation.ts index 532e33b0..a430b9e0 100644 --- a/src/utils/evaluation.ts +++ b/src/utils/evaluation.ts @@ -5,6 +5,7 @@ import { WritingExercise, } from "@/interfaces/exam"; import axios from "axios"; +import { v4 } from "uuid"; export const evaluateWritingAnswer = async ( userId: string, @@ -13,7 +14,7 @@ export const evaluateWritingAnswer = async ( task: number, solution: UserSolution, attachment?: string, -): Promise => { +): Promise => { await axios.post("/api/evaluate/writing", { question: `${exercise.prompt}`.replaceAll("\n", ""), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), @@ -23,6 +24,18 @@ export const evaluateWritingAnswer = async ( exerciseId: exercise.id, attachment, }); + + return { + ...solution, + id: v4(), + score: { + correct: 0, + missing: 0, + total: 100, + }, + solutions: [{id: exercise.id, solution: solution.solutions[0].solution}], + isDisabled: true, + }; }; export const evaluateSpeakingAnswer = async ( @@ -31,12 +44,12 @@ export const evaluateSpeakingAnswer = async ( exercise: SpeakingExercise | InteractiveSpeakingExercise, solution: UserSolution, task: number, -): Promise => { +): Promise => { switch (exercise?.type) { case "speaking": - await evaluateSpeakingExercise(userId, sessionId, exercise, solution); + return await evaluateSpeakingExercise(userId, sessionId, exercise, solution); case "interactiveSpeaking": - await evaluateInteractiveSpeakingExercise(userId, sessionId, exercise.id, solution, task); + return await evaluateInteractiveSpeakingExercise(userId, sessionId, exercise.id, solution, task); } }; @@ -50,7 +63,7 @@ const evaluateSpeakingExercise = async ( sessionId: string, exercise: SpeakingExercise, solution: UserSolution, -): Promise => { +): Promise => { const formData = new FormData(); const url = solution.solutions[0].solution.trim() as string; @@ -76,6 +89,17 @@ const evaluateSpeakingExercise = async ( }; await axios.post(`/api/evaluate/speaking`, formData, config); + return { + ...solution, + id: v4(), + score: { + correct: 0, + missing: 0, + total: 100, + }, + solutions: [{id: exercise.id, solution: null}], + isDisabled: true, + }; }; const evaluateInteractiveSpeakingExercise = async ( @@ -84,7 +108,7 @@ const evaluateInteractiveSpeakingExercise = async ( exerciseId: string, solution: UserSolution, task: number, -): Promise => { +): Promise => { const formData = new FormData(); formData.append("userId", userId); formData.append("sessionId", sessionId); @@ -111,4 +135,15 @@ const evaluateInteractiveSpeakingExercise = async ( }; await axios.post(`/api/evaluate/interactiveSpeaking`, formData, config); + return { + ...solution, + id: v4(), + score: { + correct: 0, + missing: 0, + total: 100, + }, + solutions: [{id: exerciseId, solution: null}], + isDisabled: true, + }; };