From 74dcccf089c3158a1aadade97afdce511859a371 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Wed, 17 Jan 2024 15:55:33 +0000 Subject: [PATCH 1/2] Added a new type of e-mail to send to students when a new assignment is created --- src/dashboards/AssignmentCreator.tsx | 55 +++--- src/email/index.ts | 12 ++ src/email/templates/assignment.handlebars | 28 +++ .../templates/assignment.handlebars.json | 13 ++ src/pages/api/assignments/index.ts | 185 +++++++++--------- src/pages/api/reset/sendVerification.ts | 11 +- 6 files changed, 178 insertions(+), 126 deletions(-) create mode 100644 src/email/templates/assignment.handlebars create mode 100644 src/email/templates/assignment.handlebars.json diff --git a/src/dashboards/AssignmentCreator.tsx b/src/dashboards/AssignmentCreator.tsx index 358f3721..ad0799e7 100644 --- a/src/dashboards/AssignmentCreator.tsx +++ b/src/dashboards/AssignmentCreator.tsx @@ -34,8 +34,12 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro const [assignees, setAssignees] = useState(assignment?.assignees || []); const [name, setName] = useState(assignment?.name || generate({minLength: 6, maxLength: 8, min: 2, max: 3, join: " ", formatter: capitalize})); const [isLoading, setIsLoading] = useState(false); - const [startDate, setStartDate] = useState(assignment ? moment(assignment.startDate).toDate() : moment().add(1, "day").toDate()); - const [endDate, setEndDate] = useState(assignment ? moment(assignment.endDate).toDate() : moment().add(8, "day").toDate()); + const [startDate, setStartDate] = useState( + assignment ? moment(assignment.startDate).toDate() : moment().hours(0).minutes(0).add(1, "day").toDate(), + ); + const [endDate, setEndDate] = useState( + assignment ? moment(assignment.endDate).toDate() : moment().hours(23).minutes(59).add(8, "day").toDate(), + ); // creates a new exam for each assignee or just one exam for all assignees const [generateMultiple, setGenerateMultiple] = useState(false); @@ -49,33 +53,26 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro }; const createAssignment = () => { - setIsLoading(true); + setIsLoading(true); - (assignment ? axios.patch : axios.post)( - `/api/assignments${assignment ? `/${assignment.id}` : ""}`, - { - assignees, - name, - startDate, - endDate, - selectedModules, - generateMultiple, - } - ) - .then(() => { - toast.success( - `The assignment "${name}" has been ${ - assignment ? "updated" : "created" - } successfully!` - ); - cancelCreation(); - }) - .catch((e) => { - console.log(e); - toast.error("Something went wrong, please try again later!"); - }) - .finally(() => setIsLoading(false)); - }; + (assignment ? axios.patch : axios.post)(`/api/assignments${assignment ? `/${assignment.id}` : ""}`, { + assignees, + name, + startDate, + endDate, + selectedModules, + generateMultiple, + }) + .then(() => { + toast.success(`The assignment "${name}" has been ${assignment ? "updated" : "created"} successfully!`); + cancelCreation(); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + }; const deleteAssignment = () => { if (assignment) { @@ -285,7 +282,7 @@ export default function AssignmentCreator({isCreating, assignment, assigner, gro
- setGenerateMultiple(d => !d)}> + setGenerateMultiple((d) => !d)}> Generate different exams
diff --git a/src/email/index.ts b/src/email/index.ts index 0fd6bff6..c8e98add 100644 --- a/src/email/index.ts +++ b/src/email/index.ts @@ -40,3 +40,15 @@ export function prepareMailOptions(context: object, to: string[], subject: strin context, }; } + +export async function sendEmail(template: string, context: object, to: string[], subject: string): Promise { + try { + const transport = prepareMailer(template); + const mailOptions = prepareMailOptions(context, to, subject, template); + + await transport.sendMail(mailOptions); + return true; + } catch { + return false; + } +} diff --git a/src/email/templates/assignment.handlebars b/src/email/templates/assignment.handlebars new file mode 100644 index 00000000..d4a0752e --- /dev/null +++ b/src/email/templates/assignment.handlebars @@ -0,0 +1,28 @@ + + + + + + + + +
+

Hello {{user.name}},

+
+

You have just been given the assignment "{{assignment.name}}" by your teacher {{assignment.assigner}}!

+
+

It's start date will be on {{assignment.startDate}} and will only last until {{assignment.endDate}} +

+
+

For this assignment, you've been tasked with completing exams of the following modules: + {{assignment.modules}}. +

+
+

Don't forget to do it before its end date!

+

Click here to open the EnCoach Platform!

+
+

Thanks,

+

Your EnCoach team

