Created a simple invite system that notifies users via e-mail when a corporate uploads an Excel file with already registered students

This commit is contained in:
Tiago Ribeiro
2024-01-29 09:36:59 +00:00
parent 8b7e550a70
commit 688d8ba0b2
10 changed files with 1140 additions and 392 deletions

View File

@@ -3,15 +3,28 @@ import ProgressBar from "@/components/Low/ProgressBar";
import PayPalPayment from "@/components/PayPalPayment"; import PayPalPayment from "@/components/PayPalPayment";
import ProfileSummary from "@/components/ProfileSummary"; import ProfileSummary from "@/components/ProfileSummary";
import useAssignments from "@/hooks/useAssignments"; import useAssignments from "@/hooks/useAssignments";
import useInvites from "@/hooks/useInvites";
import useStats from "@/hooks/useStats"; import useStats from "@/hooks/useStats";
import useUsers from "@/hooks/useUsers";
import { Invite } from "@/interfaces/invite";
import { Assignment } from "@/interfaces/results"; import { Assignment } from "@/interfaces/results";
import { CorporateUser, User } from "@/interfaces/user"; import { CorporateUser, User } from "@/interfaces/user";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import { getExamById } from "@/utils/exams"; import { getExamById } from "@/utils/exams";
import { getUserCorporate } from "@/utils/groups"; import { getUserCorporate } from "@/utils/groups";
import {MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; import {
MODULE_ARRAY,
sortByModule,
sortByModuleName,
} from "@/utils/moduleUtils";
import { averageScore, groupBySession } from "@/utils/stats"; import { averageScore, groupBySession } from "@/utils/stats";
import {CreateOrderActions, CreateOrderData, OnApproveActions, OnApproveData, OrderResponseBody} from "@paypal/paypal-js"; import {
CreateOrderActions,
CreateOrderData,
OnApproveActions,
OnApproveData,
OrderResponseBody,
} from "@paypal/paypal-js";
import { PayPalButtons } from "@paypal/react-paypal-js"; import { PayPalButtons } from "@paypal/react-paypal-js";
import axios from "axios"; import axios from "axios";
import clsx from "clsx"; import clsx from "clsx";
@@ -20,7 +33,17 @@ import moment from "moment";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import {BsArrowRepeat, BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; import {
BsArrowRepeat,
BsBook,
BsClipboard,
BsFileEarmarkText,
BsHeadphones,
BsMegaphone,
BsPen,
BsPencil,
BsStar,
} from "react-icons/bs";
import { toast } from "react-toastify"; import { toast } from "react-toastify";
interface Props { interface Props {
@@ -28,10 +51,21 @@ interface Props {
} }
export default function StudentDashboard({ user }: Props) { export default function StudentDashboard({ user }: Props) {
const [corporateUserToShow, setCorporateUserToShow] = useState<CorporateUser>(); const [corporateUserToShow, setCorporateUserToShow] =
useState<CorporateUser>();
const { stats } = useStats(user.id); const { stats } = useStats(user.id);
const {assignments, isLoading: isAssignmentsLoading, reload: reloadAssignments} = useAssignments({assignees: 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();
@@ -46,7 +80,9 @@ export default function StudentDashboard({user}: Props) {
}, [user]); }, [user]);
const startAssignment = (assignment: Assignment) => { const startAssignment = (assignment: Assignment) => {
const examPromises = assignment.exams.filter((e) => e.assignee === user.id).map((e) => getExamById(e.module, e.id)); const examPromises = assignment.exams
.filter((e) => e.assignee === user.id)
.map((e) => getExamById(e.module, e.id));
Promise.all(examPromises).then((exams) => { Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) { if (exams.every((x) => !!x)) {
@@ -66,28 +102,103 @@ export default function StudentDashboard({user}: Props) {
}); });
}; };
const InviteCard = (invite: Invite) => {
const [isLoading, setIsLoading] = useState(false);
const inviter = users.find((u) => u.id === invite.from);
const name = !inviter
? null
: inviter.type === "corporate"
? inviter.corporateInformation?.companyInformation?.name || inviter.name
: inviter.name;
const decide = (decision: "accept" | "decline") => {
if (!confirm(`Are you sure you want to ${decision} this invite?`)) return;
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 (
<div className="border-mti-gray-anti-flash flex min-w-[200px] flex-col gap-6 rounded-xl border p-4 text-black">
<span>Invited by {name}</span>
<div className="flex items-center gap-2">
<button
onClick={() => decide("accept")}
disabled={isLoading}
className="bg-mti-green-ultralight hover:bg-mti-green-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
>
{!isLoading && "Accept"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
<button
onClick={() => decide("decline")}
disabled={isLoading}
className="bg-mti-red-ultralight hover:bg-mti-red-light w-24 rounded-lg p-2 px-4 transition duration-300 ease-in-out hover:text-white disabled:cursor-not-allowed"
>
{!isLoading && "Decline"}
{isLoading && (
<div className="flex items-center justify-center">
<BsArrowRepeat className="animate-spin text-white" size={25} />
</div>
)}
</button>
</div>
</div>
);
};
return ( return (
<> <>
{corporateUserToShow && ( {corporateUserToShow && (
<div className="absolute top-4 right-4 bg-neutral-200 px-2 rounded-lg py-1"> <div className="absolute right-4 top-4 rounded-lg bg-neutral-200 px-2 py-1">
Linked to: <b>{corporateUserToShow?.corporateInformation?.companyInformation.name || corporateUserToShow.name}</b> Linked to:{" "}
<b>
{corporateUserToShow?.corporateInformation?.companyInformation
.name || corporateUserToShow.name}
</b>
</div> </div>
)} )}
<ProfileSummary <ProfileSummary
user={user} user={user}
items={[ items={[
{ {
icon: <BsFileEarmarkText className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: (
<BsFileEarmarkText className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: Object.keys(groupBySession(stats)).length, value: Object.keys(groupBySession(stats)).length,
label: "Exams", label: "Exams",
}, },
{ {
icon: <BsPencil className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: (
<BsPencil className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: stats.length, value: stats.length,
label: "Exercises", label: "Exercises",
}, },
{ {
icon: <BsStar className="w-6 h-6 md:w-8 md:h-8 text-mti-red-light" />, icon: (
<BsStar className="text-mti-red-light h-6 w-6 md:h-8 md:w-8" />
),
value: `${stats.length > 0 ? averageScore(stats) : 0}%`, value: `${stats.length > 0 ? averageScore(stats) : 0}%`,
label: "Average Score", label: "Average Score",
}, },
@@ -95,23 +206,33 @@ export default function StudentDashboard({user}: Props) {
/> />
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<span className="font-bold text-lg">Bio</span> <span className="text-lg font-bold">Bio</span>
<span className="text-mti-gray-taupe"> <span className="text-mti-gray-taupe">
{user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."} {user.bio ||
"Your bio will appear here, you can change it by clicking on your name in the top right corner."}
</span> </span>
</section> </section>
<section className="flex flex-col gap-1 md:gap-3"> <section className="flex flex-col gap-1 md:gap-3">
<div className="flex gap-4 items-center"> <div className="flex items-center gap-4">
<div <div
onClick={reloadAssignments} onClick={reloadAssignments}
className="flex gap-2 items-center text-mti-purple-light cursor-pointer hover:text-mti-purple-dark transition ease-in-out duration-300"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<span className="font-bold text-lg text-mti-black">Assignments</span> >
<BsArrowRepeat className={clsx("text-xl", isAssignmentsLoading && "animate-spin")} /> <span className="text-mti-black text-lg font-bold">
Assignments
</span>
<BsArrowRepeat
className={clsx(
"text-xl",
isAssignmentsLoading && "animate-spin",
)}
/>
</div> </div>
</div> </div>
<span className="text-mti-gray-taupe flex gap-8 overflow-x-scroll scrollbar-hide"> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
{assignments.filter((a) => moment(a.endDate).isSameOrAfter(moment())).length === 0 && {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 will appear here. It seems that for now there are no assignments for you."}
{assignments {assignments
.filter((a) => moment(a.endDate).isSameOrAfter(moment())) .filter((a) => moment(a.endDate).isSameOrAfter(moment()))
@@ -119,20 +240,28 @@ export default function StudentDashboard({user}: Props) {
.map((assignment) => ( .map((assignment) => (
<div <div
className={clsx( className={clsx(
"border border-mti-gray-anti-flash rounded-xl flex flex-col gap-6 p-4 min-w-[300px]", "border-mti-gray-anti-flash flex min-w-[300px] flex-col gap-6 rounded-xl border p-4",
assignment.results.map((r) => r.user).includes(user.id) && "border-mti-green-light", assignment.results.map((r) => r.user).includes(user.id) &&
"border-mti-green-light",
)} )}
key={assignment.id}> key={assignment.id}
>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h3 className="font-semibold text-xl text-mti-black/90">{assignment.name}</h3> <h3 className="text-mti-black/90 text-xl font-semibold">
<span className="flex gap-1 justify-between"> {assignment.name}
<span>{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}</span> </h3>
<span className="flex justify-between gap-1">
<span>
{moment(assignment.startDate).format("DD/MM/YY, HH:mm")}
</span>
<span>-</span> <span>-</span>
<span>{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}</span> <span>
{moment(assignment.endDate).format("DD/MM/YY, HH:mm")}
</span>
</span> </span>
</div> </div>
<div className="flex justify-between w-full items-center"> <div className="flex w-full items-center justify-between">
<div className="grid grid-cols-2 gap-2 place-items-center justify-center w-fit min-w-[104px] -md:mt-2"> <div className="-md:mt-2 grid w-fit min-w-[104px] grid-cols-2 place-items-center justify-center gap-2">
{assignment.exams {assignment.exams
.filter((e) => e.assignee === user.id) .filter((e) => e.assignee === user.id)
.map((e) => e.module) .map((e) => e.module)
@@ -142,38 +271,56 @@ export default function StudentDashboard({user}: Props) {
key={module} key={module}
data-tip={capitalize(module)} data-tip={capitalize(module)}
className={clsx( className={clsx(
"flex gap-2 items-center w-fit text-white -md:px-4 xl:px-4 md:px-2 py-2 rounded-xl tooltip", "-md:px-4 tooltip flex w-fit items-center gap-2 rounded-xl py-2 text-white md:px-2 xl:px-4",
module === "reading" && "bg-ielts-reading", module === "reading" && "bg-ielts-reading",
module === "listening" && "bg-ielts-listening", module === "listening" && "bg-ielts-listening",
module === "writing" && "bg-ielts-writing", module === "writing" && "bg-ielts-writing",
module === "speaking" && "bg-ielts-speaking", module === "speaking" && "bg-ielts-speaking",
module === "level" && "bg-ielts-level", module === "level" && "bg-ielts-level",
)}> )}
{module === "reading" && <BsBook className="w-4 h-4" />} >
{module === "listening" && <BsHeadphones className="w-4 h-4" />} {module === "reading" && (
{module === "writing" && <BsPen className="w-4 h-4" />} <BsBook className="h-4 w-4" />
{module === "speaking" && <BsMegaphone className="w-4 h-4" />} )}
{module === "level" && <BsClipboard className="w-4 h-4" />} {module === "listening" && (
<BsHeadphones className="h-4 w-4" />
)}
{module === "writing" && (
<BsPen className="h-4 w-4" />
)}
{module === "speaking" && (
<BsMegaphone className="h-4 w-4" />
)}
{module === "level" && (
<BsClipboard className="h-4 w-4" />
)}
</div> </div>
))} ))}
</div> </div>
{!assignment.results.map((r) => r.user).includes(user.id) && ( {!assignment.results.map((r) => r.user).includes(user.id) && (
<> <>
<div <div
className="tooltip w-full md:hidden h-full flex items-center justify-end pl-8" className="tooltip flex h-full w-full items-center justify-end pl-8 md:hidden"
data-tip="Your screen size is too small to perform an assignment"> data-tip="Your screen size is too small to perform an assignment"
>
<Button <Button
disabled={moment(assignment.startDate).isAfter(moment())} disabled={moment(assignment.startDate).isAfter(
className="w-full h-full !rounded-xl" moment(),
variant="outline"> )}
className="h-full w-full !rounded-xl"
variant="outline"
>
Start Start
</Button> </Button>
</div> </div>
<Button <Button
disabled={moment(assignment.startDate).isAfter(moment())} disabled={moment(assignment.startDate).isAfter(
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden" moment(),
)}
className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
onClick={() => startAssignment(assignment)} onClick={() => startAssignment(assignment)}
variant="outline"> variant="outline"
>
Start Start
</Button> </Button>
</> </>
@@ -182,8 +329,9 @@ export default function StudentDashboard({user}: Props) {
<Button <Button
onClick={() => router.push("/record")} onClick={() => router.push("/record")}
color="green" color="green"
className="w-full max-w-[50%] h-full !rounded-xl -md:hidden" className="-md:hidden h-full w-full max-w-[50%] !rounded-xl"
variant="outline"> variant="outline"
>
Submitted Submitted
</Button> </Button>
)} )}
@@ -193,23 +341,60 @@ export default function StudentDashboard({user}: Props) {
</span> </span>
</section> </section>
<section className="flex flex-col gap-3"> {invites.length > 0 && (
<span className="font-bold text-lg">Score History</span> <section className="flex flex-col gap-1 md:gap-3">
<div className="grid -md:grid-rows-4 md:grid-cols-2 gap-6"> <div className="flex items-center gap-4">
{MODULE_ARRAY.map((module) => ( <div
<div className="border border-mti-gray-anti-flash rounded-xl flex flex-col gap-2 p-4" key={module}> onClick={reloadInvites}
<div className="flex gap-2 md:gap-3 items-center"> className="text-mti-purple-light hover:text-mti-purple-dark flex cursor-pointer items-center gap-2 transition duration-300 ease-in-out"
<div className="w-8 h-8 md:w-12 md:h-12 bg-mti-gray-smoke flex items-center justify-center rounded-lg md:rounded-xl"> >
{module === "reading" && <BsBook className="text-ielts-reading w-4 h-4 md:w-5 md:h-5" />} <span className="text-mti-black text-lg font-bold">Invites</span>
{module === "listening" && <BsHeadphones className="text-ielts-listening w-4 h-4 md:w-5 md:h-5" />} <BsArrowRepeat
{module === "writing" && <BsPen className="text-ielts-writing w-4 h-4 md:w-5 md:h-5" />} className={clsx("text-xl", isInvitesLoading && "animate-spin")}
{module === "speaking" && <BsMegaphone className="text-ielts-speaking w-4 h-4 md:w-5 md:h-5" />} />
{module === "level" && <BsClipboard className="text-ielts-level w-4 h-4 md:w-5 md:h-5" />}
</div> </div>
<div className="flex justify-between w-full"> </div>
<span className="font-bold md:font-extrabold text-sm">{capitalize(module)}</span> <span className="text-mti-gray-taupe scrollbar-hide flex gap-8 overflow-x-scroll">
<span className="text-sm font-normal text-mti-gray-dim"> {invites.map((invite) => (
Level {user.levels[module] || 0} / Level {user.desiredLevels[module] || 9} <InviteCard key={invite.id} {...invite} />
))}
</span>
</section>
)}
<section className="flex flex-col gap-3">
<span className="text-lg font-bold">Score History</span>
<div className="-md:grid-rows-4 grid gap-6 md:grid-cols-2">
{MODULE_ARRAY.map((module) => (
<div
className="border-mti-gray-anti-flash flex flex-col gap-2 rounded-xl border p-4"
key={module}
>
<div className="flex items-center gap-2 md:gap-3">
<div className="bg-mti-gray-smoke flex h-8 w-8 items-center justify-center rounded-lg md:h-12 md:w-12 md:rounded-xl">
{module === "reading" && (
<BsBook className="text-ielts-reading h-4 w-4 md:h-5 md:w-5" />
)}
{module === "listening" && (
<BsHeadphones className="text-ielts-listening h-4 w-4 md:h-5 md:w-5" />
)}
{module === "writing" && (
<BsPen className="text-ielts-writing h-4 w-4 md:h-5 md:w-5" />
)}
{module === "speaking" && (
<BsMegaphone className="text-ielts-speaking h-4 w-4 md:h-5 md:w-5" />
)}
{module === "level" && (
<BsClipboard className="text-ielts-level h-4 w-4 md:h-5 md:w-5" />
)}
</div>
<div className="flex w-full justify-between">
<span className="text-sm font-bold md:font-extrabold">
{capitalize(module)}
</span>
<span className="text-mti-gray-dim text-sm font-normal">
Level {user.levels[module] || 0} / Level{" "}
{user.desiredLevels[module] || 9}
</span> </span>
</div> </div>
</div> </div>
@@ -217,8 +402,10 @@ export default function StudentDashboard({user}: Props) {
<ProgressBar <ProgressBar
color={module} color={module}
label="" label=""
percentage={Math.round((user.levels[module] * 100) / user.desiredLevels[module])} percentage={Math.round(
className="w-full h-2" (user.levels[module] * 100) / user.desiredLevels[module],
)}
className="h-2 w-full"
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Hello {{name}},</span>
<br/>
<br/>
<span>You have been invited to join {{corporateName}}'s group!</span>
<br />
<br/>
<span>Please access the platform to accept or decline the invite.</span>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="https://unpkg.com/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
</head>
<div style="background-color: #ffffff; color: #353338;"
class="h-full min-h-screen w-full flex flex-col p-8 gap-16 text-base">
<img src="/logo_title.png" class="w-48 h-48 self-center" />
<div>
<span>Hello {{corporateName}},</span>
<br />
<br />
<span>{{name}} has decided to {{decision}} your invite!</span>
</div>
<br />
<br />
<div>
<span>Thanks, <br /> Your EnCoach team</span>
</div>
</div>
</html>

35
src/hooks/useInvites.tsx Normal file
View File

@@ -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<Invite[]>([]);
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<Invite[]>(`/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 };
}

5
src/interfaces/invite.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface Invite {
id: string;
from: string;
to: string;
}

View File

@@ -15,9 +15,11 @@ 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 readXlsxFile from "read-excel-file";
import Modal from "@/components/Modal"; 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[] } = { const USER_TYPE_PERMISSIONS: { [key in Type]: Type[] } = {
student: [], student: [],
@@ -29,7 +31,9 @@ const USER_TYPE_PERMISSIONS: {[key in Type]: Type[]} = {
}; };
export default function BatchCodeGenerator({ user }: { user: User }) { export default function BatchCodeGenerator({ user }: { user: User }) {
const [infos, setInfos] = useState<{email: string; name: string; passport_id: string}[]>([]); const [infos, setInfos] = useState<
{ email: string; name: string; passport_id: string }[]
>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [expiryDate, setExpiryDate] = useState<Date | null>(null); const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true);
@@ -62,8 +66,15 @@ export default function BatchCodeGenerator({user}: {user: User}) {
const information = uniqBy( const information = uniqBy(
rows rows
.map((row) => { .map((row) => {
const [firstName, lastName, country, passport_id, email, ...phone] = row as string[]; const [
return EMAIL_REGEX.test(email.toString().trim()) && !users.map((u) => u.email).includes(email.toString().trim()) firstName,
lastName,
country,
passport_id,
email,
...phone
] = row as string[];
return EMAIL_REGEX.test(email.toString().trim())
? { ? {
email: email.toString().trim(), email: email.toString().trim(),
name: `${firstName ?? ""} ${lastName ?? ""}`.trim(), name: `${firstName ?? ""} ${lastName ?? ""}`.trim(),
@@ -94,19 +105,64 @@ export default function BatchCodeGenerator({user}: {user: User}) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filesContent]); }, [filesContent]);
const generateCode = (type: Type) => { const generateAndInvite = async () => {
if (!confirm(`You are about to generate ${infos.length} codes, are you sure you want to continue?`)) return; 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 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);
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 (newUsers.length > 0) generateCode(type, newUsers);
setInfos([]);
};
const generateCode = (type: Type, informations: typeof infos) => {
const uid = new ShortUniqueId(); const uid = new ShortUniqueId();
const codes = infos.map(() => uid.randomUUID(6)); const codes = informations.map(() => uid.randomUUID(6));
setIsLoading(true); setIsLoading(true);
axios axios
.post<{ok: boolean; valid?: number; reason?: string}>("/api/code", {type, codes, infos: infos, expiryDate}) .post<{ ok: boolean; valid?: number; reason?: string }>("/api/code", {
type,
codes,
infos: informations,
expiryDate,
})
.then(({ data, status }) => { .then(({ data, status }) => {
if (data.ok) { if (data.ok) {
toast.success( toast.success(
`Successfully generated${data.valid ? ` ${data.valid}/${infos.length}` : ""} ${capitalize( `Successfully generated${data.valid ? ` ${data.valid}/${informations.length}` : ""} ${capitalize(
type, type,
)} codes and they have been notified by e-mail!`, )} codes and they have been notified by e-mail!`,
{ toastId: "success" }, { toastId: "success" },
@@ -124,7 +180,9 @@ export default function BatchCodeGenerator({user}: {user: User}) {
return; return;
} }
toast.error(`Something went wrong, please try again later!`, {toastId: "error"}); toast.error(`Something went wrong, please try again later!`, {
toastId: "error",
});
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
@@ -134,18 +192,30 @@ export default function BatchCodeGenerator({user}: {user: User}) {
return ( return (
<> <>
<Modal isOpen={showHelp} onClose={() => setShowHelp(false)} title="Excel File Format"> <Modal
isOpen={showHelp}
onClose={() => setShowHelp(false)}
title="Excel File Format"
>
<div className="mt-4 flex flex-col gap-2"> <div className="mt-4 flex flex-col gap-2">
<span>Please upload an Excel file with the following format:</span> <span>Please upload an Excel file with the following format:</span>
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr> <tr>
<th className="border border-neutral-200 px-2 py-1">First Name</th> <th className="border border-neutral-200 px-2 py-1">
<th className="border border-neutral-200 px-2 py-1">Last Name</th> First Name
</th>
<th className="border border-neutral-200 px-2 py-1">
Last Name
</th>
<th className="border border-neutral-200 px-2 py-1">Country</th> <th className="border border-neutral-200 px-2 py-1">Country</th>
<th className="border border-neutral-200 px-2 py-1">Passport/National ID</th> <th className="border border-neutral-200 px-2 py-1">
Passport/National ID
</th>
<th className="border border-neutral-200 px-2 py-1">E-mail</th> <th className="border border-neutral-200 px-2 py-1">E-mail</th>
<th className="border border-neutral-200 px-2 py-1">Phone Number</th> <th className="border border-neutral-200 px-2 py-1">
Phone Number
</th>
</tr> </tr>
</thead> </thead>
</table> </table>
@@ -154,34 +224,55 @@ export default function BatchCodeGenerator({user}: {user: User}) {
<ul> <ul>
<li>- All incorrect e-mails will be ignored;</li> <li>- All incorrect e-mails will be ignored;</li>
<li>- All already registered e-mails will be ignored;</li> <li>- All already registered e-mails will be ignored;</li>
<li>- You may have a header row with the format above, however, it is not necessary;</li> <li>
<li>- All of the e-mails in the file will receive an e-mail to join EnCoach with the role selected below.</li> - You may have a header row with the format above, however, it
is not necessary;
</li>
<li>
- All of the e-mails in the file will receive an e-mail to join
EnCoach with the role selected below.
</li>
</ul> </ul>
</span> </span>
</div> </div>
</Modal> </Modal>
<div className="flex flex-col gap-4 border p-4 border-mti-gray-platinum rounded-xl"> <div className="border-mti-gray-platinum flex flex-col gap-4 rounded-xl border p-4">
<div className="flex justify-between items-end"> <div className="flex items-end justify-between">
<label className="font-normal text-base text-mti-gray-dim">Choose an Excel file</label> <label className="text-mti-gray-dim text-base font-normal">
<div className="cursor-pointer tooltip" data-tip="Excel File Format" onClick={() => setShowHelp(true)}> Choose an Excel file
</label>
<div
className="tooltip cursor-pointer"
data-tip="Excel File Format"
onClick={() => setShowHelp(true)}
>
<BsQuestionCircleFill /> <BsQuestionCircleFill />
</div> </div>
</div> </div>
<Button onClick={openFilePicker} isLoading={isLoading} disabled={isLoading}> <Button
onClick={openFilePicker}
isLoading={isLoading}
disabled={isLoading}
>
{filesContent.length > 0 ? filesContent[0].name : "Choose a file"} {filesContent.length > 0 ? filesContent[0].name : "Choose a file"}
</Button> </Button>
{user && (user.type === "developer" || user.type === "admin") && ( {user && (user.type === "developer" || user.type === "admin") && (
<> <>
<div className="flex -md:flex-row md:flex-col -md:items-center 2xl:flex-row 2xl:items-center justify-between gap-2"> <div className="-md:flex-row -md:items-center flex justify-between gap-2 md:flex-col 2xl:flex-row 2xl:items-center">
<label className="font-normal text-base text-mti-gray-dim">Expiry Date</label> <label className="text-mti-gray-dim text-base font-normal">
<Checkbox isChecked={isExpiryDateEnabled} onChange={setIsExpiryDateEnabled}> Expiry Date
</label>
<Checkbox
isChecked={isExpiryDateEnabled}
onChange={setIsExpiryDateEnabled}
>
Enabled Enabled
</Checkbox> </Checkbox>
</div> </div>
{isExpiryDateEnabled && ( {isExpiryDateEnabled && (
<ReactDatePicker <ReactDatePicker
className={clsx( className={clsx(
"p-6 w-full min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer", "flex min-h-[70px] w-full cursor-pointer justify-center rounded-full border p-6 text-sm font-normal focus:outline-none",
"hover:border-mti-purple tooltip", "hover:border-mti-purple tooltip",
"transition duration-300 ease-in-out", "transition duration-300 ease-in-out",
)} )}
@@ -193,14 +284,19 @@ export default function BatchCodeGenerator({user}: {user: User}) {
)} )}
</> </>
)} )}
<label className="font-normal text-base text-mti-gray-dim">Select the type of user they should be</label> <label className="text-mti-gray-dim text-base font-normal">
Select the type of user they should be
</label>
{user && ( {user && (
<select <select
defaultValue="student" defaultValue="student"
onChange={(e) => setType(e.target.value as typeof user.type)} onChange={(e) => setType(e.target.value as typeof user.type)}
className="p-6 w-full min-w-[350px] min-h-[70px] flex justify-center text-sm font-normal rounded-full border focus:outline-none cursor-pointer bg-white"> className="flex min-h-[70px] w-full min-w-[350px] cursor-pointer justify-center rounded-full border bg-white p-6 text-sm font-normal focus:outline-none"
>
{Object.keys(USER_TYPE_LABELS) {Object.keys(USER_TYPE_LABELS)
.filter((x) => USER_TYPE_PERMISSIONS[user.type].includes(x as Type)) .filter((x) =>
USER_TYPE_PERMISSIONS[user.type].includes(x as Type),
)
.map((type) => ( .map((type) => (
<option key={type} value={type}> <option key={type} value={type}>
{USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]} {USER_TYPE_LABELS[type as keyof typeof USER_TYPE_LABELS]}
@@ -208,7 +304,12 @@ export default function BatchCodeGenerator({user}: {user: User}) {
))} ))}
</select> </select>
)} )}
<Button onClick={() => generateCode(type)} disabled={infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)}> <Button
onClick={generateAndInvite}
disabled={
infos.length === 0 || (isExpiryDateEnabled ? !expiryDate : false)
}
>
Generate & Send Generate & Send
</Button> </Button>
</div> </div>

View File

@@ -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 });
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}