diff --git a/src/dashboards/Student.tsx b/src/dashboards/Student.tsx index 75d4bd89..780330f9 100644 --- a/src/dashboards/Student.tsx +++ b/src/dashboards/Student.tsx @@ -3,228 +3,415 @@ import ProgressBar from "@/components/Low/ProgressBar"; import PayPalPayment from "@/components/PayPalPayment"; import ProfileSummary from "@/components/ProfileSummary"; import useAssignments from "@/hooks/useAssignments"; +import useInvites from "@/hooks/useInvites"; import useStats from "@/hooks/useStats"; -import {Assignment} from "@/interfaces/results"; -import {CorporateUser, User} from "@/interfaces/user"; +import useUsers from "@/hooks/useUsers"; +import { Invite } from "@/interfaces/invite"; +import { Assignment } from "@/interfaces/results"; +import { CorporateUser, User } from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; -import {getExamById} from "@/utils/exams"; -import {getUserCorporate} from "@/utils/groups"; -import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; -import {averageScore, groupBySession} from "@/utils/stats"; -import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; -import {PayPalButtons} from "@paypal/react-paypal-js"; +import { getExamById } from "@/utils/exams"; +import { getUserCorporate } from "@/utils/groups"; +import { + MODULE_ARRAY, + sortByModule, + sortByModuleName, +} from "@/utils/moduleUtils"; +import { averageScore, groupBySession } from "@/utils/stats"; +import { + CreateOrderActions, + CreateOrderData, + OnApproveActions, + OnApproveData, + OrderResponseBody, +} from "@paypal/paypal-js"; +import { PayPalButtons } from "@paypal/react-paypal-js"; import axios from "axios"; import clsx from "clsx"; -import {capitalize} from "lodash"; +import { capitalize } from "lodash"; import moment from "moment"; import Link from "next/link"; -import {useRouter} from "next/router"; -import {useEffect, useState} from "react"; -import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; -import {toast} from "react-toastify"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { + BsArrowRepeat, + BsBook, + BsClipboard, + BsFileEarmarkText, + BsHeadphones, + BsMegaphone, + BsPen, + BsPencil, + BsStar, +} from "react-icons/bs"; +import { toast } from "react-toastify"; interface Props { - user: User; + user: User; } -export default function StudentDashboard({user}: Props) { - const [corporateUserToShow, setCorporateUserToShow] = useState(); +export default function StudentDashboard({ user }: Props) { + const [corporateUserToShow, setCorporateUserToShow] = + useState(); - const {stats} = useStats(user.id); - const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: user?.id}); + const { stats } = useStats(user.id); + const { users } = useUsers(); + const { + assignments, + isLoading: isAssignmentsLoading, + reload: reloadAssignments, + } = useAssignments({ assignees: user?.id }); + const { + invites, + isLoading: isInvitesLoading, + reload: reloadInvites, + } = useInvites({ to: user.id }); - const router = useRouter(); + const router = useRouter(); - const setExams = useExamStore((state) => state.setExams); - const setShowSolutions = useExamStore((state) => state.setShowSolutions); - const setUserSolutions = useExamStore((state) => state.setUserSolutions); - const setSelectedModules = useExamStore((state) => state.setSelectedModules); - const setAssignment = useExamStore((state) => state.setAssignment); + const setExams = useExamStore((state) => state.setExams); + const setShowSolutions = useExamStore((state) => state.setShowSolutions); + const setUserSolutions = useExamStore((state) => state.setUserSolutions); + const setSelectedModules = useExamStore((state) => state.setSelectedModules); + const setAssignment = useExamStore((state) => state.setAssignment); - useEffect(() => { - getUserCorporate(user.id).then(setCorporateUserToShow); - }, [user]); + useEffect(() => { + getUserCorporate(user.id).then(setCorporateUserToShow); + }, [user]); - const startAssignment = (assignment: Assignment) => { - const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id)); + const startAssignment = (assignment: Assignment) => { + const examPromises = assignment.exams + .filter((e) => e.assignee === user.id) + .map((e) => getExamById(e.module, e.id)); - Promise.all(examPromises).then((exams) => { - if (exams.every((x) => !!x)) { - setUserSolutions([]); - setShowSolutions(false); - setExams(exams.map((x) => x!).sort(sortByModule)); - setSelectedModules( - exams - .map((x) => x!) - .sort(sortByModule) - .map((x) => x!.module), - ); - setAssignment(assignment); + Promise.all(examPromises).then((exams) => { + if (exams.every((x) => !!x)) { + setUserSolutions([]); + setShowSolutions(false); + setExams(exams.map((x) => x!).sort(sortByModule)); + setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + setAssignment(assignment); - router.push("/exercises"); - } - }); - }; + router.push("/exercises"); + } + }); + }; - return ( - <> - {corporateUserToShow && ( -
- Linked to: {corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name} -
- )} - , - value: Object.keys(groupBySession(stats)).length, - label: "Exams", - }, - { - icon: , - value: stats.length, - label: "Exercises", - }, - { - icon: , - value: `${stats.length > 0 ? averageScore(stats) : 0}%`, - label: "Average Score", - }, - ]} - /> + const InviteCard = (invite: Invite) => { + const [isLoading, setIsLoading] = useState(false); -
- Bio - - {user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."} - -
+ const inviter = users.find((u) => u.id === invite.from); + const name = !inviter + ? null + : inviter.type === "corporate" + ? inviter.corporateInformation?.companyInformation?.name || inviter.name + : inviter.name; -
-
-
- Assignments - -
-
- - {assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 && - "Assignments will appear here. It seems that for now there are no assignments for you."} - {assignments - .filter((a) => moment(a.endDate).isSameOrAfter(moment())) - .sort((a, b) => moment(a.startDate).diff(b.startDate)) - .map((assignment) => ( -
r.user).includes(user.id) && "border-mti-green-light", - )} - key={assignment.id}> -
-

{assignment.name}

- - {moment(assignment.startDate).format("DD/MM/YY, HH:mm")} - - - {moment(assignment.endDate).format("DD/MM/YY, HH:mm")} - -
-
-
- {assignment.exams - .filter((e) => e.assignee === user.id) - .map((e) => e.module) - .sort(sortByModuleName) - .map((module) => ( -
- {module === "reading" && } - {module === "listening" && } - {module === "writing" && } - {module === "speaking" && } - {module === "level" && } -
- ))} -
- {!assignment.results.map((r) => r.user).includes(user.id) && ( - <> -
- -
- - - )} - {assignment.results.map((r) => r.user).includes(user.id) && ( - - )} -
-
- ))} -
-
+ const decide = (decision: "accept" | "decline") => { + if (!confirm(`Are you sure you want to ${decision} this invite?`)) return; -
- Score History -
- {MODULE_ARRAY.map((module) => ( -
-
-
- {module === "reading" && } - {module === "listening" && } - {module === "writing" && } - {module === "speaking" && } - {module === "level" && } -
-
- {capitalize(module)} - - Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9} - -
-
-
- -
-
- ))} -
-
- - ); + setIsLoading(true); + axios + .get(`/api/invites/${decision}/${invite.id}`) + .then(() => { + toast.success( + `Successfully ${decision === "accept" ? "accepted" : "declined"} the invite!`, + { toastId: "success" }, + ); + reloadInvites(); + }) + .catch((e) => { + toast.success(`Something went wrong, please try again later!`, { + toastId: "error", + }); + reloadInvites(); + }) + .finally(() => setIsLoading(false)); + }; + + return ( +
+ Invited by {name} +
+ + +
+
+ ); + }; + + return ( + <> + {corporateUserToShow && ( +
+ Linked to:{" "} + + {corporateUserToShow?.corporateInformation?.companyInformation + .name || corporateUserToShow.name} + +
+ )} + + ), + value: Object.keys(groupBySession(stats)).length, + label: "Exams", + }, + { + icon: ( + + ), + value: stats.length, + label: "Exercises", + }, + { + icon: ( + + ), + value: `${stats.length > 0 ? averageScore(stats) : 0}%`, + label: "Average Score", + }, + ]} + /> + +
+ Bio + + {user.bio || + "Your bio will appear here, you can change it by clicking on your name in the top right corner."} + +
+ +
+
+
+ + Assignments + + +
+
+ + {assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())) + .length === 0 && + "Assignments will appear here. It seems that for now there are no assignments for you."} + {assignments + .filter((a) => moment(a.endDate).isSameOrAfter(moment())) + .sort((a, b) => moment(a.startDate).diff(b.startDate)) + .map((assignment) => ( +
r.user).includes(user.id) && + "border-mti-green-light", + )} + key={assignment.id} + > +
+

