Merge branch 'develop' of https://bitbucket.org/ecropdev/ielts-ui into ENCOA-316-ENCOA-317

This commit is contained in:
José Marques Lima
2025-01-25 20:01:52 +00:00
12 changed files with 557 additions and 360 deletions

View File

@@ -1,95 +1,114 @@
import { UserSolution } from '@/interfaces/exam'; import { UserSolution } from "@/interfaces/exam";
import useExamStore from '@/stores/exam'; import useExamStore from "@/stores/exam";
import { StateFlags } from '@/stores/exam/types'; import axios from "axios";
import axios from 'axios'; import { useEffect, useRef } from "react";
import { SetStateAction, useEffect, useRef } from 'react'; import { useRouter } from "next/router";
type UseEvaluationPolling = (props: { const useEvaluationPolling = (sessionIds: string[], mode: "exam" | "records", userId: string) => {
pendingExercises: string[], const { setUserSolutions, userSolutions } = useExamStore();
setPendingExercises: React.Dispatch<SetStateAction<string[]>>, const pollingTimeoutsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
}) => void; const router = useRouter();
const useEvaluationPolling: UseEvaluationPolling = ({ const poll = async (sessionId: string) => {
pendingExercises, try {
setPendingExercises, const { data: statusData } = await axios.get('/api/evaluate/status', {
}) => { params: { op: 'pending', userId, sessionId }
const { });
flags, sessionId, user,
userSolutions, evaluated,
setEvaluated, setFlags
} = useExamStore();
const pollingTimeoutRef = useRef<NodeJS.Timeout>(); if (!statusData.hasPendingEvaluation) {
useEffect(() => { let solutionsOrStats = userSolutions;
return () => {
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
}
};
}, []);
useEffect(() => { if (mode === "records") {
if (!flags.pendingEvaluation || pendingExercises.length === 0) { const res = await axios.get(`/api/stats/session/${sessionId}`)
solutionsOrStats = res.data;
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
}
return;
} }
const { data: completedSolutions } = await axios.post('/api/evaluate/fetchSolutions?op=session', {
sessionId,
userId,
stats: solutionsOrStats,
});
const pollStatus = async () => { await axios.post('/api/stats/disabled', {
try { sessionId,
const { data } = await axios.get('/api/evaluate/status', { userId,
params: { solutions: completedSolutions,
sessionId, });
userId: user,
exerciseIds: pendingExercises.join(',')
}
});
if (data.finishedExerciseIds.length > 0) { const timeout = pollingTimeoutsRef.current.get(sessionId);
const remainingExercises = pendingExercises.filter( if (timeout) clearTimeout(timeout);
id => !data.finishedExerciseIds.includes(id) 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) { setUserSolutions(updatedSolutions);
const evaluatedData = await axios.post('/api/evaluate/fetchSolutions', { } else {
sessionId, router.reload();
userId: user, }
userSolutions } 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( useEffect(() => {
(newEval: UserSolution) => if (mode === "exam") {
!evaluated.some(existingEval => existingEval.exercise === newEval.exercise) const hasDisabledSolutions = userSolutions.some(s => s.isDisabled);
);
setEvaluated([...evaluated, ...newEvaluations]); if (hasDisabledSolutions && sessionIds.length > 0) {
setFlags({ pendingEvaluation: false }); poll(sessionIds[0]);
return; } 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) { useEffect(() => {
pollingTimeoutRef.current = setTimeout(pollStatus, 5000); if (mode === "records" && sessionIds.length > 0) {
} sessionIds.forEach(sessionId => {
} catch (error) { poll(sessionId);
console.error('Evaluation polling error:', error); });
pollingTimeoutRef.current = setTimeout(pollStatus, 5000); }
} // 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 () => { return {
if (pollingTimeoutRef.current) { isPolling: pollingTimeoutsRef.current.size > 0
clearTimeout(pollingTimeoutRef.current); };
}
};
});
}; };
export default useEvaluationPolling; export default useEvaluationPolling;

View File

@@ -43,7 +43,7 @@ export default function ExamPage({
const [variant, setVariant] = useState<Variant>("full"); const [variant, setVariant] = useState<Variant>("full");
const [avoidRepeated, setAvoidRepeated] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false);
const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false);
const [pendingExercises, setPendingExercises] = useState<string[]>([]); const [moduleLock, setModuleLock] = useState(false);
const { const {
exam, exam,
@@ -74,7 +74,6 @@ export default function ExamPage({
saveSession, saveSession,
setFlags, setFlags,
setShuffles, setShuffles,
evaluated,
} = useExamStore(); } = useExamStore();
const [isFetchingExams, setIsFetchingExams] = useState(false); const [isFetchingExams, setIsFetchingExams] = useState(false);
@@ -139,96 +138,107 @@ export default function ExamPage({
setShowAbandonPopup(false); setShowAbandonPopup(false);
}; };
useEvaluationPolling(sessionId ? [sessionId] : [], "exam", user?.id);
useEffect(() => { useEffect(() => {
if (flags.finalizeModule && !showSolutions && flags.pendingEvaluation) { setModuleLock(true);
}, [flags.finalizeModule]);
useEffect(() => {
if (flags.finalizeModule && !showSolutions) {
if ( if (
exam && exam &&
(exam.module === "writing" || exam.module === "speaking") && (exam.module === "writing" || exam.module === "speaking") &&
userSolutions.length > 0 && userSolutions.length > 0
!showSolutions
) { ) {
const exercisesToEvaluate = exam.exercises.map(
(exercise) => exercise.id
);
setPendingExercises(exercisesToEvaluate);
(async () => { (async () => {
await Promise.all( try {
exam.exercises.map(async (exercise, index) => { const results = await Promise.all(
if (exercise.type === "writing") exam.exercises.map(async (exercise, index) => {
await evaluateWritingAnswer( if (exercise.type === "writing") {
user.id, const sol = await evaluateWritingAnswer(
sessionId, user.id,
exercise, sessionId,
index + 1, exercise,
userSolutions.find((x) => x.exercise === exercise.id)!, index + 1,
exercise.attachment?.url userSolutions.find((x) => x.exercise === exercise.id)!,
); exercise.attachment?.url
);
if ( return sol;
exercise.type === "interactiveSpeaking" || }
exercise.type === "speaking" if (
) { exercise.type === "interactiveSpeaking" ||
await evaluateSpeakingAnswer( exercise.type === "speaking"
user.id, ) {
sessionId, const sol = await evaluateSpeakingAnswer(
exercise, user.id,
userSolutions.find((x) => x.exercise === exercise.id)!, sessionId,
index + 1 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]); }, [
exam,
useEvaluationPolling({ pendingExercises, setPendingExercises }); showSolutions,
userSolutions,
sessionId,
user.id,
flags.finalizeModule,
setUserSolutions,
]);
useEffect(() => { useEffect(() => {
if (flags.finalizeExam && moduleIndex !== -1) { if (flags.finalizeExam && moduleIndex !== -1 && !moduleLock) {
setModuleIndex(-1); (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(() => { useEffect(() => {
if ( if (
flags.finalizeExam && flags.finalizeExam &&
!flags.pendingEvaluation && !userSolutions.some((s) => s.isDisabled) &&
pendingExercises.length === 0 !moduleLock
) { ) {
(async () => { setShowSolutions(true);
if (evaluated.length !== 0) { setFlags({ finalizeExam: false });
setUserSolutions( dispatch({ type: "UPDATE_EXAMS" });
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" });
})();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [flags.finalizeExam, userSolutions, showSolutions, moduleLock]);
saveStats,
setFlags,
setModuleIndex,
evaluated,
pendingExercises,
setUserSolutions,
flags,
]);
const aggregateScoresByModule = ( const aggregateScoresByModule = (
isPractice?: boolean isPractice?: boolean
@@ -269,7 +279,7 @@ export default function ExamPage({
}; };
userSolutions.forEach((x) => { userSolutions.forEach((x) => {
if ((isPractice && x.isPractice) || (!isPractice && !x.isPractice)) { if (x.isPractice === isPractice) {
const examModule = const examModule =
x.module || x.module ||
(x.type === "writing" (x.type === "writing"
@@ -286,12 +296,13 @@ export default function ExamPage({
} }
}); });
return Object.keys(scores).reduce((acc, x) => { return Object.keys(scores).reduce<
if (scores[x as Module].total > 0) { { module: Module; total: number; missing: number; correct: number }[]
acc.push({ module: x as Module, ...scores[x as Module] }); >((accm, x) => {
} if (scores[x as Module].total > 0)
return acc; accm.push({ module: x as Module, ...scores[x as Module] });
}, [] as any[]); return accm;
}, []);
}; };
const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = { const ModuleExamMap: Record<Module, React.ComponentType<ExamProps<Exam>>> = {
@@ -318,8 +329,7 @@ export default function ExamPage({
useEffect(() => { useEffect(() => {
setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true)); setOnFocusLayerMouseEnter(() => () => setShowAbandonPopup(true));
}, [ }, []);
]);
useEffect(() => { useEffect(() => {
setBgColor(bgColor); setBgColor(bgColor);
@@ -339,111 +349,112 @@ export default function ExamPage({
setHideSidebar, setHideSidebar,
showSolutions, showSolutions,
]); ]);
return ( return (
<> <>
<ToastContainer /> <ToastContainer />
{user && ( {user && (
<> <>
{/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/} {/* Modules weren't yet set by an INIT_EXAM or INIT_SOLUTIONS dispatch, show Selection component*/}
{selectedModules.length === 0 && ( {selectedModules.length === 0 && (
<Selection <Selection
page={page} page={page}
user={user!} user={user!}
onStart={( onStart={(
modules: Module[], modules: Module[],
avoid: boolean, avoid: boolean,
variant: Variant variant: Variant
) => { ) => {
setModuleIndex(0);
setAvoidRepeated(avoid);
setSelectedModules(modules);
setVariant(variant);
}}
/>
)}
{isFetchingExams && (
<div className="flex flex-grow flex-col items-center justify-center animate-pulse">
<span
className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`}
/>
<span
className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
>
Loading Exam ...
</span>
</div>
)}
{moduleIndex === -1 && selectedModules.length !== 0 && (
<Finish
isLoading={flags.pendingEvaluation}
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); setModuleIndex(0);
setExam(exams[0]); setAvoidRepeated(avoid);
} else { setSelectedModules(modules);
setModuleIndex(index); setVariant(variant);
setExam(exams[index]); }}
} />
setShowSolutions(true); )}
setQuestionIndex(0); {isFetchingExams && (
setExerciseIndex(0); <div className="flex flex-grow flex-col items-center justify-center animate-pulse">
setPartIndex(0); <span
}} className={`loading loading-infinity w-32 bg-ielts-${selectedModules[0]}`}
scores={aggregateScoresByModule()}
practiceScores={aggregateScoresByModule(true)}
/>
)}
{/* Exam is on going, display it and the abandon modal */}
{isExamLoaded && moduleIndex !== -1 && (
<>
{exam && CurrentExam && (
<CurrentExam exam={exam} showSolutions={showSolutions} />
)}
{!showSolutions && (
<AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
abandonConfirmButtonText="Confirm"
onAbandon={onAbandon}
onCancel={() => setShowAbandonPopup(false)}
/> />
)} <span
</> className={`font-bold text-2xl text-ielts-${selectedModules[0]}`}
)} >
</> Loading Exam ...
</span>
</div>
)}
{moduleIndex === -1 && selectedModules.length !== 0 && (
<Finish
isLoading={userSolutions.some((s) => 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 && (
<CurrentExam exam={exam} showSolutions={showSolutions} />
)}
{!showSolutions && (
<AbandonPopup
isOpen={showAbandonPopup}
abandonPopupTitle="Leave Exercise"
abandonPopupDescription="Are you sure you want to leave the exercise? Your progress will be saved and this exam can be resumed on the Dashboard."
abandonConfirmButtonText="Confirm"
onAbandon={onAbandon}
onCancel={() => setShowAbandonPopup(false)}
/>
)}
</>
)}
</>
)} )}
</> </>
); );

View File

@@ -4,21 +4,71 @@ import { withIronSessionApiRoute } from "iron-session/next";
import { sessionOptions } from "@/lib/session"; import { sessionOptions } from "@/lib/session";
import { UserSolution } from "@/interfaces/exam"; import { UserSolution } from "@/interfaces/exam";
import { speakingReverseMarking, writingReverseMarking } from "@/utils/score"; import { speakingReverseMarking, writingReverseMarking } from "@/utils/score";
import { Stat } from "@/interfaces/user";
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);
async function handler(req: NextApiRequest, res: NextApiResponse) { 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) { if (!req.session.user) {
res.status(401).json({ ok: false }); res.status(401).json({ ok: false });
return; 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({ const completedEvals = await db.collection("evaluation").find({
session_id: sessionId, session_id: sessionId,
user: userId, user: userId,
@@ -29,52 +79,11 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
completedEvals.map(e => [e.exercise_id, e]) completedEvals.map(e => [e.exercise_id, e])
); );
const solutionsWithEvals = userSolutions.filter((solution: UserSolution) => const statsWithEvals = stats
evalsByExercise.has(solution.exercise) .filter((solution: UserSolution | Stat) => evalsByExercise.has(solution.exercise))
).map((solution: any) => { .map((solution: UserSolution | Stat) =>
const evaluation = evalsByExercise.get(solution.exercise)!; formatSolutionWithEval(solution, evalsByExercise.get(solution.exercise)!)
);
if (solution.type === 'writing') { res.status(200).json(statsWithEvals);
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)
} }

View File

@@ -11,19 +11,100 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") return get(req, res); if (req.method === "GET") return get(req, res);
} }
type Query = {
op: string;
sessionId: string;
userId: string;
}
async function get(req: NextApiRequest, res: NextApiResponse) { async function get(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) { if (!req.session.user) {
res.status(401).json({ ok: false }); return res.status(401).json({ ok: false });
return;
} }
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({ const singleEval = await db.collection("evaluation").findOne({
session_id: sessionId, session_id: sessionId,
user: userId, user: userId,
status: "pending", status: "pending",
}); });
return res.status(200).json({ hasPendingEvaluation: singleEval !== null });
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)
});
} }

View File

@@ -3,37 +3,41 @@ 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 { Stat } from "@/interfaces/user";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { UserSolution } from "@/interfaces/exam"; import { UserSolution } from "@/interfaces/exam";
import { WithId } from "mongodb";
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);
async function handler(req: NextApiRequest, res: NextApiResponse) { 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) const user = await requestUser(req, res)
if (!user) return res.status(401).json({ ok: false }); 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) => { await Promise.all(disabledStats.map(async (stat) => {
const matchingSolution = solutions.find(s => s.exercise === stat.exercise); const matchingSolution = solutions.find(s => s.exercise === stat.exercise);
if (matchingSolution) { if (matchingSolution) {
const { _id, ...updateFields } = matchingSolution as WithId<UserSolution>;
await db.collection("stats").updateOne( await db.collection("stats").updateOne(
{ id: stat.id }, { id: stat.id },
{ $set: { ...matchingSolution } } { $set: { ...updateFields } }
); );
} }
})); }));

View File

@@ -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);
}

View File

@@ -29,6 +29,8 @@ import { EntityWithRoles } from "@/interfaces/entity";
import CardList from "@/components/High/CardList"; import CardList from "@/components/High/CardList";
import { requestUser } from "@/utils/api"; import { requestUser } from "@/utils/api";
import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import { useAllowedEntities } from "@/hooks/useEntityPermissions";
import getPendingEvals from "@/utils/disabled.be";
import useEvaluationPolling from "@/hooks/useEvaluationPolling";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(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 users = await (isAdmin ? getUsers() : getEntitiesUsers(entitiesIds))
const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds)) const assignments = await (isAdmin ? getAssignments() : getEntitiesAssignments(entitiesIds))
const gradingSystems = await getGradingSystemByEntities(entitiesIds) const gradingSystems = await getGradingSystemByEntities(entitiesIds)
const pendingSessionIds = await getPendingEvals(user.id);
return { return {
props: serialize({ user, users, assignments, entities, gradingSystems,isAdmin }), props: serialize({ user, users, assignments, entities, gradingSystems, isAdmin, pendingSessionIds }),
}; };
}, sessionOptions); }, sessionOptions);
@@ -58,12 +61,13 @@ interface Props {
assignments: Assignment[]; assignments: Assignment[];
entities: EntityWithRoles[] entities: EntityWithRoles[]
gradingSystems: Grading[] gradingSystems: Grading[]
pendingSessionIds: string[];
isAdmin:boolean isAdmin:boolean
} }
const MAX_TRAINING_EXAMS = 10; 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 router = useRouter();
const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [
state.selectedUser, state.selectedUser,
@@ -85,9 +89,11 @@ export default function History({ user, users, assignments, entities, gradingSys
const groupedStats = useMemo(() => groupByDate( const groupedStats = useMemo(() => groupByDate(
stats.filter((x) => { stats.filter((x) => {
if ( if (
(x.module === "writing" || x.module === "speaking") && (
!x.isDisabled && x.module === "writing" || x.module === "speaking") &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation")) !x.isDisabled && Array.isArray(x.solutions) &&
!x.solutions.every((y) => Object.keys(y).includes("evaluation")
)
) )
return false; return false;
return true; return true;
@@ -179,6 +185,8 @@ export default function History({ user, users, assignments, entities, gradingSys
); );
}; };
useEvaluationPolling(pendingSessionIds ? pendingSessionIds : [], "records", user.id);
return ( return (
<> <>
<Head> <Head>

View File

@@ -26,7 +26,6 @@ export const initialState: ExamState = {
inactivity: 0, inactivity: 0,
shuffles: [], shuffles: [],
bgColor: "bg-white", bgColor: "bg-white",
evaluated: [],
user: undefined, user: undefined,
navigation: { navigation: {
previousDisabled: false, previousDisabled: false,
@@ -39,7 +38,6 @@ export const initialState: ExamState = {
reviewAll: false, reviewAll: false,
finalizeModule: false, finalizeModule: false,
finalizeExam: false, finalizeExam: false,
pendingEvaluation: false,
}, },
}; };
@@ -62,8 +60,6 @@ const useExamStore = create<ExamState & ExamFunctions>((set, get) => ({
setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })), setQuestionIndex: (questionIndex: number) => set(() => ({ questionIndex })),
setBgColor: (bgColor: string) => set(() => ({ bgColor })), setBgColor: (bgColor: string) => set(() => ({ bgColor })),
setEvaluated: (evaluated: UserSolution[]) => set(() => ({ evaluated })),
setNavigation: (updates: Partial<Navigation>) => set((state) => ({ setNavigation: (updates: Partial<Navigation>) => set((state) => ({
navigation: { navigation: {
...state.navigation, ...state.navigation,
@@ -166,7 +162,7 @@ export const usePersistentExamStore = create<ExamState & ExamFunctions>()(
saveStats: async () => { }, saveStats: async () => { },
saveSession: async () => { }, saveSession: async () => { },
setEvaluated: (evaluated: UserSolution[]) => {}, setEvalSolutions: (evaluated: UserSolution[]) => {},
reset: () => set(() => initialState), reset: () => set(() => initialState),
dispatch: (action) => set((state) => rootReducer(state, action)) dispatch: (action) => set((state) => rootReducer(state, action))

View File

@@ -93,20 +93,11 @@ export const rootReducer = (
}; };
case 'FINALIZE_MODULE': { case 'FINALIZE_MODULE': {
const { updateTimers } = action.payload; 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 // To finalize a module first flag the timers to be updated
if (updateTimers) { if (updateTimers) {
return { return {
flags: { ...state.flags, finalizeModule: true, pendingEvaluation: hasUnevaluatedSolutions } flags: { ...state.flags, finalizeModule: true }
} }
} else { } else {
// then check whether there are more modules in the exam, if there are // then check whether there are more modules in the exam, if there are
@@ -118,7 +109,6 @@ export const rootReducer = (
...state.flags, ...state.flags,
finalizeModule: false, finalizeModule: false,
finalizeExam: true, finalizeExam: true,
pendingEvaluation: hasUnevaluatedSolutions,
} }
} }
} else if (state.moduleIndex < state.selectedModules.length - 1) { } else if (state.moduleIndex < state.selectedModules.length - 1) {

View File

@@ -16,7 +16,6 @@ export interface StateFlags {
reviewAll: boolean; reviewAll: boolean;
finalizeModule: boolean; finalizeModule: boolean;
finalizeExam: boolean; finalizeExam: boolean;
pendingEvaluation: boolean;
} }
export interface ExamState { export interface ExamState {
@@ -39,8 +38,7 @@ export interface ExamState {
user: undefined | string; user: undefined | string;
currentSolution?: UserSolution | undefined; currentSolution?: UserSolution | undefined;
navigation: Navigation; navigation: Navigation;
flags: StateFlags, flags: StateFlags;
evaluated: UserSolution[];
} }
@@ -65,8 +63,6 @@ export interface ExamFunctions {
setTimeIsUp: (timeIsUp: boolean) => void; setTimeIsUp: (timeIsUp: boolean) => void;
setEvaluated: (evaluated: UserSolution[]) => void,
saveSession: () => Promise<void>; saveSession: () => Promise<void>;
saveStats: () => Promise<void>; saveStats: () => Promise<void>;

27
src/utils/disabled.be.ts Normal file
View File

@@ -0,0 +1,27 @@
import client from "@/lib/mongodb";
const db = client.db(process.env.MONGODB_DB);
async function getPendingEvals(userId: string): Promise<string[]> {
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;

View File

@@ -5,6 +5,7 @@ import {
WritingExercise, WritingExercise,
} from "@/interfaces/exam"; } from "@/interfaces/exam";
import axios from "axios"; import axios from "axios";
import { v4 } from "uuid";
export const evaluateWritingAnswer = async ( export const evaluateWritingAnswer = async (
userId: string, userId: string,
@@ -13,7 +14,7 @@ export const evaluateWritingAnswer = async (
task: number, task: number,
solution: UserSolution, solution: UserSolution,
attachment?: string, attachment?: string,
): Promise<void> => { ): Promise<UserSolution> => {
await axios.post("/api/evaluate/writing", { await axios.post("/api/evaluate/writing", {
question: `${exercise.prompt}`.replaceAll("\n", ""), question: `${exercise.prompt}`.replaceAll("\n", ""),
answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "),
@@ -23,6 +24,18 @@ export const evaluateWritingAnswer = async (
exerciseId: exercise.id, exerciseId: exercise.id,
attachment, 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 ( export const evaluateSpeakingAnswer = async (
@@ -31,12 +44,12 @@ export const evaluateSpeakingAnswer = async (
exercise: SpeakingExercise | InteractiveSpeakingExercise, exercise: SpeakingExercise | InteractiveSpeakingExercise,
solution: UserSolution, solution: UserSolution,
task: number, task: number,
): Promise<void> => { ): Promise<UserSolution> => {
switch (exercise?.type) { switch (exercise?.type) {
case "speaking": case "speaking":
await evaluateSpeakingExercise(userId, sessionId, exercise, solution); return await evaluateSpeakingExercise(userId, sessionId, exercise, solution);
case "interactiveSpeaking": 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, sessionId: string,
exercise: SpeakingExercise, exercise: SpeakingExercise,
solution: UserSolution, solution: UserSolution,
): Promise<void> => { ): Promise<UserSolution> => {
const formData = new FormData(); const formData = new FormData();
const url = solution.solutions[0].solution.trim() as string; const url = solution.solutions[0].solution.trim() as string;
@@ -76,6 +89,17 @@ const evaluateSpeakingExercise = async (
}; };
await axios.post(`/api/evaluate/speaking`, formData, config); 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 ( const evaluateInteractiveSpeakingExercise = async (
@@ -84,7 +108,7 @@ const evaluateInteractiveSpeakingExercise = async (
exerciseId: string, exerciseId: string,
solution: UserSolution, solution: UserSolution,
task: number, task: number,
): Promise<void> => { ): Promise<UserSolution> => {
const formData = new FormData(); const formData = new FormData();
formData.append("userId", userId); formData.append("userId", userId);
formData.append("sessionId", sessionId); formData.append("sessionId", sessionId);
@@ -111,4 +135,15 @@ const evaluateInteractiveSpeakingExercise = async (
}; };
await axios.post(`/api/evaluate/interactiveSpeaking`, formData, config); 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,
};
}; };