diff --git a/src/components/ExamEditor/SettingsEditor/listening/components.tsx b/src/components/ExamEditor/SettingsEditor/listening/components.tsx index 9b57a934..d7b03297 100644 --- a/src/components/ExamEditor/SettingsEditor/listening/components.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/components.tsx @@ -30,7 +30,6 @@ const ListeningComponents: React.FC = ({ currentSection, localSettings, u const { focusedSection, difficulty, - sections } = useExamEditorStore(state => state.modules[currentModule]); const [originalAudioUrl, setOriginalAudioUrl] = useState(); diff --git a/src/components/ExamEditor/SettingsEditor/listening/index.tsx b/src/components/ExamEditor/SettingsEditor/listening/index.tsx index ceaa8176..e3c4d416 100644 --- a/src/components/ExamEditor/SettingsEditor/listening/index.tsx +++ b/src/components/ExamEditor/SettingsEditor/listening/index.tsx @@ -27,6 +27,7 @@ const ListeningSettings: React.FC = () => { sections, minTimer, isPrivate, + instructionsState } = useExamEditorStore(state => state.modules[currentModule]); const { @@ -71,10 +72,33 @@ const ListeningSettings: React.FC = () => { try { const sectionsWithAudio = sections.filter(s => (s.state as ListeningPart).audio?.source); + if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) { + toast.error("Generate the custom instructions audio first!"); + return; + } + if (sectionsWithAudio.length > 0) { + let instructionsURL = instructionsState.currentInstructionsURL; + + if (instructionsState.chosenOption.value === "Custom") { + const instructionsFormData = new FormData(); + const instructionsResponse = await fetch(instructionsState.currentInstructionsURL); + const instructionsBlob = await instructionsResponse.blob(); + instructionsFormData.append('file', instructionsBlob, 'audio.mp3'); + + const instructionsUploadResponse = await axios.post('/api/storage', instructionsFormData, { + params: { + directory: 'listening_instructions' + }, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + instructionsURL = instructionsUploadResponse.data.urls[0]; + } + const formData = new FormData(); const sectionMap = new Map(); - await Promise.all( sectionsWithAudio.map(async (section) => { const listeningPart = section.state as ListeningPart; @@ -120,6 +144,7 @@ const ListeningSettings: React.FC = () => { variant: sections.length === 4 ? "full" : "partial", difficulty, private: isPrivate, + instructions: instructionsURL }; const result = await axios.post('/api/exam/listening', exam); @@ -140,6 +165,11 @@ const ListeningSettings: React.FC = () => { const preview = () => { + if (instructionsState.chosenOption.value === "Custom" && !instructionsState.currentInstructionsURL.startsWith("blob:")) { + toast.error("Generate the custom instructions audio first!"); + return; + } + setExam({ parts: sections.map((s) => { const exercise = s.state as ListeningPart; @@ -156,6 +186,7 @@ const ListeningSettings: React.FC = () => { variant: sections.length === 4 ? "full" : "partial", difficulty, private: isPrivate, + instructions: instructionsState.currentInstructionsURL } as ListeningExam); setExerciseIndex(0); setQuestionIndex(0); diff --git a/src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx b/src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx new file mode 100644 index 00000000..557ee2b3 --- /dev/null +++ b/src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx @@ -0,0 +1,297 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Button from "@/components/Low/Button"; +import Modal from "@/components/Modal"; +import useExamEditorStore from "@/stores/examEditor"; +import { PRESETS, isValidPresetID } from "./presets"; +import AudioPlayer from "@/components/Low/AudioPlayer"; +import Select from "@/components/Low/Select"; +import AutoExpandingTextArea from "@/components/Low/AutoExpandingTextarea"; +import { ListeningInstructionsState } from "@/stores/examEditor/types"; +import { debounce } from "lodash"; +import axios from "axios"; +import { toast } from "react-toastify"; +import { playSound } from "@/utils/sound"; +import { BsArrowRepeat } from "react-icons/bs"; +import { GiBrain } from "react-icons/gi"; + +const ListeningInstructions: React.FC = () => { + const { dispatch } = useExamEditorStore(); + const [loading, setLoading] = useState(false); + const { instructionsState: globalInstructions, sections } = useExamEditorStore(s => s.modules["listening"]); + const [localInstructions, setLocalInstructions] = useState(globalInstructions); + const pendingUpdatesRef = useRef>({}); + + useEffect(() => { + if (globalInstructions) { + setLocalInstructions(globalInstructions); + } + }, [globalInstructions]); + + const debouncedUpdateGlobal = useMemo(() => { + return debounce(() => { + if (Object.keys(pendingUpdatesRef.current).length > 0) { + dispatch({ + type: "UPDATE_MODULE", + payload: { + module: "listening", + updates: { + instructionsState: { + ...globalInstructions, + ...pendingUpdatesRef.current + } + } + } + }); + pendingUpdatesRef.current = {}; + } + }, 1000); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, globalInstructions]); + + const updateInstructionsAndSchedule = useCallback(( + updates: Partial | ((prev: ListeningInstructionsState) => Partial), + schedule: boolean = true + ) => { + const newUpdates = typeof updates === 'function' ? updates(localInstructions) : updates; + + setLocalInstructions(prev => ({ + ...prev, + ...newUpdates + })); + + if (schedule) { + pendingUpdatesRef.current = { + ...pendingUpdatesRef.current, + ...newUpdates + }; + debouncedUpdateGlobal(); + } else { + dispatch({ + type: "UPDATE_MODULE", + payload: { + module: "listening", + updates: { + instructionsState: { + ...globalInstructions, + ...newUpdates + } + } + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch, debouncedUpdateGlobal]); + + const setIsOpen = useCallback((isOpen: boolean) => { + updateInstructionsAndSchedule({ isInstructionsOpen: isOpen }, false); + }, [updateInstructionsAndSchedule]); + + const onOptionChange = useCallback((option: { value: string, label: string }) => { + const sectionIds = sections.map(s => s.sectionId); + const presetID = [...sectionIds].sort((a, b) => a - b).join('_'); + const preset = isValidPresetID(presetID) ? PRESETS[presetID] : null; + + updateInstructionsAndSchedule(prev => { + const updates: Partial = { + chosenOption: option + }; + + if (option.value === "Automatic" && preset) { + updates.currentInstructions = preset.text; + updates.currentInstructionsURL = preset.url; + } else if (option.value === "Custom") { + updates.currentInstructions = prev.customInstructions || ""; + updates.currentInstructionsURL = ""; + } + + return updates; + }, false); + }, [sections, updateInstructionsAndSchedule]); + + const onCustomInstructionChange = useCallback((text: string) => { + updateInstructionsAndSchedule({ + chosenOption: { value: 'Custom', label: 'Custom' }, + customInstructions: text, + currentInstructions: text + }); + }, [updateInstructionsAndSchedule]); + + useEffect(() => { + const sectionIds = sections.map(s => s.sectionId); + const presetID = [...sectionIds].sort((a, b) => a - b).join('_'); + + if (isValidPresetID(presetID)) { + const preset = PRESETS[presetID]; + updateInstructionsAndSchedule(prev => { + const updates: Partial = { + presetInstructions: preset.text, + presetInstructionsURL: preset.url, + }; + + if (prev.chosenOption?.value === "Automatic") { + updates.currentInstructions = preset.text; + updates.currentInstructionsURL = preset.url; + } + + return updates; + }, false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sections.length]); + + useEffect(() => { + return () => { + if (Object.keys(pendingUpdatesRef.current).length > 0) { + dispatch({ + type: "UPDATE_MODULE", + payload: { + module: "listening", + updates: { + instructionsState: { + ...globalInstructions, + ...pendingUpdatesRef.current + } + } + } + }); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dispatch]); + + const options = [ + { value: 'Automatic', label: 'Automatic' }, + { value: 'Custom', label: 'Custom' } + ]; + + const generateInstructionsMP3 = useCallback(async () => { + if (!localInstructions.currentInstructions) { + toast.error('Please enter instructions text first'); + return; + } + + setLoading(true); + + try { + const response = await axios.post( + '/api/exam/media/instructions', + { + text: localInstructions.currentInstructions + }, + { + responseType: 'arraybuffer', + headers: { + 'Accept': 'audio/mpeg' + } + } + ); + + if (localInstructions.currentInstructionsURL?.startsWith('blob:')) { + URL.revokeObjectURL(localInstructions.currentInstructionsURL); + } + + const blob = new Blob([response.data], { type: 'audio/mpeg' }); + const url = URL.createObjectURL(blob); + + updateInstructionsAndSchedule({ + customInstructionsURL: url, + currentInstructionsURL: url + }, false); + + playSound("check"); + toast.success('Audio generated successfully!'); + } catch (error: any) { + toast.error('Failed to generate audio'); + } finally { + setLoading(false); + } + }, [localInstructions.currentInstructions, localInstructions.currentInstructionsURL, updateInstructionsAndSchedule]); + + + return ( + <> + setIsOpen(false)} + > +
+
+

Listening Instructions

+

Choose instruction type or customize your own

+
+ +
+
+ + ({ value: e.id, label: e.label }))} - onChange={(e) => setEntity(e?.value || undefined)} + onChange={(e) => { + if (!e) { + setEntity(undefined); + return; + } + setEntity({ + id: e?.value, + label: e?.label + }); + }} isClearable={checkAccess(user, ["admin", "developer"])} />
diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 5ca4ef24..e3ce8305 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -22,7 +22,6 @@ import ShortUniqueId from "short-unique-id"; import { ExamProps } from "@/exams/types"; import useExamStore from "@/stores/exam"; import useEvaluationPolling from "@/hooks/useEvaluationPolling"; -import PracticeModal from "@/components/PracticeModal"; interface Props { page: "exams" | "exercises"; @@ -37,6 +36,7 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = const [avoidRepeated, setAvoidRepeated] = useState(false); const [showAbandonPopup, setShowAbandonPopup] = useState(false); const [pendingExercises, setPendingExercises] = useState([]); + const [shouldPoll, setShouldPoll] = useState(false); const { exam, setExam, @@ -149,8 +149,10 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = useEffect(() => { if (flags.finalizeExam && moduleIndex !== -1) { setModuleIndex(-1); + + } - }, [flags, moduleIndex, setModuleIndex]); + }, [flags.finalizeExam, moduleIndex, setModuleIndex]); useEffect(() => { if (flags.finalizeExam && !flags.pendingEvaluation && pendingExercises.length === 0) { diff --git a/src/pages/api/evaluate/interactiveSpeaking.ts b/src/pages/api/evaluate/interactiveSpeaking.ts index e217c7e5..4623c483 100644 --- a/src/pages/api/evaluate/interactiveSpeaking.ts +++ b/src/pages/api/evaluate/interactiveSpeaking.ts @@ -6,98 +6,128 @@ import axios from "axios"; import formidable from "formidable-serverless"; import fs from "fs"; import FormData from 'form-data'; +import client from "@/lib/mongodb"; +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 form = formidable({ keepExtensions: true }); - - await form.parse(req, async (err: any, fields: any, files: any) => { - if (err) { - console.error('Error parsing form:', err); - res.status(500).json({ ok: false, error: 'Failed to parse form data' }); + res.status(401).json({ ok: false }); return; - } - - try { - const formData = new FormData(); - - if (!fields.userId || !fields.sessionId || !fields.exerciseId || !fields.task) { - throw new Error('Missing required fields'); + } + + const form = formidable({ keepExtensions: true }); + + await form.parse(req, async (err: any, fields: any, files: any) => { + if (err) { + console.error('Error parsing form:', err); + res.status(500).json({ ok: false, error: 'Failed to parse form data' }); + return; } - - formData.append('userId', fields.userId); - formData.append('sessionId', fields.sessionId); - formData.append('exerciseId', fields.exerciseId); - - for (const fileKey of Object.keys(files)) { - const indexMatch = fileKey.match(/^audio_(\d+)$/); - if (!indexMatch) { - console.warn(`Skipping invalid file key: ${fileKey}`); - continue; - } - - const index = indexMatch[1]; - const questionKey = `question_${index}`; - const audioFile = files[fileKey]; - - if (!audioFile || !audioFile.path) { - throw new Error(`Invalid audio file for ${fileKey}`); - } - - if (!fields[questionKey]) { - throw new Error(`Missing question for audio ${index}`); - } - - try { - const buffer = fs.readFileSync(audioFile.path); - formData.append(`audio_${index}`, buffer, `audio_${index}.wav`); - formData.append(questionKey, fields[questionKey]); - fs.rmSync(audioFile.path); - } catch (fileError) { - console.error(`Error processing file ${fileKey}:`, fileError); - throw new Error(`Failed to process audio file ${index}`); - } - } - - await axios.post( - `${process.env.BACKEND_URL}/grade/speaking/${fields.task}`, - formData, - { - headers: { - ...formData.getHeaders(), - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - } - ); - - res.status(200).json({ ok: true }); - } catch (error) { - console.error('Error processing request:', error); - res.status(500).json({ - ok: false, - error: 'Internal server error' - }); - - Object.keys(files).forEach(fileKey => { - const audioFile = files[fileKey]; - if (audioFile && audioFile.path && fs.existsSync(audioFile.path)) { - try { - fs.rmSync(audioFile.path); - } catch (cleanupError) { - console.error(`Failed to clean up temp file ${audioFile.path}:`, cleanupError); + + try { + const formData = new FormData(); + + if (!fields.userId || !fields.sessionId || !fields.exerciseId || !fields.task) { + throw new Error('Missing required fields'); } - } - }); - } + + formData.append('userId', fields.userId); + formData.append('sessionId', fields.sessionId); + formData.append('exerciseId', fields.exerciseId); + + for (const fileKey of Object.keys(files)) { + const indexMatch = fileKey.match(/^audio_(\d+)$/); + if (!indexMatch) { + console.warn(`Skipping invalid file key: ${fileKey}`); + continue; + } + + const index = indexMatch[1]; + const questionKey = `question_${index}`; + const audioFile = files[fileKey]; + + if (!audioFile || !audioFile.path) { + throw new Error(`Invalid audio file for ${fileKey}`); + } + + if (!fields[questionKey]) { + throw new Error(`Missing question for audio ${index}`); + } + + try { + const buffer = fs.readFileSync(audioFile.path); + formData.append(`audio_${index}`, buffer, `audio_${index}.wav`); + formData.append(questionKey, fields[questionKey]); + fs.rmSync(audioFile.path); + } catch (fileError) { + console.error(`Error processing file ${fileKey}:`, fileError); + throw new Error(`Failed to process audio file ${index}`); + } + } + + // Check if there is one eval for the current exercise + const previousEval = await db.collection("evaluation").findOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + + // If there is delete it + if (previousEval) { + await db.collection("evaluation").deleteOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + } + + // Insert the new eval for the backend to place it's result + await db.collection("evaluation").insertOne( + { + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + type: "speaking_interactive", + task: fields.task, + status: "pending" + } + ); + + await axios.post( + `${process.env.BACKEND_URL}/grade/speaking/${fields.task}`, + formData, + { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + } + ); + + res.status(200).json({ ok: true }); + } catch (error) { + console.error('Error processing request:', error); + res.status(500).json({ + ok: false, + error: 'Internal server error' + }); + + Object.keys(files).forEach(fileKey => { + const audioFile = files[fileKey]; + if (audioFile && audioFile.path && fs.existsSync(audioFile.path)) { + try { + fs.rmSync(audioFile.path); + } catch (cleanupError) { + console.error(`Failed to clean up temp file ${audioFile.path}:`, cleanupError); + } + } + }); + } }); - } +} export const config = { api: { diff --git a/src/pages/api/evaluate/speaking.ts b/src/pages/api/evaluate/speaking.ts index faebf428..89203d2a 100644 --- a/src/pages/api/evaluate/speaking.ts +++ b/src/pages/api/evaluate/speaking.ts @@ -6,6 +6,9 @@ import axios from "axios"; import formidable from "formidable-serverless"; import fs from "fs"; import FormData from 'form-data'; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); @@ -41,6 +44,34 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { formData.append('audio_1', buffer, 'audio_1.wav'); fs.rmSync(audioFile.path); + // Check if there is one eval for the current exercise + const previousEval = await db.collection("evaluation").findOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + + // If there is delete it + if (previousEval) { + await db.collection("evaluation").deleteOne({ + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + }) + } + + // Insert the new eval for the backend to place it's result + await db.collection("evaluation").insertOne( + { + user: fields.userId, + session_id: fields.sessionId, + exercise_id: fields.exerciseId, + type: "speaking", + task: 2, + status: "pending" + } + ); + await axios.post( `${process.env.BACKEND_URL}/grade/speaking/2`, formData, diff --git a/src/pages/api/evaluate/status.ts b/src/pages/api/evaluate/status.ts index edec09d0..9679a545 100644 --- a/src/pages/api/evaluate/status.ts +++ b/src/pages/api/evaluate/status.ts @@ -1,34 +1,29 @@ -import type {NextApiRequest, NextApiResponse} from "next"; +import type { NextApiRequest, NextApiResponse } from "next"; import client from "@/lib/mongodb"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; +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.method === "GET") return get(req, res); + if (req.method === "GET") return get(req, res); } async function get(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { - res.status(401).json({ok: false}); + res.status(401).json({ ok: false }); return; } - const {sessionId, userId, exerciseIds} = req.query; - const exercises = (exerciseIds! as string).split(','); - const finishedEvaluations = await db.collection("evaluation").find({ - session_id: sessionId, - user: userId, - $or: [ - { status: "completed" }, - { status: "error" } - ], - exercise_id: { $in: exercises } - }).toArray(); + const { sessionId, userId } = req.query; - const finishedExerciseIds = finishedEvaluations.map(evaluation => evaluation.exercise_id); - res.status(200).json({ finishedExerciseIds }); -} \ No newline at end of file + const singleEval = await db.collection("evaluation").findOne({ + session_id: sessionId, + user: userId, + status: "pending", + }); + + res.status(200).json({ hasPendingEvaluation: singleEval !== null}); +} diff --git a/src/pages/api/evaluate/writing.ts b/src/pages/api/evaluate/writing.ts index 8f7b289b..9b4be221 100644 --- a/src/pages/api/evaluate/writing.ts +++ b/src/pages/api/evaluate/writing.ts @@ -3,6 +3,9 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import axios from "axios"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); interface Body { userId: string; @@ -22,13 +25,41 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return; } - const { task, ...body} = req.body as Body; - const taskNumber = task.toString() !== "1" && task.toString() !== "2" ? "1" : task.toString(); + const body = req.body as Body; + const taskNumber = body.task.toString() !== "1" && body.task.toString() !== "2" ? "1" : body.task.toString(); + + // Check if there is one eval for the current exercise + const previousEval = await db.collection("evaluation").findOne({ + user: body.userId, + session_id: body.sessionId, + exercise_id: body.exerciseId, + }) + + // If there is delete it + if (previousEval) { + await db.collection("evaluation").deleteOne({ + user: body.userId, + session_id: body.sessionId, + exercise_id: body.exerciseId, + }) + } + + // Insert the new eval for the backend to place it's result + await db.collection("evaluation").insertOne( + { + user: body.userId, + session_id: body.sessionId, + exercise_id: body.exerciseId, + type: "writing", + task: body.task, + status: "pending" + } + ); await axios.post(`${process.env.BACKEND_URL}/grade/writing/${taskNumber}`, body, { headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}`, }, }); - res.status(200).json({ok: true}); + res.status(200).json({ ok: true }); } diff --git a/src/pages/api/exam/media/instructions.ts b/src/pages/api/exam/media/instructions.ts new file mode 100644 index 00000000..06f0fa84 --- /dev/null +++ b/src/pages/api/exam/media/instructions.ts @@ -0,0 +1,36 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import axios from "axios"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return post(req, res); + + return res.status(404).json({ ok: false }); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }); + + const response = await axios.post( + `${process.env.BACKEND_URL}/listening/instructions`, + req.body, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + Accept: 'audio/mpeg' + }, + responseType: 'arraybuffer', + } + ); + + res.writeHead(200, { + 'Content-Type': 'audio/mpeg', + 'Content-Length': response.data.length + }); + + res.end(response.data); + return; +} diff --git a/src/pages/api/stats/updateDisabled.ts b/src/pages/api/stats/updateDisabled.ts new file mode 100644 index 00000000..ba7e688c --- /dev/null +++ b/src/pages/api/stats/updateDisabled.ts @@ -0,0 +1,42 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import client from "@/lib/mongodb"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Stat } from "@/interfaces/user"; +import { requestUser } from "@/utils/api"; +import { UserSolution } from "@/interfaces/exam"; + +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; + + const disabledStats = await db.collection("stats").find({ user: user.id, session: sessionID, disabled: true }).toArray(); + + await Promise.all(disabledStats.map(async (stat) => { + const matchingSolution = solutions.find(s => s.exercise === stat.exercise); + if (matchingSolution) { + await db.collection("stats").updateOne( + { id: stat.id }, + { $set: { ...matchingSolution } } + ); + } + })); + + return res.status(200).json({ ok: true }); +} diff --git a/src/pages/api/users/controller.ts b/src/pages/api/users/controller.ts index a1e9f6f4..8bd507e7 100644 --- a/src/pages/api/users/controller.ts +++ b/src/pages/api/users/controller.ts @@ -38,6 +38,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { await assignToEntity(req.body); res.status(200).json({"ok": true}); break; + case 'getEntities': + res.status(200).json(await getEntities(req.body.emails)) + break; default: res.status(400).json({ error: 'Invalid operation!' }) } @@ -276,6 +279,36 @@ async function getIds(emails: string[]): Promise> { + const users = await db.collection('users') + .find({ email: { $in: emails } }) + .project({ email: 1, entities: 1, _id: 0 }) + .toArray(); + + const entityIds = [...new Set( + users.flatMap(user => + (user.entities || []).map((entity: any) => entity.id) + ) + )]; + + const entityRecords = await db.collection('entities') + .find({ id: { $in: entityIds } }) + .project({ id: 1, label: 1, _id: 0 }) + .toArray(); + + const entityMap = new Map( + entityRecords.map(entity => [entity.id, entity.label]) + ); + + return users.map(user => ({ + email: user.email, + entityLabels: (user.entities || []) + .map((entity: any) => entityMap.get(entity.id)) + .filter((label: string): label is string => !!label) + })); +} + + async function assignToEntity(body: any) { const { ids, entity } = body; diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index 00f7ff3f..595a6c39 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -110,6 +110,10 @@ export default function Generation({ id, user, exam, examModule, permissions }: } }); + if (state.listening.instructionsState.customInstructionsURL.startsWith('blob:')) { + URL.revokeObjectURL(state.listening.instructionsState.customInstructionsURL); + } + state.speaking.sections.forEach(section => { const sectionState = section.state as Exercise; if (sectionState.type === 'speaking') { diff --git a/src/stores/examEditor/defaults.ts b/src/stores/examEditor/defaults.ts index 94916bcd..0e1d8b3e 100644 --- a/src/stores/examEditor/defaults.ts +++ b/src/stores/examEditor/defaults.ts @@ -166,6 +166,16 @@ const defaultModuleSettings = (module: Module, minTimer: number): ModuleState => importModule: true, importing: false, edit: [], + instructionsState: { + isInstructionsOpen: false, + chosenOption: { value: "Automatic", label: "Automatic" }, + currentInstructions: "", + presetInstructions: "", + customInstructions: "", + currentInstructionsURL: "", + presetInstructionsURL: "", + customInstructionsURL: "", + }, }; if (["reading", "writing"].includes(module)) { state["type"] = "general"; diff --git a/src/stores/examEditor/types.ts b/src/stores/examEditor/types.ts index 2635437e..bda1978c 100644 --- a/src/stores/examEditor/types.ts +++ b/src/stores/examEditor/types.ts @@ -38,7 +38,7 @@ export interface ListeningSectionSettings extends SectionSettings { isListeningTopicOpen: boolean; uploadedAudioURL: string | undefined; audioCutURL: string | undefined; - useEntireAudioFile: boolean; + useEntireAudioFile: boolean; } export interface WritingSectionSettings extends SectionSettings { @@ -108,6 +108,19 @@ export interface SectionState { scriptLoading: boolean; } +export interface ListeningInstructionsState { + isInstructionsOpen: boolean; + chosenOption: Option; + + currentInstructions: string; + presetInstructions: string; + customInstructions: string; + + currentInstructionsURL: string; + presetInstructionsURL: string; + customInstructionsURL: string; +} + export interface ModuleState { examLabel: string; sections: SectionState[]; @@ -122,6 +135,7 @@ export interface ModuleState { edit: number[]; type?: "general" | "academic"; academic_url?: string | undefined; + instructionsState: ListeningInstructionsState; } export interface Avatar {