+ {assignment.name} +

+ + + {moment(assignment.startDate).format("DD/MM/YY, HH:mm")} + + - + + {moment(assignment.endDate).format("DD/MM/YY, HH:mm")} + + +
+
+
+ {assignment.exams + .filter((e) => e.assignee === user.id) + .map((e) => e.module) + .sort(sortByModuleName) + .map((module) => ( +
+ {module === "reading" && ( + + )} + {module === "listening" && ( + + )} + {module === "writing" && ( + + )} + {module === "speaking" && ( + + )} + {module === "level" && ( + + )} +
+ ))} +
+ {!assignment.results.map((r) => r.user).includes(user.id) && ( + <> +
+ +
+ + + )} + {assignment.results.map((r) => r.user).includes(user.id) && ( + + )} +
+
+ ))} +
+
+ + {invites.length > 0 && ( +
+
+
+ Invites + +
+
+ + {invites.map((invite) => ( + + ))} + +
+ )} + +
+ Score History +
+ {MODULE_ARRAY.map((module) => ( +
+
+
+ {module === "reading" && ( + + )} + {module === "listening" && ( + + )} + {module === "writing" && ( + + )} + {module === "speaking" && ( + + )} + {module === "level" && ( + + )} +
+
+ + {capitalize(module)} + + + Level {user.levels[module] || 0} / Level{" "} + {user.desiredLevels[module] || 9} + +
+
+
+ +
+
+ ))} +
+
+ + ); } diff --git a/src/email/templates/receivedInvite.handlebars b/src/email/templates/receivedInvite.handlebars new file mode 100644 index 00000000..69f26208 --- /dev/null +++ b/src/email/templates/receivedInvite.handlebars @@ -0,0 +1,28 @@ + + + + + + + + +
+ +
+ Hello {{name}}, +
+
+ You have been invited to join {{corporateName}}'s group! +
+
+ Please access the platform to accept or decline the invite. +
+
+
+
+ Thanks,
Your EnCoach team
+
+
+ + diff --git a/src/email/templates/respondedInvite.handlebars b/src/email/templates/respondedInvite.handlebars new file mode 100644 index 00000000..e34581a7 --- /dev/null +++ b/src/email/templates/respondedInvite.handlebars @@ -0,0 +1,25 @@ + + + + + + + + +
+ +
+ Hello {{corporateName}}, +
+
+ {{name}} has decided to {{decision}} your invite! +
+
+
+
+ Thanks,
Your EnCoach team
+
+
+ + diff --git a/src/hooks/useInvites.tsx b/src/hooks/useInvites.tsx new file mode 100644 index 00000000..4f6ea92f --- /dev/null +++ b/src/hooks/useInvites.tsx @@ -0,0 +1,35 @@ +import { Invite } from "@/interfaces/invite"; +import { Ticket } from "@/interfaces/ticket"; +import { Code, Group, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useInvites({ + from, + to, +}: { + from?: string; + to?: string; +}) { + const [invites, setInvites] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + const filters: ((i: Invite) => boolean)[] = []; + if (from) filters.push((i: Invite) => i.from === from); + if (to) filters.push((i: Invite) => i.to === to); + + setIsLoading(true); + axios + .get(`/api/invites`) + .then((response) => + setInvites(filters.reduce((d, f) => d.filter(f), response.data)), + ) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, [to, from]); + + return { invites, isLoading, isError, reload: getData }; +} diff --git a/src/interfaces/invite.ts b/src/interfaces/invite.ts new file mode 100644 index 00000000..ce5e9f5e --- /dev/null +++ b/src/interfaces/invite.ts @@ -0,0 +1,5 @@ +export interface Invite { + id: string; + from: string; + to: string; +} diff --git a/src/pages/(admin)/BatchCodeGenerator.tsx b/src/pages/(admin)/BatchCodeGenerator.tsx index 39bb132c..7c8d2f76 100644 --- a/src/pages/(admin)/BatchCodeGenerator.tsx +++ b/src/pages/(admin)/BatchCodeGenerator.tsx @@ -1,217 +1,318 @@ import Button from "@/components/Low/Button"; import Checkbox from "@/components/Low/Checkbox"; -import {PERMISSIONS} from "@/constants/userPermissions"; +import { PERMISSIONS } from "@/constants/userPermissions"; import useUsers from "@/hooks/useUsers"; -import {Type, User} from "@/interfaces/user"; -import {USER_TYPE_LABELS} from "@/resources/user"; +import { Type, User } from "@/interfaces/user"; +import { USER_TYPE_LABELS } from "@/resources/user"; import axios from "axios"; import clsx from "clsx"; -import {capitalize, uniqBy} from "lodash"; +import { capitalize, uniqBy } from "lodash"; import moment from "moment"; -import {useEffect, useState} from "react"; +import { useEffect, useState } from "react"; import ReactDatePicker from "react-datepicker"; -import {toast} from "react-toastify"; +import { toast } from "react-toastify"; import ShortUniqueId from "short-unique-id"; -import {useFilePicker} from "use-file-picker"; +import { useFilePicker } from "use-file-picker"; import readXlsxFile from "read-excel-file"; import Modal from "@/components/Modal"; -import {BsQuestionCircleFill} from "react-icons/bs"; +import { BsFileEarmarkEaselFill, BsQuestionCircleFill } from "react-icons/bs"; -const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); +const EMAIL_REGEX = new RegExp( + /^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/, +); -const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = { - student: [], - teacher: [], - agent: [], - corporate: ["student", "teacher"], - admin: ["student", "teacher", "agent", "corporate", "admin"], - developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], +const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = { + student: [], + teacher: [], + agent: [], + corporate: ["student", "teacher"], + admin: ["student", "teacher", "agent", "corporate", "admin"], + developer: ["student", "teacher", "agent", "corporate", "admin", "developer"], }; -export default function BatchCodeGenerator({user}: {user: User}) { - const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); - const [isLoading, setIsLoading] = useState(false); - const [expiryDate, setExpiryDate] = useState(null); - const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); - const [type, setType] = useState("student"); - const [showHelp, setShowHelp] = useState(false); +export default function BatchCodeGenerator({ user }: { user: User }) { + const [infos, setInfos] = useState< + { email: string; name: string; passport_id: string }[] + >([]); + const [isLoading, setIsLoading] = useState(false); + const [expiryDate, setExpiryDate] = useState(null); + const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); + const [type, setType] = useState("student"); + const [showHelp, setShowHelp] = useState(false); - const {users} = useUsers(); + const { users } = useUsers(); - const {openFilePicker, filesContent, clear} = useFilePicker({ - accept: ".xlsx", - multiple: false, - readAs: "ArrayBuffer", - }); + const { openFilePicker, filesContent, clear } = useFilePicker({ + accept: ".xlsx", + multiple: false, + readAs: "ArrayBuffer", + }); - useEffect(() => { - if (user && (user.type === "corporate" || user.type === "teacher")) { - setExpiryDate(user.subscriptionExpirationDate || null); - } - }, [user]); + useEffect(() => { + if (user && (user.type === "corporate" || user.type === "teacher")) { + setExpiryDate(user.subscriptionExpirationDate || null); + } + }, [user]); - useEffect(() => { - if (!isExpiryDateEnabled) setExpiryDate(null); - }, [isExpiryDateEnabled]); + useEffect(() => { + if (!isExpiryDateEnabled) setExpiryDate(null); + }, [isExpiryDateEnabled]); - useEffect(() => { - if (filesContent.length > 0) { - const file = filesContent[0]; - readXlsxFile(file.content).then((rows) => { - 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, - ); + useEffect(() => { + if (filesContent.length > 0) { + const file = filesContent[0]; + readXlsxFile(file.content).then((rows) => { + 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()) + ? { + 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) { - toast.error( - "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", - ); - return clear(); - } + 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(); - } - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filesContent]); + 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(); + } + }); + } + // 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 generateAndInvite = async () => { + const newUsers = infos.filter( + (x) => !users.map((u) => u.email).includes(x.email), + ); + const existingUsers = infos + .filter((x) => users.map((u) => u.email).includes(x.email)) + .map((i) => users.find((u) => u.email === i.email)) + .filter((x) => !!x && x.type === "student") as User[]; - const uid = new ShortUniqueId(); - const codes = infos.map(() => uid.randomUUID(6)); + const newUsersSentence = + newUsers.length > 0 ? `generate ${newUsers.length} code(s)` : undefined; + const existingUsersSentence = + existingUsers.length > 0 + ? `invite ${existingUsers.length} registered student(s)` + : undefined; + if ( + !confirm( + `You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`, + ) + ) + return; - setIsLoading(true); - axios - .post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {type, codes, infos: infos, expiryDate}) - .then(({data, status}) => { - if (data.ok) { - toast.success( - `Successfully generated${data.valid ? ` ${data.valid}/${infos.length}` : ""} ${capitalize( - type, - )} codes and they have been notified by e-mail!`, - {toastId: "success"}, - ); - return; - } + setIsLoading(true); + Promise.all( + existingUsers.map( + async (u) => + await axios.post(`/api/invites`, { to: u.id, from: user.id }), + ), + ) + .then(() => + toast.success( + `Successfully invited ${existingUsers.length} registered student(s)!`, + ), + ) + .finally(() => { + if (newUsers.length === 0) setIsLoading(false); + }); - if (status === 403) { - toast.error(data.reason, {toastId: "forbidden"}); - } - }) - .catch(({response: {status, data}}) => { - if (status === 403) { - toast.error(data.reason, {toastId: "forbidden"}); - return; - } + if (newUsers.length > 0) generateCode(type, newUsers); + setInfos([]); + }; - toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); - }) - .finally(() => { - setIsLoading(false); - return clear(); - }); - }; + const generateCode = (type: Type, informations: typeof infos) => { + const uid = new ShortUniqueId(); + const codes = informations.map(() => uid.randomUUID(6)); - return ( - <> - setShowHelp(false)} title="Excel File Format"> -
- Please upload an Excel file with the following format: - - - - - - - - - - - -
First NameLast NameCountryPassport/National IDE-mailPhone Number
- - Notes: -
    -
  • - All incorrect e-mails will be ignored;
  • -
  • - All already registered e-mails will be ignored;
  • -
  • - You may have a header row with the format above, however, it is not necessary;
  • -
  • - All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.
  • -
-
-
-
-
-
- -
setShowHelp(true)}> - -
-
- - {user && (user.type === "developer" || user.type === "admin") && ( - <> -
- - - Enabled - -
- {isExpiryDateEnabled && ( - moment(date).isAfter(new Date())} - dateFormat="dd/MM/yyyy" - selected={expiryDate} - onChange={(date) => setExpiryDate(date)} - /> - )} - - )} - - {user && ( - - )} - -
- - ); + setIsLoading(true); + axios + .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", { + type, + codes, + infos: informations, + expiryDate, + }) + .then(({ data, status }) => { + if (data.ok) { + toast.success( + `Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize( + type, + )} codes and they have been notified by e-mail!`, + { toastId: "success" }, + ); + return; + } + + if (status === 403) { + toast.error(data.reason, { toastId: "forbidden" }); + } + }) + .catch(({ response: { status, data } }) => { + if (status === 403) { + toast.error(data.reason, { toastId: "forbidden" }); + return; + } + + toast.error(`Something went wrong, please try again later!`, { + toastId: "error", + }); + }) + .finally(() => { + setIsLoading(false); + return clear(); + }); + }; + + return ( + <> + setShowHelp(false)} + title="Excel File Format" + > +
+ Please upload an Excel file with the following format: + + + + + + + + + + + +
+ First Name + + Last Name + Country + Passport/National ID + E-mail + Phone Number +
+ + Notes: +
    +
  • - All incorrect e-mails will be ignored;
  • +
  • - All already registered e-mails will be ignored;
  • +
  • + - You may have a header row with the format above, however, it + is not necessary; +
  • +
  • + - All of the e-mails in the file will receive an e-mail to join + EnCoach with the role selected below. +
  • +
