From dd94228672a814f9c8e16b28029d37f310183831 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Wed, 25 Sep 2024 16:18:43 +0100 Subject: [PATCH 01/35] Created a new system for the Groups that will persist after having entities --- src/components/Low/Checkbox.tsx | 2 +- src/components/Low/Separator.tsx | 3 + src/components/Low/Tooltip.tsx | 17 + src/components/Sidebar.tsx | 4 +- src/email/templates/resetPassword.handlebars | 16 + src/hooks/useListSearch.tsx | 2 +- src/hooks/usePagination.tsx | 34 +- src/interfaces/entity.ts | 16 + src/interfaces/user.ts | 10 +- src/pages/api/groups/[id].ts | 13 +- src/pages/api/groups/index.ts | 15 +- src/pages/groups.tsx | 114 ------- src/pages/groups/[id].tsx | 336 +++++++++++++++++++ src/pages/groups/create.tsx | 204 +++++++++++ src/pages/groups/index.tsx | 137 ++++++++ src/utils/groups.be.ts | 8 +- src/utils/search.ts | 18 +- src/utils/users.be.ts | 10 +- 18 files changed, 823 insertions(+), 136 deletions(-) create mode 100644 src/components/Low/Separator.tsx create mode 100644 src/components/Low/Tooltip.tsx create mode 100644 src/email/templates/resetPassword.handlebars create mode 100644 src/interfaces/entity.ts delete mode 100644 src/pages/groups.tsx create mode 100644 src/pages/groups/[id].tsx create mode 100644 src/pages/groups/create.tsx create mode 100644 src/pages/groups/index.tsx diff --git a/src/components/Low/Checkbox.tsx b/src/components/Low/Checkbox.tsx index fa90951f..d527bc91 100644 --- a/src/components/Low/Checkbox.tsx +++ b/src/components/Low/Checkbox.tsx @@ -5,7 +5,7 @@ import {BsCheck} from "react-icons/bs"; interface Props { isChecked: boolean; onChange: (isChecked: boolean) => void; - children: ReactNode; + children?: ReactNode; disabled?: boolean; } diff --git a/src/components/Low/Separator.tsx b/src/components/Low/Separator.tsx new file mode 100644 index 00000000..5772b705 --- /dev/null +++ b/src/components/Low/Separator.tsx @@ -0,0 +1,3 @@ +const Separator = () =>
; + +export default Separator; diff --git a/src/components/Low/Tooltip.tsx b/src/components/Low/Tooltip.tsx new file mode 100644 index 00000000..bbe26a1f --- /dev/null +++ b/src/components/Low/Tooltip.tsx @@ -0,0 +1,17 @@ +import clsx from "clsx"; +import {ReactNode} from "react"; + +interface Props { + tooltip: string; + disabled?: boolean; + className?: string; + children: ReactNode; +} + +export default function Tooltip({tooltip, disabled = false, className, children}: Props) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 19d5d40f..85f806bc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -57,7 +57,7 @@ const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, "flex items-center gap-4 rounded-full p-4 text-gray-500 hover:text-white", "transition-all duration-300 ease-in-out relative", disabled ? "hover:bg-mti-gray-dim cursor-not-allowed" : "hover:bg-mti-purple-light cursor-pointer", - path === keyPath && "bg-mti-purple-light text-white", + (keyPath === "/" ? path === keyPath : path.startsWith(keyPath)) && "bg-mti-purple-light text-white", isMinimized ? "w-fit" : "w-full min-w-[200px] px-8 2xl:min-w-[220px]", )}> @@ -110,7 +110,7 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u {checkAccess(user, getTypesOfUser(["agent"]), permissions, "viewStats") && (
); - return {page, items, setPage, render}; + const renderMinimal = () => ( +
+
+ + +
+ + {page * size + 1} - {(page + 1) * size > list.length ? list.length : (page + 1) * size} / {list.length} + +
+ + +
+
+ ); + + return {page, items, setPage, render, renderMinimal}; } diff --git a/src/interfaces/entity.ts b/src/interfaces/entity.ts new file mode 100644 index 00000000..def83ddb --- /dev/null +++ b/src/interfaces/entity.ts @@ -0,0 +1,16 @@ +export interface Entity { + id: string; + label: string; +} + +export interface Roles { + id: string; + permissions: string[]; + label: string; +} + +export interface EntityWithPermissions extends Entity { + roles: Roles[]; +} + +export type WithEntity = T extends {entities: string[]} ? T & {entities: Entity[]} : T; diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 110650a5..4609a17e 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -22,6 +22,7 @@ export interface BasicUser { status: UserStatus; permissions: PermissionType[]; lastLogin?: Date; + entities: string[]; } export interface StudentUser extends BasicUser { @@ -151,6 +152,11 @@ export interface Group { disableEditing?: boolean; } +export interface GroupWithUsers extends Omit { + admin: User; + participants: User[]; +} + export interface Code { id: string; code: string; @@ -165,4 +171,6 @@ export interface Code { } export type Type = "student" | "teacher" | "corporate" | "admin" | "developer" | "agent" | "mastercorporate"; -export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; \ No newline at end of file +export const userTypes: Type[] = ["student", "teacher", "corporate", "admin", "developer", "agent", "mastercorporate"]; + +export type WithUser = T extends {participants: string[]} ? Omit & {participants: User[]} : T; diff --git a/src/pages/api/groups/[id].ts b/src/pages/api/groups/[id].ts index 48df6782..9290c479 100644 --- a/src/pages/api/groups/[id].ts +++ b/src/pages/api/groups/[id].ts @@ -75,13 +75,20 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { } const user = req.session.user; - if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { - if ("participants" in req.body) { + if ( + user.type === "admin" || + user.type === "developer" || + user.type === "mastercorporate" || + user.type === "corporate" || + user.id === group.admin + ) { + if ("participants" in req.body && req.body.participants.length > 0) { const newParticipants = (req.body.participants as string[]).filter((x) => !group.participants.includes(x)); await Promise.all(newParticipants.map(async (p) => await updateExpiryDateOnGroup(p, group.admin))); } - await db.collection("groups").updateOne({id: req.session.user.id}, {$set: {id, ...req.body}}, {upsert: true}); + console.log(req.body); + await db.collection("groups").updateOne({id}, {$set: {id, ...req.body}}, {upsert: true}); res.status(200).json({ok: true}); return; diff --git a/src/pages/api/groups/index.ts b/src/pages/api/groups/index.ts index 1165c522..854db5fe 100644 --- a/src/pages/api/groups/index.ts +++ b/src/pages/api/groups/index.ts @@ -39,11 +39,12 @@ async function post(req: NextApiRequest, res: NextApiResponse) { await Promise.all(body.participants.map(async (p) => await updateExpiryDateOnGroup(p, body.admin))); - await db.collection("groups").insertOne({ - id: v4(), - name: body.name, - admin: body.admin, - participants: body.participants, - }) - res.status(200).json({ok: true}); + const id = v4(); + await db.collection("groups").insertOne({ + id, + name: body.name, + admin: body.admin, + participants: body.participants, + }); + res.status(200).json({ok: true, id}); } diff --git a/src/pages/groups.tsx b/src/pages/groups.tsx deleted file mode 100644 index 12886c91..00000000 --- a/src/pages/groups.tsx +++ /dev/null @@ -1,114 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import Head from "next/head"; -import Navbar from "@/components/Navbar"; -import {BsFileEarmarkText, BsPencil, BsStar, BsBook, BsHeadphones, BsPen, BsMegaphone} from "react-icons/bs"; -import {withIronSessionSsr} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {useEffect, useState} from "react"; -import {averageScore, groupBySession, totalExams} from "@/utils/stats"; -import useUser from "@/hooks/useUser"; -import Diagnostic from "@/components/Diagnostic"; -import {ToastContainer} from "react-toastify"; -import {capitalize} from "lodash"; -import {Module} from "@/interfaces"; -import ProgressBar from "@/components/Low/ProgressBar"; -import Layout from "@/components/High/Layout"; -import {calculateAverageLevel} from "@/utils/score"; -import axios from "axios"; -import DemographicInformationInput from "@/components/DemographicInformationInput"; -import moment from "moment"; -import Link from "next/link"; -import {MODULE_ARRAY} from "@/utils/moduleUtils"; -import ProfileSummary from "@/components/ProfileSummary"; -import StudentDashboard from "@/dashboards/Student"; -import AdminDashboard from "@/dashboards/Admin"; -import CorporateDashboard from "@/dashboards/Corporate"; -import TeacherDashboard from "@/dashboards/Teacher"; -import AgentDashboard from "@/dashboards/Agent"; -import MasterCorporateDashboard from "@/dashboards/MasterCorporate"; -import PaymentDue from "./(status)/PaymentDue"; -import {useRouter} from "next/router"; -import {PayPalScriptProvider} from "@paypal/react-paypal-js"; -import {CorporateUser, Group, MasterCorporateUser, Type, User, userTypes} from "@/interfaces/user"; -import Select from "react-select"; -import {USER_TYPE_LABELS} from "@/resources/user"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; -import {shouldRedirectHome} from "@/utils/navigation.disabled"; -import useGroups from "@/hooks/useGroups"; -import useUsers from "@/hooks/useUsers"; -import {getUserName} from "@/utils/users"; -import {getParticipantGroups, getUserGroups} from "@/utils/groups.be"; -import {getUsers} from "@/utils/users.be"; - -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } - - const groups = await getParticipantGroups(user.id); - const users = await getUsers(); - - return { - props: {user, groups, users}, - }; -}, sessionOptions); - -interface Props { - user: User; - groups: Group[]; - users: User[]; -} -export default function Home({user, groups, users}: Props) { - return ( - <> - - EnCoach - - - - - - {user && ( - -
- {groups - .filter((x) => x.participants.includes(user.id)) - .map((group) => ( -
- - Group: - {group.name} - - - Admin: - {getUserName(users.find((x) => x.id === group.admin))} - - Participants: - {group.participants.map((x) => getUserName(users.find((u) => u.id === x))).join(", ")} -
- ))} -
-
- )} - - ); -} diff --git a/src/pages/groups/[id].tsx b/src/pages/groups/[id].tsx new file mode 100644 index 00000000..f208de00 --- /dev/null +++ b/src/pages/groups/[id].tsx @@ -0,0 +1,336 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import Checkbox from "@/components/Low/Checkbox"; +import Tooltip from "@/components/Low/Tooltip"; +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import {GroupWithUsers, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {USER_TYPE_LABELS} from "@/resources/user"; +import {convertToUsers, getGroup} from "@/utils/groups.be"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import {getUserName} from "@/utils/users"; +import {getLinkedUsers, getSpecificUsers, getUsers} from "@/utils/users.be"; +import axios from "axios"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {Divider} from "primereact/divider"; +import {useEffect, useMemo, useState} from "react"; +import { + BsArrowLeft, + BsChevronLeft, + BsClockFill, + BsEnvelopeFill, + BsFillPersonVcardFill, + BsPencil, + BsPerson, + BsPlus, + BsStopwatchFill, + BsTag, + BsTrash, + BsX, +} from "react-icons/bs"; +import {toast, ToastContainer} from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = req.session.user as User; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (shouldRedirectHome(user)) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const {id} = params as {id: string}; + + const group = await getGroup(id); + if (!group || (checkAccess(user, getTypesOfUser(["admin", "developer"])) && group.admin !== user.id && !group.participants.includes(user.id))) { + return { + redirect: { + destination: "/groups", + permanent: false, + }, + }; + } + + const linkedUsers = await getLinkedUsers(user.id, user.type); + const users = await getSpecificUsers([...group.participants, group.admin]); + const groupWithUser = convertToUsers(group, users); + + return { + props: {user, group: JSON.parse(JSON.stringify(groupWithUser)), users: JSON.parse(JSON.stringify(linkedUsers.users))}, + }; +}, sessionOptions); + +interface Props { + user: User; + group: GroupWithUsers; + users: User[]; +} + +export default function Home({user, group, users}: Props) { + const [isAdding, setIsAdding] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + + const nonParticipantUsers = useMemo( + () => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)), + [users, group.participants, group.admin], + ); + + const {rows, renderSearch} = useListSearch( + [["name"], ["corporateInformation", "companyInformation", "name"]], + isAdding ? nonParticipantUsers : group.participants, + ); + const {items, renderMinimal} = usePagination(rows, 20); + + const router = useRouter(); + + const allowGroupEdit = useMemo(() => checkAccess(user, ["admin", "developer", "mastercorporate"]) || user.id === group.admin.id, [user, group]); + + const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); + + const removeParticipants = () => { + if (selectedUsers.length === 0) return; + if (!allowGroupEdit) return; + if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`)) + return; + + setIsLoading(true); + + axios + .patch(`/api/groups/${group.id}`, {participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x))}) + .then(() => { + toast.success("The group has been updated successfully!"); + setTimeout(() => router.reload(), 500); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const addParticipants = () => { + if (selectedUsers.length === 0) return; + if (!allowGroupEdit || !isAdding) return; + if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`)) + return; + + setIsLoading(true); + + console.log([...group.participants.map((x) => x.id), selectedUsers]); + axios + .patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]}) + .then(() => { + toast.success("The group has been updated successfully!"); + setTimeout(() => router.reload(), 500); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const renameGroup = () => { + if (!allowGroupEdit) return; + + const name = prompt("Rename this group:", group.name); + if (!name) return; + + setIsLoading(true); + axios + .patch(`/api/groups/${group.id}`, {name}) + .then(() => { + toast.success("The group has been updated successfully!"); + setTimeout(() => router.reload(), 500); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const deleteGroup = () => { + if (!allowGroupEdit) return; + if (!confirm("Are you sure you want to delete this group?")) return; + + setIsLoading(true); + + axios + .delete(`/api/groups/${group.id}`) + .then(() => { + toast.success("This group has been successfully deleted!"); + setTimeout(() => router.push("/groups"), 1000); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + useEffect(() => setSelectedUsers([]), [isAdding]); + + return ( + <> + + {group.name} | EnCoach + + + + + + {user && ( + +
+
+
+
+ + + +

{group.name}

+
+ + {getUserName(group.admin)} + +
+ {allowGroupEdit && !isAdding && ( +
+ + +
+ )} +
+ +
+ Participants + {allowGroupEdit && !isAdding && ( +
+ + +
+ )} + {allowGroupEdit && isAdding && ( +
+ + +
+ )} +
+
+ {renderSearch()} + {renderMinimal()} +
+
+ +
+ {items.map((u) => ( + + ))} +
+
+ )} + + ); +} diff --git a/src/pages/groups/create.tsx b/src/pages/groups/create.tsx new file mode 100644 index 00000000..6935b9a8 --- /dev/null +++ b/src/pages/groups/create.tsx @@ -0,0 +1,204 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import Button from "@/components/Low/Button"; +import Checkbox from "@/components/Low/Checkbox"; +import Input from "@/components/Low/Input"; +import Tooltip from "@/components/Low/Tooltip"; +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import {GroupWithUsers, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {USER_TYPE_LABELS} from "@/resources/user"; +import {convertToUsers, getGroup} from "@/utils/groups.be"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import {getUserName} from "@/utils/users"; +import {getLinkedUsers, getSpecificUsers, getUsers} from "@/utils/users.be"; +import axios from "axios"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {Divider} from "primereact/divider"; +import {useEffect, useMemo, useState} from "react"; +import { + BsArrowLeft, + BsCheck, + BsChevronLeft, + BsClockFill, + BsEnvelopeFill, + BsFillPersonVcardFill, + BsPencil, + BsPerson, + BsPlus, + BsStopwatchFill, + BsTag, + BsTrash, + BsX, +} from "react-icons/bs"; +import {toast, ToastContainer} from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = req.session.user as User; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (shouldRedirectHome(user)) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const linkedUsers = await getLinkedUsers(user.id, user.type); + + return { + props: {user, users: JSON.parse(JSON.stringify(linkedUsers.users.filter((x) => x.id !== user.id)))}, + }; +}, sessionOptions); + +interface Props { + user: User; + users: User[]; +} + +export default function Home({user, users}: Props) { + const [isLoading, setIsLoading] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + const [name, setName] = useState(""); + + const {rows, renderSearch} = useListSearch([["name"], ["corporateInformation", "companyInformation", "name"]], users); + const {items, renderMinimal} = usePagination(rows, 16); + + const router = useRouter(); + + const createGroup = () => { + if (!name.trim()) return; + if (!confirm(`Are you sure you want to create this group with ${selectedUsers.length} participants?`)) return; + + setIsLoading(true); + + axios + .post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id}) + .then((result) => { + toast.success("Your group has been created successfully!"); + setTimeout(() => router.push(`/groups/${result.data.id}`), 250); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); + + return ( + <> + + Create Group | EnCoach + + + + + + {user && ( + +
+
+
+ + + +

Create Group

+
+
+ +
+
+ +
+ Group Name: + +
+ +
+ Participants ({selectedUsers.length} selected): +
+
+ {renderSearch()} + {renderMinimal()} +
+
+ +
+ {items.map((u) => ( + + ))} +
+
+ )} + + ); +} diff --git a/src/pages/groups/index.tsx b/src/pages/groups/index.tsx new file mode 100644 index 00000000..917583a5 --- /dev/null +++ b/src/pages/groups/index.tsx @@ -0,0 +1,137 @@ +/* eslint-disable @next/next/no-img-element */ +import Head from "next/head"; +import {withIronSessionSsr} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {ToastContainer} from "react-toastify"; +import Layout from "@/components/High/Layout"; +import {Group, GroupWithUsers, User, WithUser} from "@/interfaces/user"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {getUserName} from "@/utils/users"; +import {convertToUsers, getGroupsForUser, getParticipantGroups, getUserGroups} from "@/utils/groups.be"; +import {getSpecificUsers, getUsers} from "@/utils/users.be"; +import {checkAccess} from "@/utils/permissions"; +import usePagination from "@/hooks/usePagination"; +import {useListSearch} from "@/hooks/useListSearch"; +import Link from "next/link"; +import {uniq} from "lodash"; +import {BsPlus} from "react-icons/bs"; +import {Divider} from "primereact/divider"; +import Separator from "@/components/Low/Separator"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (shouldRedirectHome(user)) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const groups = await getGroupsForUser( + checkAccess(user, ["corporate", "mastercorporate"]) ? user.id : undefined, + checkAccess(user, ["teacher", "student"]) ? user.id : undefined, + ); + + const users = await getSpecificUsers(uniq(groups.flatMap((g) => [...g.participants.slice(0, 5), g.admin]))); + const groupsWithUsers: GroupWithUsers[] = groups.map((g) => convertToUsers(g, users)); + + return { + props: {user, groups: JSON.parse(JSON.stringify(groupsWithUsers))}, + }; +}, sessionOptions); + +interface Props { + user: User; + groups: GroupWithUsers[]; +} +export default function Home({user, groups}: Props) { + const {rows, renderSearch} = useListSearch( + [ + ["name"], + ["admin", "name"], + ["admin", "email"], + ["admin", "corporateInformation", "companyInformation", "name"], + ["participants", "name"], + ["participants", "email"], + ["participants", "corporateInformation", "companyInformation", "name"], + ], + groups, + ); + const {items, page, renderMinimal} = usePagination(rows, 20); + + return ( + <> + + Groups | EnCoach + + + + + + {user && ( + +
+

Groups

+ +
+ +
+
+ {renderSearch()} + {renderMinimal()} +
+
+ {page === 0 && ( + + + Create Group + + )} + {items.map((group) => ( + + + Group: + {group.name} + + + Admin: + {getUserName(group.admin)} + + Participants ({group.participants.length}): + + {group.participants.slice(0, 5).map(getUserName).join(", ")} + {group.participants.length > 5 ? ( + and {group.participants.length - 5} more + ) : ( + "" + )} + + + ))} +
+
+
+ )} + + ); +} diff --git a/src/utils/groups.be.ts b/src/utils/groups.be.ts index 4c344dc8..f500311b 100644 --- a/src/utils/groups.be.ts +++ b/src/utils/groups.be.ts @@ -1,6 +1,6 @@ import {app} from "@/firebase"; import {Assignment} from "@/interfaces/results"; -import {CorporateUser, Group, MasterCorporateUser, StudentUser, TeacherUser, Type, User} from "@/interfaces/user"; +import {CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User} from "@/interfaces/user"; import client from "@/lib/mongodb"; import moment from "moment"; import {getLinkedUsers, getUser} from "./users.be"; @@ -71,6 +71,12 @@ export const getUsersGroups = async (ids: string[]) => { .toArray(); }; +export const convertToUsers = (group: Group, users: User[]): GroupWithUsers => + Object.assign(group, { + admin: users.find((u) => u.id === group.admin), + participants: group.participants.map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[], + }); + export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise => { const linkedTeachers = await getLinkedUsers(corporateID, type, "teacher"); const linkedCorporates = await getLinkedUsers(corporateID, type, "corporate"); diff --git a/src/utils/search.ts b/src/utils/search.ts index 87b2cfc1..d40e4246 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -3,11 +3,20 @@ ['companyInformation', 'companyInformation', 'name'] ]*/ -const getFieldValue = (fields: string[], data: any): string => { +const getFieldValue = (fields: string[], data: any): string | string[] => { if (fields.length === 0) return data; const [key, ...otherFields] = fields; - if (data[key]) return getFieldValue(otherFields, data[key]); + if (Array.isArray(data[key])) { + // If the key points to an array, like "participants", iterate through each item in the array + return data[key] + .map((item: any) => getFieldValue(otherFields, item)) // Get the value for each item + .filter(Boolean); // Filter out undefined or null values + } else if (data[key] !== undefined) { + // If it's not an array, just go deeper in the object + return getFieldValue(otherFields, data[key]); + } + return data; }; @@ -16,6 +25,11 @@ export const search = (text: string, fields: string[][], rows: any[]) => { return rows.filter((row) => { return fields.some((fieldsKeys) => { const value = getFieldValue(fieldsKeys, row); + if (Array.isArray(value)) { + // If it's an array (e.g., participants' names), check each value in the array + return value.some((v) => v && typeof v === "string" && v.toLowerCase().includes(searchText)); + } + if (typeof value === "string") { return value.toLowerCase().includes(searchText); } diff --git a/src/utils/users.be.ts b/src/utils/users.be.ts index 31ecbe05..caee2762 100644 --- a/src/utils/users.be.ts +++ b/src/utils/users.be.ts @@ -8,11 +8,14 @@ import client from "@/lib/mongodb"; const db = client.db(process.env.MONGODB_DB); export async function getUsers() { - return await db.collection("users").find({}, { projection: { _id: 0 } }).toArray(); + return await db + .collection("users") + .find({}, {projection: {_id: 0}}) + .toArray(); } export async function getUser(id: string): Promise { - const user = await db.collection("users").findOne({id: id}, { projection: { _id: 0 } }); + const user = await db.collection("users").findOne({id: id}, {projection: {_id: 0}}); return !!user ? user : undefined; } @@ -21,7 +24,7 @@ export async function getSpecificUsers(ids: string[]) { return await db .collection("users") - .find({id: {$in: ids}}, { projection: { _id: 0 } }) + .find({id: {$in: ids}}, {projection: {_id: 0}}) .toArray(); } @@ -46,6 +49,7 @@ export async function getLinkedUsers( .skip(page && size ? page * size : 0) .limit(size || 0) .toArray(); + const total = await db.collection("users").countDocuments(filters); return {users, total}; } From bae02e5192a0afeabdda198c906ef4b05e1af0e8 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Wed, 25 Sep 2024 16:23:14 +0100 Subject: [PATCH 02/35] Improved the data refresh --- src/pages/groups/[id].tsx | 28 +++++++--------------------- src/pages/groups/create.tsx | 30 ++++++------------------------ src/pages/groups/index.tsx | 9 ++++----- 3 files changed, 17 insertions(+), 50 deletions(-) diff --git a/src/pages/groups/[id].tsx b/src/pages/groups/[id].tsx index f208de00..92592e0e 100644 --- a/src/pages/groups/[id].tsx +++ b/src/pages/groups/[id].tsx @@ -1,6 +1,5 @@ /* eslint-disable @next/next/no-img-element */ import Layout from "@/components/High/Layout"; -import Checkbox from "@/components/Low/Checkbox"; import Tooltip from "@/components/Low/Tooltip"; import {useListSearch} from "@/hooks/useListSearch"; import usePagination from "@/hooks/usePagination"; @@ -11,7 +10,7 @@ import {convertToUsers, getGroup} from "@/utils/groups.be"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import {getUserName} from "@/utils/users"; -import {getLinkedUsers, getSpecificUsers, getUsers} from "@/utils/users.be"; +import {getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; import axios from "axios"; import clsx from "clsx"; import {withIronSessionSsr} from "iron-session/next"; @@ -21,23 +20,10 @@ import Link from "next/link"; import {useRouter} from "next/router"; import {Divider} from "primereact/divider"; import {useEffect, useMemo, useState} from "react"; -import { - BsArrowLeft, - BsChevronLeft, - BsClockFill, - BsEnvelopeFill, - BsFillPersonVcardFill, - BsPencil, - BsPerson, - BsPlus, - BsStopwatchFill, - BsTag, - BsTrash, - BsX, -} from "react-icons/bs"; +import {BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX} from "react-icons/bs"; import {toast, ToastContainer} from "react-toastify"; -export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { +export const getServerSideProps = withIronSessionSsr(async ({req, params}) => { const user = req.session.user as User; if (!user) { @@ -119,7 +105,7 @@ export default function Home({user, group, users}: Props) { .patch(`/api/groups/${group.id}`, {participants: group.participants.map((x) => x.id).filter((x) => !selectedUsers.includes(x))}) .then(() => { toast.success("The group has been updated successfully!"); - setTimeout(() => router.reload(), 500); + router.replace(router.asPath); }) .catch((e) => { console.error(e); @@ -141,7 +127,7 @@ export default function Home({user, group, users}: Props) { .patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]}) .then(() => { toast.success("The group has been updated successfully!"); - setTimeout(() => router.reload(), 500); + router.replace(router.asPath); }) .catch((e) => { console.error(e); @@ -161,7 +147,7 @@ export default function Home({user, group, users}: Props) { .patch(`/api/groups/${group.id}`, {name}) .then(() => { toast.success("The group has been updated successfully!"); - setTimeout(() => router.reload(), 500); + router.replace(router.asPath); }) .catch((e) => { console.error(e); @@ -180,7 +166,7 @@ export default function Home({user, group, users}: Props) { .delete(`/api/groups/${group.id}`) .then(() => { toast.success("This group has been successfully deleted!"); - setTimeout(() => router.push("/groups"), 1000); + router.replace("/groups"); }) .catch((e) => { console.error(e); diff --git a/src/pages/groups/create.tsx b/src/pages/groups/create.tsx index 6935b9a8..d50b68b2 100644 --- a/src/pages/groups/create.tsx +++ b/src/pages/groups/create.tsx @@ -1,19 +1,15 @@ /* eslint-disable @next/next/no-img-element */ import Layout from "@/components/High/Layout"; -import Button from "@/components/Low/Button"; -import Checkbox from "@/components/Low/Checkbox"; import Input from "@/components/Low/Input"; import Tooltip from "@/components/Low/Tooltip"; import {useListSearch} from "@/hooks/useListSearch"; import usePagination from "@/hooks/usePagination"; -import {GroupWithUsers, User} from "@/interfaces/user"; +import {User} from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; import {USER_TYPE_LABELS} from "@/resources/user"; -import {convertToUsers, getGroup} from "@/utils/groups.be"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import {getUserName} from "@/utils/users"; -import {getLinkedUsers, getSpecificUsers, getUsers} from "@/utils/users.be"; +import {getLinkedUsers} from "@/utils/users.be"; import axios from "axios"; import clsx from "clsx"; import {withIronSessionSsr} from "iron-session/next"; @@ -22,25 +18,11 @@ import Head from "next/head"; import Link from "next/link"; import {useRouter} from "next/router"; import {Divider} from "primereact/divider"; -import {useEffect, useMemo, useState} from "react"; -import { - BsArrowLeft, - BsCheck, - BsChevronLeft, - BsClockFill, - BsEnvelopeFill, - BsFillPersonVcardFill, - BsPencil, - BsPerson, - BsPlus, - BsStopwatchFill, - BsTag, - BsTrash, - BsX, -} from "react-icons/bs"; +import {useState} from "react"; +import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs"; import {toast, ToastContainer} from "react-toastify"; -export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { +export const getServerSideProps = withIronSessionSsr(async ({req}) => { const user = req.session.user as User; if (!user) { @@ -93,7 +75,7 @@ export default function Home({user, users}: Props) { .post<{id: string}>(`/api/groups`, {name, participants: selectedUsers, admin: user.id}) .then((result) => { toast.success("Your group has been created successfully!"); - setTimeout(() => router.push(`/groups/${result.data.id}`), 250); + router.replace(`/groups/${result.data.id}`); }) .catch((e) => { console.error(e); diff --git a/src/pages/groups/index.tsx b/src/pages/groups/index.tsx index 917583a5..4199a464 100644 --- a/src/pages/groups/index.tsx +++ b/src/pages/groups/index.tsx @@ -4,21 +4,20 @@ import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {ToastContainer} from "react-toastify"; import Layout from "@/components/High/Layout"; -import {Group, GroupWithUsers, User, WithUser} from "@/interfaces/user"; +import {GroupWithUsers, User} from "@/interfaces/user"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {getUserName} from "@/utils/users"; -import {convertToUsers, getGroupsForUser, getParticipantGroups, getUserGroups} from "@/utils/groups.be"; -import {getSpecificUsers, getUsers} from "@/utils/users.be"; +import {convertToUsers, getGroupsForUser} from "@/utils/groups.be"; +import {getSpecificUsers} from "@/utils/users.be"; import {checkAccess} from "@/utils/permissions"; import usePagination from "@/hooks/usePagination"; import {useListSearch} from "@/hooks/useListSearch"; import Link from "next/link"; import {uniq} from "lodash"; import {BsPlus} from "react-icons/bs"; -import {Divider} from "primereact/divider"; import Separator from "@/components/Low/Separator"; -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { +export const getServerSideProps = withIronSessionSsr(async ({req}) => { const user = req.session.user; if (!user) { From 564e6438cbd7e3b914f331c149b0192b47e82e2f Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 1 Oct 2024 17:39:43 +0100 Subject: [PATCH 03/35] Continued creating the entity system --- src/components/High/CardList.tsx | 32 ++ src/components/Medium/InviteWithUserCard.tsx | 67 ++++ src/dashboards/Corporate/index.tsx | 42 +-- src/dashboards/IconCard.tsx | 6 +- src/interfaces/entity.ts | 11 +- src/interfaces/index.ts | 1 + src/interfaces/invite.ts | 12 +- src/interfaces/results.ts | 1 + src/interfaces/user.ts | 3 +- src/pages/api/entities/[id]/index.ts | 46 +++ src/pages/api/entities/[id]/users.ts | 65 ++++ src/pages/api/entities/index.ts | 48 +++ src/pages/api/hello.ts | 18 +- src/pages/dashboard/admin.tsx | 192 +++++++++++ src/pages/dashboard/corporate.tsx | 208 +++++++++++ src/pages/dashboard/developer.tsx | 192 +++++++++++ src/pages/dashboard/index.tsx | 27 ++ src/pages/dashboard/mastercorporate.tsx | 198 +++++++++++ src/pages/dashboard/student.tsx | 298 ++++++++++++++++ src/pages/dashboard/teacher.tsx | 182 ++++++++++ src/pages/entities/[id]/index.tsx | 343 +++++++++++++++++++ src/pages/entities/[id]/settings.tsx | 232 +++++++++++++ src/pages/entities/index.tsx | 116 +++++++ src/pages/groups/[id].tsx | 3 +- src/pages/groups/index.tsx | 102 +++--- src/pages/index.tsx | 12 +- src/utils/assignments.be.ts | 19 + src/utils/entities.be.ts | 35 ++ src/utils/exams.be.ts | 19 +- src/utils/grading.be.ts | 3 + src/utils/groups.be.ts | 8 + src/utils/index.ts | 8 + src/utils/invites.be.ts | 18 + src/utils/roles.be.ts | 14 + src/utils/sessions.be.ts | 11 + src/utils/stats.be.ts | 12 + src/utils/users.be.ts | 48 ++- 37 files changed, 2522 insertions(+), 130 deletions(-) create mode 100644 src/components/High/CardList.tsx create mode 100644 src/components/Medium/InviteWithUserCard.tsx create mode 100644 src/pages/api/entities/[id]/index.ts create mode 100644 src/pages/api/entities/[id]/users.ts create mode 100644 src/pages/api/entities/index.ts create mode 100644 src/pages/dashboard/admin.tsx create mode 100644 src/pages/dashboard/corporate.tsx create mode 100644 src/pages/dashboard/developer.tsx create mode 100644 src/pages/dashboard/index.tsx create mode 100644 src/pages/dashboard/mastercorporate.tsx create mode 100644 src/pages/dashboard/student.tsx create mode 100644 src/pages/dashboard/teacher.tsx create mode 100644 src/pages/entities/[id]/index.tsx create mode 100644 src/pages/entities/[id]/settings.tsx create mode 100644 src/pages/entities/index.tsx create mode 100644 src/utils/entities.be.ts create mode 100644 src/utils/invites.be.ts create mode 100644 src/utils/roles.be.ts create mode 100644 src/utils/sessions.be.ts create mode 100644 src/utils/stats.be.ts diff --git a/src/components/High/CardList.tsx b/src/components/High/CardList.tsx new file mode 100644 index 00000000..d54e7107 --- /dev/null +++ b/src/components/High/CardList.tsx @@ -0,0 +1,32 @@ +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import {ReactNode} from "react"; +import Checkbox from "../Low/Checkbox"; +import Separator from "../Low/Separator"; + +interface Props { + list: T[]; + searchFields: string[][]; + pageSize?: number; + firstCard?: () => ReactNode; + renderCard: (item: T) => ReactNode; +} + +export default function CardList({list, searchFields, renderCard, firstCard, pageSize = 20}: Props) { + const {rows, renderSearch} = useListSearch(searchFields, list); + + const {items, page, renderMinimal} = usePagination(rows, pageSize); + + return ( +
+
+ {renderSearch()} + {renderMinimal()} +
+
+ {page === 0 && !!firstCard && firstCard()} + {items.map(renderCard)} +
+
+ ); +} diff --git a/src/components/Medium/InviteWithUserCard.tsx b/src/components/Medium/InviteWithUserCard.tsx new file mode 100644 index 00000000..158723f7 --- /dev/null +++ b/src/components/Medium/InviteWithUserCard.tsx @@ -0,0 +1,67 @@ +import {Invite, InviteWithUsers} from "@/interfaces/invite"; +import {User} from "@/interfaces/user"; +import {getUserName} from "@/utils/users"; +import axios from "axios"; +import {useMemo, useState} from "react"; +import {BsArrowRepeat} from "react-icons/bs"; +import {toast} from "react-toastify"; + +interface Props { + invite: InviteWithUsers; + reload: () => void; +} + +export default function InviteWithUserCard({invite, reload}: Props) { + const [isLoading, setIsLoading] = useState(false); + + const name = useMemo(() => (!invite.from ? null : getUserName(invite.from)), [invite.from]); + + 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"}); + reload(); + }) + .catch((e) => { + toast.success(`Something went wrong, please try again later!`, { + toastId: "error", + }); + reload(); + }) + .finally(() => setIsLoading(false)); + }; + + return ( +
+ Invited by {name} +
+ + +
+
+ ); +} diff --git a/src/dashboards/Corporate/index.tsx b/src/dashboards/Corporate/index.tsx index e16028b3..453a9005 100644 --- a/src/dashboards/Corporate/index.tsx +++ b/src/dashboards/Corporate/index.tsx @@ -1,61 +1,40 @@ /* eslint-disable @next/next/no-img-element */ import Modal from "@/components/Modal"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; -import useUsers, {userHashStudent, userHashTeacher, userHashCorporate} from "@/hooks/useUsers"; +import useUsers from "@/hooks/useUsers"; import {CorporateUser, Group, MasterCorporateUser, Stat, User} from "@/interfaces/user"; import UserList from "@/pages/(admin)/Lists/UserList"; -import {dateSorter} from "@/utils"; +import {dateSorter, mapBy} from "@/utils"; import moment from "moment"; import {useEffect, useMemo, useState} from "react"; import { BsArrowLeft, BsClipboard2Data, - BsClipboard2DataFill, BsClock, - BsGlobeCentralSouthAsia, BsPaperclip, - BsPerson, - BsPersonAdd, BsPersonFill, BsPersonFillGear, - BsPersonGear, BsPencilSquare, - BsPersonBadge, BsPersonCheck, BsPeople, - BsArrowRepeat, - BsPlus, BsEnvelopePaper, BsDatabase, } from "react-icons/bs"; import UserCard from "@/components/UserCard"; import useGroups from "@/hooks/useGroups"; -import {averageLevelCalculator, calculateAverageLevel, calculateBandScore} from "@/utils/score"; -import {MODULE_ARRAY} from "@/utils/moduleUtils"; +import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {Module} from "@/interfaces"; import {groupByExam} from "@/utils/stats"; import IconCard from "../IconCard"; import GroupList from "@/pages/(admin)/Lists/GroupList"; import useFilterStore from "@/stores/listFilterStore"; import {useRouter} from "next/router"; -import useCodes from "@/hooks/useCodes"; -import {getUserCorporate} from "@/utils/groups"; import useAssignments from "@/hooks/useAssignments"; -import {Assignment} from "@/interfaces/results"; -import AssignmentView from "../AssignmentView"; -import AssignmentCreator from "../AssignmentCreator"; -import clsx from "clsx"; -import AssignmentCard from "../AssignmentCard"; -import {createColumnHelper} from "@tanstack/react-table"; -import Checkbox from "@/components/Low/Checkbox"; -import List from "@/components/List"; -import {getUserCompanyName} from "@/resources/user"; -import {futureAssignmentFilter, pastAssignmentFilter, archivedAssignmentFilter, activeAssignmentFilter} from "@/utils/assignments"; import useUserBalance from "@/hooks/useUserBalance"; import AssignmentsPage from "../views/AssignmentsPage"; import StudentPerformancePage from "./StudentPerformancePage"; -import MasterStatistical from "../MasterCorporate/MasterStatistical"; import MasterStatisticalPage from "./MasterStatisticalPage"; +import {getEntitiesUsers} from "@/utils/users.be"; interface Props { user: CorporateUser; @@ -91,19 +70,6 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) { const assignmentsGroups = useMemo(() => groups.filter((x) => x.admin === user.id || x.participants.includes(user.id)), [groups, user.id]); - const assignmentsUsers = useMemo( - () => - [...teachers, ...students].filter((x) => - !!selectedUser - ? groups - .filter((g) => g.admin === selectedUser.id) - .flatMap((g) => g.participants) - .includes(x.id) || false - : groups.flatMap((g) => g.participants).includes(x.id), - ), - [groups, teachers, students, selectedUser], - ); - useEffect(() => { setShowModal(!!selectedUser && router.asPath === "/#"); }, [selectedUser, router.asPath]); diff --git a/src/dashboards/IconCard.tsx b/src/dashboards/IconCard.tsx index e21e1e05..dcea0834 100644 --- a/src/dashboards/IconCard.tsx +++ b/src/dashboards/IconCard.tsx @@ -22,10 +22,10 @@ export default function IconCard({Icon, label, value, color, tooltip, onClick, c }; return ( -
-
+ ); } diff --git a/src/interfaces/entity.ts b/src/interfaces/entity.ts index def83ddb..071e031a 100644 --- a/src/interfaces/entity.ts +++ b/src/interfaces/entity.ts @@ -3,14 +3,17 @@ export interface Entity { label: string; } -export interface Roles { +export interface Role { id: string; + entityID: string; permissions: string[]; label: string; } -export interface EntityWithPermissions extends Entity { - roles: Roles[]; +export interface EntityWithRoles extends Entity { + roles: Role[]; } -export type WithEntity = T extends {entities: string[]} ? T & {entities: Entity[]} : T; +export type WithEntity = T extends {entities: {id: string; role: string}[]} + ? Omit & {entities: {entity?: Entity; role?: Role}[]} + : T; diff --git a/src/interfaces/index.ts b/src/interfaces/index.ts index cd740bd4..5bbe259a 100644 --- a/src/interfaces/index.ts +++ b/src/interfaces/index.ts @@ -8,5 +8,6 @@ export interface Step { export interface Grading { user: string; + entity?: string; steps: Step[]; } diff --git a/src/interfaces/invite.ts b/src/interfaces/invite.ts index ce5e9f5e..4dda38b7 100644 --- a/src/interfaces/invite.ts +++ b/src/interfaces/invite.ts @@ -1,5 +1,11 @@ +import {User} from "./user"; + export interface Invite { - id: string; - from: string; - to: string; + id: string; + from: string; + to: string; +} + +export interface InviteWithUsers extends Omit { + from?: User; } diff --git a/src/interfaces/results.ts b/src/interfaces/results.ts index ebc26bd8..cd9ff373 100644 --- a/src/interfaces/results.ts +++ b/src/interfaces/results.ts @@ -34,6 +34,7 @@ export interface Assignment { start?: boolean; autoStartDate?: Date; autoStart?: boolean; + entity?: string; } export type AssignmentWithCorporateId = Assignment & {corporateId: string}; diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 4609a17e..54fba92a 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -22,7 +22,7 @@ export interface BasicUser { status: UserStatus; permissions: PermissionType[]; lastLogin?: Date; - entities: string[]; + entities: {id: string; role: string}[]; } export interface StudentUser extends BasicUser { @@ -150,6 +150,7 @@ export interface Group { participants: string[]; id: string; disableEditing?: boolean; + entity?: string; } export interface GroupWithUsers extends Omit { diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts new file mode 100644 index 00000000..df9762b9 --- /dev/null +++ b/src/pages/api/entities/[id]/index.ts @@ -0,0 +1,46 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {getEntity, getEntityWithRoles} from "@/utils/entities.be"; +import client from "@/lib/mongodb"; +import {Entity} from "@/interfaces/entity"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "get") return await get(req, res); + if (req.method === "PATCH") return await patch(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {id, showRoles} = req.query as {id: string; showRoles: string}; + + const entity = await (!!showRoles ? getEntityWithRoles : getEntity)(id); + res.status(200).json(entity); +} + +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 user = req.session.user; + if (!user.entities.map((x) => x.id).includes(id)) { + return res.status(403).json({ok: false}); + } + + const entity = await db.collection("entities").updateOne({id}, {$set: {label: req.body.label}}); + + return res.status(200).json({ok: entity.acknowledged}); +} diff --git a/src/pages/api/entities/[id]/users.ts b/src/pages/api/entities/[id]/users.ts new file mode 100644 index 00000000..044f9c26 --- /dev/null +++ b/src/pages/api/entities/[id]/users.ts @@ -0,0 +1,65 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {countEntityUsers, getEntityUsers} from "@/utils/users.be"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "get") return await get(req, res); + if (req.method === "PATCH") return await patch(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const {id, onlyCount} = req.query as {id: string; onlyCount: string}; + + if (onlyCount) return res.status(200).json(await countEntityUsers(id)); + + const users = await getEntityUsers(id); + res.status(200).json(users); +} + +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 {add, members, role} = req.body as {add: boolean; members: string[]; role?: string}; + + if (add) { + await db.collection("users").updateMany( + {id: {$in: members}}, + { + // @ts-expect-error + $push: { + entities: {id, role}, + }, + }, + ); + + return res.status(204).end(); + } + + await db.collection("users").updateMany( + {id: {$in: members}}, + { + // @ts-expect-error + $pull: { + entities: {id}, + }, + }, + ); + + return res.status(204).end(); +} diff --git a/src/pages/api/entities/index.ts b/src/pages/api/entities/index.ts new file mode 100644 index 00000000..a29cf88a --- /dev/null +++ b/src/pages/api/entities/index.ts @@ -0,0 +1,48 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; +import {Entity} from "@/interfaces/entity"; +import {v4} from "uuid"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); + if (req.method === "POST") return await post(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const user = req.session.user; + const {showRoles} = req.query as {showRoles: string}; + + const getFn = showRoles ? getEntitiesWithRoles : getEntities; + + if (["admin", "developer"].includes(user.type)) return res.status(200).json(await getFn()); + res.status(200).json(await getFn(user.entities.map((x) => x.id))); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const user = req.session.user; + if (!["admin", "developer"].includes(user.type)) { + return res.status(403).json({ok: false}); + } + + const entity: Entity = { + id: v4(), + label: req.body.label, + }; + + return res.status(200).json(entity); +} diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts index f8bcc7e5..d301b5bf 100644 --- a/src/pages/api/hello.ts +++ b/src/pages/api/hello.ts @@ -1,13 +1,15 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from 'next' +import client from "@/lib/mongodb"; +import type {NextApiRequest, NextApiResponse} from "next"; + +const db = client.db(process.env.MONGODB_DB); type Data = { - name: string -} + name: string; +}; -export default function handler( - req: NextApiRequest, - res: NextApiResponse -) { - res.status(200).json({ name: 'John Doe' }) +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + await db.collection("users").updateMany({}, {$set: {entities: []}}); + + res.status(200).json({name: "John Doe"}); } diff --git a/src/pages/dashboard/admin.tsx b/src/pages/dashboard/admin.tsx new file mode 100644 index 00000000..dfe4ed7c --- /dev/null +++ b/src/pages/dashboard/admin.tsx @@ -0,0 +1,192 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import IconCard from "@/dashboards/IconCard"; +import {Module} from "@/interfaces"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {Assignment} from "@/interfaces/results"; +import {Group, Stat, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; +import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be"; +import {getEntitiesWithRoles} from "@/utils/entities.be"; +import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; +import {checkAccess} from "@/utils/permissions"; +import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; +import {groupByExam} from "@/utils/stats"; +import {getStatsByUsers} from "@/utils/stats.be"; +import {getEntitiesUsers, getUsers} from "@/utils/users.be"; +import {withIronSessionSsr} from "iron-session/next"; +import {uniqBy} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useMemo} from "react"; +import { + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, +} from "react-icons/bs"; +import {ToastContainer} from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const users = await getUsers(); + const entities = await getEntitiesWithRoles(); + const assignments = await getAssignments(); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroups(); + + return {props: serialize({user, users, entities, assignments, stats, groups})}; +}, sessionOptions); + +export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: {[key in Module]: number} = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); + + return ( + <> + + EnCoach + + + + + + +
+ + + + + + + + + + !a.archived).length} color="purple" /> +
+ +
+
+ Latest students +
+ {students + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Latest teachers +
+ {teachers + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Highest level students +
+ {students + .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) + .map((x) => ( + + ))} +
+
+
+ Highest exam count students +
+ {students + .sort( + (a, b) => + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + .map((x) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/src/pages/dashboard/corporate.tsx b/src/pages/dashboard/corporate.tsx new file mode 100644 index 00000000..5008a146 --- /dev/null +++ b/src/pages/dashboard/corporate.tsx @@ -0,0 +1,208 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import IconCard from "@/dashboards/IconCard"; +import {Module} from "@/interfaces"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {Assignment} from "@/interfaces/results"; +import {Group, Stat, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; +import {getEntitiesAssignments} from "@/utils/assignments.be"; +import {getEntitiesWithRoles} from "@/utils/entities.be"; +import {getGroupsByEntities} from "@/utils/groups.be"; +import {checkAccess} from "@/utils/permissions"; +import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; +import {groupByExam} from "@/utils/stats"; +import {getStatsByUsers} from "@/utils/stats.be"; +import {getEntitiesUsers} from "@/utils/users.be"; +import {withIronSessionSsr} from "iron-session/next"; +import {uniqBy} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useMemo} from "react"; +import { + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, +} from "react-icons/bs"; +import {ToastContainer} from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer", "corporate"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const entityIDS = mapBy(user.entities, "id") || []; + + const users = await getEntitiesUsers(entityIDS); + const entities = await getEntitiesWithRoles(entityIDS); + const assignments = await getEntitiesAssignments(entityIDS); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroupsByEntities(entityIDS); + + return {props: serialize({user, users, entities, assignments, stats, groups})}; +}, sessionOptions); + +export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: {[key in Module]: number} = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); + + return ( + <> + + EnCoach + + + + + + +
+ {entities.length > 0 && ( +
+ {mapBy(entities, "label")?.join(", ")} +
+ )} +
+ + + + + + + + + !a.archived).length} + color="purple" + /> +
+
+ +
+
+ Latest students +
+ {students + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Latest teachers +
+ {teachers + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Highest level students +
+ {students + .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) + .map((x) => ( + + ))} +
+
+
+ Highest exam count students +
+ {students + .sort( + (a, b) => + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + .map((x) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/src/pages/dashboard/developer.tsx b/src/pages/dashboard/developer.tsx new file mode 100644 index 00000000..dfe4ed7c --- /dev/null +++ b/src/pages/dashboard/developer.tsx @@ -0,0 +1,192 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import IconCard from "@/dashboards/IconCard"; +import {Module} from "@/interfaces"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {Assignment} from "@/interfaces/results"; +import {Group, Stat, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; +import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be"; +import {getEntitiesWithRoles} from "@/utils/entities.be"; +import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; +import {checkAccess} from "@/utils/permissions"; +import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; +import {groupByExam} from "@/utils/stats"; +import {getStatsByUsers} from "@/utils/stats.be"; +import {getEntitiesUsers, getUsers} from "@/utils/users.be"; +import {withIronSessionSsr} from "iron-session/next"; +import {uniqBy} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useMemo} from "react"; +import { + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, +} from "react-icons/bs"; +import {ToastContainer} from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const users = await getUsers(); + const entities = await getEntitiesWithRoles(); + const assignments = await getAssignments(); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroups(); + + return {props: serialize({user, users, entities, assignments, stats, groups})}; +}, sessionOptions); + +export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: {[key in Module]: number} = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); + + return ( + <> + + EnCoach + + + + + + +
+ + + + + + + + + + !a.archived).length} color="purple" /> +
+ +
+
+ Latest students +
+ {students + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Latest teachers +
+ {teachers + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Highest level students +
+ {students + .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) + .map((x) => ( + + ))} +
+
+
+ Highest exam count students +
+ {students + .sort( + (a, b) => + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + .map((x) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx new file mode 100644 index 00000000..41caa641 --- /dev/null +++ b/src/pages/dashboard/index.tsx @@ -0,0 +1,27 @@ +import {User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {withIronSessionSsr} from "iron-session/next"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + return { + redirect: { + destination: `/dashboard/${user.type}`, + permanent: false, + }, + }; +}, sessionOptions); + +export default function Dashboard() { + return
; +} diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx new file mode 100644 index 00000000..89ec1243 --- /dev/null +++ b/src/pages/dashboard/mastercorporate.tsx @@ -0,0 +1,198 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import IconCard from "@/dashboards/IconCard"; +import {Module} from "@/interfaces"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {Assignment} from "@/interfaces/results"; +import {Group, Stat, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; +import {getEntitiesAssignments} from "@/utils/assignments.be"; +import {getEntitiesWithRoles} from "@/utils/entities.be"; +import {getGroupsByEntities} from "@/utils/groups.be"; +import {checkAccess} from "@/utils/permissions"; +import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; +import {groupByExam} from "@/utils/stats"; +import {getStatsByUsers} from "@/utils/stats.be"; +import {getEntitiesUsers} from "@/utils/users.be"; +import {withIronSessionSsr} from "iron-session/next"; +import {uniqBy} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useMemo} from "react"; +import { + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, +} from "react-icons/bs"; +import {ToastContainer} from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const entityIDS = mapBy(user.entities, "id") || []; + + const users = await getEntitiesUsers(entityIDS); + const entities = await getEntitiesWithRoles(entityIDS); + const assignments = await getEntitiesAssignments(entityIDS); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroupsByEntities(entityIDS); + + return {props: serialize({user, users, entities, assignments, stats, groups})}; +}, sessionOptions); + +export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: {[key in Module]: number} = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); + + return ( + <> + + EnCoach + + + + + + +
+ + + + + + + + + !a.archived).length} color="purple" /> + +
+ +
+
+ Latest students +
+ {students + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Latest teachers +
+ {teachers + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Highest level students +
+ {students + .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) + .map((x) => ( + + ))} +
+
+
+ Highest exam count students +
+ {students + .sort( + (a, b) => + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + .map((x) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/src/pages/dashboard/student.tsx b/src/pages/dashboard/student.tsx new file mode 100644 index 00000000..adeb1c71 --- /dev/null +++ b/src/pages/dashboard/student.tsx @@ -0,0 +1,298 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import Button from "@/components/Low/Button"; +import ProgressBar from "@/components/Low/ProgressBar"; +import InviteWithUserCard from "@/components/Medium/InviteWithUserCard"; +import ModuleBadge from "@/components/ModuleBadge"; +import ProfileSummary from "@/components/ProfileSummary"; +import {Session} from "@/hooks/useSessions"; +import {Grading} from "@/interfaces"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {Exam} from "@/interfaces/exam"; +import {InviteWithUsers} from "@/interfaces/invite"; +import {Assignment} from "@/interfaces/results"; +import {Stat, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import useExamStore from "@/stores/examStore"; +import {mapBy, serialize} from "@/utils"; +import {activeAssignmentFilter} from "@/utils/assignments"; +import {getAssignmentsByAssignee} from "@/utils/assignments.be"; +import {getEntityWithRoles} from "@/utils/entities.be"; +import {getExamsByIds} from "@/utils/exams.be"; +import {getGradingSystemByEntity} from "@/utils/grading.be"; +import {convertInvitersToUsers, getInvitesByInvitee} from "@/utils/invites.be"; +import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule, sortByModuleName} from "@/utils/moduleUtils"; +import {checkAccess} from "@/utils/permissions"; +import {getGradingLabel} from "@/utils/score"; +import {getSessionsByUser} from "@/utils/sessions.be"; +import {averageScore} from "@/utils/stats"; +import {getStatsByUser} from "@/utils/stats.be"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import {capitalize, uniqBy} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import {useRouter} from "next/router"; +import {BsBook, BsClipboard, BsFileEarmarkText, BsHeadphones, BsMegaphone, BsPen, BsPencil, BsStar} from "react-icons/bs"; +import {ToastContainer} from "react-toastify"; + +interface Props { + user: User; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + exams: Exam[]; + sessions: Session[]; + invites: InviteWithUsers[]; + grading: Grading; +} + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const entityIDS = mapBy(user.entities, "id") || []; + const entityID = entityIDS.length > 0 ? entityIDS[0] : ""; + + const entities = await getEntityWithRoles(entityID); + const allAssignments = await getAssignmentsByAssignee(user.id, {archived: false}); + const stats = await getStatsByUser(user.id); + const sessions = await getSessionsByUser(user.id, 10); + const invites = await getInvitesByInvitee(user.id); + const grading = await getGradingSystemByEntity(entityID); + + const formattedInvites = await Promise.all(invites.map(convertInvitersToUsers)); + const assignments = allAssignments.filter(activeAssignmentFilter); + + const examIDs = uniqBy( + assignments.flatMap((a) => + a.exams.filter((e) => e.assignee === user.id).map((e) => ({module: e.module, id: e.id, key: `${e.module}_${e.id}`})), + ), + "key", + ); + const exams = await getExamsByIds(examIDs); + + return {props: serialize({user, entities, assignments, stats, exams, sessions, invites: formattedInvites, grading})}; +}, sessionOptions); + +export default function Dashboard({user, entities, assignments, stats, invites, grading, sessions, exams}: Props) { + 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 startAssignment = (assignment: Assignment) => { + 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"); + } + }; + + const studentAssignments = assignments.filter(activeAssignmentFilter); + + return ( + <> + + EnCoach + + + + + + + {entities.length > 0 && ( +
+ {mapBy(entities, "label")?.join(", ")} +
+ )} + + , + value: countFullExams(stats), + label: "Exams", + tooltip: "Number of all conducted completed exams", + }, + { + icon: , + value: countExamModules(stats), + label: "Modules", + tooltip: "Number of all exam modules performed including Level Test", + }, + { + icon: , + value: `${stats.length > 0 ? averageScore(stats) : 0}%`, + label: "Average Score", + tooltip: "Average success rate for questions responded", + }, + ]} + /> + + {/* Bio */} +
+ Bio + + {user.bio || "Your bio will appear here, you can change it by clicking on your name in the top right corner."} + +
+ + {/* Assignments */} +
+ + {studentAssignments.length === 0 && "Assignments will appear here. It seems that for now there are no assignments for you."} + {studentAssignments + .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) => ( + + ))} +
+ {!assignment.results.map((r) => r.user).includes(user.id) && ( + <> +
+ +
+
x.assignment?.id === assignment.id).length > 0 && "tooltip", + )}> + +
+ + )} + {assignment.results.map((r) => r.user).includes(user.id) && ( + + )} +
+
+ ))} +
+
+ + {/* Invites */} + {invites.length > 0 && ( +
+ + {invites.map((invite) => ( + router.replace(router.asPath)} /> + ))} + +
+ )} + + {/* Score History */} +
+ Score History +
+ {MODULE_ARRAY.map((module) => { + const desiredLevel = user.desiredLevels[module] || 9; + const level = user.levels[module] || 0; + return ( +
+
+
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } +
+
+ {capitalize(module)} + + {module === "level" && !!grading && `English Level: ${getGradingLabel(level, grading.steps)}`} + {module !== "level" && `Level ${level} / Level 9 (Desired Level: ${desiredLevel})`} + +
+
+
+ +
+
+ ); + })} +
+
+
+ + ); +} diff --git a/src/pages/dashboard/teacher.tsx b/src/pages/dashboard/teacher.tsx new file mode 100644 index 00000000..2b9d9080 --- /dev/null +++ b/src/pages/dashboard/teacher.tsx @@ -0,0 +1,182 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import IconCard from "@/dashboards/IconCard"; +import {Module} from "@/interfaces"; +import {EntityWithRoles} from "@/interfaces/entity"; +import {Assignment} from "@/interfaces/results"; +import {Group, Stat, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; +import {getEntitiesAssignments} from "@/utils/assignments.be"; +import {getEntitiesWithRoles} from "@/utils/entities.be"; +import {getGroupsByEntities} from "@/utils/groups.be"; +import {checkAccess} from "@/utils/permissions"; +import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; +import {groupByExam} from "@/utils/stats"; +import {getStatsByUsers} from "@/utils/stats.be"; +import {getEntitiesUsers} from "@/utils/users.be"; +import {withIronSessionSsr} from "iron-session/next"; +import {uniqBy} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useMemo} from "react"; +import { + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, +} from "react-icons/bs"; +import {ToastContainer} from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer", "teacher"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const entityIDS = mapBy(user.entities, "id") || []; + + const users = await getEntitiesUsers(entityIDS); + const entities = await getEntitiesWithRoles(entityIDS); + const assignments = await getEntitiesAssignments(entityIDS); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroupsByEntities(entityIDS); + + return {props: serialize({user, users, entities, assignments, stats, groups})}; +}, sessionOptions); + +export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: {[key in Module]: number} = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); + + return ( + <> + + EnCoach + + + + + + +
+ {entities.length > 0 && ( +
+ {mapBy(entities, "label")?.join(", ")} +
+ )} +
+ + + + + !a.archived).length} color="purple" /> +
+
+ +
+
+ Latest students +
+ {students + .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) + .map((x) => ( + + ))} +
+
+
+ Highest level students +
+ {students + .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) + .map((x) => ( + + ))} +
+
+
+ Highest exam count students +
+ {students + .sort( + (a, b) => + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + .map((x) => ( + + ))} +
+
+
+
+ + ); +} diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx new file mode 100644 index 00000000..85305c29 --- /dev/null +++ b/src/pages/entities/[id]/index.tsx @@ -0,0 +1,343 @@ +/* eslint-disable @next/next/no-img-element */ +import CardList from "@/components/High/CardList"; +import Layout from "@/components/High/Layout"; +import Tooltip from "@/components/Low/Tooltip"; +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; +import {GroupWithUsers, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {USER_TYPE_LABELS} from "@/resources/user"; +import {getEntityWithRoles} from "@/utils/entities.be"; +import {convertToUsers, getGroup} from "@/utils/groups.be"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import {getUserName} from "@/utils/users"; +import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; +import axios from "axios"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {Divider} from "primereact/divider"; +import {useEffect, useMemo, useState} from "react"; +import { + BsChevronLeft, + BsClockFill, + BsEnvelopeFill, + BsFillPersonVcardFill, + BsPlus, + BsSquare, + BsStopwatchFill, + BsTag, + BsTrash, + BsX, +} from "react-icons/bs"; +import {toast, ToastContainer} from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({req, params}) => { + const user = req.session.user as User; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (shouldRedirectHome(user)) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const {id} = params as {id: string}; + + const entityWithRoles = await getEntityWithRoles(id); + if (!entityWithRoles || (checkAccess(user, getTypesOfUser(["admin", "developer"])) && !user.entities.map((x) => x.id).includes(id))) { + return { + redirect: { + destination: "/entities", + permanent: false, + }, + }; + } + + const {entity, roles} = entityWithRoles; + + const linkedUsers = await getLinkedUsers(user.id, user.type); + const entityUsers = await getEntityUsers(id); + + const usersWithRole = entityUsers.map((u) => { + const e = u.entities.find((e) => e.id === id); + return {...u, role: roles.find((r) => r.id === e?.role)}; + }); + + return { + props: { + user, + entity: JSON.parse(JSON.stringify(entity)), + roles: JSON.parse(JSON.stringify(roles)), + users: JSON.parse(JSON.stringify(usersWithRole)), + linkedUsers: JSON.parse(JSON.stringify(linkedUsers.users)), + }, + }; +}, sessionOptions); + +type UserWithRole = User & {role?: Role}; + +interface Props { + user: User; + entity: Entity; + roles: Role[]; + users: UserWithRole[]; + linkedUsers: User[]; +} + +export default function Home({user, entity, roles, users, linkedUsers}: Props) { + const [isAdding, setIsAdding] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + + const router = useRouter(); + + const allowEntityEdit = useMemo(() => checkAccess(user, ["admin", "developer"]), [user]); + + const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); + + const removeParticipants = () => { + if (selectedUsers.length === 0) return; + if (!allowEntityEdit) return; + if (!confirm(`Are you sure you want to remove ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} from this entity?`)) + return; + + setIsLoading(true); + + axios + .patch(`/api/entities/${entity.id}/users`, {add: false, members: selectedUsers}) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + setSelectedUsers([]); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const addParticipants = () => { + if (selectedUsers.length === 0) return; + if (!allowEntityEdit || !isAdding) return; + if (!confirm(`Are you sure you want to add ${selectedUsers.length} member${selectedUsers.length === 1 ? "" : "s"} to this entity?`)) return; + + setIsLoading(true); + + axios + .patch(`/api/entities/${entity.id}/users`, {add: true, members: selectedUsers, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930"}) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + setIsAdding(false); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const renameGroup = () => { + if (!allowEntityEdit) return; + + const name = prompt("Rename this entity:", entity.label); + if (!name) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, {name}) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const deleteGroup = () => { + if (!allowEntityEdit) return; + if (!confirm("Are you sure you want to delete this entity?")) return; + + setIsLoading(true); + + axios + .delete(`/api/entities/${entity.id}`) + .then(() => { + toast.success("This entity has been successfully deleted!"); + router.replace("/entities"); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const renderCard = (u: UserWithRole) => { + return ( + + ); + }; + + useEffect(() => setSelectedUsers([]), [isAdding]); + + return ( + <> + + {entity.label} | EnCoach + + + + + + {user && ( + +
+
+
+
+ + + +

{entity.label}

+
+
+ {allowEntityEdit && !isAdding && ( +
+ + +
+ )} +
+ +
+ Members ({users.length}) + {allowEntityEdit && !isAdding && ( +
+ + +
+ )} + {allowEntityEdit && isAdding && ( +
+ + +
+ )} +
+ + + list={isAdding ? linkedUsers : users} + renderCard={renderCard} + searchFields={[["name"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]} + /> +
+
+ )} + + ); +} diff --git a/src/pages/entities/[id]/settings.tsx b/src/pages/entities/[id]/settings.tsx new file mode 100644 index 00000000..bce4dab7 --- /dev/null +++ b/src/pages/entities/[id]/settings.tsx @@ -0,0 +1,232 @@ +/* eslint-disable @next/next/no-img-element */ +import CardList from "@/components/High/CardList"; +import Layout from "@/components/High/Layout"; +import Tooltip from "@/components/Low/Tooltip"; +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; +import {GroupWithUsers, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {USER_TYPE_LABELS} from "@/resources/user"; +import {getEntityWithRoles} from "@/utils/entities.be"; +import {convertToUsers, getGroup} from "@/utils/groups.be"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import {getUserName} from "@/utils/users"; +import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; +import axios from "axios"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {Divider} from "primereact/divider"; +import {useEffect, useMemo, useState} from "react"; +import { + BsChevronLeft, + BsClockFill, + BsEnvelopeFill, + BsFillPersonVcardFill, + BsPlus, + BsSquare, + BsStopwatchFill, + BsTag, + BsTrash, + BsX, +} from "react-icons/bs"; +import {toast, ToastContainer} from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({req, params}) => { + const user = req.session.user as User; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (shouldRedirectHome(user)) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const {id} = params as {id: string}; + + const entityWithRoles = await getEntityWithRoles(id); + if (!entityWithRoles || (checkAccess(user, getTypesOfUser(["admin", "developer"])) && !user.entities.map((x) => x.id).includes(id))) { + return { + redirect: { + destination: "/entities", + permanent: false, + }, + }; + } + + const {entity, roles} = entityWithRoles; + + const linkedUsers = await getLinkedUsers(user.id, user.type); + const users = await getEntityUsers(id); + + return { + props: { + user, + entity: JSON.parse(JSON.stringify(entity)), + roles: JSON.parse(JSON.stringify(roles)), + users: JSON.parse(JSON.stringify(users)), + linkedUsers: JSON.parse(JSON.stringify(linkedUsers.users)), + }, + }; +}, sessionOptions); + +interface Props { + user: User; + entity: Entity; + roles: Role[]; + users: User[]; + linkedUsers: User[]; +} + +export default function Home({user, entity, roles, users, linkedUsers}: Props) { + const [isEditing, setIsEditing] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const router = useRouter(); + + const allowEntityEdit = useMemo(() => checkAccess(user, ["admin", "developer"]), [user]); + + const renameGroup = () => { + if (!allowEntityEdit) return; + + const name = prompt("Rename this entity:", entity.label); + if (!name) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, {name}) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const deleteGroup = () => { + if (!allowEntityEdit) return; + if (!confirm("Are you sure you want to delete this entity?")) return; + + setIsLoading(true); + + axios + .delete(`/api/entities/${entity.id}`) + .then(() => { + toast.success("This entity has been successfully deleted!"); + router.replace("/entities"); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const firstCard = () => ( + + + Create Role + + ); + + const renderCard = (role: Role) => { + const usersWithRole = users.filter((x) => x.entities.map((x) => x.role).includes(role.id)); + + return ( + + ); + }; + + return ( + <> + + {entity.label} | EnCoach + + + + + + {user && ( + +
+
+
+
+ + + +

{entity.label}

+
+
+ {allowEntityEdit && !isEditing && ( +
+ + +
+ )} +
+ + Roles + + +
+
+ )} + + ); +} diff --git a/src/pages/entities/index.tsx b/src/pages/entities/index.tsx new file mode 100644 index 00000000..00b12b47 --- /dev/null +++ b/src/pages/entities/index.tsx @@ -0,0 +1,116 @@ +/* eslint-disable @next/next/no-img-element */ +import Head from "next/head"; +import {withIronSessionSsr} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import {ToastContainer} from "react-toastify"; +import Layout from "@/components/High/Layout"; +import {GroupWithUsers, User} from "@/interfaces/user"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {getUserName} from "@/utils/users"; +import {convertToUsers, getGroupsForUser} from "@/utils/groups.be"; +import {countEntityUsers, getEntityUsers, getSpecificUsers} from "@/utils/users.be"; +import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import Link from "next/link"; +import {uniq} from "lodash"; +import {BsPlus} from "react-icons/bs"; +import CardList from "@/components/High/CardList"; +import {getEntitiesWithRoles} from "@/utils/entities.be"; +import {EntityWithRoles} from "@/interfaces/entity"; +import Separator from "@/components/Low/Separator"; + +type EntitiesWithCount = {entity: EntityWithRoles; users: User[]; count: number}; + +export const getServerSideProps = withIronSessionSsr(async ({req}) => { + const user = req.session.user; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (shouldRedirectHome(user)) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } + + const entities = await getEntitiesWithRoles( + checkAccess(user, getTypesOfUser(["admin", "developer"])) ? user.entities.map((x) => x.id) : undefined, + ); + + const entitiesWithCount = await Promise.all( + entities.map(async (e) => ({entity: e, count: await countEntityUsers(e.id), users: await getEntityUsers(e.id, 5)})), + ); + + return { + props: {user, entities: JSON.parse(JSON.stringify(entitiesWithCount))}, + }; +}, sessionOptions); + +const SEARCH_FIELDS: string[][] = [["entity", "label"]]; + +interface Props { + user: User; + entities: EntitiesWithCount[]; +} +export default function Home({user, entities}: Props) { + const renderCard = ({entity, users, count}: EntitiesWithCount) => ( + + + Entity: + {entity.label} + + Members ({count}): + + {users.map(getUserName).join(", ")} + {count > 5 ? and {count - 5} more : ""} + + + ); + + const firstCard = () => ( + + + Create Entity + + ); + + return ( + <> + + Entities | EnCoach + + + + + + {user && ( + +
+
+

Entities

+ +
+ + list={entities} searchFields={SEARCH_FIELDS} renderCard={renderCard} firstCard={firstCard} /> +
+
+ )} + + ); +} diff --git a/src/pages/groups/[id].tsx b/src/pages/groups/[id].tsx index 92592e0e..5a1348a6 100644 --- a/src/pages/groups/[id].tsx +++ b/src/pages/groups/[id].tsx @@ -78,7 +78,7 @@ export default function Home({user, group, users}: Props) { const nonParticipantUsers = useMemo( () => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)), - [users, group.participants, group.admin], + [users, group.participants, group.admin.id, user.id], ); const {rows, renderSearch} = useListSearch( @@ -122,7 +122,6 @@ export default function Home({user, group, users}: Props) { setIsLoading(true); - console.log([...group.participants.map((x) => x.id), selectedUsers]); axios .patch(`/api/groups/${group.id}`, {participants: [...group.participants.map((x) => x.id), ...selectedUsers]}) .then(() => { diff --git a/src/pages/groups/index.tsx b/src/pages/groups/index.tsx index 4199a464..1f48ac19 100644 --- a/src/pages/groups/index.tsx +++ b/src/pages/groups/index.tsx @@ -10,11 +10,10 @@ import {getUserName} from "@/utils/users"; import {convertToUsers, getGroupsForUser} from "@/utils/groups.be"; import {getSpecificUsers} from "@/utils/users.be"; import {checkAccess} from "@/utils/permissions"; -import usePagination from "@/hooks/usePagination"; -import {useListSearch} from "@/hooks/useListSearch"; import Link from "next/link"; import {uniq} from "lodash"; import {BsPlus} from "react-icons/bs"; +import CardList from "@/components/High/CardList"; import Separator from "@/components/Low/Separator"; export const getServerSideProps = withIronSessionSsr(async ({req}) => { @@ -51,24 +50,50 @@ export const getServerSideProps = withIronSessionSsr(async ({req}) => { }; }, sessionOptions); +const SEARCH_FIELDS = [ + ["name"], + ["admin", "name"], + ["admin", "email"], + ["admin", "corporateInformation", "companyInformation", "name"], + ["participants", "name"], + ["participants", "email"], + ["participants", "corporateInformation", "companyInformation", "name"], +]; + interface Props { user: User; groups: GroupWithUsers[]; } export default function Home({user, groups}: Props) { - const {rows, renderSearch} = useListSearch( - [ - ["name"], - ["admin", "name"], - ["admin", "email"], - ["admin", "corporateInformation", "companyInformation", "name"], - ["participants", "name"], - ["participants", "email"], - ["participants", "corporateInformation", "companyInformation", "name"], - ], - groups, + const renderCard = (group: GroupWithUsers) => ( + + + Group: + {group.name} + + + Admin: + {getUserName(group.admin)} + + Participants ({group.participants.length}): + + {group.participants.slice(0, 5).map(getUserName).join(", ")} + {group.participants.length > 5 ? and {group.participants.length - 5} more : ""} + + + ); + + const firstCard = () => ( + + + Create Group + ); - const {items, page, renderMinimal} = usePagination(rows, 20); return ( <> @@ -84,50 +109,13 @@ export default function Home({user, groups}: Props) { {user && ( -
-

Groups

- -
+
+
+

Classrooms

+ +
-
-
- {renderSearch()} - {renderMinimal()} -
-
- {page === 0 && ( - - - Create Group - - )} - {items.map((group) => ( - - - Group: - {group.name} - - - Admin: - {getUserName(group.admin)} - - Participants ({group.participants.length}): - - {group.participants.slice(0, 5).map(getUserName).join(", ")} - {group.participants.length > 5 ? ( - and {group.participants.length - 5} more - ) : ( - "" - )} - - - ))} -
+ list={groups} searchFields={SEARCH_FIELDS} renderCard={renderCard} firstCard={firstCard} />
)} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 6a4f9ca4..cc872198 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -37,14 +37,7 @@ import {getUserCorporate} from "@/utils/groups.be"; import {getUsers} from "@/utils/users.be"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; - - const envVariables: {[key: string]: string} = {}; - Object.keys(process.env) - .filter((x) => x.startsWith("NEXT_PUBLIC")) - .forEach((x: string) => { - envVariables[x] = process.env[x]!; - }); + const user = req.session.user as User | undefined; if (!user) { return { @@ -58,13 +51,12 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { const linkedCorporate = (await getUserCorporate(user.id)) || null; return { - props: {user, envVariables, linkedCorporate}, + props: {user, linkedCorporate}, }; }, sessionOptions); interface Props { user: User; - envVariables: {[key: string]: string}; linkedCorporate?: CorporateUser | MasterCorporateUser; } diff --git a/src/utils/assignments.be.ts b/src/utils/assignments.be.ts index 261f04b6..5c0bb9d5 100644 --- a/src/utils/assignments.be.ts +++ b/src/utils/assignments.be.ts @@ -18,10 +18,18 @@ export const getAssignmentsByAssigner = async (id: string, startDate?: Date, end return await db.collection("assignments").find(query).toArray(); }; + export const getAssignments = async () => { return await db.collection("assignments").find({}).toArray(); }; +export const getAssignmentsByAssignee = async (id: string, filter?: {[key in keyof Partial]: any}) => { + return await db + .collection("assignments") + .find({assignees: [id], ...(!filter ? {} : filter)}) + .toArray(); +}; + export const getAssignmentsByAssignerBetweenDates = async (id: string, startDate: Date, endDate: Date) => { return await db.collection("assignments").find({assigner: id}).toArray(); }; @@ -37,6 +45,17 @@ export const getAssignmentsByAssigners = async (ids: string[], startDate?: Date, .toArray(); }; +export const getEntityAssignments = async (id: string) => { + return await db.collection("assignments").find({entity: id}).toArray(); +}; + +export const getEntitiesAssignments = async (ids: string[]) => { + return await db + .collection("assignments") + .find({entity: {$in: ids}}) + .toArray(); +}; + export const getAssignmentsForCorporates = async (userType: Type, idsList: string[], startDate?: Date, endDate?: Date) => { const assigners = await Promise.all( idsList.map(async (id) => { diff --git a/src/utils/entities.be.ts b/src/utils/entities.be.ts new file mode 100644 index 00000000..74604011 --- /dev/null +++ b/src/utils/entities.be.ts @@ -0,0 +1,35 @@ +import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; +import client from "@/lib/mongodb"; +import {getRolesByEntities, getRolesByEntity} from "./roles.be"; + +const db = client.db(process.env.MONGODB_DB); + +export const getEntityWithRoles = async (id: string): Promise<{entity: Entity; roles: Role[]} | undefined> => { + const entity = await getEntity(id); + if (!entity) return undefined; + + const roles = await getRolesByEntity(id); + return {entity, roles}; +}; + +export const getEntity = async (id: string) => { + return (await db.collection("entities").findOne({id})) ?? undefined; +}; + +export const getEntitiesWithRoles = async (ids?: string[]): Promise => { + const entities = await db + .collection("entities") + .find(ids ? {id: {$in: ids}} : {}) + .toArray(); + + const roles = await getRolesByEntities(entities.map((x) => x.id)); + + return entities.map((x) => ({...x, roles: roles.filter((y) => y.entityID === x.id) || []})); +}; + +export const getEntities = async (ids?: string[]) => { + return await db + .collection("entities") + .find(ids ? {id: {$in: ids}} : {}) + .toArray(); +}; diff --git a/src/utils/exams.be.ts b/src/utils/exams.be.ts index 70bcd685..f9686dc2 100644 --- a/src/utils/exams.be.ts +++ b/src/utils/exams.be.ts @@ -1,5 +1,5 @@ import {collection, getDocs, query, where, setDoc, doc, Firestore, getDoc, and} from "firebase/firestore"; -import {shuffle} from "lodash"; +import {groupBy, shuffle} from "lodash"; import {Difficulty, Exam, InstructorGender, SpeakingExam, Variant, WritingExam} from "@/interfaces/exam"; import {DeveloperUser, Stat, StudentUser, User} from "@/interfaces/user"; import {Module} from "@/interfaces"; @@ -29,6 +29,23 @@ export async function getSpecificExams(ids: string[]) { return exams; } +export const getExamsByIds = async (ids: {module: Module; id: string}[]) => { + const groupedByModule = groupBy(ids, "module"); + const exams: Exam[] = ( + await Promise.all( + Object.keys(groupedByModule).map( + async (m) => + await db + .collection(m) + .find({id: {$in: groupedByModule[m]}}) + .toArray(), + ), + ) + ).flat(); + + return exams; +}; + export const getExams = async ( db: Db, module: Module, diff --git a/src/utils/grading.be.ts b/src/utils/grading.be.ts index 391d1ed3..1c7742f5 100644 --- a/src/utils/grading.be.ts +++ b/src/utils/grading.be.ts @@ -20,3 +20,6 @@ export const getGradingSystem = async (user: User): Promise => { return {steps: CEFR_STEPS, user: user.id}; }; + +export const getGradingSystemByEntity = async (id: string) => + (await db.collection("grading").findOne({entity: id})) || {steps: CEFR_STEPS, user: ""}; diff --git a/src/utils/groups.be.ts b/src/utils/groups.be.ts index f500311b..ab55c9fb 100644 --- a/src/utils/groups.be.ts +++ b/src/utils/groups.be.ts @@ -116,3 +116,11 @@ export const getCorporateNameForStudent = async (studentID: string) => { return ""; }; + +export const getGroupsByEntity = async (id: string) => await db.collection("groups").find({entity: id}).toArray(); + +export const getGroupsByEntities = async (ids: string[]) => + await db + .collection("groups") + .find({entity: {$in: ids}}) + .toArray(); diff --git a/src/utils/index.ts b/src/utils/index.ts index 892cafbc..f0862650 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -43,3 +43,11 @@ export const convertBase64 = (file: File) => { }; }); }; + +export const mapBy = (obj: T[], key: keyof T) => { + if (!obj) return undefined; + return obj.map((i) => i[key]); +}; +export const filterBy = (obj: T[], key: keyof T, value: any) => obj.filter((i) => i[key] === value); + +export const serialize = (obj: T): T => JSON.parse(JSON.stringify(obj)); diff --git a/src/utils/invites.be.ts b/src/utils/invites.be.ts new file mode 100644 index 00000000..ad68ae0c --- /dev/null +++ b/src/utils/invites.be.ts @@ -0,0 +1,18 @@ +import {Session} from "@/hooks/useSessions"; +import {Invite, InviteWithUsers} from "@/interfaces/invite"; +import {User} from "@/interfaces/user"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); + +export const getInvitesByInvitee = async (id: string, limit?: number) => + await db + .collection("invites") + .find({to: id}) + .limit(limit || 0) + .toArray(); + +export const convertInvitersToUsers = async (invite: Invite): Promise => ({ + ...invite, + from: (await db.collection("users").findOne({id: invite.from})) ?? undefined, +}); diff --git a/src/utils/roles.be.ts b/src/utils/roles.be.ts new file mode 100644 index 00000000..d0114b74 --- /dev/null +++ b/src/utils/roles.be.ts @@ -0,0 +1,14 @@ +import {Role} from "@/interfaces/entity"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); + +export const getRolesByEntities = async (entityIDs: string[]) => + await db + .collection("roles") + .find({entityID: {$in: entityIDs}}) + .toArray(); + +export const getRolesByEntity = async (entityID: string) => await db.collection("roles").find({entityID}).toArray(); + +export const getRole = async (id: string) => (await db.collection("roles").findOne({id})) ?? undefined; diff --git a/src/utils/sessions.be.ts b/src/utils/sessions.be.ts new file mode 100644 index 00000000..a6fbe357 --- /dev/null +++ b/src/utils/sessions.be.ts @@ -0,0 +1,11 @@ +import {Session} from "@/hooks/useSessions"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); + +export const getSessionsByUser = async (id: string, limit?: number) => + await db + .collection("sessions") + .find({user: id}) + .limit(limit || 0) + .toArray(); diff --git a/src/utils/stats.be.ts b/src/utils/stats.be.ts new file mode 100644 index 00000000..4abc852e --- /dev/null +++ b/src/utils/stats.be.ts @@ -0,0 +1,12 @@ +import {Stat} from "@/interfaces/user"; +import client from "@/lib/mongodb"; + +const db = client.db(process.env.MONGODB_DB); + +export const getStatsByUser = async (id: string) => await db.collection("stats").find({user: id}).toArray(); + +export const getStatsByUsers = async (ids: string[]) => + await db + .collection("stats") + .find({user: {$in: ids}}) + .toArray(); diff --git a/src/utils/users.be.ts b/src/utils/users.be.ts index caee2762..963eef79 100644 --- a/src/utils/users.be.ts +++ b/src/utils/users.be.ts @@ -1,9 +1,11 @@ -import {CorporateUser, Group, Type, User} from "@/interfaces/user"; +import {CorporateUser, Type, User} from "@/interfaces/user"; import {getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups} from "./groups.be"; -import {last, uniq, uniqBy} from "lodash"; +import {uniq} from "lodash"; import {getUserCodes} from "./codes.be"; -import moment from "moment"; import client from "@/lib/mongodb"; +import {WithEntity} from "@/interfaces/entity"; +import {getEntity} from "./entities.be"; +import {getRole} from "./roles.be"; const db = client.db(process.env.MONGODB_DB); @@ -14,6 +16,22 @@ export async function getUsers() { .toArray(); } +export async function getUserWithEntity(id: string): Promise | undefined> { + const user = await db.collection("users").findOne({id: id}, {projection: {_id: 0}}); + if (!user) return undefined; + + const entities = await Promise.all( + user.entities.map(async (e) => { + const entity = await getEntity(e.id); + const role = await getRole(e.role); + + return {entity, role}; + }), + ); + + return {...user, entities}; +} + export async function getUser(id: string): Promise { const user = await db.collection("users").findOne({id: id}, {projection: {_id: 0}}); return !!user ? user : undefined; @@ -28,6 +46,30 @@ export async function getSpecificUsers(ids: string[]) { .toArray(); } +export async function getEntityUsers(id: string, limit?: number) { + return await db + .collection("users") + .find({"entities.id": id}) + .limit(limit || 0) + .toArray(); +} + +export async function countEntityUsers(id: string) { + return await db.collection("users").countDocuments({"entities.id": id}); +} + +export async function getEntitiesUsers(ids: string[], limit?: number) { + return await db + .collection("users") + .find({"entities.id": {$in: ids}}) + .limit(limit || 0) + .toArray(); +} + +export async function countEntitiesUsers(ids: string[]) { + return await db.collection("users").countDocuments({"entities.id": {$in: ids}}); +} + export async function getLinkedUsers( userID?: string, userType?: Type, From 3d4a604aa27f9cc046795184691e77330664fa62 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Wed, 2 Oct 2024 19:20:05 +0100 Subject: [PATCH 04/35] Started working on the assignments page --- src/components/List.tsx | 36 +- src/components/Low/Select.tsx | 80 +- src/components/Sidebar.tsx | 9 +- src/dashboards/AssignmentView.tsx | 700 ++++++++---------- src/dashboards/views/AssignmentsPage.tsx | 1 + src/pages/api/assignments/[id]/index.ts | 12 +- src/pages/api/assignments/index.ts | 9 +- src/pages/api/groups/index.ts | 1 + src/pages/assignments/creator/[id].tsx | 594 +++++++++++++++ src/pages/assignments/creator/index.tsx | 537 ++++++++++++++ src/pages/assignments/index.tsx | 187 +++++ src/pages/{groups => classrooms}/[id].tsx | 4 +- src/pages/classrooms/create.tsx | 202 +++++ src/pages/{groups => classrooms}/index.tsx | 14 +- .../dashboard/{admin.tsx => admin/index.tsx} | 0 .../{corporate.tsx => corporate/index.tsx} | 27 +- .../{developer.tsx => developer/index.tsx} | 2 +- .../index.tsx} | 5 +- .../{student.tsx => student/index.tsx} | 9 +- .../{teacher.tsx => teacher/index.tsx} | 24 +- src/pages/groups/create.tsx | 186 ----- src/pages/lists/users.tsx | 259 +++++++ src/utils/assignments.be.ts | 4 + src/utils/groups.be.ts | 6 + src/utils/index.ts | 5 +- 25 files changed, 2225 insertions(+), 688 deletions(-) create mode 100644 src/pages/assignments/creator/[id].tsx create mode 100644 src/pages/assignments/creator/index.tsx create mode 100644 src/pages/assignments/index.tsx rename src/pages/{groups => classrooms}/[id].tsx (99%) create mode 100644 src/pages/classrooms/create.tsx rename src/pages/{groups => classrooms}/index.tsx (90%) rename src/pages/dashboard/{admin.tsx => admin/index.tsx} (100%) rename src/pages/dashboard/{corporate.tsx => corporate/index.tsx} (91%) rename src/pages/dashboard/{developer.tsx => developer/index.tsx} (99%) rename src/pages/dashboard/{mastercorporate.tsx => mastercorporate/index.tsx} (97%) rename src/pages/dashboard/{student.tsx => student/index.tsx} (97%) rename src/pages/dashboard/{teacher.tsx => teacher/index.tsx} (94%) delete mode 100644 src/pages/groups/create.tsx create mode 100644 src/pages/lists/users.tsx diff --git a/src/components/List.tsx b/src/components/List.tsx index f62e7172..37e6a099 100644 --- a/src/components/List.tsx +++ b/src/components/List.tsx @@ -1,13 +1,26 @@ +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; import {Column, flexRender, getCoreRowModel, getSortedRowModel, useReactTable} from "@tanstack/react-table"; +import clsx from "clsx"; import {useMemo, useState} from "react"; import Button from "./Low/Button"; const SIZE = 25; -export default function List({data, columns}: {data: T[]; columns: any[]}) { - const [page, setPage] = useState(0); +export default function List({ + data, + columns, + searchFields = [], + pageSize = SIZE, +}: { + data: T[]; + columns: any[]; + searchFields?: string[][]; + pageSize?: number; +}) { + const {rows, renderSearch} = useListSearch(searchFields, data); - const items = useMemo(() => data.slice(page * SIZE, (page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE), [data, page]); + const {items, page, renderMinimal} = usePagination(rows, pageSize); const table = useReactTable({ data: items, @@ -17,19 +30,10 @@ export default function List({data, columns}: {data: T[]; columns: any[]}) { }); return ( -
-
- -
- - {page * SIZE + 1} - {(page + 1) * SIZE > data.length ? data.length : (page + 1) * SIZE} / {data.length} - - -
+
+
+ {searchFields.length > 0 && renderSearch()} + {renderMinimal()}
diff --git a/src/components/Low/Select.tsx b/src/components/Low/Select.tsx index 186f6a2c..2b3c16b0 100644 --- a/src/components/Low/Select.tsx +++ b/src/components/Low/Select.tsx @@ -18,9 +18,10 @@ interface Props { isClearable?: boolean; styles?: StylesConfig>; className?: string; + label?: string; } -export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, className}: Props) { +export default function Select({value, defaultValue, options, placeholder, disabled, onChange, styles, isClearable, label, className}: Props) { const [target, setTarget] = useState(); useEffect(() => { @@ -28,43 +29,46 @@ export default function Select({value, defaultValue, options, placeholder, disab }, []); return ( - ({...base, zIndex: 9999}), - control: (styles) => ({ - ...styles, - paddingLeft: "4px", - border: "none", - outline: "none", - ":focus": { - outline: "none", - }, - }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), +
+ {label && } + + options={options} + value={value} + onChange={onChange as any} + placeholder={placeholder} + menuPortalTarget={target} + defaultValue={defaultValue} + styles={ + styles || { + menuPortal: (base) => ({...base, zIndex: 9999}), + control: (styles) => ({ + ...styles, + paddingLeft: "4px", + border: "none", + outline: "none", + ":focus": { + outline: "none", + }, + }), + option: (styles, state) => ({ + ...styles, + backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", + color: state.isFocused ? "black" : styles.color, + }), + } + } + isDisabled={disabled} + isClearable={isClearable} + /> +
); } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 85f806bc..761ebd7d 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -111,7 +111,14 @@ export default function Sidebar({path, navDisabled = false, focusMode = false, u
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ ) +} diff --git a/src/components/Medium/RecordFilter.tsx b/src/components/Medium/RecordFilter.tsx index c70882c7..a8e5ce89 100644 --- a/src/components/Medium/RecordFilter.tsx +++ b/src/components/Medium/RecordFilter.tsx @@ -1,11 +1,13 @@ import { User } from "@/interfaces/user"; import { checkAccess } from "@/utils/permissions"; import Select from "../Low/Select"; -import { ReactNode, useEffect, useState } from "react"; +import { ReactNode, useEffect, useMemo, useState } from "react"; import clsx from "clsx"; import useUsers from "@/hooks/useUsers"; import useGroups from "@/hooks/useGroups"; import useRecordStore from "@/stores/recordStore"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { mapBy } from "@/utils"; type TimeFilter = "months" | "weeks" | "days"; @@ -13,6 +15,8 @@ type Filter = TimeFilter | "assignments" | undefined; interface Props { user: User; + entities: EntityWithRoles[] + users: User[] filterState: { filter: Filter, setFilter: React.Dispatch> @@ -28,83 +32,41 @@ const defaultSelectableCorporate = { const RecordFilter: React.FC = ({ user, + entities, + users, filterState, assignments = true, children }) => { const { filter, setFilter } = filterState; - const [statsUserId, setStatsUserId] = useRecordStore((state) => [ + const [entity, setEntity] = useState() + + const [, setStatsUserId] = useRecordStore((state) => [ state.selectedUser, state.setSelectedUser ]); - const { users } = useUsers(); - const { groups: allGroups } = useGroups({}); - const { groups } = useGroups({ admin: user?.id, userType: user?.type }); + const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity]) + + useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]) const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { setFilter((prev) => (prev === value ? undefined : value)); }; - const selectableCorporates = [ - defaultSelectableCorporate, - ...users - .filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)) - .filter((x) => x.type === "corporate") - .map((x) => ({ - value: x.id, - label: `${x.name} - ${x.email}`, - })), - ]; - - const [selectedCorporate, setSelectedCorporate] = useState(defaultSelectableCorporate.value); - - const getUsersList = (): User[] => { - if (selectedCorporate) { - const selectedCorporateGroups = allGroups.filter((x) => x.admin === selectedCorporate); - const selectedCorporateGroupsParticipants = selectedCorporateGroups.flatMap((x) => x.participants); - - const userListWithUsers = selectedCorporateGroupsParticipants.map((x) => users.find((y) => y.id === x)) as User[]; - return userListWithUsers.filter((x) => x); - } - - return user.type !== "mastercorporate" ? users : users.filter((x) => groups.flatMap((g) => [g.admin, ...g.participants]).includes(x.id)); - }; - - const corporateFilteredUserList = getUsersList(); - - const getSelectedUser = () => { - if (selectedCorporate) { - const userInCorporate = corporateFilteredUserList.find((x) => x.id === statsUserId); - return userInCorporate || corporateFilteredUserList[0]; - } - - return users.find((x) => x.id === statsUserId) || user; - }; - - const selectedUser = getSelectedUser(); - const selectedUserSelectValue = selectedUser - ? { - value: selectedUser.id, - label: `${selectedUser.name} - ${selectedUser.email}`, - } - : { - value: "", - label: "", - }; - return (
-
+
{checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && ( <> - +
+ + }} /> +
+
groups.flatMap((y) => y.participants).includes(x.id)) .map((x) => ({ value: x.id, label: `${x.name} - ${x.email}`, }))} - value={selectedUserSelectValue} + defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} onChange={(value) => setStatsUserId(value?.value!)} styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }), @@ -155,7 +119,7 @@ const RecordFilter: React.FC = ({ }), }} /> - +
)} {children}
@@ -203,4 +167,4 @@ const RecordFilter: React.FC = ({ ); } -export default RecordFilter; \ No newline at end of file +export default RecordFilter; diff --git a/src/components/UserDisplayList.tsx b/src/components/UserDisplayList.tsx new file mode 100644 index 00000000..8f1e803f --- /dev/null +++ b/src/components/UserDisplayList.tsx @@ -0,0 +1,30 @@ +/** eslint-disable @next/next/no-img-element */ +import { User } from "@/interfaces/user" + +interface Props { + users: User[] + title: string; +} + +const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+); + +export default function UserDisplayList({ title, users }: Props) { + return (
+ {title} +
+ {users + .slice(0, 10) + .map((x) => ( + + ))} +
+
) +} diff --git a/src/hooks/useEntities.tsx b/src/hooks/useEntities.tsx new file mode 100644 index 00000000..0999363f --- /dev/null +++ b/src/hooks/useEntities.tsx @@ -0,0 +1,23 @@ +import { EntityWithRoles } from "@/interfaces/entity"; +import { Discount } from "@/interfaces/paypal"; +import { Code, Group, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useEntities(creator?: string) { + const [entities, setEntities] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get("/api/entities?showRoles=true") + .then((response) => setEntities(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, [creator]); + + return { entities, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useEntitiesGroups.tsx b/src/hooks/useEntitiesGroups.tsx new file mode 100644 index 00000000..f0bd565c --- /dev/null +++ b/src/hooks/useEntitiesGroups.tsx @@ -0,0 +1,23 @@ +import { EntityWithRoles, WithEntity, WithLabeledEntities } from "@/interfaces/entity"; +import { Discount } from "@/interfaces/paypal"; +import { Code, Group, Type, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useEntitiesGroups() { + const [groups, setGroups] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get[]>(`/api/entities/groups`) + .then((response) => setGroups(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, []); + + return { groups, isLoading, isError, reload: getData }; +} diff --git a/src/hooks/useEntitiesUsers.tsx b/src/hooks/useEntitiesUsers.tsx new file mode 100644 index 00000000..f1c7be6a --- /dev/null +++ b/src/hooks/useEntitiesUsers.tsx @@ -0,0 +1,23 @@ +import { EntityWithRoles, WithLabeledEntities } from "@/interfaces/entity"; +import { Discount } from "@/interfaces/paypal"; +import { Code, Group, Type, User } from "@/interfaces/user"; +import axios from "axios"; +import { useEffect, useState } from "react"; + +export default function useEntitiesUsers(type?: Type) { + const [users, setUsers] = useState[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const getData = () => { + setIsLoading(true); + axios + .get[]>(`/api/entities/users${type ? "?type=" + type : ""}`) + .then((response) => setUsers(response.data)) + .finally(() => setIsLoading(false)); + }; + + useEffect(getData, [type]); + + return { users, isLoading, isError, reload: getData }; +} diff --git a/src/interfaces/entity.ts b/src/interfaces/entity.ts index 071e031a..c55ee793 100644 --- a/src/interfaces/entity.ts +++ b/src/interfaces/entity.ts @@ -1,19 +1,28 @@ export interface Entity { - id: string; - label: string; + id: string; + label: string; } export interface Role { - id: string; - entityID: string; - permissions: string[]; - label: string; + id: string; + entityID: string; + permissions: string[]; + label: string; } export interface EntityWithRoles extends Entity { - roles: Role[]; -} + roles: Role[]; +}; -export type WithEntity = T extends {entities: {id: string; role: string}[]} - ? Omit & {entities: {entity?: Entity; role?: Role}[]} - : T; +export type WithLabeledEntities = T extends { entities: { id: string; role: string }[] } + ? Omit & { entities: { id: string; label?: string; role: string, roleLabel?: string }[] } + : T; + + +export type WithEntity = T extends { entity?: string } + ? Omit & { entity: Entity } + : T; + +export type WithEntities = T extends { entities: { id: string; role: string }[] } + ? Omit & { entities: { entity?: Entity; role?: Role }[] } + : T; diff --git a/src/pages/(admin)/Lists/GroupList.tsx b/src/pages/(admin)/Lists/GroupList.tsx index 993f56da..46426014 100644 --- a/src/pages/(admin)/Lists/GroupList.tsx +++ b/src/pages/(admin)/Lists/GroupList.tsx @@ -3,373 +3,300 @@ import Input from "@/components/Low/Input"; import Modal from "@/components/Modal"; import useGroups from "@/hooks/useGroups"; import useUsers from "@/hooks/useUsers"; -import {CorporateUser, Group, User} from "@/interfaces/user"; -import {createColumnHelper, flexRender, getCoreRowModel, useReactTable} from "@tanstack/react-table"; +import { CorporateUser, Group, User } from "@/interfaces/user"; +import { createColumnHelper, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import axios from "axios"; -import {capitalize, uniq} from "lodash"; -import {useEffect, useMemo, useState} from "react"; -import {BsPencil, BsQuestionCircleFill, BsTrash} from "react-icons/bs"; +import { capitalize, uniq } from "lodash"; +import { useEffect, useMemo, useState } from "react"; +import { BsPencil, BsQuestionCircleFill, BsTrash } from "react-icons/bs"; import Select from "react-select"; -import {toast} from "react-toastify"; +import { toast } from "react-toastify"; import readXlsxFile from "read-excel-file"; -import {useFilePicker} from "use-file-picker"; -import {getUserCorporate} from "@/utils/groups"; -import {isAgentUser, isCorporateUser, USER_TYPE_LABELS} from "@/resources/user"; -import {checkAccess} from "@/utils/permissions"; +import { useFilePicker } from "use-file-picker"; +import { getUserCorporate } from "@/utils/groups"; +import { isAgentUser, isCorporateUser, USER_TYPE_LABELS } from "@/resources/user"; +import { checkAccess } from "@/utils/permissions"; import usePermissions from "@/hooks/usePermissions"; -import {useListSearch} from "@/hooks/useListSearch"; +import { useListSearch } from "@/hooks/useListSearch"; +import Table from "@/components/High/Table"; +import useEntitiesGroups from "@/hooks/useEntitiesGroups"; +import useEntitiesUsers from "@/hooks/useEntitiesUsers"; +import { WithEntity } from "@/interfaces/entity"; const searchFields = [["name"]]; -const columnHelper = createColumnHelper(); +const columnHelper = createColumnHelper>(); const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); -const LinkedCorporate = ({userId, users, groups}: {userId: string; users: User[]; groups: Group[]}) => { - const [companyName, setCompanyName] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const user = users.find((u) => u.id === userId); - if (!user) return setCompanyName(""); - - if (isCorporateUser(user)) return setCompanyName(user.corporateInformation?.companyInformation?.name || user.name); - if (isAgentUser(user)) return setCompanyName(user.agentInformation?.companyName || user.name); - - const belongingGroups = groups.filter((x) => x.participants.includes(userId)); - const belongingGroupsAdmins = belongingGroups.map((x) => users.find((u) => u.id === x.admin)).filter((x) => !!x && isCorporateUser(x)); - - if (belongingGroupsAdmins.length === 0) return setCompanyName(""); - - const admin = belongingGroupsAdmins[0] as CorporateUser; - setCompanyName(admin.corporateInformation?.companyInformation.name || admin.name); - }, [userId, users, groups]); - - return isLoading ? Loading... : <>{companyName}; -}; - interface CreateDialogProps { - user: User; - users: User[]; - group?: Group; - onClose: () => void; + user: User; + users: User[]; + group?: Group; + onClose: () => void; } -const CreatePanel = ({user, users, group, onClose}: CreateDialogProps) => { - const [name, setName] = useState(group?.name || undefined); - const [admin, setAdmin] = useState(group?.admin || user.id); - const [participants, setParticipants] = useState(group?.participants || []); - const [isLoading, setIsLoading] = useState(false); +const CreatePanel = ({ user, users, group, onClose }: CreateDialogProps) => { + const [name, setName] = useState(group?.name || undefined); + const [admin, setAdmin] = useState(group?.admin || user.id); + const [participants, setParticipants] = useState(group?.participants || []); + const [isLoading, setIsLoading] = useState(false); - const {openFilePicker, filesContent, clear} = useFilePicker({ - accept: ".xlsx", - multiple: false, - readAs: "ArrayBuffer", - }); + const { openFilePicker, filesContent, clear } = useFilePicker({ + accept: ".xlsx", + multiple: false, + readAs: "ArrayBuffer", + }); - const availableUsers = useMemo(() => { - if (user.type === "teacher") return users.filter((x) => ["student"].includes(x.type)); - if (user.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type)); - if (user.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type)); + const availableUsers = useMemo(() => { + if (user.type === "teacher") return users.filter((x) => ["student"].includes(x.type)); + if (user.type === "corporate") return users.filter((x) => ["teacher", "student"].includes(x.type)); + if (user.type === "mastercorporate") return users.filter((x) => ["corporate", "teacher", "student"].includes(x.type)); - return users; - }, [user, users]); + return users; + }, [user, users]); - useEffect(() => { - if (filesContent.length > 0) { - setIsLoading(true); + useEffect(() => { + if (filesContent.length > 0) { + setIsLoading(true); - const file = filesContent[0]; - readXlsxFile(file.content).then((rows) => { - const emails = uniq( - rows - .map((row) => { - const [email] = row as string[]; - return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined; - }) - .filter((x) => !!x), - ); + const file = filesContent[0]; + readXlsxFile(file.content).then((rows) => { + const emails = uniq( + rows + .map((row) => { + const [email] = row as string[]; + return EMAIL_REGEX.test(email) && !users.map((u) => u.email).includes(email) ? email.toString().trim() : undefined; + }) + .filter((x) => !!x), + ); - if (emails.length === 0) { - toast.error("Please upload an Excel file containing e-mails!"); - clear(); - setIsLoading(false); - return; - } + if (emails.length === 0) { + toast.error("Please upload an Excel file containing e-mails!"); + clear(); + setIsLoading(false); + return; + } - const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); - const filteredUsers = emailUsers.filter( - (x) => - ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") && - (x?.type === "student" || x?.type === "teacher")) || - (user.type === "teacher" && x?.type === "student"), - ); + const emailUsers = [...new Set(emails)].map((x) => users.find((y) => y.email.toLowerCase() === x)).filter((x) => x !== undefined); + const filteredUsers = emailUsers.filter( + (x) => + ((user.type === "developer" || user.type === "admin" || user.type === "corporate" || user.type === "mastercorporate") && + (x?.type === "student" || x?.type === "teacher")) || + (user.type === "teacher" && x?.type === "student"), + ); - setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id)); - toast.success( - user.type !== "teacher" - ? "Added all teachers and students found in the file you've provided!" - : "Added all students found in the file you've provided!", - {toastId: "upload-success"}, - ); - setIsLoading(false); - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [filesContent, user.type, users]); + setParticipants(filteredUsers.filter((x) => !!x).map((x) => x!.id)); + toast.success( + user.type !== "teacher" + ? "Added all teachers and students found in the file you've provided!" + : "Added all students found in the file you've provided!", + { toastId: "upload-success" }, + ); + setIsLoading(false); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filesContent, user.type, users]); - const submit = () => { - setIsLoading(true); + const submit = () => { + setIsLoading(true); - if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) { - toast.error("That group name is reserved and cannot be used, please enter another one."); - setIsLoading(false); - return; - } + if (name !== group?.name && (name?.trim() === "Students" || name?.trim() === "Teachers" || name?.trim() === "Corporate")) { + toast.error("That group name is reserved and cannot be used, please enter another one."); + setIsLoading(false); + return; + } - (group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", {name, admin, participants}) - .then(() => { - toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`); - return true; - }) - .catch(() => { - toast.error("Something went wrong, please try again later!"); - return false; - }) - .finally(() => { - setIsLoading(false); - onClose(); - }); - }; + (group ? axios.patch : axios.post)(group ? `/api/groups/${group.id}` : "/api/groups", { name, admin, participants }) + .then(() => { + toast.success(`Group "${name}" ${group ? "edited" : "created"} successfully`); + return true; + }) + .catch(() => { + toast.error("Something went wrong, please try again later!"); + return false; + }) + .finally(() => { + setIsLoading(false); + onClose(); + }); + }; - return ( -
-
- -
-
- -
- -
-
-
- +
+
+ +
+ +
+
+
+ + - return ( -
-
- - + + - - +
+ + +
-
- - -
+ - + {type === "student" && ( + <> + + + + )} - {type === "student" && ( - <> - - - - )} +
+ + ({value: u.id, label: getUserName(u)}))} - isClearable - onChange={(e) => setSelectedCorporate(e?.value || undefined)} - /> -
- )} + {["corporate", "mastercorporate"].includes(type) && ( + + )} - {["corporate", "mastercorporate"].includes(type) && ( - - )} +
+ + (!selectedCorporate ? true : x.admin === selectedCorporate)) - .map((g) => ({value: g.id, label: g.name}))} - onChange={(e) => setGroup(e?.value || undefined)} - /> -
- )} +
+ + {user && ( + + )} +
-
- - {user && ( - - )} -
+
+ {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( + <> +
+ + + Enabled + +
+ {isExpiryDateEnabled && ( + + moment(date).isAfter(new Date()) && + (user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) + } + dateFormat="dd/MM/yyyy" + selected={expiryDate} + onChange={(date) => setExpiryDate(date)} + /> + )} + + )} +
+
-
- {user && checkAccess(user, ["developer", "admin", "corporate", "mastercorporate"]) && ( - <> -
- - - Enabled - -
- {isExpiryDateEnabled && ( - - moment(date).isAfter(new Date()) && - (user?.subscriptionExpirationDate ? moment(date).isBefore(user?.subscriptionExpirationDate) : true) - } - dateFormat="dd/MM/yyyy" - selected={expiryDate} - onChange={(date) => setExpiryDate(date)} - /> - )} - - )} -
-
- - -
- ); + +
+ ); } diff --git a/src/pages/api/batch_users.ts b/src/pages/api/batch_users.ts index f54c0d49..fdd2ec7b 100644 --- a/src/pages/api/batch_users.ts +++ b/src/pages/api/batch_users.ts @@ -1,6 +1,6 @@ -import type {NextApiRequest, NextApiResponse} from "next"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; import { FirebaseScrypt } from 'firebase-scrypt'; import { firebaseAuthScryptParams } from "@/firebase"; import crypto from 'crypto'; @@ -9,53 +9,58 @@ import axios from "axios"; export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); + if (req.method === "POST") return post(req, res); - return res.status(404).json({ok: false}); + return res.status(404).json({ ok: false }); } async function post(req: NextApiRequest, res: NextApiResponse) { - const maker = req.session.user; - if (!maker) { - return res.status(401).json({ok: false, reason: "You must be logged in to make user!"}); - } + const maker = req.session.user; + if (!maker) { + return res.status(401).json({ ok: false, reason: "You must be logged in to make user!" }); + } - const scrypt = new FirebaseScrypt(firebaseAuthScryptParams) + const scrypt = new FirebaseScrypt(firebaseAuthScryptParams) - const users = req.body.users as { - email: string; - name: string; - type: string; - passport_id: string; - groupName?: string; - corporate?: string; - studentID?: string; - expiryDate?: string; - demographicInformation: { - country?: string; - passport_id?: string; - phone: string; - }; - passwordHash: string | undefined; - passwordSalt: string | undefined; - }[]; + const users = req.body.users as { + email: string; + name: string; + type: string; + passport_id: string; + groupName?: string; + corporate?: string; + studentID?: string; + expiryDate?: string; + demographicInformation: { + country?: string; + passport_id?: string; + phone: string; + }; + entity?: string + entities: { id: string, role: string }[] + passwordHash: string | undefined; + passwordSalt: string | undefined; + }[]; - const usersWithPasswordHashes = await Promise.all(users.map(async (user) => { - const currentUser = { ...user }; - const salt = crypto.randomBytes(16).toString('base64'); - const hash = await scrypt.hash(user.passport_id, salt); - - currentUser.email = currentUser.email.toLowerCase(); - currentUser.passwordHash = hash; - currentUser.passwordSalt = salt; - return currentUser; - })); - - const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, { - headers: { - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - }); + const usersWithPasswordHashes = await Promise.all(users.map(async (user) => { + const currentUser = { ...user }; + const salt = crypto.randomBytes(16).toString('base64'); + const hash = await scrypt.hash(user.passport_id, salt); - return res.status(backendRequest.status).json(backendRequest.data) + currentUser.entities = [{ id: currentUser.entity!, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }] + delete currentUser.entity + + currentUser.email = currentUser.email.toLowerCase(); + currentUser.passwordHash = hash; + currentUser.passwordSalt = salt; + return currentUser; + })); + + const backendRequest = await axios.post(`${process.env.BACKEND_URL}/batch_users`, { makerID: maker.id, users: usersWithPasswordHashes }, { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }); + + return res.status(backendRequest.status).json(backendRequest.data) } diff --git a/src/pages/api/entities/groups.ts b/src/pages/api/entities/groups.ts new file mode 100644 index 00000000..d2d0f12f --- /dev/null +++ b/src/pages/api/entities/groups.ts @@ -0,0 +1,32 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; +import { Entity, WithEntities, WithEntity, WithLabeledEntities } from "@/interfaces/entity"; +import { v4 } from "uuid"; +import { mapBy } from "@/utils"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { Group, User } from "@/interfaces/user"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + const user = req.session.user; + + const groups: WithEntity[] = ["admin", "developer"].includes(user.type) + ? await getGroups() + : await getGroupsByEntities(mapBy(user.entities || [], 'id')) + + res.status(200).json(groups); +} diff --git a/src/pages/api/entities/users.ts b/src/pages/api/entities/users.ts new file mode 100644 index 00000000..a4df6d0b --- /dev/null +++ b/src/pages/api/entities/users.ts @@ -0,0 +1,47 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; +import { Entity, WithEntities, WithLabeledEntities } from "@/interfaces/entity"; +import { v4 } from "uuid"; +import { mapBy } from "@/utils"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { User } from "@/interfaces/user"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ ok: false }); + return; + } + + const user = req.session.user; + + const { type } = req.query as { type: string } + const entities = await getEntitiesWithRoles(mapBy(user.entities || [], 'id')) + + const filter = !type ? undefined : { type } + const users = ["admin", "developer"].includes(user.type) + ? await getUsers(filter) + : await getEntitiesUsers(mapBy(entities, 'id') as string[], filter) + + const usersWithEntities: WithLabeledEntities[] = users.map((u) => { + return { + ...u, entities: (u.entities || []).map((e) => { + const entity = entities.find((x) => x.id === e.id) + if (!entity) return e + + const role = entity.roles.find((x) => x.id === e.role) + return { id: e.id, label: entity.label, role: e.role, roleLabel: role?.label } + }) + } + }) + + res.status(200).json(usersWithEntities); +} diff --git a/src/pages/api/make_user.ts b/src/pages/api/make_user.ts index b8128015..dd148874 100644 --- a/src/pages/api/make_user.ts +++ b/src/pages/api/make_user.ts @@ -1,28 +1,28 @@ -import type {NextApiRequest, NextApiResponse} from "next"; -import {app} from "@/firebase"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {v4} from "uuid"; -import {CorporateUser, Group, Type, User} from "@/interfaces/user"; -import {createUserWithEmailAndPassword, getAuth} from "firebase/auth"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { app } from "@/firebase"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { v4 } from "uuid"; +import { CorporateUser, Group, Type, User } from "@/interfaces/user"; +import { createUserWithEmailAndPassword, getAuth } from "firebase/auth"; import ShortUniqueId from "short-unique-id"; -import {getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup} from "@/utils/groups.be"; -import {uniq} from "lodash"; -import {getSpecificUsers, getUser} from "@/utils/users.be"; +import { getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup } from "@/utils/groups.be"; +import { uniq } from "lodash"; +import { getSpecificUsers, getUser } from "@/utils/users.be"; import client from "@/lib/mongodb"; const DEFAULT_DESIRED_LEVELS = { - reading: 9, - listening: 9, - writing: 9, - speaking: 9, + reading: 9, + listening: 9, + writing: 9, + speaking: 9, }; const DEFAULT_LEVELS = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, + reading: 0, + listening: 0, + writing: 0, + speaking: 0, }; const auth = getAuth(app); @@ -30,198 +30,97 @@ const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); -const getUsersOfType = async (admin: string, type: Type) => { - const groups = await getUserGroups(admin); - const participants = groups.flatMap((x) => x.participants); - const users = await getSpecificUsers(participants); - - return users.filter((x) => x?.type === type).map((x) => x?.id); -}; - async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "POST") return post(req, res); + if (req.method === "POST") return post(req, res); - return res.status(404).json({ok: false}); + return res.status(404).json({ ok: false }); } async function post(req: NextApiRequest, res: NextApiResponse) { - const maker = req.session.user; - if (!maker) { - return res.status(401).json({ok: false, reason: "You must be logged in to make user!"}); - } + const maker = req.session.user; + if (!maker) { + return res.status(401).json({ ok: false, reason: "You must be logged in to make user!" }); + } - const corporateCorporate = await getUserCorporate(maker.id); + const { email, passport_id, password, type, groupID, entity, expiryDate, corporate } = req.body as { + email: string; + password?: string; + passport_id: string; + type: string; + entity: string; + groupID?: string; + corporate?: string; + expiryDate: null | Date; + }; - const {email, passport_id, password, type, groupID, expiryDate, corporate} = req.body as { - email: string; - password?: string; - passport_id: string; - type: string; - groupID?: string; - corporate?: string; - expiryDate: null | Date; - }; - // cleaning data - delete req.body.passport_id; - delete req.body.groupID; - delete req.body.expiryDate; - delete req.body.password; - delete req.body.corporate; + // cleaning data + delete req.body.passport_id; + delete req.body.groupID; + delete req.body.expiryDate; + delete req.body.password; + delete req.body.corporate; + delete req.body.entity - await createUserWithEmailAndPassword(auth, email.toLowerCase(), !!password ? password : passport_id) - .then(async (userCredentials) => { - const userId = userCredentials.user.uid; + await createUserWithEmailAndPassword(auth, email.toLowerCase(), !!password ? password : passport_id) + .then(async (userCredentials) => { + const userId = userCredentials.user.uid; - const profilePicture = !corporateCorporate ? "/defaultAvatar.png" : corporateCorporate.profilePicture; + const user = { + ...req.body, + bio: "", + id: userId, + type: type, + focus: "academic", + status: "active", + desiredLevels: DEFAULT_DESIRED_LEVELS, + profilePicture: "/defaultAvatar.png", + levels: DEFAULT_LEVELS, + isFirstLogin: false, + isVerified: true, + registrationDate: new Date(), + entities: [{ id: entity, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }], + subscriptionExpirationDate: expiryDate || null, + ...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate" + ? { + corporateInformation: { + companyInformation: { + name: maker.corporateInformation?.companyInformation?.name || "N/A", + userAmount: 0, + }, + }, + } + : {}), + }; - const user = { - ...req.body, - bio: "", - id: userId, - type: type, - focus: "academic", - status: "active", - desiredLevels: DEFAULT_DESIRED_LEVELS, - profilePicture, - levels: DEFAULT_LEVELS, - isFirstLogin: false, - isVerified: true, - registrationDate: new Date(), - subscriptionExpirationDate: expiryDate || null, - ...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate" - ? { - corporateInformation: { - companyInformation: { - name: maker.corporateInformation?.companyInformation?.name || "N/A", - userAmount: 0, - }, - }, - } - : {}), - }; + const uid = new ShortUniqueId(); + const code = uid.randomUUID(6); - const uid = new ShortUniqueId(); - const code = uid.randomUUID(6); + await db.collection("users").insertOne(user); + await db.collection("codes").insertOne({ + code, + creator: maker.id, + expiryDate, + type, + creationDate: new Date(), + userId, + email: email.toLowerCase(), + name: req.body.name, + ...(!!passport_id ? { passport_id } : {}), + }); - await db.collection("users").insertOne(user); - await db.collection("codes").insertOne({ - code, - creator: maker.id, - expiryDate, - type, - creationDate: new Date(), - userId, - email: email.toLowerCase(), - name: req.body.name, - ...(!!passport_id ? {passport_id} : {}), - }); + if (!!groupID) { + const group = await getGroup(groupID); + if (!!group) await db.collection("groups").updateOne({ id: group.id }, { $set: { participants: [...group.participants, userId] } }); + } - if (type === "corporate") { - const students = maker.type === "corporate" ? await getUsersOfType(maker.id, "student") : []; - const teachers = maker.type === "corporate" ? await getUsersOfType(maker.id, "teacher") : []; + console.log(`Returning - ${email}`); + return res.status(200).json({ ok: true }); + }) + .catch((error) => { + if (error.code.includes("email-already-in-use")) return res.status(403).json({ error, message: "E-mail is already in the platform." }); - const defaultTeachersGroup: Group = { - admin: userId, - id: v4(), - name: "Teachers", - participants: teachers, - disableEditing: true, - }; - - const defaultStudentsGroup: Group = { - admin: userId, - id: v4(), - name: "Students", - participants: students, - disableEditing: true, - }; - - await db.collection("groups").insertMany([defaultStudentsGroup, defaultTeachersGroup]); - } - - if (!!corporate) { - const corporateUser = await db.collection("users").findOne({email: corporate.trim().toLowerCase()}); - - if (!!corporateUser) { - await db.collection("codes").updateOne({code}, {$set: {creator: corporateUser.id}}); - const typeGroup = await db - .collection("groups") - .findOne({creator: corporateUser.id, name: type === "student" ? "Students" : "Teachers"}); - - if (!!typeGroup) { - if (!typeGroup.participants.includes(userId)) { - await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}}); - } - } else { - const defaultGroup: Group = { - admin: corporateUser.id, - id: v4(), - name: type === "student" ? "Students" : "Teachers", - participants: [userId], - disableEditing: true, - }; - - await db.collection("groups").insertOne(defaultGroup); - } - } - } - - if (maker.type === "corporate") { - await db.collection("codes").updateOne({code}, {$set: {creator: maker.id}}); - const typeGroup = await getUserNamedGroup(maker.id, type === "student" ? "Students" : "Teachers"); - - if (!!typeGroup) { - if (!typeGroup.participants.includes(userId)) { - await db.collection("groups").updateOne({id: typeGroup.id}, {$set: {participants: [...typeGroup.participants, userId]}}); - } - } else { - const defaultGroup: Group = { - admin: maker.id, - id: v4(), - name: type === "student" ? "Students" : "Teachers", - participants: [userId], - disableEditing: true, - }; - - await db.collection("groups").insertOne(defaultGroup); - } - } - - if (!!corporateCorporate && corporateCorporate.type === "mastercorporate" && type === "corporate") { - const corporateGroup = await getUserNamedGroup(corporateCorporate.id, "Corporate"); - - if (!!corporateGroup) { - if (!corporateGroup.participants.includes(userId)) { - await db - .collection("groups") - .updateOne({id: corporateGroup.id}, {$set: {participants: [...corporateGroup.participants, userId]}}); - } - } else { - const defaultGroup: Group = { - admin: corporateCorporate.id, - id: v4(), - name: "Corporate", - participants: [userId], - disableEditing: true, - }; - - await db.collection("groups").insertOne(defaultGroup); - } - } - - if (!!groupID) { - const group = await getGroup(groupID); - if (!!group) await db.collection("groups").updateOne({id: group.id}, {$set: {participants: [...group.participants, userId]}}); - } - - console.log(`Returning - ${email}`); - return res.status(200).json({ok: true}); - }) - .catch((error) => { - if (error.code.includes("email-already-in-use")) return res.status(403).json({error, message: "E-mail is already in the platform."}); - - console.log(`Failing - ${email}`); - console.log(error); - return res.status(401).json({error}); - }); + console.log(`Failing - ${email}`); + console.log(error); + return res.status(401).json({ error }); + }); } diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts index c1e8ceac..9ae196ca 100644 --- a/src/pages/api/user.ts +++ b/src/pages/api/user.ts @@ -7,7 +7,8 @@ import {withIronSessionApiRoute} from "iron-session/next"; import {NextApiRequest, NextApiResponse} from "next"; import {getPermissions, getPermissionDocs} from "@/utils/permissions.be"; import client from "@/lib/mongodb"; -import {getGroupsForUser, getParticipantGroups} from "@/utils/groups.be"; +import {getGroupsForUser, getParticipantGroups, removeParticipantFromGroup} from "@/utils/groups.be"; +import { mapBy } from "@/utils"; const auth = getAuth(adminApp); const db = client.db(process.env.MONGODB_DB); @@ -41,20 +42,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) { return; } - if (user.type === "corporate" && (targetUser.type === "student" || targetUser.type === "teacher")) { - const groups = await getGroupsForUser(user.id, targetUser.id); - await Promise.all([ - ...groups - .filter((x) => x.admin === user.id) - .map( - async (x) => - await db.collection("groups").updateOne({id: x.id}, {$set: {participants: x.participants.filter((y: string) => y !== id)}}), - ), - ]); - - return; - } - await auth.deleteUser(id); await db.collection("users").deleteOne({id: targetUser.id}); await db.collection("codes").deleteMany({userId: targetUser.id}); @@ -62,11 +49,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) { await db.collection("stats").deleteMany({user: targetUser.id}); const groups = await getParticipantGroups(targetUser.id); - await Promise.all( - groups.map( - async (x) => await db.collection("groups").updateOne({id: x.id}, {$set: {participants: x.participants.filter((y: string) => y !== id)}}), - ), - ); + await Promise.all( + groups + .map(async (g) => await removeParticipantFromGroup(g.id, targetUser.id)), + ); res.json({ok: true}); } diff --git a/src/pages/dashboard/admin.tsx b/src/pages/dashboard/admin.tsx new file mode 100644 index 00000000..52cc3d02 --- /dev/null +++ b/src/pages/dashboard/admin.tsx @@ -0,0 +1,195 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import UserDisplayList from "@/components/UserDisplayList"; +import IconCard from "@/dashboards/IconCard"; +import { Module } from "@/interfaces"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { Assignment } from "@/interfaces/results"; +import { Group, Stat, User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import { dateSorter, filterBy, mapBy, serialize } from "@/utils"; +import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import { checkAccess } from "@/utils/permissions"; +import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; +import { groupByExam } from "@/utils/stats"; +import { getStatsByUsers } from "@/utils/stats.be"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { withIronSessionSsr } from "iron-session/next"; +import { uniqBy } from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useMemo } from "react"; +import { + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, +} from "react-icons/bs"; +import { ToastContainer } from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const users = await getUsers(); + const entities = await getEntitiesWithRoles(); + const assignments = await getAssignments(); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroups(); + + return { props: serialize({ user, users, entities, assignments, stats, groups }) }; +}, sessionOptions); + +export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); + + const router = useRouter(); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: { [key in Module]: number } = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + return ( + <> + + EnCoach + + + + + + +
+ router.push("/list/users?type=student")} + Icon={BsPersonFill} + label="Students" + value={students.length} + color="purple" + /> + router.push("/list/users?type=teacher")} + Icon={BsPencilSquare} + label="Teachers" + value={teachers.length} + color="purple" + /> + router.push("/list/users?type=corporate")} + label="Corporates" + value={corporates.length} + color="purple" + /> + router.push("/list/users?type=mastercorporate")} + label="Master Corporates" + value={masterCorporates.length} + color="purple" + /> + + + + + + router.push("/assignments")} + label="Assignments" + value={assignments.filter((a) => !a.archived).length} + color="purple" + /> +
+ +
+ dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Teachers" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + } + title="Highest exam count students" + /> +
+
+ + ); +} diff --git a/src/pages/dashboard/admin/index.tsx b/src/pages/dashboard/admin/index.tsx deleted file mode 100644 index 9b50f662..00000000 --- a/src/pages/dashboard/admin/index.tsx +++ /dev/null @@ -1,225 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import Layout from "@/components/High/Layout"; -import IconCard from "@/dashboards/IconCard"; -import {Module} from "@/interfaces"; -import {EntityWithRoles} from "@/interfaces/entity"; -import {Assignment} from "@/interfaces/results"; -import {Group, Stat, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; -import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; -import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be"; -import {getEntitiesWithRoles} from "@/utils/entities.be"; -import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; -import {checkAccess} from "@/utils/permissions"; -import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; -import {groupByExam} from "@/utils/stats"; -import {getStatsByUsers} from "@/utils/stats.be"; -import {getEntitiesUsers, getUsers} from "@/utils/users.be"; -import {withIronSessionSsr} from "iron-session/next"; -import {uniqBy} from "lodash"; -import moment from "moment"; -import Head from "next/head"; -import Link from "next/link"; -import {useRouter} from "next/router"; -import {useMemo} from "react"; -import { - BsBank, - BsClipboard2Data, - BsClock, - BsEnvelopePaper, - BsPaperclip, - BsPencilSquare, - BsPeople, - BsPeopleFill, - BsPersonFill, - BsPersonFillGear, -} from "react-icons/bs"; -import {ToastContainer} from "react-toastify"; - -interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[]; - assignments: Assignment[]; - stats: Stat[]; - groups: Group[]; -} - -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user as User | undefined; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (!checkAccess(user, ["admin", "developer"])) - return { - redirect: { - destination: "/dashboard", - permanent: false, - }, - }; - - const users = await getUsers(); - const entities = await getEntitiesWithRoles(); - const assignments = await getAssignments(); - const stats = await getStatsByUsers(users.map((u) => u.id)); - const groups = await getGroups(); - - return {props: serialize({user, users, entities, assignments, stats, groups})}; -}, sessionOptions); - -export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); - const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); - const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); - - const router = useRouter(); - - const averageLevelCalculator = (studentStats: Stat[]) => { - const formattedStats = studentStats - .map((s) => ({ - focus: students.find((u) => u.id === s.user)?.focus, - score: s.score, - module: s.module, - })) - .filter((f) => !!f.focus); - const bandScores = formattedStats.map((s) => ({ - module: s.module, - level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), - })); - - const levels: {[key in Module]: number} = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, - level: 0, - }; - bandScores.forEach((b) => (levels[b.module] += b.level)); - - return calculateAverageLevel(levels); - }; - - const UserDisplay = (displayUser: User) => ( -
- {displayUser.name} -
- {displayUser.name} - {displayUser.email} -
-
- ); - - return ( - <> - - EnCoach - - - - - - -
- router.push("/lists/users?type=student")} - Icon={BsPersonFill} - label="Students" - value={students.length} - color="purple" - /> - router.push("/lists/users?type=teacher")} - Icon={BsPencilSquare} - label="Teachers" - value={teachers.length} - color="purple" - /> - router.push("/lists/users?type=corporate")} - label="Corporates" - value={corporates.length} - color="purple" - /> - router.push("/lists/users?type=mastercorporate")} - label="Master Corporates" - value={masterCorporates.length} - color="purple" - /> - - - - - - router.push("/assignments")} - label="Assignments" - value={assignments.filter((a) => !a.archived).length} - color="purple" - /> -
- -
-
- Latest students -
- {students - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Latest teachers -
- {teachers - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Highest level students -
- {students - .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) - .map((x) => ( - - ))} -
-
-
- Highest exam count students -
- {students - .sort( - (a, b) => - Object.keys(groupByExam(filterBy(stats, "user", b))).length - - Object.keys(groupByExam(filterBy(stats, "user", a))).length, - ) - .map((x) => ( - - ))} -
-
-
-
- - ); -} diff --git a/src/pages/dashboard/corporate/index.tsx b/src/pages/dashboard/corporate.tsx similarity index 77% rename from src/pages/dashboard/corporate/index.tsx rename to src/pages/dashboard/corporate.tsx index 4ce58192..1c7576d6 100644 --- a/src/pages/dashboard/corporate/index.tsx +++ b/src/pages/dashboard/corporate.tsx @@ -1,5 +1,6 @@ /* eslint-disable @next/next/no-img-element */ import Layout from "@/components/High/Layout"; +import UserDisplayList from "@/components/UserDisplayList"; import IconCard from "@/dashboards/IconCard"; import { Module } from "@/interfaces"; import { EntityWithRoles } from "@/interfaces/entity"; @@ -136,14 +137,14 @@ export default function Dashboard({ user, users, entities, assignments, stats, g )}
router.push("/lists/users?type=student")} + onClick={() => router.push("/list/users?type=student")} Icon={BsPersonFill} label="Students" value={students.length} color="purple" /> router.push("/lists/users?type=teacher")} + onClick={() => router.push("/list/users?type=teacher")} Icon={BsPencilSquare} label="Teachers" value={teachers.length} @@ -178,50 +179,29 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
-
- Latest students -
- {students - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Latest teachers -
- {teachers - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Highest level students -
- {students - .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) - .map((x) => ( - - ))} -
-
-
- Highest exam count students -
- {students + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Teachers" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", a))).length, ) - .map((x) => ( - - ))} -
-
+ } + title="Highest exam count students" + />
diff --git a/src/pages/dashboard/developer.tsx b/src/pages/dashboard/developer.tsx new file mode 100644 index 00000000..47723ae2 --- /dev/null +++ b/src/pages/dashboard/developer.tsx @@ -0,0 +1,201 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import UserDisplayList from "@/components/UserDisplayList"; +import IconCard from "@/dashboards/IconCard"; +import { Module } from "@/interfaces"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { Assignment } from "@/interfaces/results"; +import { Group, Stat, User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import { dateSorter, filterBy, mapBy, serialize } from "@/utils"; +import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import { checkAccess } from "@/utils/permissions"; +import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; +import { groupByExam } from "@/utils/stats"; +import { getStatsByUsers } from "@/utils/stats.be"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { withIronSessionSsr } from "iron-session/next"; +import { uniqBy } from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useMemo } from "react"; +import { + BsBank, + BsClipboard2Data, + BsClock, + BsEnvelopePaper, + BsPaperclip, + BsPencilSquare, + BsPeople, + BsPeopleFill, + BsPersonFill, + BsPersonFillGear, +} from "react-icons/bs"; +import { ToastContainer } from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const users = await getUsers(); + const entities = await getEntitiesWithRoles(); + const assignments = await getAssignments(); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroups(); + + return { props: serialize({ user, users, entities, assignments, stats, groups }) }; +}, sessionOptions); + +export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); + const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); + const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); + + const router = useRouter(); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: { [key in Module]: number } = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + return ( + <> + + EnCoach + + + + + + +
+ router.push("/list/users?type=student")} + Icon={BsPersonFill} + label="Students" + value={students.length} + color="purple" + /> + router.push("/list/users?type=teacher")} + Icon={BsPencilSquare} + label="Teachers" + value={teachers.length} + color="purple" + /> + router.push("/list/users?type=corporate")} + label="Corporates" + value={corporates.length} + color="purple" + /> + router.push("/list/users?type=mastercorporate")} + label="Master Corporates" + value={masterCorporates.length} + color="purple" + /> + router.push("/entities")} + label="Classrooms" + value={groups.length} + color="purple" + /> + + + + + router.push("/assignments")} + label="Assignments" + value={assignments.filter((a) => !a.archived).length} + color="purple" + /> +
+ +
+ dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Teachers" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + } + title="Highest exam count students" + /> +
+
+ + ); +} diff --git a/src/pages/dashboard/developer/index.tsx b/src/pages/dashboard/developer/index.tsx deleted file mode 100644 index 9b50f662..00000000 --- a/src/pages/dashboard/developer/index.tsx +++ /dev/null @@ -1,225 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import Layout from "@/components/High/Layout"; -import IconCard from "@/dashboards/IconCard"; -import {Module} from "@/interfaces"; -import {EntityWithRoles} from "@/interfaces/entity"; -import {Assignment} from "@/interfaces/results"; -import {Group, Stat, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; -import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; -import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be"; -import {getEntitiesWithRoles} from "@/utils/entities.be"; -import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; -import {checkAccess} from "@/utils/permissions"; -import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; -import {groupByExam} from "@/utils/stats"; -import {getStatsByUsers} from "@/utils/stats.be"; -import {getEntitiesUsers, getUsers} from "@/utils/users.be"; -import {withIronSessionSsr} from "iron-session/next"; -import {uniqBy} from "lodash"; -import moment from "moment"; -import Head from "next/head"; -import Link from "next/link"; -import {useRouter} from "next/router"; -import {useMemo} from "react"; -import { - BsBank, - BsClipboard2Data, - BsClock, - BsEnvelopePaper, - BsPaperclip, - BsPencilSquare, - BsPeople, - BsPeopleFill, - BsPersonFill, - BsPersonFillGear, -} from "react-icons/bs"; -import {ToastContainer} from "react-toastify"; - -interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[]; - assignments: Assignment[]; - stats: Stat[]; - groups: Group[]; -} - -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user as User | undefined; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (!checkAccess(user, ["admin", "developer"])) - return { - redirect: { - destination: "/dashboard", - permanent: false, - }, - }; - - const users = await getUsers(); - const entities = await getEntitiesWithRoles(); - const assignments = await getAssignments(); - const stats = await getStatsByUsers(users.map((u) => u.id)); - const groups = await getGroups(); - - return {props: serialize({user, users, entities, assignments, stats, groups})}; -}, sessionOptions); - -export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); - const corporates = useMemo(() => users.filter((u) => u.type === "corporate"), [users]); - const masterCorporates = useMemo(() => users.filter((u) => u.type === "mastercorporate"), [users]); - - const router = useRouter(); - - const averageLevelCalculator = (studentStats: Stat[]) => { - const formattedStats = studentStats - .map((s) => ({ - focus: students.find((u) => u.id === s.user)?.focus, - score: s.score, - module: s.module, - })) - .filter((f) => !!f.focus); - const bandScores = formattedStats.map((s) => ({ - module: s.module, - level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), - })); - - const levels: {[key in Module]: number} = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, - level: 0, - }; - bandScores.forEach((b) => (levels[b.module] += b.level)); - - return calculateAverageLevel(levels); - }; - - const UserDisplay = (displayUser: User) => ( -
- {displayUser.name} -
- {displayUser.name} - {displayUser.email} -
-
- ); - - return ( - <> - - EnCoach - - - - - - -
- router.push("/lists/users?type=student")} - Icon={BsPersonFill} - label="Students" - value={students.length} - color="purple" - /> - router.push("/lists/users?type=teacher")} - Icon={BsPencilSquare} - label="Teachers" - value={teachers.length} - color="purple" - /> - router.push("/lists/users?type=corporate")} - label="Corporates" - value={corporates.length} - color="purple" - /> - router.push("/lists/users?type=mastercorporate")} - label="Master Corporates" - value={masterCorporates.length} - color="purple" - /> - - - - - - router.push("/assignments")} - label="Assignments" - value={assignments.filter((a) => !a.archived).length} - color="purple" - /> -
- -
-
- Latest students -
- {students - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Latest teachers -
- {teachers - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Highest level students -
- {students - .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) - .map((x) => ( - - ))} -
-
-
- Highest exam count students -
- {students - .sort( - (a, b) => - Object.keys(groupByExam(filterBy(stats, "user", b))).length - - Object.keys(groupByExam(filterBy(stats, "user", a))).length, - ) - .map((x) => ( - - ))} -
-
-
-
- - ); -} diff --git a/src/pages/dashboard/mastercorporate/index.tsx b/src/pages/dashboard/mastercorporate.tsx similarity index 74% rename from src/pages/dashboard/mastercorporate/index.tsx rename to src/pages/dashboard/mastercorporate.tsx index df32c467..01e58e2d 100644 --- a/src/pages/dashboard/mastercorporate/index.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -1,5 +1,6 @@ /* eslint-disable @next/next/no-img-element */ import Layout from "@/components/High/Layout"; +import UserDisplayList from "@/components/UserDisplayList"; import IconCard from "@/dashboards/IconCard"; import { Module } from "@/interfaces"; import { EntityWithRoles } from "@/interfaces/entity"; @@ -133,21 +134,21 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
router.push("/lists/users?type=student")} + onClick={() => router.push("/list/users?type=student")} Icon={BsPersonFill} label="Students" value={students.length} color="purple" /> router.push("/lists/users?type=teacher")} + onClick={() => router.push("/list/users?type=teacher")} Icon={BsPencilSquare} label="Teachers" value={teachers.length} color="purple" /> router.push("/lists/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> + onClick={() => router.push("/list/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> router.push("/classrooms")} Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" /> @@ -169,50 +170,29 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
-
- Latest students -
- {students - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Latest teachers -
- {teachers - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Highest level students -
- {students - .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) - .map((x) => ( - - ))} -
-
-
- Highest exam count students -
- {students + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Teachers" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + Object.keys(groupByExam(filterBy(stats, "user", b))).length - Object.keys(groupByExam(filterBy(stats, "user", a))).length, ) - .map((x) => ( - - ))} -
-
+ } + title="Highest exam count students" + />
diff --git a/src/pages/dashboard/student/index.tsx b/src/pages/dashboard/student.tsx similarity index 100% rename from src/pages/dashboard/student/index.tsx rename to src/pages/dashboard/student.tsx diff --git a/src/pages/dashboard/teacher.tsx b/src/pages/dashboard/teacher.tsx new file mode 100644 index 00000000..32bd978c --- /dev/null +++ b/src/pages/dashboard/teacher.tsx @@ -0,0 +1,170 @@ +/* eslint-disable @next/next/no-img-element */ +import Layout from "@/components/High/Layout"; +import UserDisplayList from "@/components/UserDisplayList"; +import IconCard from "@/dashboards/IconCard"; +import { Module } from "@/interfaces"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { Assignment } from "@/interfaces/results"; +import { Group, Stat, User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import { dateSorter, filterBy, mapBy, serialize } from "@/utils"; +import { getEntitiesAssignments } from "@/utils/assignments.be"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { getGroupsByEntities } from "@/utils/groups.be"; +import { checkAccess } from "@/utils/permissions"; +import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; +import { groupByExam } from "@/utils/stats"; +import { getStatsByUsers } from "@/utils/stats.be"; +import { getEntitiesUsers } from "@/utils/users.be"; +import { withIronSessionSsr } from "iron-session/next"; +import { uniqBy } from "lodash"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useMemo } from "react"; +import { BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill } from "react-icons/bs"; +import { ToastContainer } from "react-toastify"; + +interface Props { + user: User; + users: User[]; + entities: EntityWithRoles[]; + assignments: Assignment[]; + stats: Stat[]; + groups: Group[]; +} + +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { + const user = req.session.user as User | undefined; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + if (!checkAccess(user, ["admin", "developer", "teacher"])) + return { + redirect: { + destination: "/dashboard", + permanent: false, + }, + }; + + const entityIDS = mapBy(user.entities, "id") || []; + + const users = await getEntitiesUsers(entityIDS); + const entities = await getEntitiesWithRoles(entityIDS); + const assignments = await getEntitiesAssignments(entityIDS); + const stats = await getStatsByUsers(users.map((u) => u.id)); + const groups = await getGroupsByEntities(entityIDS); + + return { props: serialize({ user, users, entities, assignments, stats, groups }) }; +}, sessionOptions); + +export default function Dashboard({ user, users, entities, assignments, stats, groups }: Props) { + const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); + const router = useRouter(); + + const averageLevelCalculator = (studentStats: Stat[]) => { + const formattedStats = studentStats + .map((s) => ({ + focus: students.find((u) => u.id === s.user)?.focus, + score: s.score, + module: s.module, + })) + .filter((f) => !!f.focus); + const bandScores = formattedStats.map((s) => ({ + module: s.module, + level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), + })); + + const levels: { [key in Module]: number } = { + reading: 0, + listening: 0, + writing: 0, + speaking: 0, + level: 0, + }; + bandScores.forEach((b) => (levels[b.module] += b.level)); + + return calculateAverageLevel(levels); + }; + + const UserDisplay = (displayUser: User) => ( +
+ {displayUser.name} +
+ {displayUser.name} + {displayUser.email} +
+
+ ); + + return ( + <> + + EnCoach + + + + + + +
+ {entities.length > 0 && ( +
+ {mapBy(entities, "label")?.join(", ")} +
+ )} +
+ + router.push("/classrooms")} + Icon={BsPeople} + label="Classrooms" + value={groups.length} + color="purple" + /> + + + router.push("/assignments")} + label="Assignments" + value={assignments.filter((a) => !a.archived).length} + color="purple" + /> +
+
+ +
+ dateSorter(a, b, "desc", "registrationDate"))} + title="Latest Students" + /> + calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + title="Highest level students" + /> + + Object.keys(groupByExam(filterBy(stats, "user", b))).length - + Object.keys(groupByExam(filterBy(stats, "user", a))).length, + ) + } + title="Highest exam count students" + /> +
+
+ + ); +} diff --git a/src/pages/dashboard/teacher/index.tsx b/src/pages/dashboard/teacher/index.tsx deleted file mode 100644 index dc163117..00000000 --- a/src/pages/dashboard/teacher/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import Layout from "@/components/High/Layout"; -import IconCard from "@/dashboards/IconCard"; -import {Module} from "@/interfaces"; -import {EntityWithRoles} from "@/interfaces/entity"; -import {Assignment} from "@/interfaces/results"; -import {Group, Stat, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; -import {dateSorter, filterBy, mapBy, serialize} from "@/utils"; -import {getEntitiesAssignments} from "@/utils/assignments.be"; -import {getEntitiesWithRoles} from "@/utils/entities.be"; -import {getGroupsByEntities} from "@/utils/groups.be"; -import {checkAccess} from "@/utils/permissions"; -import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; -import {groupByExam} from "@/utils/stats"; -import {getStatsByUsers} from "@/utils/stats.be"; -import {getEntitiesUsers} from "@/utils/users.be"; -import {withIronSessionSsr} from "iron-session/next"; -import {uniqBy} from "lodash"; -import Head from "next/head"; -import {useRouter} from "next/router"; -import {useMemo} from "react"; -import {BsClipboard2Data, BsEnvelopePaper, BsPaperclip, BsPeople, BsPersonFill} from "react-icons/bs"; -import {ToastContainer} from "react-toastify"; - -interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[]; - assignments: Assignment[]; - stats: Stat[]; - groups: Group[]; -} - -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user as User | undefined; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (!checkAccess(user, ["admin", "developer", "teacher"])) - return { - redirect: { - destination: "/dashboard", - permanent: false, - }, - }; - - const entityIDS = mapBy(user.entities, "id") || []; - - const users = await getEntitiesUsers(entityIDS); - const entities = await getEntitiesWithRoles(entityIDS); - const assignments = await getEntitiesAssignments(entityIDS); - const stats = await getStatsByUsers(users.map((u) => u.id)); - const groups = await getGroupsByEntities(entityIDS); - - return {props: serialize({user, users, entities, assignments, stats, groups})}; -}, sessionOptions); - -export default function Dashboard({user, users, entities, assignments, stats, groups}: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const router = useRouter(); - - const averageLevelCalculator = (studentStats: Stat[]) => { - const formattedStats = studentStats - .map((s) => ({ - focus: students.find((u) => u.id === s.user)?.focus, - score: s.score, - module: s.module, - })) - .filter((f) => !!f.focus); - const bandScores = formattedStats.map((s) => ({ - module: s.module, - level: calculateBandScore(s.score.correct, s.score.total, s.module, s.focus!), - })); - - const levels: {[key in Module]: number} = { - reading: 0, - listening: 0, - writing: 0, - speaking: 0, - level: 0, - }; - bandScores.forEach((b) => (levels[b.module] += b.level)); - - return calculateAverageLevel(levels); - }; - - const UserDisplay = (displayUser: User) => ( -
- {displayUser.name} -
- {displayUser.name} - {displayUser.email} -
-
- ); - - return ( - <> - - EnCoach - - - - - - -
- {entities.length > 0 && ( -
- {mapBy(entities, "label")?.join(", ")} -
- )} -
- - router.push("/classrooms")} - Icon={BsPeople} - label="Classrooms" - value={groups.length} - color="purple" - /> - - - router.push("/assignments")} - label="Assignments" - value={assignments.filter((a) => !a.archived).length} - color="purple" - /> -
-
- -
-
- Latest students -
- {students - .sort((a, b) => dateSorter(a, b, "desc", "registrationDate")) - .map((x) => ( - - ))} -
-
-
- Highest level students -
- {students - .sort((a, b) => calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels)) - .map((x) => ( - - ))} -
-
-
- Highest exam count students -
- {students - .sort( - (a, b) => - Object.keys(groupByExam(filterBy(stats, "user", b))).length - - Object.keys(groupByExam(filterBy(stats, "user", a))).length, - ) - .map((x) => ( - - ))} -
-
-
-
- - ); -} diff --git a/src/pages/list/users.tsx b/src/pages/list/users.tsx index 26069756..06c6fdcd 100644 --- a/src/pages/list/users.tsx +++ b/src/pages/list/users.tsx @@ -1,26 +1,21 @@ import Layout from "@/components/High/Layout"; import useUser from "@/hooks/useUser"; import useUsers from "@/hooks/useUsers"; +import { Type, User } from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; import useFilterStore from "@/stores/listFilterStore"; +import { serialize } from "@/utils"; import {withIronSessionSsr} from "iron-session/next"; import Head from "next/head"; import {useRouter} from "next/router"; import {useEffect} from "react"; -import {BsArrowLeft} from "react-icons/bs"; +import {BsArrowLeft, BsChevronLeft} from "react-icons/bs"; import {ToastContainer} from "react-toastify"; import UserList from "../(admin)/Lists/UserList"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { +export const getServerSideProps = withIronSessionSsr(({req, res, query}) => { const user = req.session.user; - const envVariables: {[key: string]: string} = {}; - Object.keys(process.env) - .filter((x) => x.startsWith("NEXT_PUBLIC")) - .forEach((x: string) => { - envVariables[x] = process.env[x]!; - }); - if (!user) { return { redirect: { @@ -30,17 +25,22 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { }; } + const {type} = query as {type?: Type} + return { - props: {user: req.session.user, envVariables}, + props: serialize({user: req.session.user, type}), }; }, sessionOptions); -export default function UsersListPage() { - const {user} = useUser(); +interface Props { + user: User + type?: Type +} +export default function UsersListPage({ user, type }: Props) { const [filters, clearFilters] = useFilterStore((state) => [state.userFilters, state.clearUserFilters]); - const router = useRouter(); + const router = useRouter() return ( <> @@ -59,19 +59,19 @@ export default function UsersListPage() { f.filter)} renderHeader={(total) => ( -
-
+
-

Users ({total})

+ className="text-mti-purple hover:text-mti-purple-dark transition ease-in-out duration-300 text-xl"> + + +

Users ({ total })

)} /> diff --git a/src/pages/lists/users.tsx b/src/pages/lists/users.tsx deleted file mode 100644 index c74286aa..00000000 --- a/src/pages/lists/users.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import Layout from "@/components/High/Layout"; -import List from "@/components/List"; -import useUser from "@/hooks/useUser"; -import useUsers from "@/hooks/useUsers"; -import {Type, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; -import useFilterStore from "@/stores/listFilterStore"; -import {mapBy, serialize} from "@/utils"; -import {getEntitiesUsers, getEntityUsers} from "@/utils/users.be"; -import {createColumnHelper} from "@tanstack/react-table"; -import clsx from "clsx"; -import {withIronSessionSsr} from "iron-session/next"; -import Head from "next/head"; -import {useRouter} from "next/router"; -import {useEffect, useState} from "react"; -import {BsArrowLeft, BsCheck, BsChevronLeft} from "react-icons/bs"; -import {ToastContainer} from "react-toastify"; -import UserList from "../(admin)/Lists/UserList"; -import {countries, TCountries} from "countries-list"; -import countryCodes from "country-codes-list"; -import {capitalize} from "lodash"; -import moment from "moment"; -import {USER_TYPE_LABELS} from "@/resources/user"; -import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; -import {EntityWithRoles} from "@/interfaces/entity"; - -const columnHelper = createColumnHelper(); - -const expirationDateColor = (date: Date) => { - const momentDate = moment(date); - const today = moment(new Date()); - - if (today.isAfter(momentDate)) return "!text-mti-red-light font-bold line-through"; - if (today.add(1, "weeks").isAfter(momentDate)) return "!text-mti-red-light"; - if (today.add(2, "weeks").isAfter(momentDate)) return "!text-mti-rose-light"; - if (today.add(1, "months").isAfter(momentDate)) return "!text-mti-orange-light"; -}; - -const SEARCH_FIELDS = [["name"], ["email"], ["corporateInformation", "companyInformation", "name"]]; - -export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { - const user = req.session.user as User | undefined; - - if (!user) - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - - const {type} = query as {type?: Type}; - const users = await getEntitiesUsers(mapBy(user.entities, "id")); - const entities = await getEntitiesWithRoles(); - - const filters: ((u: User) => boolean)[] = []; - if (type) filters.push((u) => u.type === type); - - return { - props: serialize({user, users: filters.reduce((d, f) => d.filter(f), users), entities}), - }; -}, sessionOptions); - -interface Props { - user: User; - users: User[]; - entities: EntityWithRoles[]; -} - -export default function UsersList({user, users, entities}: Props) { - const [showDemographicInformation, setShowDemographicInformation] = useState(false); - const router = useRouter(); - - const defaultColumns = [ - columnHelper.accessor("name", { - header: ( - - ) as any, - cell: ({row, getValue}) =>
{getValue()}
, - }), - columnHelper.accessor("email", { - header: ( - - ) as any, - cell: ({row, getValue}) =>
{getValue()}
, - }), - columnHelper.accessor("type", { - header: ( - - ) as any, - cell: (info) => USER_TYPE_LABELS[info.getValue()], - }), - columnHelper.accessor("studentID", { - header: ( - - ) as any, - cell: (info) => info.getValue() || "N/A", - }), - columnHelper.accessor("entities", { - header: ( - - ) as any, - cell: ({getValue}) => - getValue() - .map((e) => entities.find((x) => x.id === e.id)?.label) - .join(", "), - }), - columnHelper.accessor("subscriptionExpirationDate", { - header: ( - - ) as any, - cell: (info) => ( - - {!info.getValue() ? "No expiry date" : moment(info.getValue()).format("DD/MM/YYYY")} - - ), - }), - columnHelper.accessor("isVerified", { - header: ( - - ) as any, - cell: (info) => ( -
-
- -
-
- ), - }), - { - header: ( - setShowDemographicInformation((prev) => !prev)}> - Switch - - ), - id: "actions", - cell: () => "", - }, - ]; - - const demographicColumns = [ - columnHelper.accessor("name", { - header: ( - - ) as any, - cell: ({row, getValue}) =>
{getValue()}
, - }), - columnHelper.accessor("demographicInformation.country", { - header: ( - - ) as any, - cell: (info) => - info.getValue() - ? `${countryCodes.findOne("countryCode" as any, info.getValue())?.flag} ${ - countries[info.getValue() as unknown as keyof TCountries]?.name - } (+${countryCodes.findOne("countryCode" as any, info.getValue())?.countryCallingCode})` - : "N/A", - }), - columnHelper.accessor("demographicInformation.phone", { - header: ( - - ) as any, - cell: (info) => info.getValue() || "N/A", - enableSorting: true, - }), - columnHelper.accessor( - (x) => - x.type === "corporate" || x.type === "mastercorporate" ? x.demographicInformation?.position : x.demographicInformation?.employment, - { - id: "employment", - header: ( - - ) as any, - cell: (info) => (info.row.original.type === "corporate" ? info.getValue() : capitalize(info.getValue())) || "N/A", - enableSorting: true, - }, - ), - columnHelper.accessor("lastLogin", { - header: ( - - ) as any, - cell: (info) => (!!info.getValue() ? moment(info.getValue()).format("YYYY-MM-DD HH:mm") : "N/A"), - }), - columnHelper.accessor("demographicInformation.gender", { - header: ( - - ) as any, - cell: (info) => capitalize(info.getValue()) || "N/A", - enableSorting: true, - }), - { - header: ( - setShowDemographicInformation((prev) => !prev)}> - Switch - - ), - id: "actions", - cell: () => "", - }, - ]; - - return ( - <> - - EnCoach - - - - - - - -
- -

User List

-
- data={users} searchFields={SEARCH_FIELDS} columns={showDemographicInformation ? demographicColumns : defaultColumns} /> -
- - ); -} diff --git a/src/pages/record.tsx b/src/pages/record.tsx index c4d520d0..72bc156b 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -3,7 +3,7 @@ import Head from "next/head"; import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {Stat, User} from "@/interfaces/user"; -import {useEffect, useRef, useState} from "react"; +import {useEffect, useMemo, useRef, useState} from "react"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; import {groupByDate} from "@/utils/stats"; import moment from "moment"; @@ -22,9 +22,18 @@ import RecordFilter from "@/components/Medium/RecordFilter"; import {useRouter} from "next/router"; import useTrainingContentStore from "@/stores/trainingContentStore"; import {Assignment} from "@/interfaces/results"; -import {getUsers} from "@/utils/users.be"; -import {getAssignments, getAssignmentsByAssigner} from "@/utils/assignments.be"; +import {getEntitiesUsers, getUsers} from "@/utils/users.be"; +import {getAssignments, getAssignmentsByAssigner, getEntitiesAssignments} from "@/utils/assignments.be"; import useGradingSystem from "@/hooks/useGrading"; +import { mapBy, serialize } from "@/utils"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { checkAccess } from "@/utils/permissions"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import { getGradingSystemByEntity } from "@/utils/grading.be"; +import { Grading } from "@/interfaces"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { useListSearch } from "@/hooks/useListSearch"; +import CardList from "@/components/High/CardList"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { const user = req.session.user; @@ -47,11 +56,16 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { }; } - const users = await getUsers(); - const assignments = await getAssignments(); + const entityIDs = mapBy(user.entities, 'id') + + const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) + const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id'))) + const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id'))) + const assignments = await (checkAccess(user, ["admin", "developer"]) ? getAssignments() : getEntitiesAssignments(mapBy(entities, 'id'))) + const gradingSystems = await Promise.all(entityIDs.map(getGradingSystemByEntity)) return { - props: {user, users, assignments}, + props: serialize({user, users, assignments, entities, gradingSystems}), }; }, sessionOptions); @@ -61,9 +75,13 @@ interface Props { user: User; users: User[]; assignments: Assignment[]; + gradingSystems: Grading[] + entities: EntityWithRoles[] } -export default function History({user, users, assignments}: Props) { +const MAX_TRAINING_EXAMS = 10; + +export default function History({user, users, assignments, entities, gradingSystems}: Props) { const router = useRouter(); const [statsUserId, setStatsUserId, training, setTraining] = useRecordStore((state) => [ state.selectedUser, @@ -72,8 +90,6 @@ export default function History({user, users, assignments}: Props) { state.setTraining, ]); - // const [statsUserId, setStatsUserId] = useState(user.id); - const [groupedStats, setGroupedStats] = useState<{[key: string]: Stat[]}>(); const [filter, setFilter] = useState(); const {data: stats, isLoading: isStatsLoading} = useFilterRecordsByUser(statsUserId || user?.id); @@ -87,30 +103,32 @@ export default function History({user, users, assignments}: Props) { const setTimeSpent = useExamStore((state) => state.setTimeSpent); const renderPdfIcon = usePDFDownload("stats"); + const [selectedTrainingExams, setSelectedTrainingExams] = useState([]); + const setTrainingStats = useTrainingContentStore((state) => state.setStats); + + const groupedStats = useMemo(() => groupByDate( + stats.filter((x) => { + if ( + (x.module === "writing" || x.module === "speaking") && + !x.isDisabled && + !x.solutions.every((y) => Object.keys(y).includes("evaluation")) + ) + return false; + return true; + }), + ), [stats]) + useEffect(() => setStatsUserId(user.id), [setStatsUserId, user]); useEffect(() => { - if (stats && !isStatsLoading) { - setGroupedStats( - groupByDate( - stats.filter((x) => { - if ( - (x.module === "writing" || x.module === "speaking") && - !x.isDisabled && - !x.solutions.every((y) => Object.keys(y).includes("evaluation")) - ) - return false; - return true; - }), - ), - ); - } - }, [stats, isStatsLoading]); - - // useEffect(() => { - // // just set this initially - // if (!statsUserId) setStatsUserId(user.id); - // }, []); + const handleRouteChange = (url: string) => { + setTraining(false); + }; + router.events.on("routeChangeStart", handleRouteChange); + return () => { + router.events.off("routeChangeStart", handleRouteChange); + }; + }, [router.events, setTraining]); const filterStatsByDate = (stats: {[key: string]: Stat[]}) => { if (filter && filter !== "assignments") { @@ -139,39 +157,29 @@ export default function History({user, users, assignments}: Props) { return stats; }; - const MAX_TRAINING_EXAMS = 10; - const [selectedTrainingExams, setSelectedTrainingExams] = useState([]); - const setTrainingStats = useTrainingContentStore((state) => state.setStats); +const handleTrainingContentSubmission = () => { + if (groupedStats) { + const groupedStatsByDate = filterStatsByDate(groupedStats); + const allStats = Object.keys(groupedStatsByDate); + const selectedStats = selectedTrainingExams.reduce>((accumulator, moduleAndTimestamp) => { + const timestamp = moduleAndTimestamp.split("-")[1]; + if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) { + accumulator[timestamp] = groupedStatsByDate[timestamp]; + } + return accumulator; + }, {}); + setTrainingStats(Object.values(selectedStats).flat()); + router.push("/training"); + } +}; - const handleTrainingContentSubmission = () => { - if (groupedStats) { - const groupedStatsByDate = filterStatsByDate(groupedStats); - const allStats = Object.keys(groupedStatsByDate); - const selectedStats = selectedTrainingExams.reduce>((accumulator, moduleAndTimestamp) => { - const timestamp = moduleAndTimestamp.split("-")[1]; - if (allStats.includes(timestamp) && !accumulator.hasOwnProperty(timestamp)) { - accumulator[timestamp] = groupedStatsByDate[timestamp]; - } - return accumulator; - }, {}); - setTrainingStats(Object.values(selectedStats).flat()); - router.push("/training"); - } - }; - - useEffect(() => { - const handleRouteChange = (url: string) => { - setTraining(false); - }; - router.events.on("routeChangeStart", handleRouteChange); - return () => { - router.events.off("routeChangeStart", handleRouteChange); - }; - }, [router.events, setTraining]); + const filteredStats = useMemo(() => + Object.keys(filterStatsByDate(groupedStats)) + .sort((a, b) => parseInt(b) - parseInt(a)), + // eslint-disable-next-line react-hooks/exhaustive-deps + [groupedStats, filter]) const customContent = (timestamp: string) => { - if (!groupedStats) return <>; - const dateStats = groupedStats[timestamp]; return ( @@ -212,7 +220,7 @@ export default function History({user, users, assignments}: Props) { {user && ( - + {training && (
@@ -231,14 +239,12 @@ export default function History({user, users, assignments}: Props) {
)} - {groupedStats && Object.keys(groupedStats).length > 0 && !isStatsLoading && ( -
- {Object.keys(filterStatsByDate(groupedStats)) - .sort((a, b) => parseInt(b) - parseInt(a)) - .map(customContent)} -
+ + + {filteredStats.length > 0 && !isStatsLoading && ( + )} - {groupedStats && Object.keys(groupedStats).length === 0 && !isStatsLoading && ( + {filteredStats.length === 0 && !isStatsLoading && ( No record to display... )} {isStatsLoading && ( diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index e3dce976..26bcdb14 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -1,156 +1,161 @@ /* eslint-disable @next/next/no-img-element */ import Head from "next/head"; -import {withIronSessionSsr} from "iron-session/next"; -import {sessionOptions} from "@/lib/session"; -import {ToastContainer} from "react-toastify"; +import { withIronSessionSsr } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { ToastContainer } from "react-toastify"; import Layout from "@/components/High/Layout"; import CodeGenerator from "./(admin)/CodeGenerator"; import ExamLoader from "./(admin)/ExamLoader"; -import {Tab} from "@headlessui/react"; +import { Tab } from "@headlessui/react"; import clsx from "clsx"; import Lists from "./(admin)/Lists"; import BatchCodeGenerator from "./(admin)/BatchCodeGenerator"; -import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; import ExamGenerator from "./(admin)/ExamGenerator"; import BatchCreateUser from "./(admin)/BatchCreateUser"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import usePermissions from "@/hooks/usePermissions"; -import {useState} from "react"; +import { useState } from "react"; import Modal from "@/components/Modal"; import IconCard from "@/dashboards/IconCard"; -import {BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill} from "react-icons/bs"; +import { BsCode, BsCodeSquare, BsGearFill, BsPeopleFill, BsPersonFill } from "react-icons/bs"; import UserCreator from "./(admin)/UserCreator"; import CorporateGradingSystem from "./(admin)/CorporateGradingSystem"; import useGradingSystem from "@/hooks/useGrading"; -import {CEFR_STEPS} from "@/resources/grading"; -import {User} from "@/interfaces/user"; -import {getUserPermissions} from "@/utils/permissions.be"; -import {Permission, PermissionType} from "@/interfaces/permissions"; -import {getUsers} from "@/utils/users.be"; +import { CEFR_STEPS } from "@/resources/grading"; +import { User } from "@/interfaces/user"; +import { getUserPermissions } from "@/utils/permissions.be"; +import { Permission, PermissionType } from "@/interfaces/permissions"; +import { getUsers } from "@/utils/users.be"; import useUsers from "@/hooks/useUsers"; +import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be"; +import { mapBy, serialize } from "@/utils"; +import { EntityWithRoles } from "@/interfaces/entity"; -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { + const user = req.session.user as User; + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } - if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) { + return { + redirect: { + destination: "/", + permanent: false, + }, + }; + } - const permissions = await getUserPermissions(user.id); + const permissions = await getUserPermissions(user.id); + const entities = await getEntitiesWithRoles(mapBy(user.entities, 'id')) || [] - return { - props: {user, permissions}, - }; + return { + props: serialize({ user, permissions, entities }), + }; }, sessionOptions); interface Props { - user: User; - permissions: PermissionType[]; + user: User; + permissions: PermissionType[]; + entities: EntityWithRoles[] } -export default function Admin({user, permissions}: Props) { - const {gradingSystem, mutate} = useGradingSystem(); - const {users} = useUsers(); +export default function Admin({ user, entities, permissions }: Props) { + const { gradingSystem, mutate } = useGradingSystem(); + const { users } = useUsers(); - const [modalOpen, setModalOpen] = useState(); + const [modalOpen, setModalOpen] = useState(); - return ( - <> - - Settings Panel | EnCoach - - - - - - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - setModalOpen(undefined)} /> - - setModalOpen(undefined)}> - { - mutate({user: user.id, steps}); - setModalOpen(undefined); - }} - /> - + return ( + <> + + Settings Panel | EnCoach + + + + + + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + setModalOpen(undefined)} /> + + setModalOpen(undefined)}> + { + mutate({ user: user.id, steps }); + setModalOpen(undefined); + }} + /> + -
- - {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( -
- setModalOpen("createCode")} - /> - setModalOpen("batchCreateCode")} - /> - setModalOpen("createUser")} - /> - setModalOpen("batchCreateUser")} - /> - {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( - setModalOpen("gradingSystem")} - /> - )} -
- )} -
-
- -
-
- - ); +
+ + {checkAccess(user, getTypesOfUser(["teacher"]), permissions, "viewCodes") && ( +
+ setModalOpen("createCode")} + /> + setModalOpen("batchCreateCode")} + /> + setModalOpen("createUser")} + /> + setModalOpen("batchCreateUser")} + /> + {checkAccess(user, ["admin", "corporate", "developer", "mastercorporate"]) && ( + setModalOpen("gradingSystem")} + /> + )} +
+ )} +
+
+ +
+
+ + ); } diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx index 4c45090f..a4f2e3d6 100644 --- a/src/pages/stats.tsx +++ b/src/pages/stats.tsx @@ -17,22 +17,28 @@ import {calculateAverageLevel, calculateBandScore} from "@/utils/score"; import {countExamModules, countFullExams, MODULE_ARRAY, sortByModule} from "@/utils/moduleUtils"; import {Chart} from "react-chartjs-2"; import useUsers from "@/hooks/useUsers"; -import Select from "react-select"; import useGroups from "@/hooks/useGroups"; import DatePicker from "react-datepicker"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ProfileSummary from "@/components/ProfileSummary"; import moment from "moment"; -import {Stat} from "@/interfaces/user"; +import {Group, Stat, User} from "@/interfaces/user"; import {Divider} from "primereact/divider"; import Badge from "@/components/Low/Badge"; +import { mapBy, serialize } from "@/utils"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { checkAccess } from "@/utils/permissions"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import Select from "@/components/Low/Select"; ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip); const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"]; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User; if (!user) { return { @@ -52,13 +58,25 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { }; } + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) + const users = await (checkAccess(user, ["admin", "developer"]) ? getUsers() : getEntitiesUsers(mapBy(entities, 'id'))) + const groups = await (checkAccess(user, ["admin", "developer"]) ? getGroups() : getGroupsByEntities(mapBy(entities, 'id'))) + return { - props: {user: req.session.user}, + props: serialize({user, entities, users, groups}), }; }, sessionOptions); -export default function Stats() { - const [statsUserId, setStatsUserId] = useState(); +interface Props { + user: User + users: User[] + entities: EntityWithRoles[] + groups: Group[] +} + +export default function Stats({ user, entities, users, groups }: Props) { + const [statsUserId, setStatsUserId] = useState(user.id); const [startDate, setStartDate] = useState(moment(new Date()).subtract(1, "weeks").toDate()); const [endDate, setEndDate] = useState(new Date()); const [initialStatDate, setInitialStatDate] = useState(); @@ -69,15 +87,8 @@ export default function Stats() { const [dailyScoreDate, setDailyScoreDate] = useState(new Date()); const [intervalDates, setIntervalDates] = useState([]); - const {user} = useUser({redirectTo: "/login"}); - const {users} = useUsers(); - const {groups} = useGroups({admin: user?.id}); const {data: stats} = useFilterRecordsByUser(statsUserId, !statsUserId); - useEffect(() => { - if (user) setStatsUserId(user.id); - }, [user]); - useEffect(() => { setInitialStatDate( stats @@ -190,16 +201,7 @@ export default function Stats() { className="w-full" options={users.map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} - onChange={(value) => setStatsUserId(value?.value)} - menuPortalTarget={document?.body} - styles={{ - menuPortal: (base) => ({...base, zIndex: 9999}), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} + onChange={(value) => setStatsUserId(value?.value || user.id)} /> )} {["corporate", "teacher", "mastercorporate"].includes(user.type) && groups.length > 0 && ( @@ -209,16 +211,7 @@ export default function Stats() { .filter((x) => groups.flatMap((y) => y.participants).includes(x.id)) .map((x) => ({value: x.id, label: `${x.name} - ${x.email}`}))} defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} - onChange={(value) => setStatsUserId(value?.value)} - menuPortalTarget={document?.body} - styles={{ - menuPortal: (base) => ({...base, zIndex: 9999}), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} + onChange={(value) => setStatsUserId(value?.value || user.id)} /> )} diff --git a/src/pages/training/index.tsx b/src/pages/training/index.tsx index 461ca135..be1af30b 100644 --- a/src/pages/training/index.tsx +++ b/src/pages/training/index.tsx @@ -20,9 +20,15 @@ import TrainingScore from "@/training/TrainingScore"; import ModuleBadge from "@/components/ModuleBadge"; import RecordFilter from "@/components/Medium/RecordFilter"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; +import { mapBy, serialize } from "@/utils"; +import { getEntitiesWithRoles } from "@/utils/entities.be"; +import { getAssignmentsByAssignee } from "@/utils/assignments.be"; +import { getEntitiesUsers } from "@/utils/users.be"; +import { EntityWithRoles } from "@/interfaces/entity"; +import { Assignment } from "@/interfaces/results"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = req.session.user as User; if (!user) { return { @@ -42,12 +48,16 @@ export const getServerSideProps = withIronSessionSsr(({req, res}) => { }; } + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(entityIDs) + const users = await getEntitiesUsers(entityIDs) + return { - props: {user: req.session.user}, + props: serialize({user, users, entities}), }; }, sessionOptions); -const Training: React.FC<{user: User}> = ({user}) => { +const Training: React.FC<{user: User, entities: EntityWithRoles[], users: User[] }> = ({user, entities, users}) => { const [recordUserId, setRecordTraining] = useRecordStore((state) => [state.selectedUser, state.setTraining]); const [filter, setFilter] = useState<"months" | "weeks" | "days" | "assignments">(); @@ -193,7 +203,7 @@ const Training: React.FC<{user: User}> = ({user}) => {
) : ( <> - + {user.type === "student" && ( <>
diff --git a/src/resources/speakingAvatars.ts b/src/resources/speakingAvatars.ts index ddff86ac..b15d5821 100644 --- a/src/resources/speakingAvatars.ts +++ b/src/resources/speakingAvatars.ts @@ -1,37 +1,37 @@ export const AVATARS = [ { - name: "Matthew Noah", - id: "5912afa7c77c47d3883af3d874047aaf", - gender: "male", - }, - { - name: "Vera Cerise", - id: "9e58d96a383e4568a7f1e49df549e0e4", + name: "Gia", + id: "gia.business", gender: "female", }, { - name: "Edward Tony", - id: "d2cdd9c0379a4d06ae2afb6e5039bd0c", + name: "Vadim", + id: "vadim.business", gender: "male", }, { - name: "Tanya Molly", - id: "045cb5dcd00042b3a1e4f3bc1c12176b", - gender: "female", - }, - { - name: "Kayla Abbi", - id: "1ae1e5396cc444bfad332155fdb7a934", - gender: "female", - }, - { - name: "Jerome Ryan", - id: "0ee6aa7cc1084063a630ae514fccaa31", + name: "Orhan", + id: "orhan.business", gender: "male", }, { - name: "Tyler Christopher", - id: "5772cff935844516ad7eeff21f839e43", + name: "Flora", + id: "flora.business", + gender: "female", + }, + { + name: "Scarlett", + id: "scarlett.business", + gender: "female", + }, + { + name: "Parker", + id: "parker.casual", + gender: "male", + }, + { + name: "Ethan", + id: "ethan.business", gender: "male", }, ]; diff --git a/src/utils/groups.be.ts b/src/utils/groups.be.ts index 24996911..1981f519 100644 --- a/src/utils/groups.be.ts +++ b/src/utils/groups.be.ts @@ -1,132 +1,171 @@ -import {app} from "@/firebase"; -import {Assignment} from "@/interfaces/results"; -import {CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User} from "@/interfaces/user"; +import { app } from "@/firebase"; +import { WithEntity } from "@/interfaces/entity"; +import { Assignment } from "@/interfaces/results"; +import { CorporateUser, Group, GroupWithUsers, MasterCorporateUser, StudentUser, TeacherUser, Type, User } from "@/interfaces/user"; import client from "@/lib/mongodb"; import moment from "moment"; -import {getLinkedUsers, getUser} from "./users.be"; -import {getSpecificUsers} from "./users.be"; +import { getLinkedUsers, getUser } from "./users.be"; +import { getSpecificUsers } from "./users.be"; const db = client.db(process.env.MONGODB_DB); +const addEntityToGroupPipeline = [ + { + $lookup: { + from: "entities", + localField: "entity", + foreignField: "id", + as: "entity" + } + }, + { + $addFields: { + entity: { $arrayElemAt: ["$entity", 0] } + } + }, + { + $addFields: { + entity: { + $cond: { + if: { $isArray: "$entity" }, + then: undefined, + else: "$entity" + } + } + } + } +] + export const updateExpiryDateOnGroup = async (participantID: string, corporateID: string) => { - const corporate = await db.collection("users").findOne({id: corporateID}); - const participant = await db.collection("users").findOne({id: participantID}); + const corporate = await db.collection("users").findOne({ id: corporateID }); + const participant = await db.collection("users").findOne({ id: participantID }); - if (!corporate || !participant) return; - if (corporate.type !== "corporate" || (participant.type !== "student" && participant.type !== "teacher")) return; + if (!corporate || !participant) return; + if (corporate.type !== "corporate" || (participant.type !== "student" && participant.type !== "teacher")) return; - if (!corporate.subscriptionExpirationDate || !participant.subscriptionExpirationDate) - return await db.collection("users").updateOne({id: participant.id}, {$set: {subscriptionExpirationDate: null}}); + if (!corporate.subscriptionExpirationDate || !participant.subscriptionExpirationDate) + return await db.collection("users").updateOne({ id: participant.id }, { $set: { subscriptionExpirationDate: null } }); - const corporateDate = moment(corporate.subscriptionExpirationDate); - const participantDate = moment(participant.subscriptionExpirationDate); + const corporateDate = moment(corporate.subscriptionExpirationDate); + const participantDate = moment(participant.subscriptionExpirationDate); - if (corporateDate.isAfter(participantDate)) - return await db.collection("users").updateOne({id: participant.id}, {$set: {subscriptionExpirationDate: corporateDate.toISOString()}}); + if (corporateDate.isAfter(participantDate)) + return await db.collection("users").updateOne({ id: participant.id }, { $set: { subscriptionExpirationDate: corporateDate.toISOString() } }); - return; + return; }; export const getUserCorporate = async (id: string) => { - const user = await getUser(id); - if (!user) return undefined; + const user = await getUser(id); + if (!user) return undefined; - if (["admin", "developer"].includes(user.type)) return undefined; - if (user.type === "mastercorporate") return user; + if (["admin", "developer"].includes(user.type)) return undefined; + if (user.type === "mastercorporate") return user; - const groups = await getParticipantGroups(id); - const admins = await Promise.all(groups.map((x) => x.admin).map(getUser)); - const corporates = admins - .filter((x) => (user.type === "corporate" ? x?.type === "mastercorporate" : x?.type === "corporate")) - .filter((x) => !!x) as User[]; + const groups = await getParticipantGroups(id); + const admins = await Promise.all(groups.map((x) => x.admin).map(getUser)); + const corporates = admins + .filter((x) => (user.type === "corporate" ? x?.type === "mastercorporate" : x?.type === "corporate")) + .filter((x) => !!x) as User[]; - if (corporates.length === 0) return undefined; - return corporates.shift() as CorporateUser | MasterCorporateUser; + if (corporates.length === 0) return undefined; + return corporates.shift() as CorporateUser | MasterCorporateUser; }; export const getGroup = async (id: string) => { - return await db.collection("groups").findOne({id}); + return await db.collection("groups").findOne({ id }); }; -export const getGroups = async () => { - return await db.collection("groups").find({}).toArray(); +export const getGroups = async (): Promise[]> => { + return await db.collection("groups") + .aggregate>(addEntityToGroupPipeline).toArray() }; export const getParticipantGroups = async (id: string) => { - return await db.collection("groups").find({participants: id}).toArray(); + return await db.collection("groups").find({ participants: id }).toArray(); }; export const getUserGroups = async (id: string): Promise => { - return await db.collection("groups").find({admin: id}).toArray(); + return await db.collection("groups").find({ admin: id }).toArray(); }; export const getUserNamedGroup = async (id: string, name: string) => { - return await db.collection("groups").findOne({admin: id, name}); + return await db.collection("groups").findOne({ admin: id, name }); }; +export const removeParticipantFromGroup = async (id: string, user: string) => { + return await db.collection("groups").updateOne({id}, { + // @ts-expect-error + $pull: { + participants: user + } + }) +} + export const getUsersGroups = async (ids: string[]) => { - return await db - .collection("groups") - .find({admin: {$in: ids}}) - .toArray(); + return await db + .collection("groups") + .find({ admin: { $in: ids } }) + .toArray(); }; export const convertToUsers = (group: Group, users: User[]): GroupWithUsers => - Object.assign(group, { - admin: users.find((u) => u.id === group.admin), - participants: group.participants.map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[], - }); + Object.assign(group, { + admin: users.find((u) => u.id === group.admin), + participants: group.participants.map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[], + }); export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise => { - const linkedTeachers = await getLinkedUsers(corporateID, type, "teacher"); - const linkedCorporates = await getLinkedUsers(corporateID, type, "corporate"); + const linkedTeachers = await getLinkedUsers(corporateID, type, "teacher"); + const linkedCorporates = await getLinkedUsers(corporateID, type, "corporate"); - return [...linkedTeachers.users.map((x) => x.id), ...linkedCorporates.users.map((x) => x.id)]; + return [...linkedTeachers.users.map((x) => x.id), ...linkedCorporates.users.map((x) => x.id)]; }; export const getGroupsForEntities = async (ids: string[]) => - await db - .collection("groups") - .find({entity: {$in: ids}}) - .toArray(); + await db + .collection("groups") + .find({ entity: { $in: ids } }) + .toArray(); export const getGroupsForUser = async (admin?: string, participant?: string) => { - if (admin && participant) return await db.collection("groups").find({admin, participant}).toArray(); + if (admin && participant) return await db.collection("groups").find({ admin, participant }).toArray(); - if (admin) return await getUserGroups(admin); - if (participant) return await getParticipantGroups(participant); + if (admin) return await getUserGroups(admin); + if (participant) return await getParticipantGroups(participant); - return await getGroups(); + return await getGroups(); }; export const getStudentGroupsForUsersWithoutAdmin = async (admin: string, participants: string[]) => { - return await db - .collection("groups") - .find({...(admin ? {admin: {$ne: admin}} : {}), ...(participants ? {participants} : {})}) - .toArray(); + return await db + .collection("groups") + .find({ ...(admin ? { admin: { $ne: admin } } : {}), ...(participants ? { participants } : {}) }) + .toArray(); }; export const getCorporateNameForStudent = async (studentID: string) => { - const groups = await getStudentGroupsForUsersWithoutAdmin("", [studentID]); - if (groups.length === 0) return ""; + const groups = await getStudentGroupsForUsersWithoutAdmin("", [studentID]); + if (groups.length === 0) return ""; - const adminUserIds = [...new Set(groups.map((g) => g.admin))]; - const adminUsersData = (await getSpecificUsers(adminUserIds)).filter((x) => !!x) as User[]; + const adminUserIds = [...new Set(groups.map((g) => g.admin))]; + const adminUsersData = (await getSpecificUsers(adminUserIds)).filter((x) => !!x) as User[]; - if (adminUsersData.length === 0) return ""; - const admins = adminUsersData.filter((x) => x.type === "corporate"); + if (adminUsersData.length === 0) return ""; + const admins = adminUsersData.filter((x) => x.type === "corporate"); - if (admins.length > 0) { - return (admins[0] as CorporateUser).corporateInformation.companyInformation.name; - } + if (admins.length > 0) { + return (admins[0] as CorporateUser).corporateInformation.companyInformation.name; + } - return ""; + return ""; }; -export const getGroupsByEntity = async (id: string) => await db.collection("groups").find({entity: id}).toArray(); +export const getGroupsByEntity = async (id: string) => await db.collection("groups").find({ entity: id }).toArray(); -export const getGroupsByEntities = async (ids: string[]) => - await db - .collection("groups") - .find({entity: {$in: ids}}) - .toArray(); +export const getGroupsByEntities = async (ids: string[]): Promise[]> => + await db.collection("groups") + .aggregate>([ + { $match: { entity: { $in: ids } } }, + ...addEntityToGroupPipeline + ]).toArray() diff --git a/src/utils/index.ts b/src/utils/index.ts index 20ee1045..50adc521 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -44,7 +44,7 @@ export const convertBase64 = (file: File) => { }); }; -export const mapBy = (obj: T[] | undefined, key: keyof T) => (obj || []).map((i) => i[key]); +export const mapBy = (obj: T[] | undefined, key: K) => (obj || []).map((i) => i[key] as T[K]); export const filterBy = (obj: T[], key: keyof T, value: any) => obj.filter((i) => i[key] === value); export const serialize = (obj: T): T => JSON.parse(JSON.stringify(obj)); diff --git a/src/utils/users.be.ts b/src/utils/users.be.ts index 963eef79..a6b180e9 100644 --- a/src/utils/users.be.ts +++ b/src/utils/users.be.ts @@ -1,140 +1,140 @@ -import {CorporateUser, Type, User} from "@/interfaces/user"; -import {getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups} from "./groups.be"; -import {uniq} from "lodash"; -import {getUserCodes} from "./codes.be"; +import { CorporateUser, Type, User } from "@/interfaces/user"; +import { getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups } from "./groups.be"; +import { uniq } from "lodash"; +import { getUserCodes } from "./codes.be"; import client from "@/lib/mongodb"; -import {WithEntity} from "@/interfaces/entity"; -import {getEntity} from "./entities.be"; -import {getRole} from "./roles.be"; +import { WithEntities } from "@/interfaces/entity"; +import { getEntity } from "./entities.be"; +import { getRole } from "./roles.be"; const db = client.db(process.env.MONGODB_DB); -export async function getUsers() { - return await db - .collection("users") - .find({}, {projection: {_id: 0}}) - .toArray(); +export async function getUsers(filter?: object) { + return await db + .collection("users") + .find(filter || {}, { projection: { _id: 0 } }) + .toArray(); } -export async function getUserWithEntity(id: string): Promise | undefined> { - const user = await db.collection("users").findOne({id: id}, {projection: {_id: 0}}); - if (!user) return undefined; +export async function getUserWithEntity(id: string): Promise | undefined> { + const user = await db.collection("users").findOne({ id: id }, { projection: { _id: 0 } }); + if (!user) return undefined; - const entities = await Promise.all( - user.entities.map(async (e) => { - const entity = await getEntity(e.id); - const role = await getRole(e.role); + const entities = await Promise.all( + user.entities.map(async (e) => { + const entity = await getEntity(e.id); + const role = await getRole(e.role); - return {entity, role}; - }), - ); + return { entity, role }; + }), + ); - return {...user, entities}; + return { ...user, entities }; } export async function getUser(id: string): Promise { - const user = await db.collection("users").findOne({id: id}, {projection: {_id: 0}}); - return !!user ? user : undefined; + const user = await db.collection("users").findOne({ id: id }, { projection: { _id: 0 } }); + return !!user ? user : undefined; } export async function getSpecificUsers(ids: string[]) { - if (ids.length === 0) return []; + if (ids.length === 0) return []; - return await db - .collection("users") - .find({id: {$in: ids}}, {projection: {_id: 0}}) - .toArray(); + return await db + .collection("users") + .find({ id: { $in: ids } }, { projection: { _id: 0 } }) + .toArray(); } export async function getEntityUsers(id: string, limit?: number) { - return await db - .collection("users") - .find({"entities.id": id}) - .limit(limit || 0) - .toArray(); + return await db + .collection("users") + .find({ "entities.id": id }) + .limit(limit || 0) + .toArray(); } export async function countEntityUsers(id: string) { - return await db.collection("users").countDocuments({"entities.id": id}); + return await db.collection("users").countDocuments({ "entities.id": id }); } -export async function getEntitiesUsers(ids: string[], limit?: number) { - return await db - .collection("users") - .find({"entities.id": {$in: ids}}) - .limit(limit || 0) - .toArray(); +export async function getEntitiesUsers(ids: string[], filter?: object, limit?: number) { + return await db + .collection("users") + .find({ "entities.id": { $in: ids }, ...(filter || {}) }) + .limit(limit || 0) + .toArray(); } export async function countEntitiesUsers(ids: string[]) { - return await db.collection("users").countDocuments({"entities.id": {$in: ids}}); + return await db.collection("users").countDocuments({ "entities.id": { $in: ids } }); } export async function getLinkedUsers( - userID?: string, - userType?: Type, - type?: Type, - page?: number, - size?: number, - sort?: string, - direction?: "asc" | "desc", + userID?: string, + userType?: Type, + type?: Type, + page?: number, + size?: number, + sort?: string, + direction?: "asc" | "desc", ) { - const filters = { - ...(!!type ? {type} : {}), - }; + const filters = { + ...(!!type ? { type } : {}), + }; - if (!userID || userType === "admin" || userType === "developer") { - const users = await db - .collection("users") - .find(filters) - .sort(sort ? {[sort]: direction === "desc" ? -1 : 1} : {}) - .skip(page && size ? page * size : 0) - .limit(size || 0) - .toArray(); + if (!userID || userType === "admin" || userType === "developer") { + const users = await db + .collection("users") + .find(filters) + .sort(sort ? { [sort]: direction === "desc" ? -1 : 1 } : {}) + .skip(page && size ? page * size : 0) + .limit(size || 0) + .toArray(); - const total = await db.collection("users").countDocuments(filters); - return {users, total}; - } + const total = await db.collection("users").countDocuments(filters); + return { users, total }; + } - const adminGroups = await getUserGroups(userID); - const groups = await getUsersGroups(adminGroups.flatMap((x) => x.participants)); - const belongingGroups = await getParticipantGroups(userID); + const adminGroups = await getUserGroups(userID); + const groups = await getUsersGroups(adminGroups.flatMap((x) => x.participants)); + const belongingGroups = await getParticipantGroups(userID); - const participants = uniq([ - ...adminGroups.flatMap((x) => x.participants), - ...(userType === "mastercorporate" ? groups.flat().flatMap((x) => x.participants) : []), - ...(userType === "teacher" ? belongingGroups.flatMap((x) => x.participants) : []), - ]); + const participants = uniq([ + ...adminGroups.flatMap((x) => x.participants), + ...(userType === "mastercorporate" ? groups.flat().flatMap((x) => x.participants) : []), + ...(userType === "teacher" ? belongingGroups.flatMap((x) => x.participants) : []), + ]); - // ⨯ [FirebaseError: Invalid Query. A non-empty array is required for 'in' filters.] { - if (participants.length === 0) return {users: [], total: 0}; + // ⨯ [FirebaseError: Invalid Query. A non-empty array is required for 'in' filters.] { + if (participants.length === 0) return { users: [], total: 0 }; - const users = await db - .collection("users") - .find({...filters, id: {$in: participants}}) - .skip(page && size ? page * size : 0) - .limit(size || 0) - .toArray(); - const total = await db.collection("users").countDocuments({...filters, id: {$in: participants}}); + const users = await db + .collection("users") + .find({ ...filters, id: { $in: participants } }) + .skip(page && size ? page * size : 0) + .limit(size || 0) + .toArray(); + const total = await db.collection("users").countDocuments({ ...filters, id: { $in: participants } }); - return {users, total}; + return { users, total }; } export async function getUserBalance(user: User) { - const codes = await getUserCodes(user.id); - if (user.type !== "corporate" && user.type !== "mastercorporate") return codes.length; + const codes = await getUserCodes(user.id); + if (user.type !== "corporate" && user.type !== "mastercorporate") return codes.length; - const groups = await getGroupsForUser(user.id); - const participants = uniq(groups.flatMap((x) => x.participants)); + const groups = await getGroupsForUser(user.id); + const participants = uniq(groups.flatMap((x) => x.participants)); - if (user.type === "corporate") return participants.length + codes.filter((x) => !participants.includes(x.userId || "")).length; + if (user.type === "corporate") return participants.length + codes.filter((x) => !participants.includes(x.userId || "")).length; - const participantUsers = await Promise.all(participants.map(getUser)); - const corporateUsers = participantUsers.filter((x) => x?.type === "corporate") as CorporateUser[]; + const participantUsers = await Promise.all(participants.map(getUser)); + const corporateUsers = participantUsers.filter((x) => x?.type === "corporate") as CorporateUser[]; - return ( - corporateUsers.reduce((acc, curr) => acc + curr.corporateInformation?.companyInformation?.userAmount || 0, 0) + - corporateUsers.length + - codes.filter((x) => !participants.includes(x.userId || "") && !corporateUsers.map((u) => u.id).includes(x.userId || "")).length - ); + return ( + corporateUsers.reduce((acc, curr) => acc + curr.corporateInformation?.companyInformation?.userAmount || 0, 0) + + corporateUsers.length + + codes.filter((x) => !participants.includes(x.userId || "") && !corporateUsers.map((u) => u.id).includes(x.userId || "")).length + ); } diff --git a/src/utils/users.ts b/src/utils/users.ts index bdcb262e..89b1353b 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -1,45 +1,46 @@ -import {Group, User} from "@/interfaces/user"; -import {getUserCompanyName, USER_TYPE_LABELS} from "@/resources/user"; -import {capitalize} from "lodash"; +import { WithLabeledEntities } from "@/interfaces/entity"; +import { Group, User } from "@/interfaces/user"; +import { getUserCompanyName, USER_TYPE_LABELS } from "@/resources/user"; +import { capitalize } from "lodash"; import moment from "moment"; export interface UserListRow { - name: string; - email: string; - type: string; - companyName: string; - expiryDate: string; - verified: string; - country: string; - phone: string; - employmentPosition: string; - gender: string; + name: string; + email: string; + type: string; + entities: string; + expiryDate: string; + verified: string; + country: string; + phone: string; + employmentPosition: string; + gender: string; } -export const exportListToExcel = (rowUsers: User[], users: User[], groups: Group[]) => { - const rows: UserListRow[] = rowUsers.map((user) => ({ - name: user.name, - email: user.email, - type: USER_TYPE_LABELS[user.type], - companyName: getUserCompanyName(user, users, groups), - expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited", - country: user.demographicInformation?.country || "N/A", - phone: user.demographicInformation?.phone || "N/A", - employmentPosition: - (user.type === "corporate" || user.type === "mastercorporate" - ? user.demographicInformation?.position - : user.demographicInformation?.employment) || "N/A", - gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A", - verified: user.isVerified?.toString() || "FALSE", - })); - const header = "Name,Email,Type,Company Name,Expiry Date,Country,Phone,Employment/Department,Gender,Verification"; - const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n"); +export const exportListToExcel = (rowUsers: WithLabeledEntities[]) => { + const rows: UserListRow[] = rowUsers.map((user) => ({ + name: user.name, + email: user.email, + type: USER_TYPE_LABELS[user.type], + entities: user.entities.map((e) => e.label).join(', '), + expiryDate: user.subscriptionExpirationDate ? moment(user.subscriptionExpirationDate).format("DD/MM/YYYY") : "Unlimited", + country: user.demographicInformation?.country || "N/A", + phone: user.demographicInformation?.phone || "N/A", + employmentPosition: + (user.type === "corporate" || user.type === "mastercorporate" + ? user.demographicInformation?.position + : user.demographicInformation?.employment) || "N/A", + gender: user.demographicInformation?.gender ? capitalize(user.demographicInformation.gender) : "N/A", + verified: user.isVerified?.toString() || "FALSE", + })); + const header = "Name,Email,Type,Entities,Expiry Date,Country,Phone,Employment/Department,Gender,Verification"; + const rowsString = rows.map((x) => Object.values(x).join(",")).join("\n"); - return `${header}\n${rowsString}`; + return `${header}\n${rowsString}`; }; export const getUserName = (user?: User) => { - if (!user) return "N/A"; - if (user.type === "corporate" || user.type === "mastercorporate") return user.corporateInformation?.companyInformation?.name || user.name; - return user.name; + if (!user) return "N/A"; + if (user.type === "corporate" || user.type === "mastercorporate") return user.corporateInformation?.companyInformation?.name || user.name; + return user.name; }; From c43ab9a911a6c069178ba9569dffedefe04d1e79 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 8 Oct 2024 10:44:57 +0100 Subject: [PATCH 07/35] Continued with the transformation of the Entities --- src/dashboards/Admin.tsx | 6 +- src/dashboards/Corporate/index.tsx | 4 +- src/dashboards/MasterCorporate/index.tsx | 4 +- src/dashboards/Teacher.tsx | 4 +- .../(admin)/Lists/StudentPerformanceList.tsx | 111 ++++++++++++++++++ src/pages/(admin)/Lists/UserList.tsx | 6 +- src/pages/dashboard/admin.tsx | 15 ++- src/pages/dashboard/corporate.tsx | 11 +- src/pages/dashboard/developer.tsx | 15 ++- src/pages/dashboard/mastercorporate.tsx | 13 +- src/pages/{list/users.tsx => users/index.tsx} | 2 - src/pages/users/performance.tsx | 96 +++++++++++++++ src/utils/groups.be.ts | 4 + 13 files changed, 260 insertions(+), 31 deletions(-) create mode 100644 src/pages/(admin)/Lists/StudentPerformanceList.tsx rename src/pages/{list/users.tsx => users/index.tsx} (99%) create mode 100644 src/pages/users/performance.tsx diff --git a/src/dashboards/Admin.tsx b/src/dashboards/Admin.tsx index edb7d165..656a4377 100644 --- a/src/dashboards/Admin.tsx +++ b/src/dashboards/Admin.tsx @@ -581,7 +581,7 @@ export default function AdminDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -601,7 +601,7 @@ export default function AdminDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -621,7 +621,7 @@ export default function AdminDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/dashboards/Corporate/index.tsx b/src/dashboards/Corporate/index.tsx index 453a9005..b21aaf28 100644 --- a/src/dashboards/Corporate/index.tsx +++ b/src/dashboards/Corporate/index.tsx @@ -217,7 +217,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -237,7 +237,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/dashboards/MasterCorporate/index.tsx b/src/dashboards/MasterCorporate/index.tsx index efa958e0..72d34c25 100644 --- a/src/dashboards/MasterCorporate/index.tsx +++ b/src/dashboards/MasterCorporate/index.tsx @@ -276,7 +276,7 @@ export default function MasterCorporateDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -296,7 +296,7 @@ export default function MasterCorporateDashboard({user}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/dashboards/Teacher.tsx b/src/dashboards/Teacher.tsx index 18927ee4..b10c379c 100644 --- a/src/dashboards/Teacher.tsx +++ b/src/dashboards/Teacher.tsx @@ -202,7 +202,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -222,7 +222,7 @@ export default function TeacherDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/pages/(admin)/Lists/StudentPerformanceList.tsx b/src/pages/(admin)/Lists/StudentPerformanceList.tsx new file mode 100644 index 00000000..f202c088 --- /dev/null +++ b/src/pages/(admin)/Lists/StudentPerformanceList.tsx @@ -0,0 +1,111 @@ +/* eslint-disable @next/next/no-img-element */ +import {Stat, StudentUser, User} from "@/interfaces/user"; +import {useState} from "react"; +import {averageLevelCalculator} from "@/utils/score"; +import {groupByExam} from "@/utils/stats"; +import {createColumnHelper} from "@tanstack/react-table"; +import Checkbox from "@/components/Low/Checkbox"; +import List from "@/components/List"; +import Table from "@/components/High/Table"; + +type StudentPerformanceItem = StudentUser & {entitiesLabel: string; group: string}; + +const StudentPerformanceList = ({items = [], stats}: {items: StudentPerformanceItem[]; stats: Stat[]}) => { + const [isShowingAmount, setIsShowingAmount] = useState(false); + + const columnHelper = createColumnHelper(); + + const columns = [ + columnHelper.accessor("name", { + header: "Student Name", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("email", { + header: "E-mail", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("studentID", { + header: "ID", + cell: (info) => info.getValue() || "N/A", + }), + columnHelper.accessor("group", { + header: "Group", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("entitiesLabel", { + header: "Entities", + cell: (info) => info.getValue() || "N/A", + }), + columnHelper.accessor("levels.reading", { + header: "Reading", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "reading" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.listening", { + header: "Listening", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "listening" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.writing", { + header: "Writing", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "writing" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.speaking", { + header: "Speaking", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "speaking" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels.level", { + header: "Level", + cell: (info) => + !isShowingAmount + ? info.getValue() || 0 + : `${Object.keys(groupByExam(stats.filter((x) => x.module === "level" && x.user === info.row.original.id))).length} exams`, + }), + columnHelper.accessor("levels", { + id: "overall_level", + header: "Overall", + cell: (info) => + !isShowingAmount + ? averageLevelCalculator( + items, + stats.filter((x) => x.user === info.row.original.id), + ).toFixed(1) + : `${Object.keys(groupByExam(stats.filter((x) => x.user === info.row.original.id))).length} exams`, + }), + ]; + + return ( +
+ + Show Utilization + + + data={items.sort( + (a, b) => + averageLevelCalculator( + items, + stats.filter((x) => x.user === b.id), + ) - + averageLevelCalculator( + items, + stats.filter((x) => x.user === a.id), + ), + )} + columns={columns} + searchFields={[["name"], ["email"], ["studentID"], ["entitiesLabel"], ["group"]]} + /> +
+ ); +}; + +export default StudentPerformanceList; diff --git a/src/pages/(admin)/Lists/UserList.tsx b/src/pages/(admin)/Lists/UserList.tsx index 93224b0b..e9092fb6 100644 --- a/src/pages/(admin)/Lists/UserList.tsx +++ b/src/pages/(admin)/Lists/UserList.tsx @@ -329,7 +329,7 @@ export default function UserList({ filter: belongsToAdminFilter, }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -345,7 +345,7 @@ export default function UserList({ filter: belongsToAdminFilter, }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -361,7 +361,7 @@ export default function UserList({ filter: belongsToAdminFilter }); - router.push("/list/users"); + router.push("/users"); } : undefined } diff --git a/src/pages/dashboard/admin.tsx b/src/pages/dashboard/admin.tsx index 52cc3d02..a330adcd 100644 --- a/src/pages/dashboard/admin.tsx +++ b/src/pages/dashboard/admin.tsx @@ -123,14 +123,14 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
router.push("/list/users?type=student")} + onClick={() => router.push("/users?type=student")} Icon={BsPersonFill} label="Students" value={students.length} color="purple" /> router.push("/list/users?type=teacher")} + onClick={() => router.push("/users?type=teacher")} Icon={BsPencilSquare} label="Teachers" value={teachers.length} @@ -138,14 +138,14 @@ export default function Dashboard({ user, users, entities, assignments, stats, g /> router.push("/list/users?type=corporate")} + onClick={() => router.push("/users?type=corporate")} label="Corporates" value={corporates.length} color="purple" /> router.push("/list/users?type=mastercorporate")} + onClick={() => router.push("/users?type=mastercorporate")} label="Master Corporates" value={masterCorporates.length} color="purple" @@ -154,7 +154,12 @@ export default function Dashboard({ user, users, entities, assignments, stats, g - + router.push("/users/performance")} + label="Student Performance" + value={students.length} + color="purple" + /> router.push("/assignments")} diff --git a/src/pages/dashboard/corporate.tsx b/src/pages/dashboard/corporate.tsx index 1c7576d6..fe87ba44 100644 --- a/src/pages/dashboard/corporate.tsx +++ b/src/pages/dashboard/corporate.tsx @@ -137,14 +137,14 @@ export default function Dashboard({ user, users, entities, assignments, stats, g )}
router.push("/list/users?type=student")} + onClick={() => router.push("/users?type=student")} Icon={BsPersonFill} label="Students" value={students.length} color="purple" /> router.push("/list/users?type=teacher")} + onClick={() => router.push("/users?type=teacher")} Icon={BsPencilSquare} label="Teachers" value={teachers.length} @@ -160,7 +160,12 @@ export default function Dashboard({ user, users, entities, assignments, stats, g - + router.push("/users/performance")} + label="Student Performance" + value={students.length} + color="purple" + />
router.push("/list/users?type=student")} + onClick={() => router.push("/users?type=student")} Icon={BsPersonFill} label="Students" value={students.length} color="purple" /> router.push("/list/users?type=teacher")} + onClick={() => router.push("/users?type=teacher")} Icon={BsPencilSquare} label="Teachers" value={teachers.length} @@ -138,14 +138,14 @@ export default function Dashboard({ user, users, entities, assignments, stats, g /> router.push("/list/users?type=corporate")} + onClick={() => router.push("/users?type=corporate")} label="Corporates" value={corporates.length} color="purple" /> router.push("/list/users?type=mastercorporate")} + onClick={() => router.push("/users?type=mastercorporate")} label="Master Corporates" value={masterCorporates.length} color="purple" @@ -160,7 +160,12 @@ export default function Dashboard({ user, users, entities, assignments, stats, g - + router.push("/users/performance")} + label="Student Performance" + value={students.length} + color="purple" + /> router.push("/assignments")} diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx index 01e58e2d..392bd0a1 100644 --- a/src/pages/dashboard/mastercorporate.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -134,26 +134,31 @@ export default function Dashboard({ user, users, entities, assignments, stats, g
router.push("/list/users?type=student")} + onClick={() => router.push("/users?type=student")} Icon={BsPersonFill} label="Students" value={students.length} color="purple" /> router.push("/list/users?type=teacher")} + onClick={() => router.push("/users?type=teacher")} Icon={BsPencilSquare} label="Teachers" value={teachers.length} color="purple" /> router.push("/list/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> + onClick={() => router.push("/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> router.push("/classrooms")} Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" /> - + router.push("/users/performance")} + label="Student Performance" + value={students.length} + color="purple" + /> router.push("/assignments")} diff --git a/src/pages/list/users.tsx b/src/pages/users/index.tsx similarity index 99% rename from src/pages/list/users.tsx rename to src/pages/users/index.tsx index 06c6fdcd..a70d9f6c 100644 --- a/src/pages/list/users.tsx +++ b/src/pages/users/index.tsx @@ -55,7 +55,6 @@ export default function UsersListPage({ user, type }: Props) { - {user && ( - )} ); } diff --git a/src/pages/users/performance.tsx b/src/pages/users/performance.tsx new file mode 100644 index 00000000..6d369bc9 --- /dev/null +++ b/src/pages/users/performance.tsx @@ -0,0 +1,96 @@ +import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; +import useGroups from "@/hooks/useGroups"; +import useUsers, {userHashStudent} from "@/hooks/useUsers"; +import {Group, Stat, StudentUser, User} from "@/interfaces/user"; +import {getUserCompanyName} from "@/resources/user"; +import clsx from "clsx"; +import {useRouter} from "next/router"; +import {BsArrowLeft, BsArrowRepeat, BsChevronLeft} from "react-icons/bs"; +import { mapBy, serialize } from "@/utils"; +import {withIronSessionSsr} from "iron-session/next"; +import { getEntitiesUsers, getUsers } from "@/utils/users.be"; +import { sessionOptions } from "@/lib/session"; +import { checkAccess } from "@/utils/permissions"; +import { getEntities } from "@/utils/entities.be"; +import { Entity } from "@/interfaces/entity"; +import { getParticipantGroups, getParticipantsGroups } from "@/utils/groups.be"; +import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList"; +import Head from "next/head"; +import { ToastContainer } from "react-toastify"; +import Layout from "@/components/High/Layout"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { + const user = req.session.user as User; + + if (!user) { + return { + redirect: { + destination: "/login", + permanent: false, + }, + }; + } + + const entityIDs = mapBy(user.entities, 'id') + + const entities = await getEntities(checkAccess(user, ["admin", 'developer']) ? undefined : entityIDs) + const students = await (checkAccess(user, ["admin", 'developer']) + ? getUsers({type: 'student'}) + : getEntitiesUsers(entityIDs, {type: 'student'}) + ) + const groups = await getParticipantsGroups(mapBy(students, 'id')) + + return { + props: serialize({user, students, entities, groups}), + }; +}, sessionOptions); + +interface Props { + user: User; + students: StudentUser[] + entities: Entity[] + groups: Group[] +} + +const StudentPerformance = ({user, students, entities, groups}: Props) => { + const {data: stats} = useFilterRecordsByUser(); + + const router = useRouter(); + + const performanceStudents = students.map((u) => ({ + ...u, + group: groups.find((x) => x.participants.includes(u.id))?.name || "N/A", + entitiesLabel: mapBy(u.entities, 'id').map((id) => entities.find((e) => e.id === id)?.label).filter((e) => !!e).join(', '), + })); + + return ( + <> + + EnCoach + + + + + + + +
+ +

Student Performance ({ students.length })

+
+ +
+ + ); +}; + +export default StudentPerformance; diff --git a/src/utils/groups.be.ts b/src/utils/groups.be.ts index 1981f519..d5018ef1 100644 --- a/src/utils/groups.be.ts +++ b/src/utils/groups.be.ts @@ -85,6 +85,10 @@ export const getParticipantGroups = async (id: string) => { return await db.collection("groups").find({ participants: id }).toArray(); }; +export const getParticipantsGroups = async (ids: string[]) => { + return await db.collection("groups").find({ participants: { $in: ids } }).toArray(); +}; + export const getUserGroups = async (id: string): Promise => { return await db.collection("groups").find({ admin: id }).toArray(); }; From 55204e2ce1120d06a98f614ff7d589c8051e968e Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 10 Oct 2024 19:13:18 +0100 Subject: [PATCH 08/35] Started implementing the roles permissions --- src/components/High/Layout.tsx | 3 +- src/components/Sidebar.tsx | 13 - src/hooks/useEntities.tsx | 4 +- src/hooks/useEntityPermissions.tsx | 16 ++ src/interfaces/entity.ts | 1 + src/pages/(admin)/Lists/CodeList.tsx | 6 +- src/pages/(admin)/Lists/GroupList.tsx | 6 +- src/pages/(admin)/Lists/UserList.tsx | 72 +++-- src/pages/api/entities/[id]/index.ts | 33 ++- src/pages/api/entities/groups.ts | 11 +- src/pages/api/entities/index.ts | 18 +- src/pages/api/entities/users.ts | 58 ++-- src/pages/api/groups/[id].ts | 21 +- src/pages/api/invites/[id].ts | 21 +- src/pages/api/payments/[id].ts | 15 +- src/pages/api/paypal/approve.ts | 4 + src/pages/api/roles/[id]/index.ts | 79 ++++++ src/pages/api/roles/[id]/users.ts | 40 +++ src/pages/api/roles/index.ts | 50 ++++ src/pages/api/stats/index.ts | 16 +- src/pages/api/stats/update.ts | 22 +- src/pages/api/user.ts | 8 +- src/pages/assignments/[id].tsx | 22 +- src/pages/assignments/creator/[id].tsx | 6 +- src/pages/assignments/creator/index.tsx | 22 +- src/pages/assignments/index.tsx | 22 +- src/pages/classrooms/[id].tsx | 25 +- src/pages/classrooms/create.tsx | 26 +- src/pages/classrooms/index.tsx | 26 +- src/pages/dashboard/admin.tsx | 23 +- src/pages/dashboard/corporate.tsx | 23 +- src/pages/dashboard/developer.tsx | 23 +- src/pages/dashboard/index.tsx | 21 +- src/pages/dashboard/mastercorporate.tsx | 22 +- src/pages/dashboard/student.tsx | 22 +- src/pages/dashboard/teacher.tsx | 22 +- src/pages/entities/[id]/index.tsx | 289 +++++++++++--------- src/pages/entities/[id]/roles/[role].tsx | 326 +++++++++++++++++++++++ src/pages/entities/[id]/roles/index.tsx | 158 +++++++++++ src/pages/entities/[id]/settings.tsx | 232 ---------------- src/pages/entities/index.tsx | 50 ++-- src/pages/exam.tsx | 27 +- src/pages/exercises.tsx | 27 +- src/pages/generation.tsx | 28 +- src/pages/index.tsx | 21 +- src/pages/login.tsx | 25 +- src/pages/payment-record.tsx | 25 +- src/pages/payment.tsx | 27 +- src/pages/permissions/[id].tsx | 37 +-- src/pages/permissions/index.tsx | 27 +- src/pages/profile.tsx | 24 +- src/pages/record.tsx | 24 +- src/pages/settings.tsx | 24 +- src/pages/stats.tsx | 24 +- src/pages/tickets.tsx | 28 +- src/pages/training/[id]/index.tsx | 27 +- src/pages/training/index.tsx | 24 +- src/pages/users/index.tsx | 20 +- src/pages/users/performance.tsx | 14 +- src/pages/v1/index.tsx | 16 +- src/resources/entityPermissions.ts | 39 +++ src/utils/api.ts | 16 ++ src/utils/entities.be.ts | 30 ++- src/utils/index.ts | 8 + src/utils/permissions.ts | 24 +- src/utils/roles.be.ts | 20 ++ src/utils/users.be.ts | 8 +- 67 files changed, 1357 insertions(+), 1134 deletions(-) create mode 100644 src/hooks/useEntityPermissions.tsx create mode 100644 src/pages/api/roles/[id]/index.ts create mode 100644 src/pages/api/roles/[id]/users.ts create mode 100644 src/pages/api/roles/index.ts create mode 100644 src/pages/entities/[id]/roles/[role].tsx create mode 100644 src/pages/entities/[id]/roles/index.tsx delete mode 100644 src/pages/entities/[id]/settings.tsx create mode 100644 src/resources/entityPermissions.ts create mode 100644 src/utils/api.ts diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index f148146f..eb72323f 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,7 +1,7 @@ import {User} from "@/interfaces/user"; import clsx from "clsx"; import {useRouter} from "next/router"; -import BottomBar from "../BottomBar"; +import { ToastContainer } from "react-toastify"; import Navbar from "../Navbar"; import Sidebar from "../Sidebar"; @@ -20,6 +20,7 @@ export default function Layout({user, children, className, bgColor="bg-white", n return (
+ )} - {checkAccess(user, ["developer", "admin", "corporate", "mastercorporate", "agent"]) && ( -
-
- )} + +
+
+
+
+ + + +

{entity.label}

+
+
+
+ + + +
+
+ +
+ Members ({users.length}) + {!isAdding && ( +
+ + + + + + Assign Role + + + {entity.roles.map((role) => ( + + + + ))} + + + + +
+ )} + {isAdding && ( +
+ + +
+ )} +
+ + + list={isAdding ? linkedUsers : users} + renderCard={renderCard} + searchFields={[["name"], ["corporateInformation", "companyInformation", "name"], ["role", "label"], ["type"]]} + /> +
+
); } diff --git a/src/pages/entities/[id]/roles/[role].tsx b/src/pages/entities/[id]/roles/[role].tsx new file mode 100644 index 00000000..5a15dd68 --- /dev/null +++ b/src/pages/entities/[id]/roles/[role].tsx @@ -0,0 +1,326 @@ +import Layout from "@/components/High/Layout"; +import Checkbox from "@/components/Low/Checkbox"; +import Separator from "@/components/Low/Separator"; +import { useEntityPermission } from "@/hooks/useEntityPermissions"; +import {EntityWithRoles, Role} from "@/interfaces/entity"; +import {User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import { RolePermission } from "@/resources/entityPermissions"; +import { findBy, mapBy, redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; +import {getEntityWithRoles} from "@/utils/entities.be"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {doesEntityAllow} from "@/utils/permissions"; +import {countEntityUsers} from "@/utils/users.be"; +import axios from "axios"; +import {withIronSessionSsr} from "iron-session/next"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {Divider} from "primereact/divider"; +import {useState} from "react"; +import { + BsCheck, + BsChevronLeft, + BsTag, + BsTrash, +} from "react-icons/bs"; +import {toast} from "react-toastify"; + +type PermissionLayout = {label: string, key: RolePermission} + +const USER_MANAGEMENT: PermissionLayout[] = [ + {label: "View Students", key: "view_students"}, + {label: "View Teachers", key: "view_teachers"}, + {label: "View Corporate Accounts", key: "view_corporates"}, + {label: "View Master Corporate Accounts", key: "view_mastercorporates"}, + {label: "Edit Students", key: "edit_students"}, + {label: "Edit Teachers", key: "edit_teachers"}, + {label: "Edit Corporate Accounts", key: "edit_corporates"}, + {label: "Edit Master Corporate Accounts", key: "edit_mastercorporates"}, + {label: "Delete Students", key: "delete_students"}, + {label: "Delete Teachers", key: "delete_teachers"}, + {label: "Delete Corporate Accounts", key: "delete_corporates"}, + {label: "Delete Master Corporate Accounts", key: "delete_mastercorporates"}, +] + +const EXAM_MANAGEMENT: PermissionLayout[] = [ + {label: "Generate Reading", key: "generate_reading"}, + {label: "Delete Reading", key: "delete_reading"}, + {label: "Generate Listening", key: "generate_listening"}, + {label: "Delete Listening", key: "delete_listening"}, + {label: "Generate Writing", key: "generate_writing"}, + {label: "Delete Writing", key: "delete_writing"}, + {label: "Generate Speaking", key: "generate_speaking"}, + {label: "Delete Speaking", key: "delete_speaking"}, + {label: "Generate Level", key: "generate_level"}, + {label: "Delete Level", key: "delete_level"}, +] + +const CLASSROOM_MANAGEMENT: PermissionLayout[] = [ + {label: "View Classrooms", key: "view_classrooms"}, + {label: "Create Classrooms", key: "create_classroom"}, + {label: "Rename Classrooms", key: "rename_classrooms"}, + {label: "Add to Classroom", key: "add_to_classroom"}, + {label: "Remove from Classroom", key: "remove_from_classroom"}, + {label: "Delete Classroom", key: "delete_classroom"}, +] + +const ENTITY_MANAGEMENT: PermissionLayout[] = [ + {label: "View Entities", key: "view_entities"}, + {label: "Rename Entity", key: "rename_entity"}, + {label: "Add to Entity", key: "add_to_entity"}, + {label: "Remove from Entity", key: "remove_from_entity"}, + {label: "Delete Entity", key: "delete_entity"}, + {label: "View Entity Roles", key: "view_entity_roles"}, + {label: "Create Entity Role", key: "create_entity_role"}, + {label: "Rename Entity Role", key: "rename_entity_role"}, + {label: "Edit Role Permissions", key: "edit_role_permissions"}, + {label: "Assign Role to User", key: "assign_to_role"}, + {label: "Delete Entity Role", key: "delete_entity_role"}, +] + +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + if (shouldRedirectHome(user)) return redirect("/") + + const {id, role} = params as {id: string, role: string}; + + if (!mapBy(user.entities, 'id').includes(id) && !["admin", "developer"].includes(user.type)) return redirect("/entities") + + const entity = await getEntityWithRoles(id); + if (!entity) return redirect("/entities") + + const entityRole = findBy(entity.roles, 'id', role) + if (!entityRole) return redirect(`/entities/${id}/roles`) + + if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`) + + const userCount = await countEntityUsers(id, { "entities.role": role }); + + return { + props: serialize({ + user, + entity, + role: entityRole, + userCount, + }), + }; +}, sessionOptions); + +interface Props { + user: User; + entity: EntityWithRoles; + role: Role; + userCount: number; +} + +export default function Role({user, entity, role, userCount}: Props) { + const [permissions, setPermissions] = useState(role.permissions) + const [isLoading, setIsLoading] = useState(false); + + const router = useRouter(); + + const canEditPermissions = useEntityPermission(user, entity, "edit_role_permissions") + const canRenameRole = useEntityPermission(user, entity, "rename_entity_role") + const canDeleteRole = useEntityPermission(user, entity, "delete_entity_role") + + const renameRole = () => { + if (!canRenameRole) return; + + const label = prompt("Rename this role:", role.label); + if (!label) return; + + setIsLoading(true); + axios + .patch(`/api/roles/${role.id}`, {label}) + .then(() => { + toast.success("The role has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const deleteRole = () => { + if (!canDeleteRole || role.isDefault) return; + if (!confirm("Are you sure you want to delete this role?")) return; + + setIsLoading(true); + + axios + .delete(`/api/roles/${role.id}`) + .then(() => { + toast.success("This role has been successfully deleted!"); + router.replace(`/entities/${entity.id}/roles`); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const editPermissions = () => { + if (!canEditPermissions) return + + setIsLoading(true); + + axios + .patch(`/api/roles/${role.id}`, {permissions}) + .then(() => { + toast.success("This role has been successfully updated!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + } + + const togglePermissions = (p: string) => setPermissions(prev => prev.includes(p) ? prev.filter(x => x !== p) : [...prev, p]) + + return ( + <> + + { role.label } | {entity.label} | EnCoach + + + + + +
+
+
+
+ + + +

{role.label} Role ({ userCount } users)

+
+
+
+
+ + +
+ +
+
+ + +
+
+
+ User Management + permissions.includes(k))} + onChange={() => mapBy(USER_MANAGEMENT, 'key').forEach(togglePermissions)} + > + Select all + +
+ +
+ {USER_MANAGEMENT.map(({label, key}) => ( + togglePermissions(key)}> + { label } + + )) } +
+
+ +
+
+ Exam Management + permissions.includes(k))} + onChange={() => mapBy(EXAM_MANAGEMENT, 'key').forEach(togglePermissions)} + > + Select all + +
+ +
+ {EXAM_MANAGEMENT.map(({label, key}) => ( + togglePermissions(key)}> + { label } + + )) } +
+
+ +
+
+ Clasroom Management + permissions.includes(k))} + onChange={() => mapBy(CLASSROOM_MANAGEMENT, 'key').forEach(togglePermissions)} + > + Select all + +
+ +
+ {CLASSROOM_MANAGEMENT.map(({label, key}) => ( + togglePermissions(key)}> + { label } + + )) } +
+
+ +
+
+ Entity Management + permissions.includes(k))} + onChange={() => mapBy(ENTITY_MANAGEMENT, 'key').forEach(togglePermissions)} + > + Select all + +
+ +
+ {ENTITY_MANAGEMENT.map(({label, key}) => ( + togglePermissions(key)}> + { label } + + )) } +
+
+
+
+
+ + ); +} diff --git a/src/pages/entities/[id]/roles/index.tsx b/src/pages/entities/[id]/roles/index.tsx new file mode 100644 index 00000000..cb4a8639 --- /dev/null +++ b/src/pages/entities/[id]/roles/index.tsx @@ -0,0 +1,158 @@ +/* eslint-disable @next/next/no-img-element */ +import CardList from "@/components/High/CardList"; +import Layout from "@/components/High/Layout"; +import Tooltip from "@/components/Low/Tooltip"; +import { useEntityPermission } from "@/hooks/useEntityPermissions"; +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; +import {GroupWithUsers, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {USER_TYPE_LABELS} from "@/resources/user"; +import { redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; +import {getEntityWithRoles} from "@/utils/entities.be"; +import {convertToUsers, getGroup} from "@/utils/groups.be"; +import {shouldRedirectHome} from "@/utils/navigation.disabled"; +import {checkAccess, doesEntityAllow, getTypesOfUser} from "@/utils/permissions"; +import {getUserName} from "@/utils/users"; +import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; +import axios from "axios"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {Divider} from "primereact/divider"; +import {useEffect, useMemo, useState} from "react"; +import { + BsChevronLeft, + BsClockFill, + BsEnvelopeFill, + BsFillPersonVcardFill, + BsPlus, + BsSquare, + BsStopwatchFill, + BsTag, + BsTrash, + BsX, +} from "react-icons/bs"; +import {toast, ToastContainer} from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + if (shouldRedirectHome(user)) return redirect("/") + + const {id} = params as {id: string}; + + const entity = await getEntityWithRoles(id); + if (!entity) return redirect("/entities") + if (!doesEntityAllow(user, entity, "view_entity_roles")) return redirect(`/entities/${id}`) + + const users = await getEntityUsers(id); + + return { + props: serialize({ + user, + entity, + roles: entity.roles, + users, + }), + }; +}, sessionOptions); + +interface Props { + user: User; + entity: EntityWithRoles; + roles: Role[]; + users: User[]; +} + +export default function Home({user, entity, roles, users}: Props) { + const router = useRouter(); + + const canCreateRole = useEntityPermission(user, entity, "create_entity_role") + + const createRole = () => { + if (!canCreateRole) return + const label = prompt("What is the name of this new role?") + if (!label) return + + axios.post('/api/roles', {label, permissions: [], entityID: entity.id}) + .then((result) => { + toast.success(`'${label}' role created successfully!`) + router.push(`/entities/${entity.id}/roles/${result.data.id}`) + }) + .catch(() => { + toast.error("Something went wrong!") + }) + } + + const firstCard = () => ( + + ); + + const renderCard = (role: Role) => { + const usersWithRole = users.filter((x) => x.entities.map((x) => x.role).includes(role.id)); + + return ( + +
+ {role.label} + {usersWithRole.length} members +
+ + {role.permissions.length} Permissions + + ); + }; + + return ( + <> + + {entity.label} | EnCoach + + + + + + +
+
+
+
+ + + +

{entity.label}

+
+
+
+ + Roles + + +
+
+ + ); +} diff --git a/src/pages/entities/[id]/settings.tsx b/src/pages/entities/[id]/settings.tsx deleted file mode 100644 index bce4dab7..00000000 --- a/src/pages/entities/[id]/settings.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/* eslint-disable @next/next/no-img-element */ -import CardList from "@/components/High/CardList"; -import Layout from "@/components/High/Layout"; -import Tooltip from "@/components/Low/Tooltip"; -import {useListSearch} from "@/hooks/useListSearch"; -import usePagination from "@/hooks/usePagination"; -import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; -import {GroupWithUsers, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; -import {USER_TYPE_LABELS} from "@/resources/user"; -import {getEntityWithRoles} from "@/utils/entities.be"; -import {convertToUsers, getGroup} from "@/utils/groups.be"; -import {shouldRedirectHome} from "@/utils/navigation.disabled"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; -import {getUserName} from "@/utils/users"; -import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; -import axios from "axios"; -import clsx from "clsx"; -import {withIronSessionSsr} from "iron-session/next"; -import moment from "moment"; -import Head from "next/head"; -import Link from "next/link"; -import {useRouter} from "next/router"; -import {Divider} from "primereact/divider"; -import {useEffect, useMemo, useState} from "react"; -import { - BsChevronLeft, - BsClockFill, - BsEnvelopeFill, - BsFillPersonVcardFill, - BsPlus, - BsSquare, - BsStopwatchFill, - BsTag, - BsTrash, - BsX, -} from "react-icons/bs"; -import {toast, ToastContainer} from "react-toastify"; - -export const getServerSideProps = withIronSessionSsr(async ({req, params}) => { - const user = req.session.user as User; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } - - const {id} = params as {id: string}; - - const entityWithRoles = await getEntityWithRoles(id); - if (!entityWithRoles || (checkAccess(user, getTypesOfUser(["admin", "developer"])) && !user.entities.map((x) => x.id).includes(id))) { - return { - redirect: { - destination: "/entities", - permanent: false, - }, - }; - } - - const {entity, roles} = entityWithRoles; - - const linkedUsers = await getLinkedUsers(user.id, user.type); - const users = await getEntityUsers(id); - - return { - props: { - user, - entity: JSON.parse(JSON.stringify(entity)), - roles: JSON.parse(JSON.stringify(roles)), - users: JSON.parse(JSON.stringify(users)), - linkedUsers: JSON.parse(JSON.stringify(linkedUsers.users)), - }, - }; -}, sessionOptions); - -interface Props { - user: User; - entity: Entity; - roles: Role[]; - users: User[]; - linkedUsers: User[]; -} - -export default function Home({user, entity, roles, users, linkedUsers}: Props) { - const [isEditing, setIsEditing] = useState(false); - const [isLoading, setIsLoading] = useState(false); - - const router = useRouter(); - - const allowEntityEdit = useMemo(() => checkAccess(user, ["admin", "developer"]), [user]); - - const renameGroup = () => { - if (!allowEntityEdit) return; - - const name = prompt("Rename this entity:", entity.label); - if (!name) return; - - setIsLoading(true); - axios - .patch(`/api/entities/${entity.id}`, {name}) - .then(() => { - toast.success("The entity has been updated successfully!"); - router.replace(router.asPath); - }) - .catch((e) => { - console.error(e); - toast.error("Something went wrong!"); - }) - .finally(() => setIsLoading(false)); - }; - - const deleteGroup = () => { - if (!allowEntityEdit) return; - if (!confirm("Are you sure you want to delete this entity?")) return; - - setIsLoading(true); - - axios - .delete(`/api/entities/${entity.id}`) - .then(() => { - toast.success("This entity has been successfully deleted!"); - router.replace("/entities"); - }) - .catch((e) => { - console.error(e); - toast.error("Something went wrong!"); - }) - .finally(() => setIsLoading(false)); - }; - - const firstCard = () => ( - - - Create Role - - ); - - const renderCard = (role: Role) => { - const usersWithRole = users.filter((x) => x.entities.map((x) => x.role).includes(role.id)); - - return ( - - ); - }; - - return ( - <> - - {entity.label} | EnCoach - - - - - - {user && ( - -
-
-
-
- - - -

{entity.label}

-
-
- {allowEntityEdit && !isEditing && ( -
- - -
- )} -
- - Roles - - -
-
- )} - - ); -} diff --git a/src/pages/entities/index.tsx b/src/pages/entities/index.tsx index 00b12b47..226b1950 100644 --- a/src/pages/entities/index.tsx +++ b/src/pages/entities/index.tsx @@ -17,29 +17,16 @@ import CardList from "@/components/High/CardList"; import {getEntitiesWithRoles} from "@/utils/entities.be"; import {EntityWithRoles} from "@/interfaces/entity"; import Separator from "@/components/Low/Separator"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; type EntitiesWithCount = {entity: EntityWithRoles; users: User[]; count: number}; -export const getServerSideProps = withIronSessionSsr(async ({req}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") const entities = await getEntitiesWithRoles( checkAccess(user, getTypesOfUser(["admin", "developer"])) ? user.entities.map((x) => x.id) : undefined, @@ -99,18 +86,21 @@ export default function Home({user, entities}: Props) { - {user && ( - -
-
-

Entities

- -
+ +
+
+

Entities

+ +
- list={entities} searchFields={SEARCH_FIELDS} renderCard={renderCard} firstCard={firstCard} /> -
-
- )} + + list={entities} + searchFields={SEARCH_FIELDS} + renderCard={renderCard} + firstCard={["admin", "developer"].includes(user.type) ? firstCard : undefined} + /> +
+
); } diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx index 84cc0317..c8ecfba7 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -6,30 +6,17 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ExamPage from "./(exam)/ExamPage"; import Head from "next/head"; import {User} from "@/interfaces/user"; +import { redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") return { - props: {user: req.session.user}, + props: serialize({user}), }; }, sessionOptions); diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx index 983fdfeb..29034f93 100644 --- a/src/pages/exercises.tsx +++ b/src/pages/exercises.tsx @@ -6,30 +6,17 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ExamPage from "./(exam)/ExamPage"; import Head from "next/head"; import {User} from "@/interfaces/user"; +import { redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") return { - props: {user: req.session.user}, + props: serialize({user}), }; }, sessionOptions); diff --git a/src/pages/generation.tsx b/src/pages/generation.tsx index 4d23a995..1d52f5e0 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -23,30 +23,18 @@ import LevelGeneration from "./(generation)/LevelGeneration"; import SpeakingGeneration from "./(generation)/SpeakingGeneration"; import {checkAccess} from "@/utils/permissions"; import {User} from "@/interfaces/user"; +import { redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "mastercorporate", "developer", "corporate"])) + return redirect("/") return { - props: {user: req.session.user}, + props: serialize({user}), }; }, sessionOptions); diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 41caa641..abefdaad 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,25 +1,14 @@ import {User} from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; +import { redirect } from "@/utils"; +import { requestUser } from "@/utils/api"; import {withIronSessionSsr} from "iron-session/next"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user as User | undefined; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - return { - redirect: { - destination: `/dashboard/${user.type}`, - permanent: false, - }, - }; + return redirect(`/dashboard/${user.type}`) }, sessionOptions); export default function Dashboard() { diff --git a/src/pages/login.tsx b/src/pages/login.tsx index fe7d37ad..7510d2fc 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -15,30 +15,17 @@ import {useRouter} from "next/router"; import EmailVerification from "./(auth)/EmailVerification"; import {withIronSessionSsr} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g); -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; - - const envVariables: {[key: string]: string} = {}; - Object.keys(process.env) - .filter((x) => x.startsWith("NEXT_PUBLIC")) - .forEach((x: string) => { - envVariables[x] = process.env[x]!; - }); - - if (user) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (user) return redirect("/") return { - props: {user: null, envVariables}, + props: {user: null}, }; }, sessionOptions); diff --git a/src/pages/payment-record.tsx b/src/pages/payment-record.tsx index 8d99512c..20b5c081 100644 --- a/src/pages/payment-record.tsx +++ b/src/pages/payment-record.tsx @@ -31,30 +31,19 @@ import {CSVLink} from "react-csv"; import {Tab} from "@headlessui/react"; import {useListSearch} from "@/hooks/useListSearch"; import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") if (shouldRedirectHome(user) || checkAccess(user, getTypesOfUser(["admin", "developer", "agent", "corporate", "mastercorporate"]))) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; + return redirect("/") } return { - props: {user: req.session.user}, + props: {user}, }; }, sessionOptions); diff --git a/src/pages/payment.tsx b/src/pages/payment.tsx index 78f3b573..4b80f906 100644 --- a/src/pages/payment.tsx +++ b/src/pages/payment.tsx @@ -5,32 +5,19 @@ import {sessionOptions} from "@/lib/session"; import useUser from "@/hooks/useUser"; import PaymentDue from "./(status)/PaymentDue"; import {useRouter} from "next/router"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; - - const envVariables: {[key: string]: string} = {}; - Object.keys(process.env) - .filter((x) => x.startsWith("NEXT_PUBLIC")) - .forEach((x: string) => { - envVariables[x] = process.env[x]!; - }); - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") return { - props: {user: req.session.user, envVariables}, + props: {user}, }; }, sessionOptions); -export default function Home({envVariables}: {envVariables: {[key: string]: string}}) { +export default function Home() { const {user} = useUser({redirectTo: "/login"}); const router = useRouter(); diff --git a/src/pages/permissions/[id].tsx b/src/pages/permissions/[id].tsx index 6e66f109..e7ac515c 100644 --- a/src/pages/permissions/[id].tsx +++ b/src/pages/permissions/[id].tsx @@ -16,6 +16,8 @@ import axios from "axios"; import {toast, ToastContainer} from "react-toastify"; import {Type as UserType} from "@/interfaces/user"; import {getGroups} from "@/utils/groups.be"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; interface BasicUser { id: string; name: string; @@ -28,36 +30,13 @@ interface PermissionWithBasicUsers { users: BasicUser[]; } -export const getServerSideProps = withIronSessionSsr(async (context) => { - const {req, params} = context; - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } - - if (!params?.id) { - return { - redirect: { - destination: "/permissions", - permanent: false, - }, - }; - } + if (!params?.id) return redirect("/permissions") // Fetch data from external API const permission: Permission = await getPermissionDoc(params.id as string); @@ -100,7 +79,7 @@ export const getServerSideProps = withIronSessionSsr(async (context) => { id: params.id, users: usersData, }, - user: req.session.user, + user, users: filteredUsers, }, }; diff --git a/src/pages/permissions/index.tsx b/src/pages/permissions/index.tsx index 6e116600..4ca504b8 100644 --- a/src/pages/permissions/index.tsx +++ b/src/pages/permissions/index.tsx @@ -8,27 +8,14 @@ import {getPermissionDocs} from "@/utils/permissions.be"; import {User} from "@/interfaces/user"; import Layout from "@/components/High/Layout"; import PermissionList from "@/components/PermissionList"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(async ({req}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") // Fetch data from external API const permissions: Permission[] = await getPermissionDocs(); @@ -51,7 +38,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req}) => { const {users, ...rest} = p; return rest; }), - user: req.session.user, + user, }, }; }, sessionOptions); diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index d864670b..40e98fe9 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -29,7 +29,7 @@ import {BsCamera, BsQuestionCircleFill} from "react-icons/bs"; import {USER_TYPE_LABELS} from "@/resources/user"; import useGroups from "@/hooks/useGroups"; import useUsers from "@/hooks/useUsers"; -import {convertBase64} from "@/utils"; +import {convertBase64, redirect} from "@/utils"; import {Divider} from "primereact/divider"; import GenderInput from "@/components/High/GenderInput"; import EmploymentStatusInput from "@/components/High/EmploymentStatusInput"; @@ -46,27 +46,13 @@ import {checkAccess, getTypesOfUser} from "@/utils/permissions"; import {getParticipantGroups, getUserCorporate} from "@/utils/groups.be"; import {InferGetServerSidePropsType} from "next"; import {getUsers} from "@/utils/users.be"; +import { requestUser } from "@/utils/api"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") return { props: { diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 72bc156b..68c3edb4 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -25,7 +25,7 @@ import {Assignment} from "@/interfaces/results"; import {getEntitiesUsers, getUsers} from "@/utils/users.be"; import {getAssignments, getAssignmentsByAssigner, getEntitiesAssignments} from "@/utils/assignments.be"; import useGradingSystem from "@/hooks/useGrading"; -import { mapBy, serialize } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; import { getEntitiesWithRoles } from "@/utils/entities.be"; import { checkAccess } from "@/utils/permissions"; import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; @@ -34,27 +34,13 @@ import { Grading } from "@/interfaces"; import { EntityWithRoles } from "@/interfaces/entity"; import { useListSearch } from "@/hooks/useListSearch"; import CardList from "@/components/High/CardList"; +import { requestUser } from "@/utils/api"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") const entityIDs = mapBy(user.entities, 'id') diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 26bcdb14..9bdb0307 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -29,28 +29,16 @@ import { Permission, PermissionType } from "@/interfaces/permissions"; import { getUsers } from "@/utils/users.be"; import useUsers from "@/hooks/useUsers"; import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be"; -import { mapBy, serialize } from "@/utils"; +import { mapBy, serialize, redirect } from "@/utils"; import { EntityWithRoles } from "@/interfaces/entity"; +import { requestUser } from "@/utils/api"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { - const user = req.session.user as User; - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user) || !checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) + return redirect("/") const permissions = await getUserPermissions(user.id); const entities = await getEntitiesWithRoles(mapBy(user.entities, 'id')) || [] diff --git a/src/pages/stats.tsx b/src/pages/stats.tsx index a4f2e3d6..36e5a944 100644 --- a/src/pages/stats.tsx +++ b/src/pages/stats.tsx @@ -25,38 +25,24 @@ import moment from "moment"; import {Group, Stat, User} from "@/interfaces/user"; import {Divider} from "primereact/divider"; import Badge from "@/components/Low/Badge"; -import { mapBy, serialize } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; import { getEntitiesWithRoles } from "@/utils/entities.be"; import { checkAccess } from "@/utils/permissions"; import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { EntityWithRoles } from "@/interfaces/entity"; import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; import Select from "@/components/Low/Select"; +import { requestUser } from "@/utils/api"; ChartJS.register(LinearScale, CategoryScale, PointElement, LineElement, LineController, Legend, Tooltip); const COLORS = ["#1EB3FF", "#FF790A", "#3D9F11", "#EF5DA8", "#414288"]; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user as User; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") const entityIDs = mapBy(user.entities, 'id') const entities = await getEntitiesWithRoles(checkAccess(user, ["admin", "developer"]) ? undefined : entityIDs) diff --git a/src/pages/tickets.tsx b/src/pages/tickets.tsx index fb6e2b47..21f2a6cc 100644 --- a/src/pages/tickets.tsx +++ b/src/pages/tickets.tsx @@ -16,32 +16,20 @@ import Head from "next/head"; import {useEffect, useState} from "react"; import {BsArrowDown, BsArrowUp} from "react-icons/bs"; import {ToastContainer} from "react-toastify"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; const columnHelper = createColumnHelper(); -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user) || !["admin", "developer", "agent"].includes(user.type)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user) || !["admin", "developer", "agent"].includes(user.type)) + return redirect("/") return { - props: {user: req.session.user}, + props: {user}, }; }, sessionOptions); diff --git a/src/pages/training/[id]/index.tsx b/src/pages/training/[id]/index.tsx index 69aab218..d7502277 100644 --- a/src/pages/training/[id]/index.tsx +++ b/src/pages/training/[id]/index.tsx @@ -31,30 +31,17 @@ import {uniqBy} from "lodash"; import {getExamById} from "@/utils/exams"; import {convertToUserSolutions} from "@/utils/stats"; import {sortByModule} from "@/utils/moduleUtils"; +import { requestUser } from "@/utils/api"; +import { redirect, serialize } from "@/utils"; -export const getServerSideProps = withIronSessionSsr(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) redirect("/") return { - props: {user: req.session.user}, + props: serialize({user}), }; }, sessionOptions); diff --git a/src/pages/training/index.tsx b/src/pages/training/index.tsx index be1af30b..c59765e3 100644 --- a/src/pages/training/index.tsx +++ b/src/pages/training/index.tsx @@ -20,33 +20,19 @@ import TrainingScore from "@/training/TrainingScore"; import ModuleBadge from "@/components/ModuleBadge"; import RecordFilter from "@/components/Medium/RecordFilter"; import useFilterRecordsByUser from "@/hooks/useFilterRecordsByUser"; -import { mapBy, serialize } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; import { getEntitiesWithRoles } from "@/utils/entities.be"; import { getAssignmentsByAssignee } from "@/utils/assignments.be"; import { getEntitiesUsers } from "@/utils/users.be"; import { EntityWithRoles } from "@/interfaces/entity"; import { Assignment } from "@/interfaces/results"; +import { requestUser } from "@/utils/api"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { - const user = req.session.user as User; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") const entityIDs = mapBy(user.entities, 'id') const entities = await getEntitiesWithRoles(entityIDs) diff --git a/src/pages/users/index.tsx b/src/pages/users/index.tsx index a70d9f6c..bbf73b40 100644 --- a/src/pages/users/index.tsx +++ b/src/pages/users/index.tsx @@ -4,7 +4,9 @@ import useUsers from "@/hooks/useUsers"; import { Type, User } from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; import useFilterStore from "@/stores/listFilterStore"; -import { serialize } from "@/utils"; +import { redirect, serialize } from "@/utils"; +import { requestUser } from "@/utils/api"; +import { shouldRedirectHome } from "@/utils/navigation.disabled"; import {withIronSessionSsr} from "iron-session/next"; import Head from "next/head"; import {useRouter} from "next/router"; @@ -13,22 +15,16 @@ import {BsArrowLeft, BsChevronLeft} from "react-icons/bs"; import {ToastContainer} from "react-toastify"; import UserList from "../(admin)/Lists/UserList"; -export const getServerSideProps = withIronSessionSsr(({req, res, query}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + if (shouldRedirectHome(user)) return redirect("/") const {type} = query as {type?: Type} return { - props: serialize({user: req.session.user, type}), + props: serialize({user, type}), }; }, sessionOptions); diff --git a/src/pages/users/performance.tsx b/src/pages/users/performance.tsx index 6d369bc9..0ccd1773 100644 --- a/src/pages/users/performance.tsx +++ b/src/pages/users/performance.tsx @@ -18,18 +18,12 @@ import StudentPerformanceList from "../(admin)/Lists/StudentPerformanceList"; import Head from "next/head"; import { ToastContainer } from "react-toastify"; import Layout from "@/components/High/Layout"; +import { requestUser } from "@/utils/api"; +import { redirect } from "@/utils"; export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { - const user = req.session.user as User; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + const user = await requestUser(req, res) + if (!user) return redirect("/login") const entityIDs = mapBy(user.entities, 'id') diff --git a/src/pages/v1/index.tsx b/src/pages/v1/index.tsx index e987ae68..514b4091 100644 --- a/src/pages/v1/index.tsx +++ b/src/pages/v1/index.tsx @@ -35,23 +35,17 @@ import { USER_TYPE_LABELS } from "@/resources/user"; import { checkAccess, getTypesOfUser } from "@/utils/permissions"; import { getUserCorporate } from "@/utils/groups.be"; import { getUsers } from "@/utils/users.be"; +import { requestUser } from "@/utils/api"; +import { redirect, serialize } from "@/utils"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { - const user = req.session.user as User | undefined; - - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } + const user = await requestUser(req, res) + if (!user) return redirect("/login") const linkedCorporate = (await getUserCorporate(user.id)) || null; return { - props: { user, linkedCorporate }, + props: serialize({ user, linkedCorporate }), }; }, sessionOptions); diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts new file mode 100644 index 00000000..966d0962 --- /dev/null +++ b/src/resources/entityPermissions.ts @@ -0,0 +1,39 @@ +export type RolePermission = + "view_students" | + "view_teachers" | + "view_corporates" | + "view_mastercorporates" | + "edit_students" | + "edit_teachers" | + "edit_corporates" | + "edit_mastercorporates" | + "delete_students" | + "delete_teachers" | + "delete_corporates" | + "delete_mastercorporates" | + "generate_reading" | + "delete_reading" | + "generate_listening" | + "delete_listening" | + "generate_writing" | + "delete_writing" | + "generate_speaking" | + "delete_speaking" | + "generate_level" | + "delete_level" | + "view_classrooms" | + "create_classroom" | + "rename_classrooms" | + "add_to_classroom" | + "remove_from_classroom" | + "delete_classroom" | + "view_entities" | "rename_entity" | + "add_to_entity" | + "remove_from_entity" | + "delete_entity" | + "view_entity_roles" | + "create_entity_role" | + "rename_entity_role" | + "edit_role_permissions" | + "assign_to_role" | + "delete_entity_role"; diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 00000000..ef805baa --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,16 @@ +import { User } from "@/interfaces/user"; +import { IncomingMessage, ServerResponse } from "http"; +import { IronSession } from "iron-session"; +import { NextApiRequest, NextApiResponse } from "next"; +import { getUser } from "./users.be"; + + +export async function requestUser(req: NextApiRequest | IncomingMessage, res: NextApiResponse | ServerResponse): Promise { + if (!req.session.user) return undefined + const user = await getUser(req.session.user.id) + + req.session.user = user + req.session.save() + + return user +} diff --git a/src/utils/entities.be.ts b/src/utils/entities.be.ts index 74604011..5f846c59 100644 --- a/src/utils/entities.be.ts +++ b/src/utils/entities.be.ts @@ -1,15 +1,16 @@ import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; import client from "@/lib/mongodb"; +import { v4 } from "uuid"; import {getRolesByEntities, getRolesByEntity} from "./roles.be"; const db = client.db(process.env.MONGODB_DB); -export const getEntityWithRoles = async (id: string): Promise<{entity: Entity; roles: Role[]} | undefined> => { +export const getEntityWithRoles = async (id: string): Promise => { const entity = await getEntity(id); if (!entity) return undefined; const roles = await getRolesByEntity(id); - return {entity, roles}; + return {...entity, roles}; }; export const getEntity = async (id: string) => { @@ -33,3 +34,28 @@ export const getEntities = async (ids?: string[]) => { .find(ids ? {id: {$in: ids}} : {}) .toArray(); }; + +export const createEntity = async (entity: Entity) => { + await db.collection("entities").insertOne(entity) + await db.collection("roles").insertOne({ + id: v4(), + label: "Default", + permissions: [], + entityID: entity.id + }) +} + +export const deleteEntity = async (entity: Entity) => { + await db.collection("entities").deleteOne({id: entity.id}) + await db.collection("roles").deleteMany({entityID: entity.id}) + + await db.collection("users").updateMany( + {"entities.id": entity.id}, + { + // @ts-expect-error + $pull: { + entities: {id: entity.id}, + }, + }, + ); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 50adc521..fcd8f64c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -44,7 +44,15 @@ export const convertBase64 = (file: File) => { }); }; +export const redirect = (destination: string) => ({ + redirect: { + destination: destination, + permanent: false, + }, +}) + export const mapBy = (obj: T[] | undefined, key: K) => (obj || []).map((i) => i[key] as T[K]); export const filterBy = (obj: T[], key: keyof T, value: any) => obj.filter((i) => i[key] === value); +export const findBy = (obj: T[], key: keyof T, value: any) => obj.find((i) => i[key] === value); export const serialize = (obj: T): T => JSON.parse(JSON.stringify(obj)); diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index da3d2d59..6597c827 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -1,6 +1,9 @@ +import { EntityWithRoles, Role } from "@/interfaces/entity"; import {PermissionType} from "@/interfaces/permissions"; import {User, Type, userTypes} from "@/interfaces/user"; +import { RolePermission } from "@/resources/entityPermissions"; import axios from "axios"; +import { findBy, mapBy } from "."; export function checkAccess(user: User, types: Type[], permissions?: PermissionType[], permission?: PermissionType) { if (!user) { @@ -8,7 +11,7 @@ export function checkAccess(user: User, types: Type[], permissions?: PermissionT } // if(user.type === '') { - if (!user.type) { + if (!user?.type) { return false; } @@ -32,6 +35,25 @@ export function checkAccess(user: User, types: Type[], permissions?: PermissionT return true; } +export function findAllowedEntities(user: User, entities: EntityWithRoles[], permission: RolePermission) { + if (["admin", "developer"].includes(user?.type)) return entities + + const allowedEntities = entities.filter((e) => doesEntityAllow(user, e, permission)) + return allowedEntities +} + +export function doesEntityAllow(user: User, entity: EntityWithRoles, permission: RolePermission) { + if (["admin", "developer"].includes(user?.type)) return true + + const userEntity = findBy(user.entities, 'id', entity.id) + if (!userEntity) return false + + const role = findBy(entity.roles, 'id', userEntity.role) + if (!role) return false + + return role.permissions.includes(permission) +} + export function getTypesOfUser(types: Type[]) { // basicly generate a list of all types except the excluded ones return userTypes.filter((userType) => { diff --git a/src/utils/roles.be.ts b/src/utils/roles.be.ts index d0114b74..0dea6694 100644 --- a/src/utils/roles.be.ts +++ b/src/utils/roles.be.ts @@ -11,4 +11,24 @@ export const getRolesByEntities = async (entityIDs: string[]) => export const getRolesByEntity = async (entityID: string) => await db.collection("roles").find({entityID}).toArray(); +export const getRoles = async (ids?: string[]) => await db.collection("roles").find(!ids ? {} : {id: {$in: ids}}).toArray(); export const getRole = async (id: string) => (await db.collection("roles").findOne({id})) ?? undefined; + +export const createRole = async (role: Role) => await db.collection("roles").insertOne(role) +export const deleteRole = async (id: string) => await db.collection("roles").deleteOne({id}) + +export const transferRole = async (previousRole: string, newRole: string) => + await db.collection("users") + .updateMany( + { "entities.role": previousRole }, + { $set: { 'entities.$[elem].role': newRole } }, + { arrayFilters: [{ 'elem.role': previousRole }] } + ); + +export const assignRoleToUsers = async (users: string[], entity: string, newRole: string) => + await db.collection("users") + .updateMany( + { id: { $in: users } }, + { $set: { 'entities.$[elem].role': newRole } }, + { arrayFilters: [{ 'elem.id': entity }] } + ); diff --git a/src/utils/users.be.ts b/src/utils/users.be.ts index a6b180e9..8685cd8d 100644 --- a/src/utils/users.be.ts +++ b/src/utils/users.be.ts @@ -46,16 +46,16 @@ export async function getSpecificUsers(ids: string[]) { .toArray(); } -export async function getEntityUsers(id: string, limit?: number) { +export async function getEntityUsers(id: string, limit?: number, filter?: object) { return await db .collection("users") - .find({ "entities.id": id }) + .find({ "entities.id": id, ...(filter || {}) }) .limit(limit || 0) .toArray(); } -export async function countEntityUsers(id: string) { - return await db.collection("users").countDocuments({ "entities.id": id }); +export async function countEntityUsers(id: string, filter?: object) { + return await db.collection("users").countDocuments({ "entities.id": id, ...(filter || {}) }); } export async function getEntitiesUsers(ids: string[], filter?: object, limit?: number) { From a53ee79c0a729e1221d4af8a92aa87a1e19bddad Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 11 Oct 2024 10:47:35 +0100 Subject: [PATCH 09/35] Continued creating the permission system --- src/pages/assignments/[id].tsx | 63 ++++++++++++------------ src/pages/assignments/creator/[id].tsx | 40 +++++---------- src/pages/assignments/creator/index.tsx | 15 +++--- src/pages/assignments/index.tsx | 38 +++++++++++--- src/pages/classrooms/[id].tsx | 62 +++++++++++++---------- src/pages/classrooms/create.tsx | 12 +++-- src/pages/classrooms/index.tsx | 25 ++++++++-- src/pages/entities/[id]/index.tsx | 2 +- src/pages/entities/[id]/roles/[role].tsx | 28 +++++++++++ src/pages/entities/index.tsx | 14 +++--- src/resources/entityPermissions.ts | 8 ++- src/utils/entities.be.ts | 10 +++- 12 files changed, 196 insertions(+), 121 deletions(-) diff --git a/src/pages/assignments/[id].tsx b/src/pages/assignments/[id].tsx index e95d6cd6..9cafc5ba 100644 --- a/src/pages/assignments/[id].tsx +++ b/src/pages/assignments/[id].tsx @@ -20,12 +20,12 @@ import {BsBook, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen} fr import {toast} from "react-toastify"; import {futureAssignmentFilter} from "@/utils/assignments"; import {withIronSessionSsr} from "iron-session/next"; -import {checkAccess} from "@/utils/permissions"; +import {checkAccess, doesEntityAllow} from "@/utils/permissions"; import {mapBy, redirect, serialize} from "@/utils"; import {getAssignment} from "@/utils/assignments.be"; -import {getEntitiesUsers, getUsers} from "@/utils/users.be"; -import {getEntitiesWithRoles} from "@/utils/entities.be"; -import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; +import {getEntitiesUsers, getEntityUsers, getUsers} from "@/utils/users.be"; +import {getEntitiesWithRoles, getEntityWithRoles} from "@/utils/entities.be"; +import {getGroups, getGroupsByEntities, getGroupsByEntity} from "@/utils/groups.be"; import {sessionOptions} from "@/lib/session"; import {EntityWithRoles} from "@/interfaces/entity"; import Head from "next/head"; @@ -33,6 +33,7 @@ import Layout from "@/components/High/Layout"; import Separator from "@/components/Low/Separator"; import Link from "next/link"; import { requestUser } from "@/utils/api"; +import { useEntityPermission } from "@/hooks/useEntityPermissions"; export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { const user = await requestUser(req, res) @@ -44,33 +45,31 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); const {id} = params as {id: string}; - const entityIDS = mapBy(user.entities, "id") || []; const assignment = await getAssignment(id); - if (!assignment) - return { - redirect: { - destination: "/assignments", - permanent: false, - }, - }; + if (!assignment) return redirect("/assignments") - const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(entityIDS)); - const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); - const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(entityIDS)); + const entity = await getEntityWithRoles(assignment.entity || "") + if (!entity) return redirect("/assignments") - return {props: serialize({user, users, entities, assignment, groups})}; + if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") + + const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); + + return {props: serialize({user, users, entity, assignment})}; }, sessionOptions); interface Props { user: User; users: User[]; assignment: Assignment; - groups: Group[]; - entities: EntityWithRoles[]; + entity: EntityWithRoles } -export default function AssignmentView({user, users, entities, groups, assignment}: Props) { +export default function AssignmentView({user, users, entity, assignment}: Props) { + const canDeleteAssignment = useEntityPermission(user, entity, 'delete_assignment') + const canStartAssignment = useEntityPermission(user, entity, 'start_assignment') + const setExams = useExamStore((state) => state.setExams); const setShowSolutions = useExamStore((state) => state.setShowSolutions); const setUserSolutions = useExamStore((state) => state.setUserSolutions); @@ -79,6 +78,7 @@ export default function AssignmentView({user, users, entities, groups, assignmen const router = useRouter(); const deleteAssignment = async () => { + if (!canDeleteAssignment) return if (!confirm("Are you sure you want to delete this assignment?")) return; axios @@ -89,18 +89,19 @@ export default function AssignmentView({user, users, entities, groups, assignmen }; const startAssignment = () => { - if (assignment) { - axios - .post(`/api/assignments/${assignment.id}/start`) - .then(() => { - toast.success(`The assignment "${assignment.name}" has been started successfully!`); - router.replace(router.asPath); - }) - .catch((e) => { - console.log(e); - toast.error("Something went wrong, please try again later!"); - }); - } + if (!canStartAssignment) return + if (!confirm("Are you sure you want to start this assignment?")) return; + + axios + .post(`/api/assignments/${assignment.id}/start`) + .then(() => { + toast.success(`The assignment "${assignment.name}" has been started successfully!`); + router.replace(router.asPath); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }); }; const formatTimestamp = (timestamp: string) => { diff --git a/src/pages/assignments/creator/[id].tsx b/src/pages/assignments/creator/[id].tsx index 32f60d8b..2bf0b20d 100644 --- a/src/pages/assignments/creator/[id].tsx +++ b/src/pages/assignments/creator/[id].tsx @@ -19,7 +19,7 @@ import { requestUser } from "@/utils/api"; import {getAssignment} from "@/utils/assignments.be"; import {getEntitiesWithRoles} from "@/utils/entities.be"; import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; -import {checkAccess} from "@/utils/permissions"; +import {checkAccess, doesEntityAllow, findAllowedEntities} from "@/utils/permissions"; import {calculateAverageLevel} from "@/utils/score"; import {getEntitiesUsers, getUsers} from "@/utils/users.be"; import axios from "axios"; @@ -40,42 +40,26 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) const user = await requestUser(req, res) if (!user) return redirect("/login") - if (!user) { - return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) - return { - redirect: { - destination: "/dashboard", - permanent: false, - }, - }; - res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); const {id} = params as {id: string}; const entityIDS = mapBy(user.entities, "id") || []; const assignment = await getAssignment(id); - if (!assignment) - return { - redirect: { - destination: "/assignments", - permanent: false, - }, - }; + if (!assignment) return redirect("/assignments") - const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(entityIDS)); const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); - const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(entityIDS)); + const entity = entities.find((e) => assignment.entity === assignment.entity) - return {props: serialize({user, users, entities, assignment, groups})}; + if (!entity) return redirect("/assignments") + if (!doesEntityAllow(user, entity, 'edit_assignment')) return redirect("/assignments") + + const allowedEntities = findAllowedEntities(user, entities, 'edit_assignment') + + const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); + const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); + + return {props: serialize({user, users, entities: allowedEntities, assignment, groups})}; }, sessionOptions); interface Props { diff --git a/src/pages/assignments/creator/index.tsx b/src/pages/assignments/creator/index.tsx index a9eace89..edf0a3fc 100644 --- a/src/pages/assignments/creator/index.tsx +++ b/src/pages/assignments/creator/index.tsx @@ -18,7 +18,7 @@ import {mapBy, redirect, serialize} from "@/utils"; import { requestUser } from "@/utils/api"; import {getEntitiesWithRoles} from "@/utils/entities.be"; import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; -import {checkAccess} from "@/utils/permissions"; +import {checkAccess, findAllowedEntities} from "@/utils/permissions"; import {calculateAverageLevel} from "@/utils/score"; import {getEntitiesUsers, getUsers} from "@/utils/users.be"; import axios from "axios"; @@ -39,14 +39,14 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { const user = await requestUser(req, res) if (!user) return redirect("/login") - if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) - return redirect("/") - const entityIDS = mapBy(user.entities, "id") || []; - - const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(entityIDS)); const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); - const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(entityIDS)); + + const allowedEntities = findAllowedEntities(user, entities, 'create_assignment') + if (allowedEntities.length === 0) return redirect("/assignments") + + const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); + const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); return {props: serialize({user, users, entities, groups})}; }, sessionOptions); @@ -535,6 +535,7 @@ export default function AssignmentsPage({user, users, groups, entities}: Props) !name || !startDate || !endDate || + !entity || assignees.length === 0 || (!useRandomExams && examIDs.length < selectedModules.length) } diff --git a/src/pages/assignments/index.tsx b/src/pages/assignments/index.tsx index 962d5535..6a33c1ca 100644 --- a/src/pages/assignments/index.tsx +++ b/src/pages/assignments/index.tsx @@ -2,8 +2,10 @@ import Layout from "@/components/High/Layout"; import Separator from "@/components/Low/Separator"; import AssignmentCard from "@/dashboards/AssignmentCard"; import AssignmentView from "@/dashboards/AssignmentView"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; import {useListSearch} from "@/hooks/useListSearch"; import usePagination from "@/hooks/usePagination"; +import { EntityWithRoles } from "@/interfaces/entity"; import {Assignment} from "@/interfaces/results"; import {CorporateUser, Group, User} from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; @@ -20,7 +22,7 @@ import { import {getAssignments, getEntitiesAssignments} from "@/utils/assignments.be"; import {getEntitiesWithRoles} from "@/utils/entities.be"; import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; -import {checkAccess} from "@/utils/permissions"; +import {checkAccess, findAllowedEntities} from "@/utils/permissions"; import {getEntitiesUsers, getUsers} from "@/utils/users.be"; import {withIronSessionSsr} from "iron-session/next"; import {groupBy} from "lodash"; @@ -38,11 +40,18 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { return redirect("/") const entityIDS = mapBy(user.entities, "id") || []; - - const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(entityIDS)); const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); - const assignments = await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(entityIDS)); - const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(entityIDS)); + + const allowedEntities = findAllowedEntities(user, entities, "view_assignments") + + const users = + await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); + + const assignments = + await (checkAccess(user, ["developer", "admin"]) ? getAssignments() : getEntitiesAssignments(mapBy(allowedEntities, 'id'))); + + const groups = + await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); return {props: serialize({user, users, entities, assignments, groups})}; }, sessionOptions); @@ -52,12 +61,16 @@ const SEARCH_FIELDS = [["name"]]; interface Props { assignments: Assignment[]; corporateAssignments?: ({corporate?: CorporateUser} & Assignment)[]; + entities: EntityWithRoles[] groups: Group[]; user: User; users: User[]; } -export default function AssignmentsPage({assignments, corporateAssignments, user, users, groups}: Props) { +export default function AssignmentsPage({assignments, corporateAssignments, entities, user, users, groups}: Props) { + const entitiesAllowCreate = useAllowedEntities(user, entities, 'create_assignment') + const entitiesAllowEdit = useAllowedEntities(user, entities, 'edit_assignment') + const activeAssignments = useMemo(() => assignments.filter(activeAssignmentFilter), [assignments]); const plannedAssignments = useMemo(() => assignments.filter(futureAssignmentFilter), [assignments]); const pastAssignments = useMemo(() => assignments.filter(pastAssignmentFilter), [assignments]); @@ -139,13 +152,22 @@ export default function AssignmentsPage({assignments, corporateAssignments, user
0 ? "/assignments/creator" : ""} className="w-[250px] h-[200px] flex flex-col gap-2 items-center justify-center bg-white hover:bg-mti-purple-ultralight text-mti-purple-light hover:text-mti-purple-dark border border-mti-gray-platinum hover:drop-shadow p-4 cursor-pointer rounded-xl transition ease-in-out duration-300"> New Assignment {plannedItems.map((a) => ( - router.push(`/assignments/creator/${a.id}`)} key={a.id} /> + 0 + ? () => router.push(`/assignments/creator/${a.id}`) + : undefined + } + key={a.id} + /> ))}
diff --git a/src/pages/classrooms/[id].tsx b/src/pages/classrooms/[id].tsx index a2c3d8ec..774c411d 100644 --- a/src/pages/classrooms/[id].tsx +++ b/src/pages/classrooms/[id].tsx @@ -1,18 +1,21 @@ /* eslint-disable @next/next/no-img-element */ import Layout from "@/components/High/Layout"; import Tooltip from "@/components/Low/Tooltip"; +import { useEntityPermission } from "@/hooks/useEntityPermissions"; import {useListSearch} from "@/hooks/useListSearch"; import usePagination from "@/hooks/usePagination"; +import { EntityWithRoles } from "@/interfaces/entity"; import {GroupWithUsers, User} from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; import {USER_TYPE_LABELS} from "@/resources/user"; -import { redirect } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; +import { getEntitiesWithRoles, getEntityWithRoles } from "@/utils/entities.be"; import {convertToUsers, getGroup} from "@/utils/groups.be"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import {checkAccess, doesEntityAllow, findAllowedEntities, getTypesOfUser} from "@/utils/permissions"; import {getUserName} from "@/utils/users"; -import {getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; +import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; import axios from "axios"; import clsx from "clsx"; import {withIronSessionSsr} from "iron-session/next"; @@ -34,21 +37,20 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) const {id} = params as {id: string}; const group = await getGroup(id); - if (!group || (checkAccess(user, getTypesOfUser(["admin", "developer"])) && group.admin !== user.id && !group.participants.includes(user.id))) { - return { - redirect: { - destination: "/groups", - permanent: false, - }, - }; - } + if (!group || !group.entity) return redirect("/classrooms") - const linkedUsers = await getLinkedUsers(user.id, user.type); + const entity = await getEntityWithRoles(group.entity) + if (!entity) return redirect("/classrooms") + + const canView = doesEntityAllow(user, entity, "view_classrooms") + if (!canView) return redirect("/") + + const linkedUsers = await getEntityUsers(entity.id) const users = await getSpecificUsers([...group.participants, group.admin]); const groupWithUser = convertToUsers(group, users); return { - props: {user, group: JSON.parse(JSON.stringify(groupWithUser)), users: JSON.parse(JSON.stringify(linkedUsers.users))}, + props: serialize({user, group: groupWithUser, users: linkedUsers, entity}), }; }, sessionOptions); @@ -56,13 +58,19 @@ interface Props { user: User; group: GroupWithUsers; users: User[]; + entity: EntityWithRoles } -export default function Home({user, group, users}: Props) { +export default function Home({user, group, users, entity}: Props) { const [isAdding, setIsAdding] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); + const canAddParticipants = useEntityPermission(user, entity, "add_to_classroom") + const canRemoveParticipants = useEntityPermission(user, entity, "remove_from_classroom") + const canRenameClassroom = useEntityPermission(user, entity, "rename_classrooms") + const canDeleteClassroom = useEntityPermission(user, entity, "delete_classroom") + const nonParticipantUsers = useMemo( () => users.filter((x) => ![...group.participants.map((g) => g.id), group.admin.id, user.id].includes(x.id)), [users, group.participants, group.admin.id, user.id], @@ -82,7 +90,7 @@ export default function Home({user, group, users}: Props) { const removeParticipants = () => { if (selectedUsers.length === 0) return; - if (!allowGroupEdit) return; + if (!canRemoveParticipants) return; if (!confirm(`Are you sure you want to remove ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} from this group?`)) return; @@ -103,7 +111,7 @@ export default function Home({user, group, users}: Props) { const addParticipants = () => { if (selectedUsers.length === 0) return; - if (!allowGroupEdit || !isAdding) return; + if (!canAddParticipants || !isAdding) return; if (!confirm(`Are you sure you want to add ${selectedUsers.length} participant${selectedUsers.length === 1 ? "" : "s"} to this group?`)) return; @@ -123,7 +131,7 @@ export default function Home({user, group, users}: Props) { }; const renameGroup = () => { - if (!allowGroupEdit) return; + if (!canRenameClassroom) return; const name = prompt("Rename this group:", group.name); if (!name) return; @@ -143,7 +151,7 @@ export default function Home({user, group, users}: Props) { }; const deleteGroup = () => { - if (!allowGroupEdit) return; + if (!canDeleteClassroom) return; if (!confirm("Are you sure you want to delete this group?")) return; setIsLoading(true); @@ -192,18 +200,18 @@ export default function Home({user, group, users}: Props) { {getUserName(group.admin)} - {allowGroupEdit && !isAdding && ( + {!isAdding && (
)} - {allowGroupEdit && isAdding && ( + {isAdding && (
- list={groups} searchFields={SEARCH_FIELDS} renderCard={renderCard} firstCard={firstCard} /> + + list={groups} + searchFields={SEARCH_FIELDS} + renderCard={renderCard} + firstCard={entitiesAllowCreate.length === 0 ? undefined : firstCard} + /> diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index ba70d5fa..e331b6b1 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -40,7 +40,7 @@ import { BsTrash, BsX, } from "react-icons/bs"; -import {toast, ToastContainer} from "react-toastify"; +import {toast} from "react-toastify"; export const getServerSideProps = withIronSessionSsr(async ({req, params}) => { const user = req.session.user as User; diff --git a/src/pages/entities/[id]/roles/[role].tsx b/src/pages/entities/[id]/roles/[role].tsx index 5a15dd68..b01e28a8 100644 --- a/src/pages/entities/[id]/roles/[role].tsx +++ b/src/pages/entities/[id]/roles/[role].tsx @@ -80,6 +80,14 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [ {label: "Delete Entity Role", key: "delete_entity_role"}, ] +const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [ + {label: "View Assignments", key: "view_assignments"}, + {label: "Create Assignments", key: "create_assignment"}, + {label: "Start Assignments", key: "start_assignment"}, + {label: "Delete Assignments", key: "delete_assignment"}, + {label: "Archive Assignments", key: "archive_assignment"}, +] + export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { const user = await requestUser(req, res) if (!user) return redirect("/login") @@ -318,6 +326,26 @@ export default function Role({user, entity, role, userCount}: Props) { )) } + +
+
+ Assignment Management + permissions.includes(k))} + onChange={() => mapBy(ASSIGNMENT_MANAGEMENT, 'key').forEach(togglePermissions)} + > + Select all + +
+ +
+ {ASSIGNMENT_MANAGEMENT.map(({label, key}) => ( + togglePermissions(key)}> + { label } + + )) } +
+
diff --git a/src/pages/entities/index.tsx b/src/pages/entities/index.tsx index 226b1950..96b26998 100644 --- a/src/pages/entities/index.tsx +++ b/src/pages/entities/index.tsx @@ -9,7 +9,7 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {getUserName} from "@/utils/users"; import {convertToUsers, getGroupsForUser} from "@/utils/groups.be"; import {countEntityUsers, getEntityUsers, getSpecificUsers} from "@/utils/users.be"; -import {checkAccess, getTypesOfUser} from "@/utils/permissions"; +import {checkAccess, findAllowedEntities, getTypesOfUser} from "@/utils/permissions"; import Link from "next/link"; import {uniq} from "lodash"; import {BsPlus} from "react-icons/bs"; @@ -18,7 +18,7 @@ import {getEntitiesWithRoles} from "@/utils/entities.be"; import {EntityWithRoles} from "@/interfaces/entity"; import Separator from "@/components/Low/Separator"; import { requestUser } from "@/utils/api"; -import { redirect } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; type EntitiesWithCount = {entity: EntityWithRoles; users: User[]; count: number}; @@ -28,16 +28,16 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { if (shouldRedirectHome(user)) return redirect("/") - const entities = await getEntitiesWithRoles( - checkAccess(user, getTypesOfUser(["admin", "developer"])) ? user.entities.map((x) => x.id) : undefined, - ); + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(entityIDs); + const allowedEntities = findAllowedEntities(user, entities, 'view_entities') const entitiesWithCount = await Promise.all( - entities.map(async (e) => ({entity: e, count: await countEntityUsers(e.id), users: await getEntityUsers(e.id, 5)})), + allowedEntities.map(async (e) => ({entity: e, count: await countEntityUsers(e.id), users: await getEntityUsers(e.id, 5)})), ); return { - props: {user, entities: JSON.parse(JSON.stringify(entitiesWithCount))}, + props: serialize({user, entities: entitiesWithCount}), }; }, sessionOptions); diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts index 966d0962..d785aacb 100644 --- a/src/resources/entityPermissions.ts +++ b/src/resources/entityPermissions.ts @@ -36,4 +36,10 @@ export type RolePermission = "rename_entity_role" | "edit_role_permissions" | "assign_to_role" | - "delete_entity_role"; + "delete_entity_role" | + "view_assignments" | + "create_assignment" | + "edit_assignment" | + "delete_assignment" | + "start_assignment" | + "archive_assignment"; diff --git a/src/utils/entities.be.ts b/src/utils/entities.be.ts index 5f846c59..f6162119 100644 --- a/src/utils/entities.be.ts +++ b/src/utils/entities.be.ts @@ -1,9 +1,17 @@ import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; import client from "@/lib/mongodb"; +import { RolePermission } from "@/resources/entityPermissions"; import { v4 } from "uuid"; import {getRolesByEntities, getRolesByEntity} from "./roles.be"; const db = client.db(process.env.MONGODB_DB); +const DEFAULT_PERMISSIONS: RolePermission[] = [ + "view_students", + "view_teachers", + "view_assignments", + "view_classrooms", + "view_entity_roles" +] export const getEntityWithRoles = async (id: string): Promise => { const entity = await getEntity(id); @@ -40,7 +48,7 @@ export const createEntity = async (entity: Entity) => { await db.collection("roles").insertOne({ id: v4(), label: "Default", - permissions: [], + permissions: DEFAULT_PERMISSIONS, entityID: entity.id }) } From 1fb7343aa73132e6d9cd216f89a2ead5e9541904 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Wed, 16 Oct 2024 10:23:59 +0100 Subject: [PATCH 10/35] Solved some issues with the redirect as well as adding a way to create entities --- src/pages/api/entities/[id]/index.ts | 8 +- src/pages/api/entities/index.ts | 12 +- src/pages/dashboard/admin.tsx | 15 +- src/pages/dashboard/corporate.tsx | 7 +- src/pages/dashboard/developer.tsx | 9 +- src/pages/dashboard/index.tsx | 2 +- src/pages/dashboard/mastercorporate.tsx | 15 +- src/pages/entities/create.tsx | 176 ++++++++++++++++++++++++ src/resources/entityPermissions.ts | 56 ++++++++ src/utils/entities.be.ts | 49 +++++-- 10 files changed, 326 insertions(+), 23 deletions(-) create mode 100644 src/pages/entities/create.tsx diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts index b2e7179b..efe76896 100644 --- a/src/pages/api/entities/[id]/index.ts +++ b/src/pages/api/entities/[id]/index.ts @@ -14,8 +14,9 @@ const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === "get") return await get(req, res); + if (req.method === "GET") return await get(req, res); if (req.method === "PATCH") return await patch(req, res); + if (req.method === "DELETE") return await del(req, res); } async function get(req: NextApiRequest, res: NextApiResponse) { @@ -35,9 +36,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) { const { id } = req.query as { id: string }; const entity = await getEntityWithRoles(id) - if (!entity) return res.status(404).json({ok: false}) + if (!entity) return res.status(404).json({ ok: false }) - if (!doesEntityAllow(user, entity, "delete_entity_role")) return res.status(403).json({ok: false}) + if (!doesEntityAllow(user, entity, "delete_entity") && !["admin", "developer"].includes(user.type)) + return res.status(403).json({ok: false}) await deleteEntity(entity) return res.status(200).json({ok: true}); diff --git a/src/pages/api/entities/index.ts b/src/pages/api/entities/index.ts index 11b5bd64..7c8b3896 100644 --- a/src/pages/api/entities/index.ts +++ b/src/pages/api/entities/index.ts @@ -2,7 +2,7 @@ import type {NextApiRequest, NextApiResponse} from "next"; import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; -import {createEntity, getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; +import {addUsersToEntity, addUserToEntity, createEntity, getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; import {Entity} from "@/interfaces/entity"; import {v4} from "uuid"; import { requestUser } from "@/utils/api"; @@ -39,6 +39,14 @@ async function post(req: NextApiRequest, res: NextApiResponse) { label: req.body.label, }; - await createEntity(entity) + const members = req.body.members as string[] | undefined || [] + console.log(members) + + const roles = await createEntity(entity) + console.log(roles) + + await addUserToEntity(user.id, entity.id, roles.admin.id) + if (members.length > 0) await addUsersToEntity(members, entity.id, roles.default.id) + return res.status(200).json(entity); } diff --git a/src/pages/dashboard/admin.tsx b/src/pages/dashboard/admin.tsx index 51ce62d8..3d2d1722 100644 --- a/src/pages/dashboard/admin.tsx +++ b/src/pages/dashboard/admin.tsx @@ -137,8 +137,19 @@ export default function Dashboard({ user, users, entities, assignments, stats, g value={masterCorporates.length} color="purple" /> - - + router.push("/classrooms")} + label="Classrooms" + value={groups.length} + color="purple" + /> + router.push("/entities")} + label="Entities" + value={entities.length} + color="purple" + /> - + router.push("/entities")} + label="Entities" + value={entities.length} + color="purple" + /> router.push("/entities")} + onClick={() => router.push("/classrooms")} label="Classrooms" value={groups.length} color="purple" /> - + router.push("/entities")} + label="Entities" + value={entities.length} + color="purple" + /> { const user = await requestUser(req, res) diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx index 8d1dfc16..5d9000c8 100644 --- a/src/pages/dashboard/mastercorporate.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -137,8 +137,19 @@ export default function Dashboard({ user, users, entities, assignments, stats, g /> router.push("/users?type=corporate")} Icon={BsBank} label="Corporate Accounts" value={corporates.length} color="purple" /> - router.push("/classrooms")} Icon={BsPeople} label="Classrooms" value={groups.length} color="purple" /> - + router.push("/classrooms")} + label="Classrooms" + value={groups.length} + color="purple" + /> + router.push("/entities")} + label="Entities" + value={entities.length} + color="purple" + /> { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + if (shouldRedirectHome(user)) return redirect("/") + if (!["admin", "developer"].includes(user.type)) return redirect("/entities") + + const users = await getUsers() + + return { + props: serialize({user, users: users.filter((x) => x.id !== user.id)}), + }; +}, sessionOptions); + +interface Props { + user: User; + users: User[]; +} + +export default function Home({user, users}: Props) { + const [isLoading, setIsLoading] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + const [label, setLabel] = useState(""); + + const {rows, renderSearch} = useListSearch([["name"], ["corporateInformation", "companyInformation", "name"]], users); + const {items, renderMinimal} = usePagination(rows, 16); + + const router = useRouter(); + + const createGroup = () => { + if (!label.trim()) return; + if (!confirm(`Are you sure you want to create this entity with ${selectedUsers.length} members?`)) return; + + setIsLoading(true); + + axios + .post(`/api/entities`, {label, members: selectedUsers}) + .then((result) => { + toast.success("Your entity has been created successfully!"); + router.replace(`/entities/${result.data.id}`); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + + const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); + + return ( + <> + + Create Entity | EnCoach + + + + + + +
+
+
+ + + +

Create Entity

+
+
+ +
+
+ +
+ Entity Label: + +
+ +
+ Members ({selectedUsers.length} selected): +
+
+ {renderSearch()} + {renderMinimal()} +
+
+ +
+ {items.map((u) => ( + + ))} +
+
+ + ); +} diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts index d785aacb..a5d95600 100644 --- a/src/resources/entityPermissions.ts +++ b/src/resources/entityPermissions.ts @@ -43,3 +43,59 @@ export type RolePermission = "delete_assignment" | "start_assignment" | "archive_assignment"; + +export const DEFAULT_PERMISSIONS: RolePermission[] = [ + "view_students", + "view_teachers", + "view_assignments", + "view_classrooms", + "view_entity_roles" +] + +export const ADMIN_PERMISSIONS: RolePermission[] = [ + "view_students", + "view_teachers", + "view_corporates", + "view_mastercorporates", + "edit_students", + "edit_teachers", + "edit_corporates", + "edit_mastercorporates", + "delete_students", + "delete_teachers", + "delete_corporates", + "delete_mastercorporates", + "generate_reading", + "delete_reading", + "generate_listening", + "delete_listening", + "generate_writing", + "delete_writing", + "generate_speaking", + "delete_speaking", + "generate_level", + "delete_level", + "view_classrooms", + "create_classroom", + "rename_classrooms", + "add_to_classroom", + "remove_from_classroom", + "delete_classroom", + "view_entities", + "rename_entity", + "add_to_entity", + "remove_from_entity", + "delete_entity", + "view_entity_roles", + "create_entity_role", + "rename_entity_role", + "edit_role_permissions", + "assign_to_role", + "delete_entity_role", + "view_assignments", + "create_assignment", + "edit_assignment", + "delete_assignment", + "start_assignment", + "archive_assignment" +] diff --git a/src/utils/entities.be.ts b/src/utils/entities.be.ts index f6162119..93b8badd 100644 --- a/src/utils/entities.be.ts +++ b/src/utils/entities.be.ts @@ -1,17 +1,10 @@ import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; import client from "@/lib/mongodb"; -import { RolePermission } from "@/resources/entityPermissions"; +import { ADMIN_PERMISSIONS, DEFAULT_PERMISSIONS, RolePermission } from "@/resources/entityPermissions"; import { v4 } from "uuid"; import {getRolesByEntities, getRolesByEntity} from "./roles.be"; const db = client.db(process.env.MONGODB_DB); -const DEFAULT_PERMISSIONS: RolePermission[] = [ - "view_students", - "view_teachers", - "view_assignments", - "view_classrooms", - "view_entity_roles" -] export const getEntityWithRoles = async (id: string): Promise => { const entity = await getEntity(id); @@ -45,14 +38,50 @@ export const getEntities = async (ids?: string[]) => { export const createEntity = async (entity: Entity) => { await db.collection("entities").insertOne(entity) - await db.collection("roles").insertOne({ + + const defaultRole = { id: v4(), label: "Default", permissions: DEFAULT_PERMISSIONS, + isDefault: true, entityID: entity.id - }) + } + + const adminRole = { + id: v4(), + label: "Admin", + permissions: ADMIN_PERMISSIONS, + entityID: entity.id + } + + await db.collection("roles").insertOne(defaultRole) + await db.collection("roles").insertOne(adminRole) + + return {default: defaultRole, admin: adminRole} } +export const addUserToEntity = async (user: string, entity: string, role: string) => + await db.collection("users").updateOne( + {id: user}, + { + // @ts-expect-error + $push: { + entities: {id: entity, role}, + }, + }, + ); + +export const addUsersToEntity = async (users: string[], entity: string, role: string) => + await db.collection("users").updateMany( + {id: {$in: users}}, + { + // @ts-expect-error + $push: { + entities: {id: entity, role}, + }, + }, + ); + export const deleteEntity = async (entity: Entity) => { await db.collection("entities").deleteOne({id: entity.id}) await db.collection("roles").deleteMany({entityID: entity.id}) From 22b8aed12705bbdd48ff822b33281e2cb86d9ccb Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 17 Oct 2024 12:02:35 +0100 Subject: [PATCH 11/35] Continued with the entities for the batch users --- src/pages/(admin)/BatchCreateUser.tsx | 36 ++++++++++++++++++++------- src/pages/api/batch_users.ts | 7 +++++- src/pages/api/make_user.ts | 7 +++++- src/pages/settings.tsx | 2 +- 4 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/pages/(admin)/BatchCreateUser.tsx b/src/pages/(admin)/BatchCreateUser.tsx index 8b97d6a2..2cad5c0e 100644 --- a/src/pages/(admin)/BatchCreateUser.tsx +++ b/src/pages/(admin)/BatchCreateUser.tsx @@ -17,6 +17,8 @@ import ReactDatePicker from "react-datepicker"; import clsx from "clsx"; import usePermissions from "@/hooks/usePermissions"; import countryCodes from "country-codes-list"; +import { EntityWithRoles } from "@/interfaces/entity"; +import Select from "@/components/Low/Select"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/); @@ -65,10 +67,11 @@ interface Props { user: User; users: User[]; permissions: PermissionType[]; + entities: EntityWithRoles[] onFinish: () => void; } -export default function BatchCreateUser({user, users, permissions, onFinish}: Props) { +export default function BatchCreateUser({user, users, entities = [], permissions, onFinish}: Props) { const [infos, setInfos] = useState< { email: string; @@ -89,6 +92,7 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr const [isExpiryDateEnabled, setIsExpiryDateEnabled] = useState(true); const [type, setType] = useState("student"); const [showHelp, setShowHelp] = useState(false); + const [entity, setEntity] = useState((entities || [])[0]?.id || undefined) const {openFilePicker, filesContent, clear} = useFilePicker({ accept: ".xlsx", @@ -122,6 +126,7 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr groupName: group, corporate, studentID, + entity, demographicInformation: { country: countryItem?.countryCode, passport_id: passport_id?.toString().trim() || undefined, @@ -206,15 +211,28 @@ export default function BatchCreateUser({user, users, permissions, onFinish}: Pr
-
- -
setShowHelp(true)}> - +
+
+
+ +
setShowHelp(true)}> + +
+
+ +
+
+ + )} -
diff --git a/src/pages/api/batch_users.ts b/src/pages/api/batch_users.ts index fdd2ec7b..fc7f6663 100644 --- a/src/pages/api/batch_users.ts +++ b/src/pages/api/batch_users.ts @@ -5,6 +5,8 @@ import { FirebaseScrypt } from 'firebase-scrypt'; import { firebaseAuthScryptParams } from "@/firebase"; import crypto from 'crypto'; import axios from "axios"; +import { getEntityWithRoles } from "@/utils/entities.be"; +import { findBy } from "@/utils"; export default withIronSessionApiRoute(handler, sessionOptions); @@ -47,7 +49,10 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const salt = crypto.randomBytes(16).toString('base64'); const hash = await scrypt.hash(user.passport_id, salt); - currentUser.entities = [{ id: currentUser.entity!, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }] + const entity = await getEntityWithRoles(currentUser.entity!) + const defaultRole = findBy(entity?.roles || [], "isDefault", true) + + currentUser.entities = [{ id: entity?.id || "", role: defaultRole?.id || "" }] delete currentUser.entity currentUser.email = currentUser.email.toLowerCase(); diff --git a/src/pages/api/make_user.ts b/src/pages/api/make_user.ts index dd148874..bd05953a 100644 --- a/src/pages/api/make_user.ts +++ b/src/pages/api/make_user.ts @@ -10,6 +10,8 @@ import { getGroup, getGroups, getUserCorporate, getUserGroups, getUserNamedGroup import { uniq } from "lodash"; import { getSpecificUsers, getUser } from "@/utils/users.be"; import client from "@/lib/mongodb"; +import { getEntityWithRoles } from "@/utils/entities.be"; +import { findBy } from "@/utils"; const DEFAULT_DESIRED_LEVELS = { reading: 9, @@ -65,6 +67,9 @@ async function post(req: NextApiRequest, res: NextApiResponse) { .then(async (userCredentials) => { const userId = userCredentials.user.uid; + const entityWithRole = await getEntityWithRoles(entity) + const defaultRole = findBy(entityWithRole?.roles || [], "isDefault", true) + const user = { ...req.body, bio: "", @@ -78,7 +83,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { isFirstLogin: false, isVerified: true, registrationDate: new Date(), - entities: [{ id: entity, role: "90ce8f08-08c8-41e4-9848-f1500ddc3930" }], + entities: [{ id: entity, role: defaultRole?.id || "" }], subscriptionExpirationDate: expiryDate || null, ...((maker.type === "corporate" || maker.type === "mastercorporate") && type === "corporate" ? { diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 9bdb0307..db768563 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -74,7 +74,7 @@ export default function Admin({ user, entities, permissions }: Props) { setModalOpen(undefined)}> - setModalOpen(undefined)} /> + setModalOpen(undefined)} /> setModalOpen(undefined)}> setModalOpen(undefined)} /> From a0a9402945beb41561f9a0b92284c0778f008586 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 17 Oct 2024 16:18:50 +0100 Subject: [PATCH 12/35] Updated entities --- src/pages/entities/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/entities/index.tsx b/src/pages/entities/index.tsx index 96b26998..2ae539a2 100644 --- a/src/pages/entities/index.tsx +++ b/src/pages/entities/index.tsx @@ -29,7 +29,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { if (shouldRedirectHome(user)) return redirect("/") const entityIDs = mapBy(user.entities, 'id') - const entities = await getEntitiesWithRoles(entityIDs); + const entities = await getEntitiesWithRoles(["admin", "developer"].includes(user.type) ? undefined : entityIDs); const allowedEntities = findAllowedEntities(user, entities, 'view_entities') const entitiesWithCount = await Promise.all( From 4917583c677fb2692e10e53560c01a96134c06c0 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 17 Oct 2024 18:24:39 +0100 Subject: [PATCH 13/35] Created a system to go directly to an assignment from a URL --- src/components/High/Layout.tsx | 32 ++++++----- src/pages/(exam)/ExamPage.tsx | 21 ++++---- src/pages/assignments/[id].tsx | 3 +- src/pages/assignments/index.tsx | 9 ---- src/pages/classrooms/create.tsx | 14 ++--- src/pages/exam.tsx | 93 ++++++++++++++++++++++++++++++-- src/pages/exercises.tsx | 94 ++++++++++++++++++++++++++++++--- src/pages/login.tsx | 15 +++--- src/utils/exams.be.ts | 3 +- src/utils/sessions.be.ts | 5 ++ 10 files changed, 232 insertions(+), 57 deletions(-) diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index eb72323f..b4a3dbc6 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -11,36 +11,42 @@ interface Props { className?: string; navDisabled?: boolean; focusMode?: boolean; + hideSidebar?: boolean bgColor?: string; onFocusLayerMouseEnter?: () => void; } -export default function Layout({user, children, className, bgColor="bg-white", navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { +export default function Layout({user, children, className, bgColor="bg-white", hideSidebar, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { const router = useRouter(); return (
- -
- + )} +
+ {!hideSidebar && ( + + )}
{children} diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index d9150598..2f080c77 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -25,13 +25,16 @@ import useSessions from "@/hooks/useSessions"; import ShortUniqueId from "short-unique-id"; import clsx from "clsx"; import useGradingSystem from "@/hooks/useGrading"; +import { Assignment } from "@/interfaces/results"; +import { mapBy } from "@/utils"; interface Props { page: "exams" | "exercises"; user: User; + hideSidebar?: boolean } -export default function ExamPage({page, user}: Props) { +export default function ExamPage({page, user, hideSidebar = false}: Props) { const [variant, setVariant] = useState("full"); const [avoidRepeated, setAvoidRepeated] = useState(false); const [hasBeenUploaded, setHasBeenUploaded] = useState(false); @@ -210,15 +213,14 @@ export default function ExamPage({page, user}: Props) { }, [setModuleIndex, showSolutions]); useEffect(() => { - (async () => { - if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) { - const nextExam = exams[moduleIndex]; + console.log(selectedModules) + if (selectedModules.length > 0 && exams.length > 0 && moduleIndex < selectedModules.length) { + const nextExam = exams[moduleIndex]; - if (partIndex === -1 && nextExam?.module !== "listening") setPartIndex(0); - if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0); - setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); - } - })(); + if (partIndex === -1 && nextExam?.module !== "listening") setPartIndex(0); + if (exerciseIndex === -1 && !["reading", "listening"].includes(nextExam?.module)) setExerciseIndex(0); + setExam(nextExam ? updateExamWithUserSolutions(nextExam) : undefined); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedModules, moduleIndex, exams]); @@ -520,6 +522,7 @@ export default function ExamPage({page, user}: Props) { setShowAbandonPopup(true)}> diff --git a/src/pages/assignments/[id].tsx b/src/pages/assignments/[id].tsx index 9cafc5ba..07e2f010 100644 --- a/src/pages/assignments/[id].tsx +++ b/src/pages/assignments/[id].tsx @@ -52,7 +52,8 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) const entity = await getEntityWithRoles(assignment.entity || "") if (!entity) return redirect("/assignments") - if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") + if (!doesEntityAllow(user, entity, 'view_assignments') && !["admin", "developer"].includes(user.type)) + return redirect("/assignments") const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); diff --git a/src/pages/assignments/index.tsx b/src/pages/assignments/index.tsx index 6a33c1ca..33e2ca69 100644 --- a/src/pages/assignments/index.tsx +++ b/src/pages/assignments/index.tsx @@ -119,15 +119,6 @@ export default function AssignmentsPage({assignments, corporateAssignments, enti Total: {activeAssignments.reduce((acc, curr) => acc + curr.results.length, 0)}/ {activeAssignments.reduce((acc, curr) => curr.exams.length + acc, 0)} - {Object.keys(groupBy(corporateAssignments, (x) => x.corporate?.id)).map((x) => ( -
- {getUserCompanyName(users.find((u) => u.id === x)!, users, groups)}: - - {groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.results.length + acc, 0)}/ - {groupBy(corporateAssignments, (x) => x.corporate?.id)[x].reduce((acc, curr) => curr.exams.length + acc, 0)} - -
- ))}
diff --git a/src/pages/classrooms/create.tsx b/src/pages/classrooms/create.tsx index 2a61bd18..c62682e8 100644 --- a/src/pages/classrooms/create.tsx +++ b/src/pages/classrooms/create.tsx @@ -9,11 +9,11 @@ import {Entity, EntityWithRoles} from "@/interfaces/entity"; import {User} from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; import {USER_TYPE_LABELS} from "@/resources/user"; -import {mapBy, redirect, serialize} from "@/utils"; +import {filterBy, mapBy, redirect, serialize} from "@/utils"; import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {getUserName} from "@/utils/users"; -import {getLinkedUsers} from "@/utils/users.be"; +import {getEntitiesUsers, getLinkedUsers} from "@/utils/users.be"; import axios from "axios"; import clsx from "clsx"; import {withIronSessionSsr} from "iron-session/next"; @@ -22,7 +22,7 @@ import Head from "next/head"; import Link from "next/link"; import {useRouter} from "next/router"; import {Divider} from "primereact/divider"; -import {useState} from "react"; +import {useMemo, useState} from "react"; import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs"; import {toast, ToastContainer} from "react-toastify"; import { requestUser } from "@/utils/api"; @@ -34,12 +34,12 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { if (shouldRedirectHome(user)) return redirect("/") - const linkedUsers = await getLinkedUsers(user.id, user.type); + const users = await getEntitiesUsers(mapBy(user.entities, 'id')) const entities = await getEntitiesWithRoles(mapBy(user.entities, "id")); const allowedEntities = findAllowedEntities(user, entities, "create_classroom") return { - props: serialize({user, entities: allowedEntities, users: linkedUsers.users.filter((x) => x.id !== user.id)}), + props: serialize({user, entities: allowedEntities, users: users.filter((x) => x.id !== user.id)}), }; }, sessionOptions); @@ -55,7 +55,9 @@ export default function Home({user, users, entities}: Props) { const [name, setName] = useState(""); const [entity, setEntity] = useState(entities[0]?.id); - const {rows, renderSearch} = useListSearch([["name"], ["corporateInformation", "companyInformation", "name"]], users); + const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users]) + + const {rows, renderSearch} = useListSearch([["name"], ["corporateInformation", "companyInformation", "name"]], entityUsers); const {items, renderMinimal} = usePagination(rows, 16); const router = useRouter(); diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx index c8ecfba7..e8f4440e 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -6,15 +6,52 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ExamPage from "./(exam)/ExamPage"; import Head from "next/head"; import {User} from "@/interfaces/user"; -import { redirect, serialize } from "@/utils"; +import { filterBy, findBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; +import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be"; +import { Assignment } from "@/interfaces/results"; +import useExamStore from "@/stores/examStore"; +import { useEffect } from "react"; +import { Exam } from "@/interfaces/exam"; +import { getExamsByIds } from "@/utils/exams.be"; +import { sortByModule } from "@/utils/moduleUtils"; +import { uniqBy } from "lodash"; +import { useRouter } from "next/router"; +import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be"; +import { Session } from "@/hooks/useSessions"; +import moment from "moment"; -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { const user = await requestUser(req, res) - if (!user) return redirect("/login") + const destination = Buffer.from(req.url || "/").toString("base64") + if (!user) return redirect(`/login?destination=${destination}`) if (shouldRedirectHome(user)) return redirect("/") + const {assignment: assignmentID} = query as {assignment?: string} + + if (assignmentID) { + const assignment = await getAssignment(assignmentID) + + if (!assignment) return redirect("/exam") + if (!assignment.assignees.includes(user.id) && !["admin", "developer"].includes(user.type)) + return redirect("/exam") + + const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) + const session = await getSessionByAssignment(assignmentID) + + if ( + filterBy(assignment.results, 'user', user.id).length > 0 || + moment(assignment.startDate).isAfter(moment()) || + moment(assignment.endDate).isBefore(moment()) + ) + return redirect("/exam") + + return { + props: serialize({user, assignment, exams, session}) + } + } + return { props: serialize({user}), }; @@ -22,9 +59,55 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { interface Props { user: User; + assignment?: Assignment + exams?: Exam[] + session?: Session } -export default function Page({user}: Props) { +export default function Page({user, assignment, exams = [], session}: Props) { + const router = useRouter() + + const state = useExamStore((state) => state) + + useEffect(() => { + if (assignment && exams.length > 0 && !state.assignment && !session) { + state.setUserSolutions([]); + state.setShowSolutions(false); + state.setAssignment(assignment); + state.setExams(exams.sort(sortByModule)); + state.setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + + router.replace(router.asPath) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignment, exams, session]) + + useEffect(() => { + if (assignment && exams.length > 0 && !state.assignment && !!session) { + state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); + state.setSelectedModules(session.selectedModules); + state.setExam(session.exam); + state.setExams(session.exams); + state.setSessionId(session.sessionId); + state.setAssignment(session.assignment); + state.setExerciseIndex(session.exerciseIndex); + state.setPartIndex(session.partIndex); + state.setModuleIndex(session.moduleIndex); + state.setTimeSpent(session.timeSpent); + state.setUserSolutions(session.userSolutions); + state.setShowSolutions(false); + state.setQuestionIndex(session.questionIndex); + + router.replace(router.asPath) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignment, exams, session]) + return ( <> @@ -36,7 +119,7 @@ export default function Page({user}: Props) { - + ); } diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx index 29034f93..87585ade 100644 --- a/src/pages/exercises.tsx +++ b/src/pages/exercises.tsx @@ -6,15 +6,51 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ExamPage from "./(exam)/ExamPage"; import Head from "next/head"; import {User} from "@/interfaces/user"; -import { redirect, serialize } from "@/utils"; +import { filterBy, findBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; +import { getAssignment, getAssignments, getAssignmentsByAssignee } from "@/utils/assignments.be"; +import { Assignment } from "@/interfaces/results"; +import useExamStore from "@/stores/examStore"; +import { useEffect } from "react"; +import { Exam } from "@/interfaces/exam"; +import { getExamsByIds } from "@/utils/exams.be"; +import { sortByModule } from "@/utils/moduleUtils"; +import { uniqBy } from "lodash"; +import { useRouter } from "next/router"; +import { getSessionByAssignment, getSessionsByUser } from "@/utils/sessions.be"; +import { Session } from "@/hooks/useSessions"; +import moment from "moment"; -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { const user = await requestUser(req, res) - if (!user) return redirect("/login") + const destination = Buffer.from(req.url || "/").toString("base64") + if (!user) return redirect(`/login?destination=${destination}`) if (shouldRedirectHome(user)) return redirect("/") + const {assignment: assignmentID} = query as {assignment?: string} + + if (assignmentID) { + const assignment = await getAssignment(assignmentID) + + if (!assignment) return redirect("/exercises") + if (!["admin", "developer"].includes(user.type) && !assignment.assignees.includes(user.id)) return redirect("/exercises") + + const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) + const session = await getSessionByAssignment(assignmentID) + + if ( + filterBy(assignment.results, 'user', user.id) || + moment(assignment.startDate).isBefore(moment()) || + moment(assignment.endDate).isAfter(moment()) + ) + return redirect("/exercises") + + return { + props: serialize({user, assignment, exams, session}) + } + } + return { props: serialize({user}), }; @@ -22,13 +58,59 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { interface Props { user: User; + assignment?: Assignment + exams?: Exam[] + session?: Session } -export default function Page({user}: Props) { +export default function Page({user, assignment, exams = [], session}: Props) { + const router = useRouter() + + const state = useExamStore((state) => state) + + useEffect(() => { + if (assignment && exams.length > 0 && !state.assignment && !session) { + state.setUserSolutions([]); + state.setShowSolutions(false); + state.setAssignment(assignment); + state.setExams(exams.sort(sortByModule)); + state.setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + + router.replace(router.asPath) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignment, exams, session]) + + useEffect(() => { + if (assignment && exams.length > 0 && !state.assignment && !!session) { + state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); + state.setSelectedModules(session.selectedModules); + state.setExam(session.exam); + state.setExams(session.exams); + state.setSessionId(session.sessionId); + state.setAssignment(session.assignment); + state.setExerciseIndex(session.exerciseIndex); + state.setPartIndex(session.partIndex); + state.setModuleIndex(session.moduleIndex); + state.setTimeSpent(session.timeSpent); + state.setUserSolutions(session.userSolutions); + state.setShowSolutions(false); + state.setQuestionIndex(session.questionIndex); + + router.replace(router.asPath) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignment, exams, session]) + return ( <> - Exercises | EnCoach + Exams | EnCoach - + ); } diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 7510d2fc..d03ffe4a 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -20,16 +20,17 @@ import { redirect } from "@/utils"; const EMAIL_REGEX = new RegExp(/^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*@[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$/g); -export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { + const destination = !query.destination ? "/" : Buffer.from(query.destination as string, 'base64').toString() const user = await requestUser(req, res) - if (user) return redirect("/") + if (user) return redirect(destination) return { - props: {user: null}, + props: {user: null, destination}, }; }, sessionOptions); -export default function Login() { +export default function Login({ destination }: { destination: string }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [rememberPassword, setRememberPassword] = useState(false); @@ -38,13 +39,13 @@ export default function Login() { const router = useRouter(); const {user, mutateUser} = useUser({ - redirectTo: "/", + redirectTo: destination, redirectIfFound: true, }); useEffect(() => { - if (user) router.push("/"); - }, [router, user]); + if (user) router.push(destination); + }, [router, user, destination]); const forgotPassword = () => { if (!email || email.length < 0 || !EMAIL_REGEX.test(email)) { diff --git a/src/utils/exams.be.ts b/src/utils/exams.be.ts index f9686dc2..dedc4d4c 100644 --- a/src/utils/exams.be.ts +++ b/src/utils/exams.be.ts @@ -8,6 +8,7 @@ import {getUserCorporate} from "./groups.be"; import {Db, ObjectId} from "mongodb"; import client from "@/lib/mongodb"; import {MODULE_ARRAY} from "./moduleUtils"; +import { mapBy } from "."; const db = client.db(process.env.MONGODB_DB); @@ -37,7 +38,7 @@ export const getExamsByIds = async (ids: {module: Module; id: string}[]) => { async (m) => await db .collection(m) - .find({id: {$in: groupedByModule[m]}}) + .find({id: {$in: mapBy(groupedByModule[m], 'id')}}) .toArray(), ), ) diff --git a/src/utils/sessions.be.ts b/src/utils/sessions.be.ts index a6fbe357..ef4e710c 100644 --- a/src/utils/sessions.be.ts +++ b/src/utils/sessions.be.ts @@ -9,3 +9,8 @@ export const getSessionsByUser = async (id: string, limit?: number) => .find({user: id}) .limit(limit || 0) .toArray(); + +export const getSessionByAssignment = async (assignmentID: string) => + await db + .collection("sessions") + .findOne({"assignment.id": assignmentID}) From 87d7d6f12b6170d58f970ba7dfbd2e4cde6a0b0e Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Thu, 17 Oct 2024 22:41:20 +0100 Subject: [PATCH 14/35] Added more filters to the classroom --- src/pages/assignments/[id].tsx | 12 ++++++-- src/pages/assignments/creator/[id].tsx | 11 ++++++- src/pages/assignments/creator/index.tsx | 5 +-- src/pages/classrooms/[id].tsx | 30 ++++++++++++++++-- src/pages/classrooms/create.tsx | 41 ++++++++++++++++++++++--- src/pages/classrooms/index.tsx | 4 +-- src/utils/users.ts | 2 ++ 7 files changed, 90 insertions(+), 15 deletions(-) diff --git a/src/pages/assignments/[id].tsx b/src/pages/assignments/[id].tsx index 07e2f010..712a02e9 100644 --- a/src/pages/assignments/[id].tsx +++ b/src/pages/assignments/[id].tsx @@ -52,8 +52,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) const entity = await getEntityWithRoles(assignment.entity || "") if (!entity) return redirect("/assignments") - if (!doesEntityAllow(user, entity, 'view_assignments') && !["admin", "developer"].includes(user.type)) - return redirect("/assignments") + if (!doesEntityAllow(user, entity, 'view_assignments')) return redirect("/assignments") const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntityUsers(entity.id)); @@ -296,6 +295,12 @@ export default function AssignmentView({user, users, entity, assignment}: Props) return false; }; + const copyLink = async () => { + const origin = window.location.origin + await navigator.clipboard.writeText(`${origin}/exam?assignment=${assignment.id}`) + toast.success("The URL to the assignment has been copied to your clipboard!") + } + return ( <> @@ -388,6 +393,9 @@ export default function AssignmentView({user, users, entity, assignment}: Props)
+ {assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && (
+
+
+ {['student', 'teacher', 'corporate'].map((type) => ( + + ))} +
diff --git a/src/pages/classrooms/create.tsx b/src/pages/classrooms/create.tsx index c62682e8..b0192c08 100644 --- a/src/pages/classrooms/create.tsx +++ b/src/pages/classrooms/create.tsx @@ -12,7 +12,7 @@ import {USER_TYPE_LABELS} from "@/resources/user"; import {filterBy, mapBy, redirect, serialize} from "@/utils"; import {getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; -import {getUserName} from "@/utils/users"; +import {getUserName, isAdmin} from "@/utils/users"; import {getEntitiesUsers, getLinkedUsers} from "@/utils/users.be"; import axios from "axios"; import clsx from "clsx"; @@ -22,11 +22,12 @@ import Head from "next/head"; import Link from "next/link"; import {useRouter} from "next/router"; import {Divider} from "primereact/divider"; -import {useMemo, useState} from "react"; +import {useEffect, useMemo, useState} from "react"; import {BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsStopwatchFill} from "react-icons/bs"; import {toast, ToastContainer} from "react-toastify"; import { requestUser } from "@/utils/api"; import { findAllowedEntities } from "@/utils/permissions"; +import { capitalize } from "lodash"; export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { const user = await requestUser(req, res) @@ -34,8 +35,8 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { if (shouldRedirectHome(user)) return redirect("/") - const users = await getEntitiesUsers(mapBy(user.entities, 'id')) - const entities = await getEntitiesWithRoles(mapBy(user.entities, "id")); + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : mapBy(user.entities, "id")); + const users = await getEntitiesUsers(mapBy(entities, 'id')) const allowedEntities = findAllowedEntities(user, entities, "create_classroom") return { @@ -57,11 +58,16 @@ export default function Home({user, users, entities}: Props) { const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [entity, users]) - const {rows, renderSearch} = useListSearch([["name"], ["corporateInformation", "companyInformation", "name"]], entityUsers); + const {rows, renderSearch} = useListSearch( + [["name"], ["type"], ["corporateInformation", "companyInformation", "name"]], entityUsers + ); + const {items, renderMinimal} = usePagination(rows, 16); const router = useRouter(); + useEffect(() => setSelectedUsers([]), [entity]) + const createGroup = () => { if (!name.trim()) return; if (!entity) return; @@ -140,6 +146,31 @@ export default function Home({user, users, entities}: Props) { {renderSearch()} {renderMinimal()}
+
+ {['student', 'teacher', 'corporate'].map((type) => ( + + ))} +
diff --git a/src/pages/classrooms/index.tsx b/src/pages/classrooms/index.tsx index a30561bf..657c49df 100644 --- a/src/pages/classrooms/index.tsx +++ b/src/pages/classrooms/index.tsx @@ -6,7 +6,7 @@ import {ToastContainer} from "react-toastify"; import Layout from "@/components/High/Layout"; import {GroupWithUsers, User} from "@/interfaces/user"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; -import {getUserName} from "@/utils/users"; +import {getUserName, isAdmin} from "@/utils/users"; import {convertToUsers, getGroupsForEntities} from "@/utils/groups.be"; import {getSpecificUsers} from "@/utils/users.be"; import Link from "next/link"; @@ -28,7 +28,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { if (shouldRedirectHome(user)) return redirect("/") const entityIDS = mapBy(user.entities, "id"); - const entities = await getEntitiesWithRoles(entityIDS) + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS) const allowedEntities = findAllowedEntities(user, entities, "view_classrooms") const groups = await getGroupsForEntities(mapBy(allowedEntities, 'id')); diff --git a/src/utils/users.ts b/src/utils/users.ts index 89b1353b..dae4fd80 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -44,3 +44,5 @@ export const getUserName = (user?: User) => { if (user.type === "corporate" || user.type === "mastercorporate") return user.corporateInformation?.companyInformation?.name || user.name; return user.name; }; + +export const isAdmin = (user: User) => ["admin", "developer"].includes(user.type) From 70a027f85bb1db80e0b4b4dc7e98f94e67cab8ae Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 18 Oct 2024 14:24:47 +0100 Subject: [PATCH 15/35] Quick fix --- src/pages/assignments/creator/index.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/pages/assignments/creator/index.tsx b/src/pages/assignments/creator/index.tsx index e3daabed..512ba202 100644 --- a/src/pages/assignments/creator/index.tsx +++ b/src/pages/assignments/creator/index.tsx @@ -9,7 +9,7 @@ import useExams from "@/hooks/useExams"; import {useListSearch} from "@/hooks/useListSearch"; import usePagination from "@/hooks/usePagination"; import {Module} from "@/interfaces"; -import {EntityWithRoles} from "@/interfaces/entity"; +import {EntityWithRoles, WithEntity} from "@/interfaces/entity"; import {InstructorGender, Variant} from "@/interfaces/exam"; import {Assignment} from "@/interfaces/results"; import {Group, User} from "@/interfaces/user"; @@ -20,6 +20,7 @@ import {getEntitiesWithRoles} from "@/utils/entities.be"; import {getGroups, getGroupsByEntities} from "@/utils/groups.be"; import {checkAccess, findAllowedEntities} from "@/utils/permissions"; import {calculateAverageLevel} from "@/utils/score"; +import { isAdmin } from "@/utils/users"; import {getEntitiesUsers, getUsers} from "@/utils/users.be"; import axios from "axios"; import clsx from "clsx"; @@ -45,15 +46,15 @@ export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { const allowedEntities = findAllowedEntities(user, entities, 'create_assignment') if (allowedEntities.length === 0) return redirect("/assignments") - const users = await (checkAccess(user, ["developer", "admin"]) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); - const groups = await (checkAccess(user, ["developer", "admin"]) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); + const users = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(allowedEntities, 'id'))); + const groups = await (isAdmin(user) ? getGroups() : getGroupsByEntities(mapBy(allowedEntities, 'id'))); return {props: serialize({user, users, entities, groups})}; }, sessionOptions); interface Props { assignment: Assignment; - groups: Group[]; + groups: WithEntity[]; user: User; users: User[]; entities: EntityWithRoles[]; @@ -95,7 +96,7 @@ export default function AssignmentsPage({user, users, groups, entities}: Props) const {exams} = useExams(); const router = useRouter(); - const classrooms = useMemo(() => groups.filter((e) => e.entity === entity), [entity, groups]); + const classrooms = useMemo(() => groups.filter((e) => e.entity?.id === entity), [entity, groups]); const allowedUsers = useMemo(() => users.filter((u) => mapBy(u.entities, 'id').includes(entity || "")), [users, entity]) const userStudents = useMemo(() => allowedUsers.filter((x) => x.type === "student"), [allowedUsers]); From 184a5fd820dfd2ba6a55cd06ebf33642be8340c2 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 18 Oct 2024 14:33:08 +0100 Subject: [PATCH 16/35] Added the entity to the classroom --- src/pages/classrooms/[id].tsx | 64 +++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/pages/classrooms/[id].tsx b/src/pages/classrooms/[id].tsx index 1ae4c83c..9a6e6f62 100644 --- a/src/pages/classrooms/[id].tsx +++ b/src/pages/classrooms/[id].tsx @@ -26,7 +26,7 @@ import Link from "next/link"; import {useRouter} from "next/router"; import {Divider} from "primereact/divider"; import {useEffect, useMemo, useState} from "react"; -import {BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX} from "react-icons/bs"; +import {BsBuilding, BsChevronLeft, BsClockFill, BsEnvelopeFill, BsFillPersonVcardFill, BsPlus, BsStopwatchFill, BsTag, BsTrash, BsX} from "react-icons/bs"; import {toast, ToastContainer} from "react-toastify"; export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { @@ -185,38 +185,44 @@ export default function Home({user, group, users, entity}: Props) { {user && (
-
-
-
- - - -

{group.name}

-
+
+
+
+ + + +

{group.name}

+
+ + {!isAdding && ( +
+ + +
+ )} +
+
+ + {entity.label} + {getUserName(group.admin)}
- {!isAdding && ( -
- - -
- )}
From 0becd295b0e517b6ab99e82664968125fdbd1fed Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 18 Oct 2024 17:49:03 +0100 Subject: [PATCH 17/35] Fixed the thing with the entity users --- src/pages/entities/[id]/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index e331b6b1..6842c3a1 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -10,13 +10,13 @@ import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; import {GroupWithUsers, User} from "@/interfaces/user"; import {sessionOptions} from "@/lib/session"; import {USER_TYPE_LABELS} from "@/resources/user"; -import { findBy, redirect, serialize } from "@/utils"; +import { findBy, mapBy, redirect, serialize } from "@/utils"; import {getEntityWithRoles} from "@/utils/entities.be"; import {convertToUsers, getGroup} from "@/utils/groups.be"; import {shouldRedirectHome} from "@/utils/navigation.disabled"; import {checkAccess, doesEntityAllow, getTypesOfUser} from "@/utils/permissions"; -import {getUserName} from "@/utils/users"; -import {getEntityUsers, getLinkedUsers, getSpecificUsers} from "@/utils/users.be"; +import {getUserName, isAdmin} from "@/utils/users"; +import {getEntitiesUsers, getEntityUsers, getLinkedUsers, getSpecificUsers, getUsers} from "@/utils/users.be"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import axios from "axios"; import clsx from "clsx"; @@ -55,7 +55,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, params}) => { if (!doesEntityAllow(user, entity, "view_entities")) return redirect(`/entities`) - const linkedUsers = await getLinkedUsers(user.id, user.type); + const linkedUsers = await (isAdmin(user) ? getUsers() : getEntitiesUsers(mapBy(user.entities, 'id'))) const entityUsers = await getEntityUsers(id); const usersWithRole = entityUsers.map((u) => { @@ -68,7 +68,7 @@ export const getServerSideProps = withIronSessionSsr(async ({req, params}) => { user, entity, users: usersWithRole, - linkedUsers: linkedUsers.users, + linkedUsers: linkedUsers.filter(x => x.id !== user.id && !mapBy(entityUsers, 'id').includes(x.id)), }), }; }, sessionOptions); From fa0c502467f18a0ce23a5e7ba8a54dba75d2392b Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 28 Oct 2024 14:40:26 +0000 Subject: [PATCH 18/35] Cleared of the stuff the EnCoach team wanted changed --- src/components/Diagnostic.tsx | 2 +- src/components/High/Layout.tsx | 15 +++- src/components/Medium/StatGridItem.tsx | 2 +- src/components/MobileMenu.tsx | 10 --- src/components/Sidebar.tsx | 68 +++++++++---------- src/dashboards/AssignmentView.tsx | 2 +- src/dashboards/Student.tsx | 2 +- src/hooks/useEntityPermissions.tsx | 7 +- src/interfaces/entity.ts | 4 +- src/pages/(admin)/BatchCreateUser.tsx | 2 +- src/pages/(admin)/ExamLoader.tsx | 2 +- src/pages/(admin)/Lists/ExamList.tsx | 36 ++++++++-- src/pages/(admin)/Lists/index.tsx | 34 ++++------ src/pages/(admin)/UserCreator.tsx | 2 +- src/pages/(generation)/LevelGeneration.tsx | 2 +- .../(generation)/ListeningGeneration.tsx | 2 +- src/pages/(generation)/ReadingGeneration.tsx | 2 +- src/pages/(generation)/SpeakingGeneration.tsx | 2 +- src/pages/(generation)/WritingGeneration.tsx | 2 +- src/pages/_app.tsx | 2 +- src/pages/assignments/[id].tsx | 2 +- src/pages/assignments/creator/[id].tsx | 2 +- src/pages/assignments/index.tsx | 2 +- src/pages/classrooms/[id].tsx | 12 ++-- src/pages/classrooms/index.tsx | 2 +- src/pages/dashboard/student.tsx | 2 +- src/pages/dashboard/teacher.tsx | 8 ++- src/pages/entities/[id]/roles/[role].tsx | 9 ++- src/pages/exercises.tsx | 4 +- src/pages/settings.tsx | 2 +- src/pages/training/[id]/index.tsx | 2 +- src/resources/entityPermissions.ts | 17 ++++- src/utils/permissions.ts | 7 ++ src/utils/search.ts | 2 +- 34 files changed, 166 insertions(+), 107 deletions(-) diff --git a/src/components/Diagnostic.tsx b/src/components/Diagnostic.tsx index b38215d6..142436a0 100644 --- a/src/components/Diagnostic.tsx +++ b/src/components/Diagnostic.tsx @@ -43,7 +43,7 @@ export default function Diagnostic({onFinish}: Props) { if (exams.every((x) => !!x)) { setExams(exams.map((x) => x!)); setSelectedModules(exams.map((x) => x!.module)); - router.push("/exercises"); + router.push("/exam"); } }); }; diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index b4a3dbc6..fefe4fb6 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,3 +1,4 @@ +import { EntityWithRoles } from "@/interfaces/entity"; import {User} from "@/interfaces/user"; import clsx from "clsx"; import {useRouter} from "next/router"; @@ -7,6 +8,7 @@ import Sidebar from "../Sidebar"; interface Props { user: User; + entities?: EntityWithRoles[] children: React.ReactNode; className?: string; navDisabled?: boolean; @@ -16,7 +18,17 @@ interface Props { onFocusLayerMouseEnter?: () => void; } -export default function Layout({user, children, className, bgColor="bg-white", hideSidebar, navDisabled = false, focusMode = false, onFocusLayerMouseEnter}: Props) { +export default function Layout({ + user, + entities = [], + children, + className, + bgColor="bg-white", + hideSidebar, + navDisabled = false, + focusMode = false, + onFocusLayerMouseEnter +}: Props) { const router = useRouter(); return ( @@ -40,6 +52,7 @@ export default function Layout({user, children, className, bgColor="bg-white", h onFocusLayerMouseEnter={onFocusLayerMouseEnter} className="-md:hidden" user={user} + entities={entities} /> )}
= ({ .sort(sortByModule) .map((x) => x!.module), ); - router.push("/exercises"); + router.push("/exam"); } }); } diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index df55e6d6..0d3a9ffa 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -105,16 +105,6 @@ export default function MobileMenu({ > Exams - - Exercises - )} void; className?: string; user: User; + entities?: EntityWithRoles[] } interface NavProps { @@ -76,7 +76,15 @@ const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false ); }; -export default function Sidebar({ path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className }: Props) { +export default function Sidebar({ + path, + entities = [], + navDisabled = false, + focusMode = false, + user, + onFocusLayerMouseEnter, + className +}: Props) { const router = useRouter(); const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); @@ -84,6 +92,10 @@ export default function Sidebar({ path, navDisabled = false, focusMode = false, const { totalAssignedTickets } = useTicketsListener(user.id); const { permissions } = usePermissions(user.id); + const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [ + "generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level" + ]) + const logout = async () => { axios.post("/api/logout").finally(() => { setTimeout(() => router.reload(), 500); @@ -104,9 +116,6 @@ export default function Sidebar({ path, navDisabled = false, focusMode = false, {checkAccess(user, ["student", "teacher", "developer"], permissions, "viewExams") && (