diff --git a/src/components/Exercises/Writing.tsx b/src/components/Exercises/Writing.tsx index 1ba75275..5560c2b7 100644 --- a/src/components/Exercises/Writing.tsx +++ b/src/components/Exercises/Writing.tsx @@ -26,6 +26,8 @@ export default function Writing({ const hasExamEnded = useExamStore((state) => state.hasExamEnded); useEffect(() => { + if (localStorage.getItem("enable_paste")) return; + const listener = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "v") { e.preventDefault(); diff --git a/src/exams/Finish.tsx b/src/exams/Finish.tsx index 47bc559f..d3c76218 100644 --- a/src/exams/Finish.tsx +++ b/src/exams/Finish.tsx @@ -10,8 +10,8 @@ import Link from "next/link"; import {useRouter} from "next/router"; import {Fragment, useEffect, useState} from "react"; import {BsArrowCounterclockwise, BsBook, BsClipboard, BsEyeFill, BsHeadphones, BsMegaphone, BsPen, BsShareFill} from "react-icons/bs"; -import { LevelScore } from "@/constants/ielts"; -import { getLevelScore } from "@/utils/score"; +import {LevelScore} from "@/constants/ielts"; +import {getLevelScore} from "@/utils/score"; interface Score { module: Module; @@ -71,20 +71,18 @@ export default function Finish({user, scores, modules, isLoading, onViewResults} const bandScore: number = calculateBandScore(selectedScore.correct, selectedScore.total, selectedModule, user.focus); const showLevel = (level: number) => { - if(selectedModule === "level") { + if (selectedModule === "level") { const [levelStr, grade] = getLevelScore(level); return (
{levelStr} {grade}
- ) - } - - - return {level}; + ); + } - } + return {level}; + }; return ( <> @@ -156,14 +154,16 @@ export default function Finish({user, scores, modules, isLoading, onViewResults} {isLoading && (
- Evaluating your answers... + + Evaluating your answers, please be patient... +
+ You can also check it later on your records page! +
)} {!isLoading && (
- - {moduleResultText(selectedModule, bandScore)} - + {moduleResultText(selectedModule, bandScore)}
{ setIsLoading(true); axios - .get(!id ? "/api/stats" : `/api/stats/${id}`) + .get(!id ? "/api/stats" : `/api/stats/user/${id}`) .then((response) => setStats(response.data)) .finally(() => setIsLoading(false)); }, [id]); diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index d9977d9a..fc10ac5d 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -44,6 +44,7 @@ export interface ListeningPart { } export interface UserSolution { + id?: string; solutions: any[]; module?: Module; exam?: string; diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index cd337ac5..c7b0efff 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -98,6 +98,7 @@ export const EMPLOYMENT_STATUS: {status: EmploymentStatus; label: string}[] = [ ]; export interface Stat { + id: string; user: string; exam: string; exercise: string; diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index f092ffcb..67cc275a 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -37,6 +37,7 @@ export default function ExamPage({page}: Props) { const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [avoidRepeated, setAvoidRepeated] = useState(false); const [timeSpent, setTimeSpent] = useState(0); + const [statsAwaitingEvaluation, setStatsAwaitingEvaluation] = useState([]); const [exams, setExams] = useExamStore((state) => [state.exams, state.setExams]); const [userSolutions, setUserSolutions] = useExamStore((state) => [state.userSolutions, state.setUserSolutions]); @@ -94,6 +95,7 @@ export default function ExamPage({page}: Props) { if (selectedModules.length > 0 && exams.length !== 0 && moduleIndex >= selectedModules.length && !hasBeenUploaded && !showSolutions) { const newStats: Stat[] = userSolutions.map((solution) => ({ ...solution, + id: solution.id || uuidv4(), timeSpent, session: sessionId, exam: solution.exam!, @@ -111,6 +113,41 @@ export default function ExamPage({page}: Props) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedModules, moduleIndex, hasBeenUploaded]); + useEffect(() => { + if (statsAwaitingEvaluation.length === 0) return setIsEvaluationLoading(false); + return setIsEvaluationLoading(true); + }, [statsAwaitingEvaluation]); + + useEffect(() => { + if (statsAwaitingEvaluation.length > 0) { + statsAwaitingEvaluation.forEach(checkIfStatHasBeenEvaluated); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [statsAwaitingEvaluation]); + + const checkIfStatHasBeenEvaluated = (id: string) => { + setTimeout(async () => { + const statRequest = await axios.get(`/api/stats/${id}`); + const stat = statRequest.data; + if (stat.solutions.every((x) => x.evaluation !== null)) { + const userSolution: UserSolution = { + id, + exercise: stat.exercise, + score: stat.score, + solutions: stat.solutions, + type: stat.type, + exam: stat.exam, + module: stat.module, + }; + + setUserSolutions(userSolutions.map((x) => (x.exercise === userSolution.exercise ? userSolution : x))); + return setStatsAwaitingEvaluation((prev) => prev.filter((x) => x !== id)); + } + + return checkIfStatHasBeenEvaluated(id); + }, 5 * 1000); + }; + const updateExamWithUserSolutions = (exam: Exam): Exam => { if (exam.module === "reading" || exam.module === "listening") { const parts = exam.parts.map((p) => @@ -137,20 +174,19 @@ export default function ExamPage({page}: Props) { Promise.all( exam.exercises.map(async (exercise) => { - if (exercise.type === "writing") { - return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!); - } + const evaluationID = uuidv4(); + if (exercise.type === "writing") + return await evaluateWritingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID); - if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") { - return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!); - } + if (exercise.type === "interactiveSpeaking" || exercise.type === "speaking") + return await evaluateSpeakingAnswer(exercise, solutions.find((x) => x.exercise === exercise.id)!, evaluationID); }), ) .then((responses) => { + setStatsAwaitingEvaluation((prev) => [...prev, ...responses.filter((x) => !!x).map((r) => (r as any).id)]); setUserSolutions([...userSolutions, ...responses.filter((x) => !!x)] as any); }) .finally(() => { - setIsEvaluationLoading(false); setHasBeenUploaded(false); }); } diff --git a/src/pages/api/evaluate/interactiveSpeaking.ts b/src/pages/api/evaluate/interactiveSpeaking.ts index aa761ff1..62e9f53e 100644 --- a/src/pages/api/evaluate/interactiveSpeaking.ts +++ b/src/pages/api/evaluate/interactiveSpeaking.ts @@ -2,12 +2,16 @@ import type {NextApiRequest, NextApiResponse} from "next"; import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; -import axios from "axios"; +import axios, {AxiosResponse} from "axios"; import formidable from "formidable-serverless"; import {ref, uploadBytes} from "firebase/storage"; import fs from "fs"; -import {storage} from "@/firebase"; +import {app, storage} from "@/firebase"; +import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore"; +import {Stat} from "@/interfaces/user"; +import {speakingReverseMarking} from "@/utils/score"; +const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -36,20 +40,41 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { }), ); - const backendRequest = await axios.post( - `${process.env.BACKEND_URL}/speaking_task_3`, - {answers: uploadingAudios}, + res.status(200).json(null); + + console.log("🌱 - Still processing"); + const backendRequest = await evaluate({answers: uploadingAudios}); + console.log("🌱 - Process complete"); + + const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat; + const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data, solution: uploadingAudios})); + await setDoc( + doc(db, "stats", fields.id), { - headers: { - Authorization: `Bearer ${process.env.BACKEND_JWT}`, + solutions, + score: { + correct: speakingReverseMarking[backendRequest.data.overall], + missing: 0, + total: 100, }, }, + {merge: true}, ); - - res.status(200).json({...backendRequest.data, answer: uploadingAudios}); + console.log("🌱 - Updated the DB"); }); } +async function evaluate(body: {answers: object[]}): Promise { + const backendRequest = await axios.post(`${process.env.BACKEND_URL}/speaking_task_3`, body, { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }); + + if (typeof backendRequest.data === "string") return evaluate(body); + return backendRequest; +} + export const config = { api: { bodyParser: false, diff --git a/src/pages/api/evaluate/speaking.ts b/src/pages/api/evaluate/speaking.ts index 2706f9d9..a299df9e 100644 --- a/src/pages/api/evaluate/speaking.ts +++ b/src/pages/api/evaluate/speaking.ts @@ -6,8 +6,12 @@ import axios, {AxiosResponse} from "axios"; import formidable from "formidable-serverless"; import {ref, uploadBytes} from "firebase/storage"; import fs from "fs"; -import {storage} from "@/firebase"; +import {app, storage} from "@/firebase"; +import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore"; +import {Stat} from "@/interfaces/user"; +import {speakingReverseMarking} from "@/utils/score"; +const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -26,10 +30,32 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const binary = fs.readFileSync((audioFile as any).path).buffer; const snapshot = await uploadBytes(audioFileRef, binary); - const backendRequest = await evaluate({answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]}); + res.status(200).json(null); + console.log("🌱 - Still processing"); + const backendRequest = await evaluate({answers: [{question: fields.question, answer: snapshot.metadata.fullPath}]}); fs.rmSync((audioFile as any).path); - res.status(200).json({...backendRequest.data, fullPath: snapshot.metadata.fullPath}); + console.log("🌱 - Process complete"); + + const correspondingStat = (await getDoc(doc(db, "stats", fields.id))).data() as Stat; + const solutions = correspondingStat.solutions.map((x) => ({ + ...x, + evaluation: backendRequest.data, + solution: snapshot.metadata.fullPath, + })); + await setDoc( + doc(db, "stats", fields.id), + { + solutions, + score: { + correct: speakingReverseMarking[backendRequest.data.overall], + total: 100, + missing: 0, + }, + }, + {merge: true}, + ); + console.log("🌱 - Updated the DB"); }); } diff --git a/src/pages/api/evaluate/writing.ts b/src/pages/api/evaluate/writing.ts index 40c84958..841b4c92 100644 --- a/src/pages/api/evaluate/writing.ts +++ b/src/pages/api/evaluate/writing.ts @@ -1,15 +1,20 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction import type {NextApiRequest, NextApiResponse} from "next"; -import {getFirestore, doc, getDoc} from "firebase/firestore"; +import {getFirestore, doc, getDoc, setDoc} from "firebase/firestore"; import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import axios, {AxiosResponse} from "axios"; +import {app} from "@/firebase"; +import {Stat} from "@/interfaces/user"; +import {writingReverseMarking} from "@/utils/score"; interface Body { question: string; answer: string; + id: string; } +const db = getFirestore(app); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -18,9 +23,27 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return; } - const backendRequest = await evaluate(req.body as Body); + res.status(200).json(null); - res.status(backendRequest.status).json(backendRequest.data); + console.log("🌱 - Still processing"); + const backendRequest = await evaluate(req.body as Body); + console.log("🌱 - Process complete"); + + const correspondingStat = (await getDoc(doc(db, "stats", req.body.id))).data() as Stat; + const solutions = correspondingStat.solutions.map((x) => ({...x, evaluation: backendRequest.data})); + await setDoc( + doc(db, "stats", (req.body as Body).id), + { + solutions, + score: { + correct: writingReverseMarking[backendRequest.data.overall], + total: 100, + missing: 0, + }, + }, + {merge: true}, + ); + console.log("🌱 - Updated the DB"); } async function evaluate(body: Body): Promise { diff --git a/src/pages/api/stats/[id].ts b/src/pages/api/stats/[id].ts new file mode 100644 index 00000000..0597f529 --- /dev/null +++ b/src/pages/api/stats/[id].ts @@ -0,0 +1,23 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {app} from "@/firebase"; +import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc, deleteDoc} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {uuidv4} from "@firebase/util"; + +const db = getFirestore(app); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return GET(req, res); + + res.status(404).json({ok: false}); +} + +async function GET(req: NextApiRequest, res: NextApiResponse) { + const {id} = req.query; + + const snapshot = await getDoc(doc(db, "stats", id as string)); + + res.status(200).json({...snapshot.data(), id: snapshot.id}); +} diff --git a/src/pages/api/stats/index.ts b/src/pages/api/stats/index.ts index 7444b50d..779427ba 100644 --- a/src/pages/api/stats/index.ts +++ b/src/pages/api/stats/index.ts @@ -42,7 +42,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } const stats = req.body as Stat[]; - await stats.forEach(async (stat) => await addDoc(collection(db, "stats"), stat)); + await stats.forEach(async (stat) => await setDoc(doc(db, "stats", stat.id), stat)); const groupedStatsByAssignment = groupBy( stats.filter((x) => !!x.assignment), diff --git a/src/pages/api/stats/update.ts b/src/pages/api/stats/update.ts index 1a74a60a..c1c01674 100644 --- a/src/pages/api/stats/update.ts +++ b/src/pages/api/stats/update.ts @@ -25,8 +25,8 @@ async function update(req: NextApiRequest, res: NextApiResponse) { const q = query(collection(db, "stats"), where("user", "==", req.session.user.id)); const stats = (await getDocs(q)).docs.map((doc) => ({ - id: doc.id, ...(doc.data() as Stat), + id: doc.id, })) as Stat[]; const groupedStats = groupBySession(stats); diff --git a/src/pages/api/stats/[user].ts b/src/pages/api/stats/user/[user].ts similarity index 100% rename from src/pages/api/stats/[user].ts rename to src/pages/api/stats/user/[user].ts diff --git a/src/utils/evaluation.ts b/src/utils/evaluation.ts index 55bec84f..d9c53b19 100644 --- a/src/utils/evaluation.ts +++ b/src/utils/evaluation.ts @@ -11,17 +11,19 @@ import { import axios from "axios"; import {speakingReverseMarking, writingReverseMarking} from "./score"; -export const evaluateWritingAnswer = async (exercise: WritingExercise, solution: UserSolution): Promise => { +export const evaluateWritingAnswer = async (exercise: WritingExercise, solution: UserSolution, id: string): Promise => { const response = await axios.post("/api/evaluate/writing", { question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), + id, }); if (response.status === 200) { return { ...solution, + id, score: { - correct: writingReverseMarking[response.data.overall] || 0, + correct: response.data ? writingReverseMarking[response.data.overall] : 0, missing: 0, total: 100, }, @@ -32,12 +34,12 @@ export const evaluateWritingAnswer = async (exercise: WritingExercise, solution: return undefined; }; -export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | InteractiveSpeakingExercise, solution: UserSolution) => { +export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | InteractiveSpeakingExercise, solution: UserSolution, id: string) => { switch (exercise?.type) { case "speaking": - return await evaluateSpeakingExercise(exercise, exercise.id, solution); + return {...(await evaluateSpeakingExercise(exercise, exercise.id, solution, id)), id}; case "interactiveSpeaking": - return await evaluateInteractiveSpeakingExercise(exercise.id, solution); + return {...(await evaluateInteractiveSpeakingExercise(exercise.id, solution, id)), id}; default: return undefined; } @@ -48,7 +50,7 @@ const downloadBlob = async (url: string): Promise => { return Buffer.from(blobResponse.data, "binary"); }; -const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution) => { +const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution, id: string) => { const audioBlob = await downloadBlob(solution.solutions[0].solution.trim()); const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"}); @@ -58,6 +60,7 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: const evaluationQuestion = `${exercise.text.replaceAll("\n", "")}` + (exercise.prompts.length > 0 ? `You should talk about: ${exercise.prompts.join(", ")}` : ""); formData.append("question", evaluationQuestion); + formData.append("id", id); const config = { headers: { @@ -71,18 +74,18 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: return { ...solution, score: { - correct: speakingReverseMarking[response.data.overall] || 0, + correct: response.data ? speakingReverseMarking[response.data.overall] : 0, missing: 0, total: 100, }, - solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}], + solutions: [{id: exerciseId, solution: response.data ? response.data.fullPath : null, evaluation: response.data}], }; } return undefined; }; -const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution) => { +const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution, id: string) => { const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => ({ question: x.prompt, answer: await downloadBlob(x.blob), @@ -98,6 +101,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: formData.append(`question_${seed}`, question); formData.append(`answer_${seed}`, audioFile, `${seed}.wav`); }); + formData.append("id", id); const config = { headers: { @@ -111,11 +115,11 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: return { ...solution, score: { - correct: speakingReverseMarking[response.data.overall] || 0, + correct: response.data ? speakingReverseMarking[response.data.overall] : 0, missing: 0, total: 100, }, - solutions: [{id: exerciseId, solution: response.data.answer, evaluation: response.data}], + solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}], }; }