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..b2f8963c 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"; @@ -149,8 +148,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/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx index 82a081dc..5dfc9842 100644 --- a/src/pages/(status)/PaymentDue.tsx +++ b/src/pages/(status)/PaymentDue.tsx @@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages"; import useUsers from "@/hooks/useUsers"; import { User } from "@/interfaces/user"; import clsx from "clsx"; -import { capitalize } from "lodash"; -import { useEffect, useState } from "react"; +import { capitalize, sortBy } from "lodash"; +import { useEffect, useMemo, useState } from "react"; import useInvites from "@/hooks/useInvites"; import { BsArrowRepeat } from "react-icons/bs"; import InviteCard from "@/components/Medium/InviteCard"; @@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts"; import PaymobPayment from "@/components/PaymobPayment"; import moment from "moment"; import { EntityWithRoles } from "@/interfaces/entity"; +import { Discount, Package } from "@/interfaces/paypal"; +import { isAdmin } from "@/utils/users"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; +import Select from "@/components/Low/Select"; interface Props { - user: User; + user: User + discounts: Discount[] + packages: Package[] entities: EntityWithRoles[] hasExpired?: boolean; reload: () => void; } -export default function PaymentDue({ user, hasExpired = false, reload }: Props) { +export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) { const [isLoading, setIsLoading] = useState(false); - const [appliedDiscount, setAppliedDiscount] = useState(0); + const [entity, setEntity] = useState() const router = useRouter(); - const { packages } = usePackages(); - const { discounts } = useDiscounts(); const { users } = useUsers(); - const { groups } = useGroups({}); const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); - useEffect(() => { - const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`)); - if (userDiscounts.length === 0) return; - - const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift(); - if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return; - - setAppliedDiscount(biggestDiscount.percentage); - }, [discounts, user]); - - const isIndividual = () => { - if (user?.type === "developer") return true; + const isIndividual = useMemo(() => { + if (isAdmin(user)) return false; if (user?.type !== "student") return false; - const userGroups = groups.filter((g) => g.participants.includes(user?.id)); - if (userGroups.length === 0) return true; + return user.entities.length === 0 + }, [user]) - const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t); - return userGroupsAdminTypes.every((t) => t !== "corporate"); - }; + const appliedDiscount = useMemo(() => { + const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift(); + + if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) + return 0; + + return biggestDiscount.percentage + }, [discounts]) + + const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity') + + useEffect(() => { + if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]) + }, [entitiesThatCanBePaid]) return ( <> @@ -74,169 +78,185 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
)} - {user ? ( - - {invites.length > 0 && ( -
-
-
- Invites - -
+ + {invites.length > 0 && ( +
+
+
+ Invites +
- - {invites.map((invite) => ( - { - reloadInvites(); - router.reload(); - }} - /> - ))} - -
- )} +
+ + {invites.map((invite) => ( + { + reloadInvites(); + router.reload(); + }} + /> + ))} + +
+ )} -
- {hasExpired && You do not have time credits for your account type!} - {isIndividual() && ( -
- - To add to your use of EnCoach, please purchase one of the time packages available below: - -
- {packages.map((p) => ( -
-
- EnCoach's Logo - - EnCoach - {p.duration}{" "} - {capitalize( - p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, - )} - -
-
- {appliedDiscount === 0 && ( - - {p.price} {p.currency} - - )} - {appliedDiscount > 0 && ( -
- - {p.price} {p.currency} - - - {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} - -
- )} - { - setTimeout(reload, 500); - }} - currency={p.currency} - duration={p.duration} - duration_unit={p.duration_unit} - price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} - /> -
-
- This includes: -
    -
  • - Train your abilities for the IELTS exam
  • -
  • - Gain insights into your weaknesses and strengths
  • -
  • - Allow yourself to correctly prepare for the exam
  • -
-
-
- ))} -
-
- )} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - user?.corporateInformation.payment && ( -
- - To add to your use of EnCoach and that of your students and teachers, please pay your designated package - below: - -
+
+ {hasExpired && You do not have time credits for your account type!} + {isIndividual && ( +
+ + To add to your use of EnCoach, please purchase one of the time packages available below: + +
+ {packages.map((p) => ( +
EnCoach's Logo - EnCoach - {12} Months + EnCoach - {p.duration}{" "} + {capitalize( + p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, + )}
- - {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency} - + {appliedDiscount === 0 && ( + + {p.price} {p.currency} + + )} + {appliedDiscount > 0 && ( +
+ + {p.price} {p.currency} + + + {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} + +
+ )} { - setIsLoading(false); setTimeout(reload, 500); }} + currency={p.currency} + duration={p.duration} + duration_unit={p.duration_unit} + price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} />
This includes:
    -
  • - - Allow a total of 0 students and teachers to use EnCoach -
  • -
  • - Train their abilities for the IELTS exam
  • -
  • - Gain insights into your students' weaknesses and strengths
  • -
  • - Allow them to correctly prepare for the exam
  • +
  • - Train your abilities for the IELTS exam
  • +
  • - Gain insights into your weaknesses and strengths
  • +
  • - Allow yourself to correctly prepare for the exam
+ ))} +
+
+ )} + + {!isIndividual && entitiesThatCanBePaid.length > 0 && + entity?.payment && ( +
+
+ + ({ value: e.id, label: e.label, entity: e }))} + onChange={(e) => e?.value ? setEntity(e?.entity) : null} + className="!w-full max-w-[400px] self-center" + /> +
+ + An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users + you desire and your expected monthly duration. - If you believe this to be a mistake, please contact the platform's administration, thank you for your - patience. + Please try again later or contact your agent or an admin, thank you for your patience.
)} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - !user.corporateInformation.payment && ( -
- - An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users - you desire and your expected monthly duration. - - - Please try again later or contact your agent or an admin, thank you for your patience. - -
- )} -
- - ) : ( -
- )} +
+ ); } diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts index c329f832..0323ac05 100644 --- a/src/pages/api/entities/[id]/index.ts +++ b/src/pages/api/entities/[id]/index.ts @@ -6,9 +6,11 @@ import { deleteEntity, getEntity, getEntityWithRoles } from "@/utils/entities.be import client from "@/lib/mongodb"; import { Entity } from "@/interfaces/entity"; import { doesEntityAllow } from "@/utils/permissions"; -import { getUser } from "@/utils/users.be"; +import { getEntityUsers, getUser } from "@/utils/users.be"; import { requestUser } from "@/utils/api"; import { isAdmin } from "@/utils/users"; +import { filterBy, mapBy } from "@/utils"; +import { User } from "@/interfaces/user"; const db = client.db(process.env.MONGODB_DB); @@ -66,5 +68,27 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return res.status(200).json({ ok: entity.acknowledged }); } + if (req.body.payment) { + const entity = await db.collection("entities").updateOne({ id }, { $set: { payment: req.body.payment } }); + return res.status(200).json({ ok: entity.acknowledged }); + } + + if (req.body.expiryDate !== undefined) { + const entity = await getEntity(id) + const result = await db.collection("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } }); + + const users = await getEntityUsers(id, 0, { + subscriptionExpirationDate: entity?.expiryDate, + $and: [ + { type: { $ne: "admin" } }, + { type: { $ne: "developer" } }, + ] + }) + + await db.collection("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } }) + + return res.status(200).json({ ok: result.acknowledged }); + } + return res.status(200).json({ ok: true }); } 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/[module]/index.ts b/src/pages/api/exam/[module]/index.ts index d54e7835..563b953b 100644 --- a/src/pages/api/exam/[module]/index.ts +++ b/src/pages/api/exam/[module]/index.ts @@ -7,6 +7,9 @@ import { Exam, ExamBase, InstructorGender, Variant } from "@/interfaces/exam"; import { getExams } from "@/utils/exams.be"; import { Module } from "@/interfaces"; import { getUserCorporate } from "@/utils/groups.be"; +import { requestUser } from "@/utils/api"; +import { isAdmin } from "@/utils/users"; +import { mapBy } from "@/utils"; const db = client.db(process.env.MONGODB_DB); @@ -37,25 +40,20 @@ async function GET(req: NextApiRequest, res: NextApiResponse) { } async function POST(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const { module } = req.query as { module: string }; - const corporate = await getUserCorporate(req.session.user.id); const session = client.startSession(); + const entities = isAdmin(user) ? [] : mapBy(user.entities, 'id') try { const exam = { ...req.body, module: module, - owners: [ - ...(["mastercorporate", "corporate"].includes(req.session.user.type) ? [req.session.user.id] : []), - ...(!!corporate ? [corporate.id] : []), - ], - createdBy: req.session.user.id, + entities, + createdBy: user.id, createdAt: new Date().toISOString(), }; 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/paymob/webhook.ts b/src/pages/api/paymob/webhook.ts index 7b428c6c..0fd4b416 100644 --- a/src/pages/api/paymob/webhook.ts +++ b/src/pages/api/paymob/webhook.ts @@ -1,15 +1,19 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type {NextApiRequest, NextApiResponse} from "next"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {Group, User} from "@/interfaces/user"; -import {DurationUnit, Package, Payment} from "@/interfaces/paypal"; -import {v4} from "uuid"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Group, User } from "@/interfaces/user"; +import { DurationUnit, Package, Payment } from "@/interfaces/paypal"; +import { v4 } from "uuid"; import ShortUniqueId from "short-unique-id"; import axios from "axios"; -import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob"; +import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob"; import moment from "moment"; import client from "@/lib/mongodb"; +import { getEntity } from "@/utils/entities.be"; +import { Entity } from "@/interfaces/entity"; +import { getEntityUsers } from "@/utils/users.be"; +import { mapBy } from "@/utils"; const db = client.db(process.env.MONGODB_DB); @@ -22,21 +26,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const authToken = await authenticatePaymob(); console.log("WEBHOOK: ", transactionResult); - if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); - if (!transactionResult.transaction.success) return res.status(400).json({ok: false}); + if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false }); + if (!transactionResult.transaction.success) return res.status(400).json({ ok: false }); - const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as { + const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as { userID: string; duration: number; duration_unit: DurationUnit; + entity: string }; const user = await db.collection("users").findOne({ id: userID as string }); - if (!user || !duration || !duration_unit) return res.status(404).json({ok: false}); + if (!user || !duration || !duration_unit) return res.status(404).json({ ok: false }); const subscriptionExpirationDate = user.subscriptionExpirationDate; - if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); + if (!subscriptionExpirationDate) return res.status(200).json({ ok: false }); const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); @@ -44,8 +49,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { await db.collection("users").updateOne( { id: userID as string }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); + { $set: { subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active" } } + ); await db.collection("paypalpayments").insertOne({ id: v4(), @@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { value: transactionResult.transaction.amount_cents / 1000, }); - if (user.type === "corporate") { - const groups = await db.collection("groups").find({ admin: user.id }).toArray(); + if (entityID) { + const entity = await getEntity(entityID) + await db.collection("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } }); - const participants = (await Promise.all( - groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})), - )) as User[]; - const sameExpiryDateParticipants = participants.filter( - (x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled", - ); + const users = await getEntityUsers(entityID, 0, { + subscriptionExpirationDate: entity?.expiryDate, + $and: [ + { type: { $ne: "admin" } }, + { type: { $ne: "developer" } }, + ] + }) - for (const participant of sameExpiryDateParticipants) { - await db.collection("users").updateOne( - { id: participant.id }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); - } + await db.collection("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } }) } res.status(200).json({ @@ -84,19 +86,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } const authenticatePaymob = async () => { - const response = await axios.post<{token: string}>( + const response = await axios.post<{ token: string }>( "https://oman.paymob.com/api/auth/tokens", { api_key: process.env.PAYMOB_API_KEY, }, - {headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}}, + { headers: { Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}` } }, ); return response.data.token; }; const checkTransaction = async (token: string, orderID: number) => { - const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", {auth_token: token, order_id: orderID}); + const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", { auth_token: token, order_id: orderID }); return response.status === 200; }; 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/classrooms/index.tsx b/src/pages/classrooms/index.tsx index 2f70bcbd..958450c4 100644 --- a/src/pages/classrooms/index.tsx +++ b/src/pages/classrooms/index.tsx @@ -14,7 +14,7 @@ import { uniq } from "lodash"; import { BsFillMortarboardFill, BsPlus } from "react-icons/bs"; import CardList from "@/components/High/CardList"; import Separator from "@/components/Low/Separator"; -import { mapBy, redirect, serialize } from "@/utils"; +import { findBy, mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; import { findAllowedEntities } from "@/utils/permissions"; import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; @@ -80,6 +80,12 @@ export default function Home({ user, groups, entities }: Props) { Admin {getUserName(group.admin)} + {!!group.entity && ( + + Entity + {findBy(entities, 'id', group.entity)?.label} + + )} Participants {group.participants.length} diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index 1546d933..34b35978 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -2,6 +2,8 @@ import CardList from "@/components/High/CardList"; import Layout from "@/components/High/Layout"; import Select from "@/components/Low/Select"; +import Input from "@/components/Low/Input"; +import Checkbox from "@/components/Low/Checkbox"; import Tooltip from "@/components/Low/Tooltip"; import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useListSearch } from "@/hooks/useListSearch"; @@ -27,7 +29,11 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Divider } from "primereact/divider"; import { useEffect, useMemo, useState } from "react"; +import ReactDatePicker from "react-datepicker"; +import { CURRENCIES } from "@/resources/paypal"; + import { + BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, @@ -43,6 +49,20 @@ import { } from "react-icons/bs"; import { toast } from "react-toastify"; +const expirationDateColor = (date: Date) => { + const momentDate = moment(date); + const today = moment(new Date()); + + if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; + if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; + if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; +}; + +const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({ + value: currency, + label, +})); + export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const user = req.session.user as User; @@ -88,6 +108,9 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const [isAdding, setIsAdding] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); + const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) + const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price) + const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency) const router = useRouter(); @@ -99,6 +122,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity") const canAssignRole = useEntityPermission(user, entity, "assign_to_role") + const canPay = useEntityPermission(user, entity, 'pay_entity') const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); @@ -166,6 +190,40 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { .finally(() => setIsLoading(false)); }; + const updateExpiryDate = () => { + if (!isAdmin(user)) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, { expiryDate }) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const updatePayment = () => { + if (!isAdmin(user)) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } }) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + const editLicenses = () => { if (!isAdmin(user)) return; @@ -289,7 +347,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
-
+

{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}

+ + {!isAdmin(user) && canPay && ( + + {!entity.expiryDate && "Unlimited"} + {entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")} + + )}
+ + {isAdmin(user) && ( + <> + + +
+
+ {!!expiryDate && ( + moment(date).isAfter(new Date())} + dateFormat="dd/MM/yyyy" + selected={expiryDate ? moment(expiryDate).toDate() : null} + onChange={(date) => setExpiryDate(date)} + /> + )} + + {!expiryDate && ( +
+ Unlimited +
+ )} + + setExpiryDate(checked ? entity.expiryDate || new Date() : null)} + > + Enable expiry date + +
+ + + +
+ + + +
+
+ setPaymentPrice(e ? parseInt(e) : undefined)} + type="number" + defaultValue={entity.payment?.price || 0} + thin + /> +