+
+ + \ No newline at end of file diff --git a/src/email/templates/assignment.handlebars.json b/src/email/templates/assignment.handlebars.json new file mode 100644 index 00000000..08486418 --- /dev/null +++ b/src/email/templates/assignment.handlebars.json @@ -0,0 +1,13 @@ +{ + "user": { + "name": "Tiago Ribeiro" + }, + "assignment": { + "name": "Final Exam", + "assigner": "Teacher", + "assignees": [], + "modules": "Reading and Writing", + "startDate": "24/12/2023", + "endDate": "27/01/2024" + } +} \ No newline at end of file diff --git a/src/pages/api/assignments/index.ts b/src/pages/api/assignments/index.ts index f5a87acd..a34228d1 100644 --- a/src/pages/api/assignments/index.ts +++ b/src/pages/api/assignments/index.ts @@ -1,14 +1,17 @@ // 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} from "firebase/firestore"; +import {getFirestore, collection, getDocs, query, where, setDoc, doc, getDoc} from "firebase/firestore"; import {withIronSessionApiRoute} from "iron-session/next"; 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 { flatten } from "lodash"; +import {Module} from "@/interfaces"; +import {getExams} from "@/utils/exams.be"; +import {Exam} from "@/interfaces/exam"; +import {capitalize, flatten} from "lodash"; +import {User} from "@/interfaces/user"; +import moment from "moment"; +import {sendEmail} from "@/email"; const db = getFirestore(app); @@ -39,106 +42,108 @@ async function GET(req: NextApiRequest, res: NextApiResponse) { } interface ExamWithUser { - module: Module; - id: string; - assignee: string; + module: Module; + id: string; + assignee: string; } function getRandomIndex(arr: any[]): number { - const randomIndex = Math.floor(Math.random() * arr.length); - return randomIndex; + const randomIndex = Math.floor(Math.random() * arr.length); + return randomIndex; } -const generateExams = async ( - generateMultiple: Boolean, - selectedModules: Module[], - assignees: string[] -): 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 generateExams = async (generateMultiple: Boolean, selectedModules: Module[], assignees: string[]): 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 exam = exams[getRandomIndex(exams)]; - if (exam) { - return { module: exam.module, id: exam.id, assignee }; - } - return null; - } catch (e) { - console.error(e); - return null; - } - }, - [] - ); - const newModules = await Promise.all(selectedModulePromises); + const exam = exams[getRandomIndex(exams)]; + if (exam) { + return {module: exam.module, id: exam.id, assignee}; + } + return null; + } catch (e) { + console.error(e); + return null; + } + }, []); + const newModules = await Promise.all(selectedModulePromises); - return newModules; - }, []); + return newModules; + }, []); - const exams = flatten(await Promise.all(allExams)).filter( - (x) => x !== null - ) as ExamWithUser[]; - return exams; - } + const exams = flatten(await Promise.all(allExams)).filter((x) => x !== null) as ExamWithUser[]; + return exams; + } - const selectedModulePromises = await selectedModules.map( - async (module: Module) => { - const exams: Exam[] = await getExams(db, module, "false", undefined); - const exam = exams[getRandomIndex(exams)]; + const selectedModulePromises = await selectedModules.map(async (module: Module) => { + const exams: Exam[] = await getExams(db, module, "false", undefined); + const exam = exams[getRandomIndex(exams)]; - if (exam) { - return { module: exam.module, id: exam.id }; - } - return null; - } - ); + if (exam) { + return {module: exam.module, id: exam.id}; + } + return null; + }); - const exams = await Promise.all(selectedModulePromises); - const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[]; - return flatten( - assignees.map((assignee) => - examesFiltered.map((exam) => ({ ...exam, assignee })) - ) - ); + const exams = await Promise.all(selectedModulePromises); + const examesFiltered = exams.filter((x) => x !== null) as ExamWithUser[]; + return flatten(assignees.map((assignee) => examesFiltered.map((exam) => ({...exam, assignee})))); }; async function POST(req: NextApiRequest, res: NextApiResponse) { - const { - selectedModules, - assignees, - // Generarte multiple true would generate an unique exam for eacah user - // false would generate the same exam for all usersa - generateMultiple = false, - ...body - } = req.body as { - selectedModules: Module[]; - assignees: string[]; - generateMultiple: Boolean; - }; + const { + selectedModules, + assignees, + // Generate multiple true would generate an unique exam for each user + // false would generate the same exam for all users + generateMultiple = false, + ...body + } = req.body as { + selectedModules: Module[]; + assignees: string[]; + generateMultiple: Boolean; + name: string; + startDate: string; + endDate: string; + }; - const exams: ExamWithUser[] = await generateExams( - generateMultiple, - selectedModules, - assignees - ); - if (exams.length === 0) { - res - .status(400) - .json({ ok: false, error: "No exams found for the selected modules" }); - return; - } + const exams: ExamWithUser[] = await generateExams(generateMultiple, selectedModules, assignees); - await setDoc(doc(db, "assignments", uuidv4()), { - assigner: req.session.user?.id, - assignees, - results: [], - exams, - ...body, - }); + if (exams.length === 0) { + res.status(400).json({ok: false, error: "No exams found for the selected modules"}); + return; + } - res.status(200).json({ ok: true }); + await setDoc(doc(db, "assignments", uuidv4()), { + assigner: req.session.user?.id, + assignees, + results: [], + exams, + ...body, + }); + + res.status(200).json({ok: true}); + + for (const assigneeID of assignees) { + const assigneeSnapshot = await getDoc(doc(db, "users", assigneeID)); + if (!assigneeSnapshot.exists()) continue; + + const assignee = {id: assigneeID, ...assigneeSnapshot.data()} as User; + const name = body.name; + const teacher = req.session.user!; + const examModulesLabel = exams.map((x) => capitalize(x.module)).join(", "); + const startDate = moment(body.startDate).format("DD/MM/YYYY - HH:mm"); + const endDate = moment(body.endDate).format("DD/MM/YYYY - HH:mm"); + + await sendEmail( + "assignment", + {user: {name: assignee.name}, assignment: {name, startDate, endDate, modules: examModulesLabel, assigner: teacher.name}}, + [assignee.email], + "EnCoach - New Assignment!", + ); + } } diff --git a/src/pages/api/reset/sendVerification.ts b/src/pages/api/reset/sendVerification.ts index cf028f59..cb582cb1 100644 --- a/src/pages/api/reset/sendVerification.ts +++ b/src/pages/api/reset/sendVerification.ts @@ -4,7 +4,7 @@ import {getAuth as getAdminAuth, UserRecord} from "firebase-admin/auth"; import {app, adminApp} from "@/firebase"; import {sessionOptions} from "@/lib/session"; import {withIronSessionApiRoute} from "iron-session/next"; -import {prepareMailer, prepareMailOptions} from "@/email"; +import {prepareMailer, prepareMailOptions, sendEmail} from "@/email"; import ShortUniqueId from "short-unique-id"; export default withIronSessionApiRoute(sendVerification, sessionOptions); @@ -13,8 +13,8 @@ async function sendVerification(req: NextApiRequest, res: NextApiResponse) { const short = new ShortUniqueId(); if (req.session.user) { - const transport = prepareMailer("verification"); - const mailOptions = prepareMailOptions( + const ok = await sendEmail( + "verification", { name: req.session.user.name, code: short.randomUUID(6), @@ -22,12 +22,9 @@ async function sendVerification(req: NextApiRequest, res: NextApiResponse) { }, [req.session.user.email], "EnCoach Verification", - "verification", ); - await transport.sendMail(mailOptions); - - res.status(200).json({ok: true}); + res.status(200).json({ok}); return; } res.status(404).json({ok: false}); From 68069d118f90f13ab6dbaac17c19d5d8055fe0f4 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Wed, 17 Jan 2024 23:40:46 +0000 Subject: [PATCH 2/2] Added correction visualizers for the Speaking transcript correction --- src/components/Low/Button.tsx | 7 ++- .../Solutions/InteractiveSpeaking.tsx | 56 +++++++++++++++++- src/components/Solutions/Speaking.tsx | 57 ++++++++++++++++++- src/interfaces/exam.ts | 15 ++++- 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/components/Low/Button.tsx b/src/components/Low/Button.tsx index 9bd207f0..0adcd018 100644 --- a/src/components/Low/Button.tsx +++ b/src/components/Low/Button.tsx @@ -4,7 +4,7 @@ import {BsArrowRepeat} from "react-icons/bs"; interface Props { children: ReactNode; - color?: "rose" | "purple" | "red" | "green" | "gray"; + color?: "rose" | "purple" | "red" | "green" | "gray" | "pink"; variant?: "outline" | "solid"; className?: string; disabled?: boolean; @@ -49,6 +49,11 @@ export default function Button({ outline: "bg-transparent text-mti-rose-light border border-mti-rose-light hover:bg-mti-rose-light disabled:text-mti-rose disabled:bg-mti-rose-ultralight disabled:border-none selection:bg-mti-rose-dark hover:text-white selection:text-white", }, + pink: { + solid: "bg-ielts-speaking text-white border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent selection:bg-ielts-speaking", + outline: + "bg-transparent text-ielts-speaking border border-ielts-speaking hover:bg-ielts-speaking disabled:text-ielts-speaking disabled:bg-ielts-speaking-transparent disabled:border-none selection:bg-ielts-speaking hover:text-white selection:text-white", + }, }; return ( diff --git a/src/components/Solutions/InteractiveSpeaking.tsx b/src/components/Solutions/InteractiveSpeaking.tsx index 3495b905..4293096b 100644 --- a/src/components/Solutions/InteractiveSpeaking.tsx +++ b/src/components/Solutions/InteractiveSpeaking.tsx @@ -8,6 +8,8 @@ import axios from "axios"; import {speakingReverseMarking} from "@/utils/score"; import {Tab} from "@headlessui/react"; import clsx from "clsx"; +import Modal from "../Modal"; +import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); @@ -22,6 +24,7 @@ export default function InteractiveSpeaking({ onBack, }: InteractiveSpeakingExercise & CommonProps) { const [solutionsURL, setSolutionsURL] = useState([]); + const [diffNumber, setDiffNumber] = useState<0 | 1 | 2 | 3>(0); useEffect(() => { if (userSolutions && userSolutions.length > 0) { @@ -42,6 +45,44 @@ export default function InteractiveSpeaking({ return ( <> + setDiffNumber(0)}> + <> + {userSolutions && + userSolutions.length > 0 && + diffNumber !== 0 && + userSolutions[0].evaluation && + userSolutions[0].evaluation[`transcript_${diffNumber}`] && + userSolutions[0].evaluation[`fixed_text_${diffNumber}`] && ( +
+
+ Transcript + Recommended Improvements +
+ +
+ )} + +
+
@@ -67,10 +108,23 @@ export default function InteractiveSpeaking({ {solutionsURL.map((x, index) => (
+ className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex flex-col gap-4">
+ {userSolutions && + userSolutions.length > 0 && + userSolutions[0].evaluation && + userSolutions[0].evaluation[`transcript_${(index + 1) as 1 | 2 | 3}`] && + userSolutions[0].evaluation[`fixed_text_${(index + 1) as 1 | 2 | 3}`] && ( + + )}
))}
diff --git a/src/components/Solutions/Speaking.tsx b/src/components/Solutions/Speaking.tsx index 8447b8e3..128750e8 100644 --- a/src/components/Solutions/Speaking.tsx +++ b/src/components/Solutions/Speaking.tsx @@ -8,11 +8,15 @@ import axios from "axios"; import {speakingReverseMarking} from "@/utils/score"; import {Tab} from "@headlessui/react"; import clsx from "clsx"; +import Modal from "../Modal"; +import {BsQuestionCircleFill} from "react-icons/bs"; +import ReactDiffViewer, {DiffMethod} from "react-diff-viewer"; const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); export default function Speaking({id, type, title, video_url, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { const [solutionURL, setSolutionURL] = useState(); + const [showDiff, setShowDiff] = useState(false); useEffect(() => { if (userSolutions && userSolutions.length > 0) { @@ -25,8 +29,48 @@ export default function Speaking({id, type, title, video_url, text, prompts, use } }, [userSolutions]); + useEffect(() => { + console.log(userSolutions); + }, [userSolutions]); + return ( <> + setShowDiff(false)}> + <> + {userSolutions && + userSolutions.length > 0 && + userSolutions[0].evaluation?.transcript_1 && + userSolutions[0].evaluation?.fixed_text_1 && ( +
+
+ Transcript + Recommended Improvements +
+ +
+ )} + +
+
@@ -65,10 +109,19 @@ export default function Speaking({id, type, title, video_url, text, prompts, use
-
-
+
+
{solutionURL && } + + {userSolutions && + userSolutions.length > 0 && + userSolutions[0].evaluation?.transcript_1 && + userSolutions[0].evaluation?.fixed_text_1 && ( + + )}
{userSolutions && userSolutions.length > 0 && userSolutions[0].evaluation && typeof userSolutions[0].evaluation !== "string" && ( diff --git a/src/interfaces/exam.ts b/src/interfaces/exam.ts index bd4802c7..ee08f0f5 100644 --- a/src/interfaces/exam.ts +++ b/src/interfaces/exam.ts @@ -103,13 +103,24 @@ export interface Evaluation { interface InteractiveSpeakingEvaluation extends Evaluation { perfect_answer_1?: string; + transcript_1?: string; + fixed_text_1?: string; perfect_answer_2?: string; + transcript_2?: string; + fixed_text_2?: string; perfect_answer_3?: string; + transcript_3?: string; + fixed_text_3?: string; +} + +interface SpeakingEvaluation extends CommonEvaluation { + perfect_answer_1?: string; + transcript_1?: string; + fixed_text_1?: string; } interface CommonEvaluation extends Evaluation { perfect_answer?: string; - perfect_answer_1?: string; fixed_text?: string; } @@ -141,7 +152,7 @@ export interface SpeakingExercise { userSolutions: { id: string; solution: string; - evaluation?: CommonEvaluation; + evaluation?: SpeakingEvaluation; }[]; }