diff --git a/src/components/Exercises/Writing.tsx b/src/components/Exercises/Writing.tsx index 5560c2b7..58366a5c 100644 --- a/src/components/Exercises/Writing.tsx +++ b/src/components/Exercises/Writing.tsx @@ -132,7 +132,14 @@ export default function Writing({ diff --git a/src/components/Solutions/InteractiveSpeaking.tsx b/src/components/Solutions/InteractiveSpeaking.tsx index 4293096b..37cd76bb 100644 --- a/src/components/Solutions/InteractiveSpeaking.tsx +++ b/src/components/Solutions/InteractiveSpeaking.tsx @@ -27,7 +27,7 @@ export default function InteractiveSpeaking({ const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0); useEffect(() => { - if (userSolutions && userSolutions.length > 0) { + if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) { Promise.all(userSolutions[0].solution.map((x) => axios.post(`/api/speaking`, {path: x.answer}, {responseType: "arraybuffer"}))).then( (values) => { setSolutionsURL( @@ -239,7 +239,11 @@ export default function InteractiveSpeaking({ onNext({ exercise: id, solutions: userSolutions, - score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, + score: { + total: 100, + missing: 0, + correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0, + }, type, }) } diff --git a/src/components/Solutions/Speaking.tsx b/src/components/Solutions/Speaking.tsx index 128750e8..d498c5c1 100644 --- a/src/components/Solutions/Speaking.tsx +++ b/src/components/Solutions/Speaking.tsx @@ -19,7 +19,7 @@ export default function Speaking({id, type, title, video_url, text, prompts, use const [showDiff, setShowDiff] = useState(false); useEffect(() => { - if (userSolutions && userSolutions.length > 0) { + if (userSolutions && userSolutions.length > 0 && userSolutions[0].solution) { axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { const blob = new Blob([data], {type: "audio/wav"}); const url = URL.createObjectURL(blob); @@ -29,10 +29,6 @@ export default function Speaking({id, type, title, video_url, text, prompts, use } }, [userSolutions]); - useEffect(() => { - console.log(userSolutions); - }, [userSolutions]); - return ( <> setShowDiff(false)}> @@ -205,7 +201,11 @@ export default function Speaking({id, type, title, video_url, text, prompts, use onNext({ exercise: id, solutions: userSolutions, - score: {total: 100, missing: 0, correct: speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, + score: { + total: 100, + missing: 0, + correct: userSolutions[0]?.evaluation ? speakingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0, + }, type, }) } diff --git a/src/components/Solutions/Writing.tsx b/src/components/Solutions/Writing.tsx index ea082e0d..303a4242 100644 --- a/src/components/Solutions/Writing.tsx +++ b/src/components/Solutions/Writing.tsx @@ -76,7 +76,7 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on )} - {showDiff && ( + {showDiff && userSolutions[0].evaluation && ( <> Correction:
@@ -191,7 +191,11 @@ export default function Writing({id, type, prompt, attachment, userSolutions, on onNext({ exercise: id, solutions: userSolutions, - score: {total: 100, missing: 0, correct: writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0}, + score: { + total: 100, + missing: 0, + correct: userSolutions[0]?.evaluation ? writingReverseMarking[userSolutions[0]!.evaluation!.overall] || 0 : 0, + }, type, }) } diff --git a/src/dashboards/AssignmentCreator.tsx b/src/dashboards/AssignmentCreator.tsx index fd721ab4..dcd94a9d 100644 --- a/src/dashboards/AssignmentCreator.tsx +++ b/src/dashboards/AssignmentCreator.tsx @@ -19,6 +19,7 @@ import {toast} from "react-toastify"; import {uuidv4} from "@firebase/util"; import {Assignment} from "@/interfaces/results"; import Checkbox from "@/components/Low/Checkbox"; +import {Variant} from "@/interfaces/exam"; interface Props { isCreating: boolean; @@ -38,6 +39,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro const [endDate, setEndDate] = useState( assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), ); + const [variant, setVariant] = useState("full"); // creates a new exam for each assignee or just one exam for all assignees const [generateMultiple, setGenerateMultiple] = useState(false); @@ -60,6 +62,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro endDate, selectedModules, generateMultiple, + variant, }) .then(() => { toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); @@ -279,7 +282,10 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro ))}
-
+
+ setVariant((prev) => (prev === "full" ? "partial" : "full"))}> + Full length exams + setGenerateMultiple((d) => !d)}> Generate different exams diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index b8e12632..2f3e2128 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -1,7 +1,7 @@ import {Module} from "."; export type Exam = ReadingExam | ListeningExam | WritingExam | SpeakingExam | LevelExam; -export type Variant = "full" | "diagnostic" | "partial"; +export type Variant = "full" | "partial"; export interface ReadingExam { parts: ReadingPart[]; diff --git a/src/pages/(admin)/BatchCodeGenerator.tsx b/src/pages/(admin)/BatchCodeGenerator.tsx index 4eb1ece0..39bb132c 100644 --- a/src/pages/(admin)/BatchCodeGenerator.tsx +++ b/src/pages/(admin)/BatchCodeGenerator.tsx @@ -58,45 +58,59 @@ export default function BatchCodeGenerator({user}: {user: User}) { if (filesContent.length > 0) { const file = filesContent[0]; readXlsxFile(file.content).then((rows) => { - const information = uniqBy( - rows - .map((row) => { - const [firstName, lastName, country, passport_id, email, phone] = row as string[]; - return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) - ? { - email: email.toString(), - name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), - passport_id: passport_id.toString(), - } - : undefined; - }) - .filter((x) => !!x) as typeof infos, - (x) => x.email, - ); + try { + const information = uniqBy( + rows + .map((row) => { + const [firstName, lastName, country, passport_id, email, ...phone] = row as string[]; + return EMAIL_REGEX.test(email.toString().trim()) && !users.map((u) => u.email).includes(email.toString().trim()) + ? { + email: email.toString().trim(), + name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), + passport_id: passport_id?.toString().trim() || undefined, + } + : undefined; + }) + .filter((x) => !!x) as typeof infos, + (x) => x.email, + ); - if (information.length === 0) { + if (information.length === 0) { + toast.error( + "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", + ); + return clear(); + } + + setInfos(information); + } catch { toast.error( "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", ); return clear(); } - - setInfos(information); }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [filesContent]); const generateCode = (type: Type) => { + if (!confirm(`You are about to generate ${infos.length} codes, are you sure you want to continue?`)) return; + const uid = new ShortUniqueId(); const codes = infos.map(() => uid.randomUUID(6)); setIsLoading(true); axios - .post("/api/code", {type, codes, infos: infos, expiryDate}) + .post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {type, codes, infos: infos, expiryDate}) .then(({data, status}) => { if (data.ok) { - toast.success(`Successfully generated ${capitalize(type)} codes and they have been notified by e-mail!`, {toastId: "success"}); + toast.success( + `Successfully generated${data.valid ? ` ${data.valid}/${infos.length}` : ""} ${capitalize( + type, + )} codes and they have been notified by e-mail!`, + {toastId: "success"}, + ); return; } @@ -112,7 +126,10 @@ export default function BatchCodeGenerator({user}: {user: User}) { toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); }) - .finally(() => setIsLoading(false)); + .finally(() => { + setIsLoading(false); + return clear(); + }); }; return ( diff --git a/src/pages/api/assignments/index.ts b/src/pages/api/assignments/index.ts index a34228d1..5d454fe1 100644 --- a/src/pages/api/assignments/index.ts +++ b/src/pages/api/assignments/index.ts @@ -7,7 +7,7 @@ import {sessionOptions} from "@/lib/session"; import {uuidv4} from "@firebase/util"; import {Module} from "@/interfaces"; import {getExams} from "@/utils/exams.be"; -import {Exam} from "@/interfaces/exam"; +import {Exam, Variant} from "@/interfaces/exam"; import {capitalize, flatten} from "lodash"; import {User} from "@/interfaces/user"; import moment from "moment"; @@ -52,13 +52,18 @@ function getRandomIndex(arr: any[]): number { return randomIndex; } -const generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): Promise => { +const generateExams = async ( + generateMultiple: Boolean, + selectedModules: Module[], + assignees: string[], + variant?: Variant, +): Promise => { if (generateMultiple) { // for optimization purposes, it would be better to create a new endpoint that returned the answers for all users at once const allExams = await assignees.map(async (assignee) => { const selectedModulePromises = await selectedModules.map(async (module: Module) => { try { - const exams: Exam[] = await getExams(db, module, "true", assignee); + const exams: Exam[] = await getExams(db, module, "true", assignee, variant); const exam = exams[getRandomIndex(exams)]; if (exam) { @@ -101,6 +106,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { // Generate multiple true would generate an unique exam for each user // false would generate the same exam for all users generateMultiple = false, + variant, ...body } = req.body as { selectedModules: Module[]; @@ -109,9 +115,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { name: string; startDate: string; endDate: string; + variant?: Variant; }; - const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees); + const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees, variant); if (exams.length === 0) { res.status(400).json({ok: false, error: "No exams found for the selected modules"}); diff --git a/src/pages/api/code/index.ts b/src/pages/api/code/index.ts index 39787f11..d855e1f8 100644 --- a/src/pages/api/code/index.ts +++ b/src/pages/api/code/index.ts @@ -42,7 +42,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const {type, codes, infos, expiryDate} = req.body as { type: Type; codes: string[]; - infos?: {email: string; name: string; passport_id: string}[]; + infos?: {email: string; name: string; passport_id?: string}[]; expiryDate: null | Date; }; const permission = PERMISSIONS.generateCode[type]; @@ -70,11 +70,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const codePromises = codes.map(async (code, index) => { const codeRef = doc(db, "codes", code); - await setDoc(codeRef, {type, code, creator: req.session.user!.id, expiryDate}); + const codeInformation = {type, code, creator: req.session.user!.id, expiryDate}; if (infos && infos.length > index) { const {email, name, passport_id} = infos[index]; - await setDoc(codeRef, {email: email.trim(), name: name.trim(), passport_id: passport_id.trim()}, {merge: true}); const transport = prepareMailer(); const mailOptions = prepareMailOptions( @@ -87,11 +86,24 @@ async function post(req: NextApiRequest, res: NextApiResponse) { "main", ); - await transport.sendMail(mailOptions); + try { + await transport.sendMail(mailOptions); + await setDoc( + codeRef, + {...codeInformation, email: email.trim(), name: name.trim(), ...(passport_id ? {passport_id: passport_id.trim()} : {})}, + {merge: true}, + ); + + return true; + } catch (e) { + return false; + } + } else { + await setDoc(codeRef, codeInformation); } }); - Promise.all(codePromises).then(() => { - res.status(200).json({ok: true}); + Promise.all(codePromises).then((results) => { + res.status(200).json({ok: true, valid: results.filter((x) => x).length}); }); } diff --git a/src/pages/api/stats/[id].ts b/src/pages/api/stats/[id].ts deleted file mode 100644 index 0597f529..00000000 --- a/src/pages/api/stats/[id].ts +++ /dev/null @@ -1,23 +0,0 @@ -// 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/index.tsx b/src/pages/index.tsx index 79322a97..02ff0a8e 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -30,6 +30,9 @@ import AgentDashboard from "@/dashboards/Agent"; import PaymentDue from "./(status)/PaymentDue"; import {useRouter} from "next/router"; import {PayPalScriptProvider} from "@paypal/react-paypal-js"; +import {CorporateUser, Type, userTypes} from "@/interfaces/user"; +import Select from "react-select"; +import {USER_TYPE_LABELS} from "@/resources/user"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -61,8 +64,9 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { export default function Home({envVariables}: {envVariables: {[key: string]: string}}) { const [showDiagnostics, setShowDiagnostics] = useState(false); const [showDemographicInput, setShowDemographicInput] = useState(false); + const [selectedScreen, setSelectedScreen] = useState("admin"); + const {user, mutateUser} = useUser({redirectTo: "/login"}); - const {stats} = useStats(user?.id); const router = useRouter(); useEffect(() => { @@ -176,7 +180,21 @@ export default function Home({envVariables}: {envVariables: {[key: string]: stri {user.type === "corporate" && } {user.type === "agent" && } {user.type === "admin" && } - {user.type === "developer" && } + {user.type === "developer" && ( + <> +