+
+
+
+
+
+ +
setShowHelp(true)} + > + +
+
+ + {user && (user.type === "developer" || user.type === "admin") && ( + <> +
+ + + Enabled + +
+ {isExpiryDateEnabled && ( + moment(date).isAfter(new Date())} + dateFormat="dd/MM/yyyy" + selected={expiryDate} + onChange={(date) => setExpiryDate(date)} + /> + )} + + )} + + {user && ( + + )} + +
+ + ); } diff --git a/src/pages/api/invites/[id].ts b/src/pages/api/invites/[id].ts new file mode 100644 index 00000000..99b2ba36 --- /dev/null +++ b/src/pages/api/invites/[id].ts @@ -0,0 +1,82 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { + getFirestore, + getDoc, + doc, + deleteDoc, + setDoc, +} from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Ticket } from "@/interfaces/ticket"; +import { Invite } from "@/interfaces/invite"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); + if (req.method === "DELETE") return await del(req, res); + if (req.method === "PATCH") return await patch(req, res); + + res.status(404).json(undefined); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + const { id } = req.query as { id: string }; + + const snapshot = await getDoc(doc(db, "invites", id)); + + if (snapshot.exists()) { + res.status(200).json({ ...snapshot.data(), id: snapshot.id }); + } else { + res.status(404).json(undefined); + } +} + +async function del(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + const { id } = req.query as { id: string }; + + const snapshot = await getDoc(doc(db, "invites", id)); + const data = snapshot.data() as Invite; + + const user = req.session.user; + if (user.type === "admin" || user.type === "developer") { + await deleteDoc(snapshot.ref); + res.status(200).json({ ok: true }); + return; + } + + res.status(403).json({ ok: false }); +} + +async function patch(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + const { id } = req.query as { id: string }; + const snapshot = await getDoc(doc(db, "invites", id)); + + const user = req.session.user; + if (user.type === "admin" || user.type === "developer") { + await setDoc(snapshot.ref, req.body, { merge: true }); + return res.status(200).json({ ok: true }); + } + + res.status(403).json({ ok: false }); +} diff --git a/src/pages/api/invites/accept/[id].ts b/src/pages/api/invites/accept/[id].ts new file mode 100644 index 00000000..41ca356d --- /dev/null +++ b/src/pages/api/invites/accept/[id].ts @@ -0,0 +1,134 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { + getFirestore, + getDoc, + doc, + deleteDoc, + setDoc, + getDocs, + collection, + where, + query, +} from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Ticket } from "@/interfaces/ticket"; +import { Invite } from "@/interfaces/invite"; +import { Group, User } from "@/interfaces/user"; +import { v4 } from "uuid"; +import { sendEmail } from "@/email"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); + + res.status(404).json(undefined); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + const { id } = req.query as { id: string }; + const snapshot = await getDoc(doc(db, "invites", id)); + + if (snapshot.exists()) { + const invite = { ...snapshot.data(), id: snapshot.id } as Invite; + if (invite.to !== req.session.user.id) + return res.status(403).json({ ok: false }); + + await deleteDoc(snapshot.ref); + const invitedByRef = await getDoc(doc(db, "users", invite.from)); + if (!invitedByRef.exists()) return res.status(404).json({ ok: false }); + + const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User; + const invitedByGroupsRef = await getDocs( + query(collection(db, "groups"), where("admin", "==", invitedBy.id)), + ); + const invitedByGroups = invitedByGroupsRef.docs.map((g) => ({ + ...g.data(), + id: g.id, + })) as Group[]; + + const typeGroupName = + req.session.user.type === "student" + ? "Students" + : req.session.user.type === "teacher" + ? "Teachers" + : undefined; + + if (typeGroupName) { + const typeGroup: Group = invitedByGroups.find( + (g) => g.name === typeGroupName, + ) || { + id: v4(), + admin: invitedBy.id, + name: typeGroupName, + participants: [], + disableEditing: true, + }; + await setDoc( + doc(db, "groups", typeGroup.id), + { + ...typeGroup, + participants: [ + ...typeGroup.participants.filter((x) => x !== req.session.user!.id), + req.session.user.id, + ], + }, + { merge: true }, + ); + } + + const invitationsGroup: Group = invitedByGroups.find( + (g) => g.name === "Invited", + ) || { + id: v4(), + admin: invitedBy.id, + name: "Invited", + participants: [], + disableEditing: true, + }; + await setDoc( + doc(db, "groups", invitationsGroup.id), + { + ...invitationsGroup, + participants: [ + ...invitationsGroup.participants.filter( + (x) => x !== req.session.user!.id, + ), + req.session.user.id, + ], + }, + { + merge: true, + }, + ); + + try { + await sendEmail( + "respondedInvite", + { + corporateName: invitedBy.name, + name: req.session.user.name, + decision: "accept", + }, + [invitedBy.email], + `${req.session.user.name} has accepted your invite!`, + ); + } catch (e) { + console.log(e); + } + + res.status(200).json({ ok: true }); + } else { + res.status(404).json(undefined); + } +} diff --git a/src/pages/api/invites/decline/[id].ts b/src/pages/api/invites/decline/[id].ts new file mode 100644 index 00000000..e8ab55a9 --- /dev/null +++ b/src/pages/api/invites/decline/[id].ts @@ -0,0 +1,72 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { + getFirestore, + getDoc, + doc, + deleteDoc, + setDoc, + getDocs, + collection, + where, + query, +} from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Ticket } from "@/interfaces/ticket"; +import { Invite } from "@/interfaces/invite"; +import { Group, User } from "@/interfaces/user"; +import { v4 } from "uuid"; +import { sendEmail } from "@/email"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); + + res.status(404).json(undefined); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + const { id } = req.query as { id: string }; + const snapshot = await getDoc(doc(db, "invites", id)); + + if (snapshot.exists()) { + const invite = { ...snapshot.data(), id: snapshot.id } as Invite; + if (invite.to !== req.session.user.id) + return res.status(403).json({ ok: false }); + + await deleteDoc(snapshot.ref); + const invitedByRef = await getDoc(doc(db, "users", invite.from)); + if (!invitedByRef.exists()) return res.status(404).json({ ok: false }); + + const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User; + + try { + await sendEmail( + "respondedInvite", + { + corporateName: invitedBy.name, + name: req.session.user.name, + decision: "decline", + }, + [invitedBy.email], + `${req.session.user.name} has declined your invite!`, + ); + } catch (e) { + console.log(e); + } + + res.status(200).json({ ok: true }); + } else { + res.status(404).json(undefined); + } +} diff --git a/src/pages/api/invites/index.ts b/src/pages/api/invites/index.ts new file mode 100644 index 00000000..4481680e --- /dev/null +++ b/src/pages/api/invites/index.ts @@ -0,0 +1,79 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { sendEmail } from "@/email"; +import { app } from "@/firebase"; +import { Invite } from "@/interfaces/invite"; +import { Ticket } from "@/interfaces/ticket"; +import { User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import { + collection, + doc, + getDoc, + getDocs, + getFirestore, + setDoc, +} from "firebase/firestore"; +import { withIronSessionApiRoute } from "iron-session/next"; +import type { NextApiRequest, NextApiResponse } from "next"; +import ShortUniqueId from "short-unique-id"; + +const db = getFirestore(app); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + if (req.method === "GET") await get(req, res); + if (req.method === "POST") await post(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const snapshot = await getDocs(collection(db, "invites")); + + res.status(200).json( + snapshot.docs.map((doc) => ({ + id: doc.id, + ...doc.data(), + })), + ); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const body = req.body as Invite; + + const shortUID = new ShortUniqueId(); + await setDoc(doc(db, "invites", body.id || shortUID.randomUUID(8)), body); + + res.status(200).json({ ok: true }); + + const invitedRef = await getDoc(doc(db, "users", body.to)); + if (!invitedRef.exists()) return res.status(404).json({ ok: false }); + + const invitedByRef = await getDoc(doc(db, "users", body.from)); + if (!invitedByRef.exists()) return res.status(404).json({ ok: false }); + + const invited = { ...invitedRef.data(), id: invitedRef.id } as User; + const invitedBy = { ...invitedByRef.data(), id: invitedByRef.id } as User; + + try { + await sendEmail( + "receivedInvite", + { + name: invited.name, + corporateName: + invitedBy.type === "corporate" + ? invitedBy.corporateInformation?.companyInformation?.name || + invitedBy.name + : invitedBy.name, + }, + [invited.email], + "You have been invited to a group!", + ); + } catch (e) { + console.log(e); + } +}