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/CardList.tsx b/src/components/High/CardList.tsx new file mode 100644 index 00000000..31075dcd --- /dev/null +++ b/src/components/High/CardList.tsx @@ -0,0 +1,34 @@ +import {useListSearch} from "@/hooks/useListSearch"; +import usePagination from "@/hooks/usePagination"; +import { clsx } from "clsx"; +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; + className?: string +} + +export default function CardList({list, searchFields, renderCard, firstCard, className, pageSize = 20}: Props) { + const {rows, renderSearch} = useListSearch(searchFields, list); + + const {items, page, render, renderMinimal} = usePagination(rows, pageSize); + + return ( +
+
+ {searchFields.length > 0 && renderSearch()} + {searchFields.length > 0 ? renderMinimal() : render()} +
+
+ {page === 0 && !!firstCard && firstCard()} + {items.map(renderCard)} +
+
+ ); +} diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index f148146f..c29d62c4 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,45 +1,66 @@ +import useEntities from "@/hooks/useEntities"; +import { EntityWithRoles } from "@/interfaces/entity"; 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"; interface Props { user: User; + entities?: EntityWithRoles[] children: React.ReactNode; 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(); + const {entities} = useEntities() return (
- -
- + {!hideSidebar && ( + + )} +
+ {!hideSidebar && ( + + )}
{children} diff --git a/src/components/High/Table.tsx b/src/components/High/Table.tsx new file mode 100644 index 00000000..c5b01e78 --- /dev/null +++ b/src/components/High/Table.tsx @@ -0,0 +1,107 @@ +import { useListSearch } from "@/hooks/useListSearch" +import { ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, PaginationState, useReactTable } from "@tanstack/react-table" +import clsx from "clsx" +import { useState } from "react" +import { BsArrowDown, BsArrowUp } from "react-icons/bs" +import Button from "../Low/Button" + +interface Props { + data: T[] + columns: ColumnDef[] + searchFields: string[][] + size?: number + onDownload?: (rows: T[]) => void +} + +export default function Table({ data, columns, searchFields, size = 16, onDownload }: Props) { + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 16, + }) + + const { rows, renderSearch } = useListSearch(searchFields, data); + + const table = useReactTable({ + data: rows, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + state: { + pagination + } + }); + + return ( +
+
+ {renderSearch()} + {onDownload && ( + + ) + } +
+ +
+
+ +
+
+ +
Page
+ + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount().toLocaleString()} + +
| Total: {table.getRowCount().toLocaleString()}
+
+ +
+
+ + + + {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/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/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/Select.tsx b/src/components/Low/Select.tsx index 3bfab016..bdc9c68a 100644 --- a/src/components/Low/Select.tsx +++ b/src/components/Low/Select.tsx @@ -13,9 +13,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(() => { @@ -23,43 +24,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/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/Medium/InviteWithUserCard.tsx b/src/components/Medium/InviteWithUserCard.tsx new file mode 100644 index 00000000..d0e14aac --- /dev/null +++ b/src/components/Medium/InviteWithUserCard.tsx @@ -0,0 +1,67 @@ +import {Invite, InviteWithEntity} 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: InviteWithEntity; + reload: () => void; +} + +export default function InviteWithUserCard({invite, reload}: Props) { + const [isLoading, setIsLoading] = useState(false); + + const name = useMemo(() => (!invite.entity ? null : invite.entity.label), [invite.entity]); + + 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 to {name} +
+ + +
+
+ ); +} 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/Medium/StatGridItem.tsx b/src/components/Medium/StatGridItem.tsx index c4c31040..eab1c52d 100644 --- a/src/components/Medium/StatGridItem.tsx +++ b/src/components/Medium/StatGridItem.tsx @@ -182,7 +182,7 @@ const StatsGridItem: React.FC = ({ .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; + path: string; + navDisabled?: boolean; + focusMode?: boolean; + onFocusLayerMouseEnter?: () => void; + className?: string; + user: User; + entities?: EntityWithRoles[] } interface NavProps { - Icon: IconType; - label: string; - path: string; - keyPath: string; - disabled?: boolean; - isMinimized?: boolean; - badge?: number; + Icon: IconType; + label: string; + path: string; + keyPath: string; + disabled?: boolean; + isMinimized?: boolean; + badge?: number; } -const Nav = ({Icon, label, path, keyPath, disabled = false, isMinimized = false, badge}: NavProps) => { - return ( - - - {!isMinimized && {label}} - {!!badge && badge > 0 && ( -
- {badge} -
- )} - - ); +const Nav = ({ Icon, label, path, keyPath, disabled = false, isMinimized = false, badge }: NavProps) => { + return ( + + + {!isMinimized && {label}} + {!!badge && badge > 0 && ( +
+ {badge} +
+ )} + + ); }; -export default function Sidebar({path, navDisabled = false, focusMode = false, user, onFocusLayerMouseEnter, className}: Props) { - const router = useRouter(); +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]); + const isAdmin = useMemo(() => ['developer', 'admin'].includes(user?.type), [user?.type]) - const {totalAssignedTickets} = useTicketsListener(user.id); - const {permissions} = usePermissions(user.id); + const [isMinimized, toggleMinimize] = usePreferencesStore((state) => [state.isSidebarMinimized, state.toggleSidebarMinimized]); - const logout = async () => { - axios.post("/api/logout").finally(() => { - setTimeout(() => router.reload(), 500); - }); - }; + const { totalAssignedTickets } = useTicketsListener(user.id); + const { permissions } = usePermissions(user.id); - const disableNavigation = preventNavigation(navDisabled, focusMode); + const entitiesAllowGeneration = useAllowedEntitiesSomePermissions(user, entities, [ + "generate_reading", "generate_listening", "generate_writing", "generate_speaking", "generate_level" + ]) - return ( -
-
-
-
-
+ const logout = async () => { + axios.post("/api/logout").finally(() => { + setTimeout(() => router.reload(), 500); + }); + }; -
-
- {isMinimized ? : } - {!isMinimized && Minimize} -
-
{} : logout} - className={clsx( - "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", - isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", - )}> - - {!isMinimized && Log Out} -
-
- {focusMode && } -
- ); + const disableNavigation = preventNavigation(navDisabled, focusMode); + + return ( +
+
+
+
+
+ +
+
+ {isMinimized ? : } + {!isMinimized && Minimize} +
+
{ } : logout} + className={clsx( + "hover:text-mti-rose flex cursor-pointer items-center gap-4 rounded-full p-4 text-black transition duration-300 ease-in-out", + isMinimized ? "w-fit" : "w-full min-w-[250px] px-8", + )}> + + {!isMinimized && Log Out} +
+
+ {focusMode && } +
+ ); } 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/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/AssignmentCard.tsx b/src/dashboards/AssignmentCard.tsx index d3243656..94fc1714 100644 --- a/src/dashboards/AssignmentCard.tsx +++ b/src/dashboards/AssignmentCard.tsx @@ -13,6 +13,7 @@ import {useAssignmentUnarchive} from "@/hooks/useAssignmentUnarchive"; import {useAssignmentRelease} from "@/hooks/useAssignmentRelease"; import {getUserName} from "@/utils/users"; import {User} from "@/interfaces/user"; +import { EntityWithRoles } from "@/interfaces/entity"; interface Props { users: User[]; @@ -22,6 +23,7 @@ interface Props { allowArchive?: boolean; allowUnarchive?: boolean; allowExcelDownload?: boolean; + entityObj?: EntityWithRoles } export default function AssignmentCard({ @@ -30,6 +32,7 @@ export default function AssignmentCard({ assigner, startDate, endDate, + entityObj, assignees, results, exams, @@ -49,7 +52,6 @@ export default function AssignmentCard({ const renderUnarchiveIcon = useAssignmentUnarchive(id, reload); const renderReleaseIcon = useAssignmentRelease(id, reload); - const calculateAverageModuleScore = (module: Module) => { const resultModuleBandScores = results.map((r) => { const moduleStats = r.stats.filter((s) => s.module === module); @@ -65,26 +67,26 @@ export default function AssignmentCard({ const uniqModules = uniqBy(exams, (x) => x.module); const shouldRenderPDF = () => { - if(released && allowDownload) { + if (released && allowDownload) { // in order to be downloadable, the assignment has to be released // the component should have the allowDownload prop // and the assignment should not have the level module - return uniqModules.every(({ module }) => module !== 'level'); + return uniqModules.every(({module}) => module !== "level"); } return false; - } + }; const shouldRenderExcel = () => { - if(released && allowExcelDownload) { + if (released && allowExcelDownload) { // in order to be downloadable, the assignment has to be released // the component should have the allowExcelDownload prop // and the assignment should have the level module - return uniqModules.some(({ module }) => module === 'level'); + return uniqModules.some(({module}) => module === "level"); } return false; - } + }; return (
{moment(endDate).format("DD/MM/YY, HH:mm")} Assigner: {getUserName(users.find((x) => x.id === assigner))} + {entityObj && Entity: {entityObj.label}}
{uniqModules.map(({module}) => ( diff --git a/src/dashboards/AssignmentView.tsx b/src/dashboards/AssignmentView.tsx index 2565e532..8b19aa0c 100644 --- a/src/dashboards/AssignmentView.tsx +++ b/src/dashboards/AssignmentView.tsx @@ -2,433 +2,341 @@ import Button from "@/components/Low/Button"; import ProgressBar from "@/components/Low/ProgressBar"; import Modal from "@/components/Modal"; import useUsers from "@/hooks/useUsers"; -import { Module } from "@/interfaces"; -import { Assignment } from "@/interfaces/results"; -import { Stat, User } from "@/interfaces/user"; +import {Module} from "@/interfaces"; +import {Assignment} from "@/interfaces/results"; +import {Stat, User} from "@/interfaces/user"; import useExamStore from "@/stores/examStore"; -import { getExamById } from "@/utils/exams"; -import { sortByModule } from "@/utils/moduleUtils"; -import { calculateBandScore } from "@/utils/score"; -import { convertToUserSolutions } from "@/utils/stats"; -import { getUserName } from "@/utils/users"; +import {getExamById} from "@/utils/exams"; +import {sortByModule} from "@/utils/moduleUtils"; +import {calculateBandScore} from "@/utils/score"; +import {convertToUserSolutions} from "@/utils/stats"; +import {getUserName} from "@/utils/users"; import axios from "axios"; import clsx from "clsx"; -import { capitalize, uniqBy } from "lodash"; +import {capitalize, uniqBy} from "lodash"; import moment from "moment"; -import { useRouter } from "next/router"; -import { - BsBook, - BsClipboard, - BsHeadphones, - BsMegaphone, - BsPen, -} from "react-icons/bs"; -import { toast } from "react-toastify"; -import { futureAssignmentFilter } from "@/utils/assignments"; +import {useRouter} from "next/router"; +import {BsBook, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import {toast} from "react-toastify"; +import {futureAssignmentFilter} from "@/utils/assignments"; interface Props { - isOpen: boolean; - assignment?: Assignment; - onClose: () => void; + isOpen: boolean; + users: User[]; + assignment?: Assignment; + onClose: () => void; } -export default function AssignmentView({ isOpen, assignment, onClose }: Props) { - const { users } = useUsers(); - const router = useRouter(); +export default function AssignmentView({isOpen, users, assignment, onClose}: 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 setExams = useExamStore((state) => state.setExams); + const setShowSolutions = useExamStore((state) => state.setShowSolutions); + const setUserSolutions = useExamStore((state) => state.setUserSolutions); + const setSelectedModules = useExamStore((state) => state.setSelectedModules); - const deleteAssignment = async () => { - if (!confirm("Are you sure you want to delete this assignment?")) return; + const deleteAssignment = async () => { + if (!confirm("Are you sure you want to delete this assignment?")) return; - axios - .delete(`/api/assignments/${assignment?.id}`) - .then(() => - toast.success( - `Successfully deleted the assignment "${assignment?.name}".` - ) - ) - .catch(() => toast.error("Something went wrong, please try again later.")) - .finally(onClose); - }; + axios + .delete(`/api/assignments/${assignment?.id}`) + .then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`)) + .catch(() => toast.error("Something went wrong, please try again later.")) + .finally(onClose); + }; - const startAssignment = () => { - if (assignment) { - axios - .post(`/api/assignments/${assignment.id}/start`) - .then(() => { - toast.success( - `The assignment "${assignment.name}" has been started successfully!` - ); - }) - .catch((e) => { - console.log(e); - toast.error("Something went wrong, please try again later!"); - }); - } - }; + const startAssignment = () => { + if (assignment) { + axios + .post(`/api/assignments/${assignment.id}/start`) + .then(() => { + toast.success(`The assignment "${assignment.name}" has been started successfully!`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }); + } + }; - const formatTimestamp = (timestamp: string) => { - const date = moment(parseInt(timestamp)); - const formatter = "YYYY/MM/DD - HH:mm"; + const formatTimestamp = (timestamp: string) => { + const date = moment(parseInt(timestamp)); + const formatter = "YYYY/MM/DD - HH:mm"; - return date.format(formatter); - }; + return date.format(formatter); + }; - const calculateAverageModuleScore = (module: Module) => { - if (!assignment) return -1; + const calculateAverageModuleScore = (module: Module) => { + if (!assignment) return -1; - const resultModuleBandScores = assignment.results.map((r) => { - const moduleStats = r.stats.filter((s) => s.module === module); + const resultModuleBandScores = assignment.results.map((r) => { + const moduleStats = r.stats.filter((s) => s.module === module); - const correct = moduleStats.reduce( - (acc, curr) => acc + curr.score.correct, - 0 - ); - const total = moduleStats.reduce( - (acc, curr) => acc + curr.score.total, - 0 - ); - return calculateBandScore(correct, total, module, r.type); - }); + const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); + const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); + return calculateBandScore(correct, total, module, r.type); + }); - return resultModuleBandScores.length === 0 - ? -1 - : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / - assignment.results.length; - }; + return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; + }; - const aggregateScoresByModule = ( - stats: Stat[] - ): { module: Module; total: number; missing: number; correct: number }[] => { - const scores: { - [key in Module]: { total: number; missing: number; correct: number }; - } = { - reading: { - total: 0, - correct: 0, - missing: 0, - }, - listening: { - total: 0, - correct: 0, - missing: 0, - }, - writing: { - total: 0, - correct: 0, - missing: 0, - }, - speaking: { - total: 0, - correct: 0, - missing: 0, - }, - level: { - total: 0, - correct: 0, - missing: 0, - }, - }; + const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { + const scores: { + [key in Module]: {total: number; missing: number; correct: number}; + } = { + reading: { + total: 0, + correct: 0, + missing: 0, + }, + listening: { + total: 0, + correct: 0, + missing: 0, + }, + writing: { + total: 0, + correct: 0, + missing: 0, + }, + speaking: { + total: 0, + correct: 0, + missing: 0, + }, + level: { + total: 0, + correct: 0, + missing: 0, + }, + }; - stats.forEach((x) => { - scores[x.module!] = { - total: scores[x.module!].total + x.score.total, - correct: scores[x.module!].correct + x.score.correct, - missing: scores[x.module!].missing + x.score.missing, - }; - }); + stats.forEach((x) => { + scores[x.module!] = { + total: scores[x.module!].total + x.score.total, + correct: scores[x.module!].correct + x.score.correct, + missing: scores[x.module!].missing + x.score.missing, + }; + }); - return Object.keys(scores) - .filter((x) => scores[x as Module].total > 0) - .map((x) => ({ module: x as Module, ...scores[x as Module] })); - }; + return Object.keys(scores) + .filter((x) => scores[x as Module].total > 0) + .map((x) => ({module: x as Module, ...scores[x as Module]})); + }; - const customContent = ( - stats: Stat[], - user: string, - focus: "academic" | "general" - ) => { - const correct = stats.reduce( - (accumulator, current) => accumulator + current.score.correct, - 0 - ); - const total = stats.reduce( - (accumulator, current) => accumulator + current.score.total, - 0 - ); - const aggregatedScores = aggregateScoresByModule(stats).filter( - (x) => x.total > 0 - ); + const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { + const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); + const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); + const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); - const aggregatedLevels = aggregatedScores.map((x) => ({ - module: x.module, - level: calculateBandScore(x.correct, x.total, x.module, focus), - })); + const aggregatedLevels = aggregatedScores.map((x) => ({ + module: x.module, + level: calculateBandScore(x.correct, x.total, x.module, focus), + })); - const timeSpent = stats[0].timeSpent; + const timeSpent = stats[0].timeSpent; - const selectExam = () => { - const examPromises = uniqBy(stats, "exam").map((stat) => - getExamById(stat.module, stat.exam) - ); + const selectExam = () => { + const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam)); - Promise.all(examPromises).then((exams) => { - if (exams.every((x) => !!x)) { - setUserSolutions(convertToUserSolutions(stats)); - setShowSolutions(true); - setExams(exams.map((x) => x!).sort(sortByModule)); - setSelectedModules( - exams - .map((x) => x!) - .sort(sortByModule) - .map((x) => x!.module) - ); - router.push("/exercises"); - } - }); - }; + Promise.all(examPromises).then((exams) => { + if (exams.every((x) => !!x)) { + setUserSolutions(convertToUserSolutions(stats)); + setShowSolutions(true); + setExams(exams.map((x) => x!).sort(sortByModule)); + setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + router.push("/exam"); + } + }); + }; - const content = ( - <> -
-
- - {formatTimestamp(stats[0].date.toString())} - - {timeSpent && ( - <> - - - {Math.floor(timeSpent / 60)} minutes - - - )} -
- = 0.7 && "text-mti-purple", - correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", - correct / total < 0.3 && "text-mti-rose" - )} - > - Level{" "} - {( - aggregatedLevels.reduce( - (accumulator, current) => accumulator + current.level, - 0 - ) / aggregatedLevels.length - ).toFixed(1)} - -
+ const content = ( + <> +
+
+ {formatTimestamp(stats[0].date.toString())} + {timeSpent && ( + <> + + {Math.floor(timeSpent / 60)} minutes + + )} +
+ = 0.7 && "text-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", + correct / total < 0.3 && "text-mti-rose", + )}> + Level{" "} + {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} + +
-
-
- {aggregatedLevels.map(({ module, level }) => ( -
- {module === "reading" && } - {module === "listening" && } - {module === "writing" && } - {module === "speaking" && } - {module === "level" && } - {level.toFixed(1)} -
- ))} -
-
- - ); +
+
+ {aggregatedLevels.map(({module, level}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {level.toFixed(1)} +
+ ))} +
+
+ + ); - return ( -
- - {(() => { - const student = users.find((u) => u.id === user); - return `${student?.name} (${student?.email})`; - })()} - -
= 0.7 && "hover:border-mti-purple", - correct / total >= 0.3 && - correct / total < 0.7 && - "hover:border-mti-red", - correct / total < 0.3 && "hover:border-mti-rose" - )} - onClick={selectExam} - role="button" - > - {content} -
-
= 0.7 && "hover:border-mti-purple", - correct / total >= 0.3 && - correct / total < 0.7 && - "hover:border-mti-red", - correct / total < 0.3 && "hover:border-mti-rose" - )} - data-tip="Your screen size is too small to view previous exams." - role="button" - > - {content} -
-
- ); - }; + return ( +
+ + {(() => { + const student = users.find((u) => u.id === user); + return `${student?.name} (${student?.email})`; + })()} + +
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + onClick={selectExam} + role="button"> + {content} +
+
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + data-tip="Your screen size is too small to view previous exams." + role="button"> + {content} +
+
+ ); + }; - const shouldRenderStart = () => { - if (assignment) { - if (futureAssignmentFilter(assignment)) { - return true; - } - } + const shouldRenderStart = () => { + if (assignment) { + if (futureAssignmentFilter(assignment)) { + return true; + } + } - return false; - }; + return false; + }; - return ( - -
- -
-
- - Start Date:{" "} - {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} - - - End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} - -
-
- - Assignees:{" "} - {users - .filter((u) => assignment?.assignees.includes(u.id)) - .map((u) => `${u.name} (${u.email})`) - .join(", ")} - - - Assigner:{" "} - {getUserName(users.find((x) => x.id === assignment?.assigner))} - -
-
-
- Average Scores -
- {assignment && - uniqBy(assignment.exams, (x) => x.module).map(({ module }) => ( -
- {module === "reading" && } - {module === "listening" && ( - - )} - {module === "writing" && } - {module === "speaking" && } - {module === "level" && } - {calculateAverageModuleScore(module) > -1 && ( - - {calculateAverageModuleScore(module).toFixed(1)} - - )} -
- ))} -
-
-
- - Results ({assignment?.results.length}/{assignment?.assignees.length} - ) - -
- {assignment && assignment?.results.length > 0 && ( -
- {assignment.results.map((r) => - customContent(r.stats, r.user, r.type) - )} -
- )} - {assignment && assignment?.results.length === 0 && ( - No results yet... - )} -
-
+ return ( + +
+ +
+
+ Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} + End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} +
+
+ + Assignees:{" "} + {users + .filter((u) => assignment?.assignees.includes(u.id)) + .map((u) => `${u.name} (${u.email})`) + .join(", ")} + + Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))} +
+
+
+ Average Scores +
+ {assignment && + uniqBy(assignment.exams, (x) => x.module).map(({module}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {calculateAverageModuleScore(module) > -1 && ( + {calculateAverageModuleScore(module).toFixed(1)} + )} +
+ ))} +
+
+
+ + Results ({assignment?.results.length}/{assignment?.assignees.length}) + +
+ {assignment && assignment?.results.length > 0 && ( +
+ {assignment.results.map((r) => customContent(r.stats, r.user, r.type))} +
+ )} + {assignment && assignment?.results.length === 0 && No results yet...} +
+
-
- {assignment && - (assignment.results.length === assignment.assignees.length || - moment().isAfter(moment(assignment.endDate))) && ( - - )} - {/** if the assignment is not deemed as active yet, display start */} - {shouldRenderStart() && ( - - )} - -
-
-
- ); +
+ {assignment && (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( + + )} + {/** if the assignment is not deemed as active yet, display start */} + {shouldRenderStart() && ( + + )} + +
+
+
+ ); } diff --git a/src/dashboards/Corporate/index.tsx b/src/dashboards/Corporate/index.tsx index e16028b3..b21aaf28 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]); @@ -251,7 +217,7 @@ export default function CorporateDashboard({user, linkedCorporate}: Props) { .includes(x.id), }); - router.push("/list/users"); + router.push("/users"); } : undefined } @@ -271,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/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/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/Student.tsx b/src/dashboards/Student.tsx index 1539c0d3..14bf963e 100644 --- a/src/dashboards/Student.tsx +++ b/src/dashboards/Student.tsx @@ -71,7 +71,7 @@ export default function StudentDashboard({user, linkedCorporate}: Props) { ); setAssignment(assignment); - router.push("/exercises"); + router.push("/exam"); } }); }; 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/dashboards/views/AssignmentsPage.tsx b/src/dashboards/views/AssignmentsPage.tsx index 805f364d..a0fc2b3a 100644 --- a/src/dashboards/views/AssignmentsPage.tsx +++ b/src/dashboards/views/AssignmentsPage.tsx @@ -42,6 +42,7 @@ export default function AssignmentsPage({assignments, corporateAssignments, user {displayAssignmentView && ( { setSelectedAssignment(undefined); setIsCreatingAssignment(false); diff --git a/src/email/templates/assignment.handlebars b/src/email/templates/assignment.handlebars index 4774de28..4d098c23 100644 --- a/src/email/templates/assignment.handlebars +++ b/src/email/templates/assignment.handlebars @@ -19,10 +19,10 @@


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

-

Click here to open the EnCoach Platform!

+

Click here to open the assignment on EnCoach!


Thanks,

Your EnCoach team

- \ No newline at end of file + diff --git a/src/email/templates/assignment.handlebars.json b/src/email/templates/assignment.handlebars.json index 08486418..14377bc8 100644 --- a/src/email/templates/assignment.handlebars.json +++ b/src/email/templates/assignment.handlebars.json @@ -8,6 +8,7 @@ "assignees": [], "modules": "Reading and Writing", "startDate": "24/12/2023", - "endDate": "27/01/2024" + "endDate": "27/01/2024", + "id": "123" } -} \ No newline at end of file +} diff --git a/src/email/templates/receivedInvite.handlebars b/src/email/templates/receivedInvite.handlebars index 69f26208..678ab972 100644 --- a/src/email/templates/receivedInvite.handlebars +++ b/src/email/templates/receivedInvite.handlebars @@ -13,7 +13,7 @@ Hello {{name}},

- You have been invited to join {{corporateName}}'s group! + You have been invited to join the {{entity}} entity!

Please access the platform to accept or decline the invite. diff --git a/src/email/templates/resetPassword.handlebars b/src/email/templates/resetPassword.handlebars new file mode 100644 index 00000000..487c3a1a --- /dev/null +++ b/src/email/templates/resetPassword.handlebars @@ -0,0 +1,16 @@ + + + + + + + +

Hi {{name}},

+

You requested to reset your password.

+

Please, click the link below to reset your password

+ Reset Password + + + \ No newline at end of file diff --git a/src/exams/Selection.tsx b/src/exams/Selection.tsx index b6f77c98..c1b023c8 100644 --- a/src/exams/Selection.tsx +++ b/src/exams/Selection.tsx @@ -1,5 +1,5 @@ /* eslint-disable @next/next/no-img-element */ -import {useState} from "react"; +import {useMemo, useState} from "react"; import {Module} from "@/interfaces"; import clsx from "clsx"; import {Stat, User} from "@/interfaces/user"; @@ -22,10 +22,9 @@ interface Props { user: User; page: "exercises" | "exams"; onStart: (modules: Module[], avoidRepeated: boolean, variant: Variant) => void; - disableSelection?: boolean; } -export default function Selection({user, page, onStart, disableSelection = false}: Props) { +export default function Selection({user, page, onStart}: Props) { const [selectedModules, setSelectedModules] = useState([]); const [avoidRepeatedExams, setAvoidRepeatedExams] = useState(true); const [variant, setVariant] = useState("full"); @@ -40,6 +39,10 @@ export default function Selection({user, page, onStart, disableSelection = false setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); }; + const isCompleteExam = useMemo(() => + ["reading", "listening", "writing", "speaking"].every(m => selectedModules.includes(m as Module)), [selectedModules] + ) + const loadSession = async (session: Session) => { state.setShuffles(session.userSolutions.map((x) => ({exerciseID: x.exercise, shuffles: x.shuffleMaps ? x.shuffleMaps : []}))); state.setSelectedModules(session.selectedModules); @@ -146,10 +149,10 @@ export default function Selection({user, page, onStart, disableSelection = false
toggleModule("reading") : undefined} + onClick={!selectedModules.includes("level") ? () => toggleModule("reading") : undefined} className={clsx( "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("reading") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", + selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", )}>
@@ -158,19 +161,19 @@ export default function Selection({user, page, onStart, disableSelection = false

Expand your vocabulary, improve your reading comprehension and improve your ability to interpret texts in English.

- {!selectedModules.includes("reading") && !selectedModules.includes("level") && !disableSelection && ( + {!selectedModules.includes("reading") && !selectedModules.includes("level") && (
)} - {(selectedModules.includes("reading") || disableSelection) && ( + {(selectedModules.includes("reading")) && ( )} {selectedModules.includes("level") && }
toggleModule("listening") : undefined} + onClick={!selectedModules.includes("level") ? () => toggleModule("listening") : undefined} className={clsx( "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("listening") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", + selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", )}>
@@ -179,19 +182,19 @@ export default function Selection({user, page, onStart, disableSelection = false

Improve your ability to follow conversations in English and your ability to understand different accents and intonations.

- {!selectedModules.includes("listening") && !selectedModules.includes("level") && !disableSelection && ( + {!selectedModules.includes("listening") && !selectedModules.includes("level") && (
)} - {(selectedModules.includes("listening") || disableSelection) && ( + {(selectedModules.includes("listening")) && ( )} {selectedModules.includes("level") && }
toggleModule("writing") : undefined} + onClick={!selectedModules.includes("level") ? () => toggleModule("writing") : undefined} className={clsx( "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("writing") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", + selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", )}>
@@ -200,19 +203,19 @@ export default function Selection({user, page, onStart, disableSelection = false

Allow you to practice writing in a variety of formats, from simple paragraphs to complex essays.

- {!selectedModules.includes("writing") && !selectedModules.includes("level") && !disableSelection && ( + {!selectedModules.includes("writing") && !selectedModules.includes("level") && (
)} - {(selectedModules.includes("writing") || disableSelection) && ( + {(selectedModules.includes("writing")) && ( )} {selectedModules.includes("level") && }
toggleModule("speaking") : undefined} + onClick={!selectedModules.includes("level") ? () => toggleModule("speaking") : undefined} className={clsx( "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("speaking") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", + selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", )}>
@@ -221,37 +224,35 @@ export default function Selection({user, page, onStart, disableSelection = false

You'll have access to interactive dialogs, pronunciation exercises and speech recordings.

- {!selectedModules.includes("speaking") && !selectedModules.includes("level") && !disableSelection && ( + {!selectedModules.includes("speaking") && !selectedModules.includes("level") && (
)} - {(selectedModules.includes("speaking") || disableSelection) && ( + {(selectedModules.includes("speaking")) && ( )} {selectedModules.includes("level") && }
- {!disableSelection && ( -
toggleModule("level") : undefined} - className={clsx( - "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", - selectedModules.includes("level") || disableSelection ? "border-mti-purple-light" : "border-mti-gray-platinum", - )}> -
- -
- Level: -

You'll be able to test your english level with multiple choice questions.

- {!selectedModules.includes("level") && selectedModules.length === 0 && !disableSelection && ( -
- )} - {(selectedModules.includes("level") || disableSelection) && ( - - )} - {!selectedModules.includes("level") && selectedModules.length > 0 && ( - - )} +
toggleModule("level") : undefined} + className={clsx( + "bg-mti-white-alt relative flex w-64 max-w-xs cursor-pointer flex-col items-center gap-2 rounded-xl border p-5 pt-12 transition duration-300 ease-in-out", + selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+
- )} + Level: +

You'll be able to test your english level with multiple choice questions.

+ {!selectedModules.includes("level") && selectedModules.length === 0 && ( +
+ )} + {(selectedModules.includes("level")) && ( + + )} + {!selectedModules.includes("level") && selectedModules.length > 0 && ( + + )} +
@@ -291,19 +292,29 @@ export default function Selection({user, page, onStart, disableSelection = false Start Exam
- +
+ + +
diff --git a/src/exams/Speaking.tsx b/src/exams/Speaking.tsx index 81a85e21..07c7352f 100644 --- a/src/exams/Speaking.tsx +++ b/src/exams/Speaking.tsx @@ -27,6 +27,12 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props) const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); + useEffect(() => { + if (hasExamEnded && exerciseIndex === -1) { + setExerciseIndex(exerciseIndex + 1); + } + }, [hasExamEnded, exerciseIndex, setExerciseIndex]); + const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const nextExercise = (solution?: UserSolution) => { diff --git a/src/exams/Writing.tsx b/src/exams/Writing.tsx index c04c4e96..352cafcd 100644 --- a/src/exams/Writing.tsx +++ b/src/exams/Writing.tsx @@ -32,6 +32,12 @@ export default function Writing({ exam, showSolutions = false, preview = false, const [seenParts, setSeenParts] = useState>(new Set(showSolutions ? exam.exercises.map((_, index) => index) : [])); const [showPartDivider, setShowPartDivider] = useState(typeof exam.exercises[0].intro === "string" && exam.exercises[0].intro !== ""); + useEffect(() => { + if (hasExamEnded && exerciseIndex === -1) { + setExerciseIndex(exerciseIndex + 1); + } + }, [hasExamEnded, exerciseIndex, setExerciseIndex]); + const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); useEffect(() => { diff --git a/src/hooks/useEntities.tsx b/src/hooks/useEntities.tsx new file mode 100644 index 00000000..434d5d1f --- /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() { + 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, []); + + 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/hooks/useEntityPermissions.tsx b/src/hooks/useEntityPermissions.tsx new file mode 100644 index 00000000..0c0574d6 --- /dev/null +++ b/src/hooks/useEntityPermissions.tsx @@ -0,0 +1,24 @@ +import { EntityWithRoles } from "@/interfaces/entity"; +import { User } from "@/interfaces/user"; +import { RolePermission } from "@/resources/entityPermissions"; +import { mapBy } from "@/utils"; +import { doesEntityAllow, findAllowedEntities, findAllowedEntitiesSomePermissions } from "@/utils/permissions"; +import { isAdmin } from "@/utils/users"; +import { useMemo, useState } from "react"; + +export const useAllowedEntities = (user: User, entities: EntityWithRoles[], permission: RolePermission) => { + const allowedEntityIds = useMemo(() => findAllowedEntities(user, entities, permission), [user, entities, permission]) + return allowedEntityIds +} + +export const useAllowedEntitiesSomePermissions = (user: User, entities: EntityWithRoles[], permissions: RolePermission[]) => { + const allowedEntityIds = useMemo(() => findAllowedEntitiesSomePermissions(user, entities, permissions), [user, entities, permissions]) + return allowedEntityIds +} + +export const useEntityPermission = (user: User, entity?: EntityWithRoles, permission?: RolePermission) => { + if (isAdmin(user)) return true + if (!entity || !permission) return false + + return doesEntityAllow(user, entity, permission) +} diff --git a/src/hooks/useListSearch.tsx b/src/hooks/useListSearch.tsx index 512a644a..af4ed1e4 100644 --- a/src/hooks/useListSearch.tsx +++ b/src/hooks/useListSearch.tsx @@ -5,7 +5,7 @@ import {search} from "@/utils/search"; export function useListSearch(fields: string[][], rows: T[]) { const [text, setText] = useState(""); - const renderSearch = () => ; + const renderSearch = () => ; const updatedRows = useMemo(() => { if (text.length > 0) return search(text, fields, rows); diff --git a/src/hooks/usePagination.tsx b/src/hooks/usePagination.tsx index 490c6c9e..9075dbbe 100644 --- a/src/hooks/usePagination.tsx +++ b/src/hooks/usePagination.tsx @@ -1,5 +1,7 @@ import Button from "@/components/Low/Button"; import {useMemo, useState} from "react"; +import {BiChevronLeft} from "react-icons/bi"; +import {BsChevronDoubleLeft, BsChevronDoubleRight, BsChevronLeft, BsChevronRight} from "react-icons/bs"; export default function usePagination(list: T[], size = 25) { const [page, setPage] = useState(0); @@ -24,5 +26,35 @@ export default function usePagination(list: T[], size = 25) {
); - 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..f543d872 --- /dev/null +++ b/src/interfaces/entity.ts @@ -0,0 +1,31 @@ +import { RolePermission } from "@/resources/entityPermissions"; + +export interface Entity { + id: string; + label: string; +} + +export interface Role { + id: string; + entityID: string; + permissions: RolePermission[]; + label: string; + isDefault?: boolean +} + +export interface EntityWithRoles extends Entity { + roles: Role[]; +}; + +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/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..4fe1b2bf 100644 --- a/src/interfaces/invite.ts +++ b/src/interfaces/invite.ts @@ -1,5 +1,12 @@ +import { Entity } from "./entity"; + export interface Invite { - id: string; - from: string; - to: string; + id: string; + entity: string; + from: string + to: string; +} + +export interface InviteWithEntity extends Omit { + entity?: Entity; } 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 110650a5..54fba92a 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: {id: string; role: string}[]; } export interface StudentUser extends BasicUser { @@ -149,6 +150,12 @@ export interface Group { participants: string[]; id: string; disableEditing?: boolean; + entity?: string; +} + +export interface GroupWithUsers extends Omit { + admin: User; + participants: User[]; } export interface Code { @@ -165,4 +172,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/(admin)/BatchCreateUser/IUserImport.ts b/src/pages/(admin)/BatchCreateUser/IUserImport.ts index 4b8529e5..e9b0088b 100644 --- a/src/pages/(admin)/BatchCreateUser/IUserImport.ts +++ b/src/pages/(admin)/BatchCreateUser/IUserImport.ts @@ -3,12 +3,13 @@ import { Type as UserType} from "@/interfaces/user"; export type Type = Exclude; export interface UserImport { + id: string; email: string; name: string; passport_id: string; type: Type; groupName: string; - corporate: string; + entity: string; studentID: string; demographicInformation: { country: string; diff --git a/src/pages/(admin)/BatchCreateUser/UserTable.tsx b/src/pages/(admin)/BatchCreateUser/UserTable.tsx index 60ad0b05..ec93e8f6 100644 --- a/src/pages/(admin)/BatchCreateUser/UserTable.tsx +++ b/src/pages/(admin)/BatchCreateUser/UserTable.tsx @@ -40,13 +40,9 @@ const columns = [ cell: info => info.getValue(), header: () => 'Phone Number', }), - columnHelper.accessor('corporate', { - cell: info => info.getValue(), - header: () => 'Corporate (e-mail)', - }), columnHelper.accessor('groupName', { cell: info => info.getValue(), - header: () => 'Group Name', + header: () => 'Classroom Name', }), columnHelper.accessor('demographicInformation.country', { cell: info => info.getValue(), diff --git a/src/pages/(admin)/BatchCreateUser/index.tsx b/src/pages/(admin)/BatchCreateUser/index.tsx index c286c844..9eae0799 100644 --- a/src/pages/(admin)/BatchCreateUser/index.tsx +++ b/src/pages/(admin)/BatchCreateUser/index.tsx @@ -17,6 +17,8 @@ import countryCodes from "country-codes-list"; import { User, Type as UserType } from "@/interfaces/user"; import { Type, UserImport } from "./IUserImport"; import UserTable from "./UserTable"; +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]+)*$/); @@ -62,10 +64,11 @@ const USER_TYPE_PERMISSIONS: { interface Props { user: User; permissions: PermissionType[]; + entities: EntityWithRoles[] onFinish: () => void; } -export default function BatchCreateUser({ user, permissions, onFinish }: Props) { +export default function BatchCreateUser({ user, entities, permissions, onFinish }: Props) { const [infos, setInfos] = useState([]); const [duplicatedUsers, setDuplicatedUsers] = useState([]); @@ -78,6 +81,7 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props) 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", @@ -97,7 +101,7 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props) const information = uniqBy( rows .map((row) => { - const [firstName, lastName, studentID, passport_id, email, phone, corporate, group, country] = row as string[]; + const [firstName, lastName, studentID, passport_id, email, phone, group, country] = row as string[]; const countryItem = countryCodes.findOne("countryCode" as any, country.toUpperCase()) || countryCodes.all().find((x) => x.countryNameEn.toLowerCase() === country.toLowerCase()); @@ -109,8 +113,8 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props) type: type, passport_id: passport_id?.toString().trim() || undefined, groupName: group, - corporate, studentID, + entity, demographicInformation: { country: countryItem?.countryCode, passport_id: passport_id?.toString().trim() || undefined, @@ -131,7 +135,9 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props) } setInfos(information); - } catch { + } catch(e) { + console.log(e) + toast.error( "Please upload an Excel file containing user information, one per line! All already registered e-mails have also been ignored!", ); @@ -170,22 +176,37 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props) }, [infos]); const makeUsers = async () => { - if (!confirm(`You are about to add ${newUsers.length} user${newUsers.length !== 1 ? 's' : ''}, are you sure you want to continue?`)) return; + const newUsersSentence = newUsers.length > 0 ? `create ${newUsers.length} user(s)` : undefined; + const existingUsersSentence = duplicatedUsers.length > 0 ? `invite ${duplicatedUsers.length} registered student(s)` : undefined; + if (!confirm(`You are about to ${[newUsersSentence, existingUsersSentence].filter((x) => !!x).join(" and ")}, are you sure you want to continue?`)) + return; + + /*Promise.all(duplicatedUsers.map(async (u) => await axios.post(`/api/invites`, {to: u.id, entity, from: user.id}))) + .then(() => toast.success(`Successfully invited ${duplicatedUsers.length} registered student(s)!`)) + .finally(() => { + if (newUsers.length === 0) setIsLoading(false); + }); + */ if (newUsers.length > 0) { setIsLoading(true); try { - //await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))}); + await axios.post("/api/batch_users", {users: newUsers.map((user) => ({...user, type, expiryDate}))}); toast.success(`Successfully added ${newUsers.length} user(s)!`); onFinish(); - } catch { + } catch(e) { + console.error(e) toast.error("Something went wrong, please try again later!"); } finally { setIsLoading(false); setInfos([]); clear(); } + } else { + setIsLoading(false); + setInfos([]); + clear(); } }; @@ -203,8 +224,7 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
- {user?.type !== "corporate" && } - + @@ -221,15 +241,28 @@ export default function BatchCreateUser({ user, permissions, onFinish }: Props)
-
- -
setShowHelp(true)}> - +
+
+
+ +
setShowHelp(true)}> + +
+
+ +
+
+ +
Passport/National ID E-mail Phone NumberCorporate (e-mail)Group NameClassroom Name Country
); -} +} \ No newline at end of file diff --git a/src/pages/(admin)/Lists/GroupList.tsx b/src/pages/(admin)/Lists/GroupList.tsx index 993f56da..12e5a491 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/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index d9150598..79069e30 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]); @@ -441,7 +443,6 @@ export default function ExamPage({page, user}: Props) { { setModuleIndex(0); setAvoidRepeated(avoid); @@ -520,6 +521,7 @@ export default function ExamPage({page, user}: Props) { setShowAbandonPopup(true)}> diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 0f17938c..fb6c2493 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -19,7 +19,7 @@ export default function App({Component, pageProps}: AppProps) { const router = useRouter(); useEffect(() => { - if (router.pathname !== "/exercises") reset(); + if (router.pathname !== "/exam" && router.pathname !== "/exercises") reset(); }, [router.pathname, reset]); useEffect(() => { diff --git a/src/pages/api/assignments/[id]/index.ts b/src/pages/api/assignments/[id]/index.ts index 89b8ef9f..b0fe5b8b 100644 --- a/src/pages/api/assignments/[id]/index.ts +++ b/src/pages/api/assignments/[id]/index.ts @@ -4,7 +4,6 @@ import client from "@/lib/mongodb"; import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; - const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(handler, sessionOptions); @@ -25,7 +24,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { async function GET(req: NextApiRequest, res: NextApiResponse) { const {id} = req.query; - const snapshot = await db.collection("assignments").findOne({ id: id as string }); + const snapshot = await db.collection("assignments").findOne({id: id as string}); if (snapshot) { res.status(200).json({...snapshot, id: snapshot.id}); @@ -35,9 +34,7 @@ async function GET(req: NextApiRequest, res: NextApiResponse) { async function DELETE(req: NextApiRequest, res: NextApiResponse) { const {id} = req.query; - await db.collection("assignments").deleteOne( - { id: id as string } - ); + await db.collection("assignments").deleteOne({id}); res.status(200).json({ok: true}); } @@ -45,10 +42,7 @@ async function DELETE(req: NextApiRequest, res: NextApiResponse) { async function PATCH(req: NextApiRequest, res: NextApiResponse) { const {id} = req.query; - await db.collection("assignments").updateOne( - { id: id as string }, - { $set: {assigner: req.session.user?.id, ...req.body} } - ); + await db.collection("assignments").updateOne({id: id as string}, {$set: {...req.body}}); res.status(200).json({ok: true}); } diff --git a/src/pages/api/assignments/index.ts b/src/pages/api/assignments/index.ts index 35819b58..cebcc219 100644 --- a/src/pages/api/assignments/index.ts +++ b/src/pages/api/assignments/index.ts @@ -128,8 +128,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { return; } + const id = uuidv4(); + await db.collection("assignments").insertOne({ - id: uuidv4(), + id, assigner: req.session.user?.id, assignees, results: [], @@ -138,11 +140,10 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { ...body, }); - res.status(200).json({ok: true}); + res.status(200).json({ok: true, id}); for (const assigneeID of assignees) { - - const assignee = await db.collection("users").findOne({ id: assigneeID }); + const assignee = await db.collection("users").findOne({id: assigneeID}); if (!assignee) continue; const name = body.name; @@ -163,6 +164,7 @@ async function POST(req: NextApiRequest, res: NextApiResponse) { endDate, modules: examModulesLabel, assigner: teacher.name, + id }, environment: process.env.ENVIRONMENT, }, diff --git a/src/pages/api/batch_users.ts b/src/pages/api/batch_users.ts index 4b58621c..e6ca32c4 100644 --- a/src/pages/api/batch_users.ts +++ b/src/pages/api/batch_users.ts @@ -1,61 +1,71 @@ -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'; import axios from "axios"; +import { getEntityWithRoles } from "@/utils/entities.be"; +import { findBy } from "@/utils"; 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}/user/import`, { makerID: maker.id, users: usersWithPasswordHashes }, { + 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); + + 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(); + currentUser.passwordHash = hash; + currentUser.passwordSalt = salt; + return currentUser; + })); + + const backendRequest = await axios.post(`${process.env.BACKEND_URL}/user/import`, { makerID: maker.id, users: usersWithPasswordHashes }, { headers: { Authorization: `Bearer ${process.env.BACKEND_JWT}`, }, }); - return res.status(backendRequest.status).json(backendRequest.data) + return res.status(backendRequest.status).json(backendRequest.data) } diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts new file mode 100644 index 00000000..efe76896 --- /dev/null +++ b/src/pages/api/entities/[id]/index.ts @@ -0,0 +1,61 @@ +// 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 {deleteEntity, getEntity, getEntityWithRoles} from "@/utils/entities.be"; +import client from "@/lib/mongodb"; +import {Entity} from "@/interfaces/entity"; +import { doesEntityAllow } from "@/utils/permissions"; +import { getUser } from "@/utils/users.be"; +import { requestUser } from "@/utils/api"; + +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); + if (req.method === "DELETE") return await del(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {id, showRoles} = req.query as {id: string; showRoles: string}; + + const entity = await (!!showRoles ? getEntityWithRoles : getEntity)(id); + res.status(200).json(entity); +} + +async function del(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const { id } = req.query as { id: string }; + + const entity = await getEntityWithRoles(id) + if (!entity) return res.status(404).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}); +} + +async function patch(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {id} = req.query as {id: string}; + + 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/groups.ts b/src/pages/api/entities/groups.ts new file mode 100644 index 00000000..cf210d2f --- /dev/null +++ b/src/pages/api/entities/groups.ts @@ -0,0 +1,29 @@ +// 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, getUser, getUsers } from "@/utils/users.be"; +import { Group, User } from "@/interfaces/user"; +import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; +import { requestUser } from "@/utils/api"; + +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) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + 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/index.ts b/src/pages/api/entities/index.ts new file mode 100644 index 00000000..7c8b3896 --- /dev/null +++ b/src/pages/api/entities/index.ts @@ -0,0 +1,52 @@ +// 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 {addUsersToEntity, addUserToEntity, createEntity, getEntities, getEntitiesWithRoles} from "@/utils/entities.be"; +import {Entity} from "@/interfaces/entity"; +import {v4} from "uuid"; +import { requestUser } from "@/utils/api"; + +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) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + 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) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + if (!["admin", "developer"].includes(user.type)) { + return res.status(403).json({ok: false}); + } + + const entity: Entity = { + id: v4(), + label: req.body.label, + }; + + 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/api/entities/users.ts b/src/pages/api/entities/users.ts new file mode 100644 index 00000000..ed162db4 --- /dev/null +++ b/src/pages/api/entities/users.ts @@ -0,0 +1,63 @@ +// 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, EntityWithRoles, WithEntities, WithLabeledEntities } from "@/interfaces/entity"; +import { v4 } from "uuid"; +import { mapBy } from "@/utils"; +import { getEntitiesUsers, getUser, getUsers } from "@/utils/users.be"; +import { User } from "@/interfaces/user"; +import { findAllowedEntities } from "@/utils/permissions"; +import { RolePermission } from "@/resources/entityPermissions"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "GET") return await get(req, res); +} + +const labelUserEntity = (u: User, entities: EntityWithRoles[]) => ({ + ...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 } + }) +}) + +async function get(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }); + + const user = await getUser(req.session.user.id) + if (!user) return res.status(401).json({ ok: false }); + + const { type } = req.query as { type: string } + + const entityIDs = mapBy(user.entities || [], 'id') + const entities = await getEntitiesWithRoles(entityIDs) + + const isAdmin = ["admin", "developer"].includes(user.type) + + const filter = !type ? undefined : { type } + const users = isAdmin + ? await getUsers(filter) + : await getEntitiesUsers(mapBy(entities, 'id') as string[], filter) + + const filteredUsers = users.map((u) => { + if (isAdmin) return labelUserEntity(u, entities) + if (!isAdmin && ["admin", "developer", "agent"].includes(user.type)) return undefined + + const userEntities = mapBy(u.entities || [], 'id') + const sameEntities = entities.filter(e => userEntities.includes(e.id)) + + const permission = `view_${u.type}s` as RolePermission + const allowedEntities = findAllowedEntities(user, sameEntities, permission) + + if (allowedEntities.length === 0) return undefined + return labelUserEntity(u, allowedEntities) + }).filter(x => !!x) as WithLabeledEntities[] + + res.status(200).json(filteredUsers); +} diff --git a/src/pages/api/groups/[id].ts b/src/pages/api/groups/[id].ts index 48df6782..cdc09c1d 100644 --- a/src/pages/api/groups/[id].ts +++ b/src/pages/api/groups/[id].ts @@ -5,6 +5,7 @@ import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {Group} from "@/interfaces/user"; import {updateExpiryDateOnGroup} from "@/utils/groups.be"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -19,10 +20,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } async function get(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; @@ -36,10 +35,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } async function del(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; const group = await db.collection("groups").findOne({id: id}); @@ -49,7 +46,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) { return; } - const user = req.session.user; if (user.type === "admin" || user.type === "developer" || user.id === group.admin) { await db.collection("groups").deleteOne({id: id}); @@ -61,10 +57,8 @@ async function del(req: NextApiRequest, res: NextApiResponse) { } async function patch(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; @@ -74,14 +68,20 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return; } - 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..86c0ae63 100644 --- a/src/pages/api/groups/index.ts +++ b/src/pages/api/groups/index.ts @@ -39,11 +39,13 @@ 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, + entity: body.entity, + }); + res.status(200).json({ok: true, id}); } diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts index f8bcc7e5..6bd87e9d 100644 --- a/src/pages/api/hello.ts +++ b/src/pages/api/hello.ts @@ -1,13 +1,16 @@ // 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: []}}); + await db.collection("invites").deleteMany({}); + + res.status(200).json({name: "John Doe"}); } diff --git a/src/pages/api/invites/[id].ts b/src/pages/api/invites/[id].ts index eb0ce24f..ac069e95 100644 --- a/src/pages/api/invites/[id].ts +++ b/src/pages/api/invites/[id].ts @@ -4,6 +4,7 @@ import client from "@/lib/mongodb"; import { withIronSessionApiRoute } from "iron-session/next"; import { sessionOptions } from "@/lib/session"; import { Invite } from "@/interfaces/invite"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -18,10 +19,8 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } async function get(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const { id } = req.query as { id: string }; @@ -35,10 +34,8 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } async function del(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const { id } = req.query as { id: string }; @@ -48,7 +45,6 @@ async function del(req: NextApiRequest, res: NextApiResponse) { return; } - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { await db.collection("invites").deleteOne({ id: id }); res.status(200).json({ ok: true }); @@ -59,13 +55,10 @@ async function del(req: NextApiRequest, res: NextApiResponse) { } async function patch(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const { id } = req.query as { id: string }; - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { await db.collection("invites").updateOne( diff --git a/src/pages/api/invites/accept/[id].ts b/src/pages/api/invites/accept/[id].ts index 26522100..7093ff47 100644 --- a/src/pages/api/invites/accept/[id].ts +++ b/src/pages/api/invites/accept/[id].ts @@ -8,6 +8,8 @@ import { CorporateUser, Group, User } from "@/interfaces/user"; import { v4 } from "uuid"; import { sendEmail } from "@/email"; import { updateExpiryDateOnGroup } from "@/utils/groups.be"; +import { addUserToEntity, getEntity, getEntityWithRoles } from "@/utils/entities.be"; +import { findBy } from "@/utils"; const db = client.db(process.env.MONGODB_DB); @@ -19,72 +21,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { res.status(404).json(undefined); } -async function addToInviterGroup(user: User, invitedBy: User) { - const invitedByGroups = await db.collection("groups").find({ admin: invitedBy.id }).toArray(); - const typeGroupName = user.type === "student" ? "Students" : user.type === "teacher" ? "Teachers" : undefined; - - if (typeGroupName) { - const typeGroup: Group = invitedByGroups.find((g) => g.name === typeGroupName) || { - id: v4(), - admin: invitedBy.id, - name: typeGroupName, - participants: [], - disableEditing: true, - }; - - await db.collection("groups").updateOne( - { id: typeGroup.id }, - { - $set: { - ...typeGroup, - participants: [...typeGroup.participants.filter((x) => x !== user.id), user.id], - }, - }, - { upsert: true } - ); - - } - - const invitationsGroup: Group = invitedByGroups.find((g) => g.name === "Invited") || { - id: v4(), - admin: invitedBy.id, - name: "Invited", - participants: [], - disableEditing: true, - }; - - await db.collection("groups").updateOne( - { id: invitationsGroup.id }, - { - $set: { - ...invitationsGroup, - participants: [...invitationsGroup.participants.filter((x) => x !== user.id), user.id], - } - }, - { upsert: true } - ); -} - -async function deleteFromPreviousCorporateGroups(user: User, invitedBy: User) { - const corporatesRef = await db.collection("users").find({ type: "corporate" }).toArray(); - const corporates = corporatesRef.filter((x) => x.id !== invitedBy.id); - - const userGroups = await db.collection("groups").find({ - participants: user.id - }).toArray(); - - const corporateGroups = userGroups.filter((x) => corporates.map((c) => c.id).includes(x.admin)); - await Promise.all( - corporateGroups.map(async (group) => { - await db.collection("groups").updateOne( - { id: group.id }, - { $set: { participants: group.participants.filter((x) => x !== user.id) } }, - { upsert: true } - ); - }), - ); -} - async function get(req: NextApiRequest, res: NextApiResponse) { if (!req.session.user) { res.status(401).json({ ok: false }); @@ -102,10 +38,11 @@ async function get(req: NextApiRequest, res: NextApiResponse) { const invitedBy = await db.collection("users").findOne({ id: invite.from}); if (!invitedBy) return res.status(404).json({ ok: false }); - await updateExpiryDateOnGroup(invite.to, invite.from); + const inviteEntity = await getEntityWithRoles(invite.entity) + if (!inviteEntity) return res.status(404).json({ ok: false }); - if (invitedBy.type === "corporate") await deleteFromPreviousCorporateGroups(req.session.user, invitedBy); - await addToInviterGroup(req.session.user, invitedBy); + const defaultRole = findBy(inviteEntity.roles, 'isDefault', true)! + await addUserToEntity(invite.to, inviteEntity.id, defaultRole.id) try { await sendEmail( diff --git a/src/pages/api/invites/index.ts b/src/pages/api/invites/index.ts index 9478abd2..837a7598 100644 --- a/src/pages/api/invites/index.ts +++ b/src/pages/api/invites/index.ts @@ -8,6 +8,8 @@ import client from "@/lib/mongodb"; import {withIronSessionApiRoute} from "iron-session/next"; import type {NextApiRequest, NextApiResponse} from "next"; import ShortUniqueId from "short-unique-id"; +import { Entity } from "@/interfaces/entity"; +import { getEntity } from "@/utils/entities.be"; const db = client.db(process.env.MONGODB_DB); @@ -36,20 +38,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const invited = await db.collection("users").findOne({ id: body.to}); if (!invited) return res.status(404).json({ok: false}); - const invitedBy = await db.collection("users").findOne({ id: body.from}); - if (!invitedBy) return res.status(404).json({ok: false}); + const entity = await getEntity(body.entity) + if (!entity) return res.status(404).json({ok: false}); try { await sendEmail( "receivedInvite", { name: invited.name, - corporateName: - invitedBy.type === "corporate" ? invitedBy.corporateInformation?.companyInformation?.name || invitedBy.name : invitedBy.name, + entity: entity.label, environment: process.env.ENVIRONMENT, }, [invited.email], - "You have been invited to a group!", + "You have been invited to an entity!", ); } catch (e) { console.log(e); diff --git a/src/pages/api/make_user.ts b/src/pages/api/make_user.ts index b8128015..bd05953a 100644 --- a/src/pages/api/make_user.ts +++ b/src/pages/api/make_user.ts @@ -1,28 +1,30 @@ -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"; +import { getEntityWithRoles } from "@/utils/entities.be"; +import { findBy } from "@/utils"; 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 +32,100 @@ 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 entityWithRole = await getEntityWithRoles(entity) + const defaultRole = findBy(entityWithRole?.roles || [], "isDefault", true) - 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 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: defaultRole?.id || "" }], + 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 (type === "corporate") { - const students = maker.type === "corporate" ? await getUsersOfType(maker.id, "student") : []; - const teachers = maker.type === "corporate" ? await getUsersOfType(maker.id, "teacher") : []; + if (!!groupID) { + const group = await getGroup(groupID); + if (!!group) await db.collection("groups").updateOne({ id: group.id }, { $set: { participants: [...group.participants, userId] } }); + } - const defaultTeachersGroup: Group = { - admin: userId, - id: v4(), - name: "Teachers", - participants: teachers, - disableEditing: true, - }; + 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 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/payments/[id].ts b/src/pages/api/payments/[id].ts index 58167029..d69ba47d 100644 --- a/src/pages/api/payments/[id].ts +++ b/src/pages/api/payments/[id].ts @@ -7,6 +7,7 @@ import {Group} from "@/interfaces/user"; import {Payment} from "@/interfaces/paypal"; import {deleteObject, ref} from "firebase/storage"; import client from "@/lib/mongodb"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -38,17 +39,14 @@ async function get(req: NextApiRequest, res: NextApiResponse) { } async function del(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; const payment = await db.collection("payments").findOne({id}); if (!payment) return res.status(404).json({ok: false}); - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { if (payment.commissionTransfer) await deleteObject(ref(storage, payment.commissionTransfer)); if (payment.corporateTransfer) await deleteObject(ref(storage, payment.corporateTransfer)); @@ -62,17 +60,14 @@ async function del(req: NextApiRequest, res: NextApiResponse) { } async function patch(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ok: false}); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const {id} = req.query as {id: string}; const payment = await db.collection("payments").findOne({id}); if (!payment) return res.status(404).json({ok: false}); - const user = req.session.user; if (user.type === "admin" || user.type === "developer") { await db.collection("payments").updateOne({id: payment.id}, {$set: req.body}); diff --git a/src/pages/api/paypal/approve.ts b/src/pages/api/paypal/approve.ts index f2603125..9908f69a 100644 --- a/src/pages/api/paypal/approve.ts +++ b/src/pages/api/paypal/approve.ts @@ -11,6 +11,7 @@ import { OrderResponseBody } from "@paypal/paypal-js"; import { getAccessToken } from "@/utils/paypal"; import moment from "moment"; import { Group } from "@/interfaces/user"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -25,6 +26,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { if (!accessToken) return res.status(401).json({ ok: false, reason: "Authorization failed!" }); + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + const { id, duration, duration_unit, trackingId } = req.body as { id: string; duration: number; diff --git a/src/pages/api/roles/[id]/index.ts b/src/pages/api/roles/[id]/index.ts new file mode 100644 index 00000000..9e9e4397 --- /dev/null +++ b/src/pages/api/roles/[id]/index.ts @@ -0,0 +1,79 @@ +// 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 {getEntityWithRoles} from "@/utils/entities.be"; +import client from "@/lib/mongodb"; +import {Entity} from "@/interfaces/entity"; +import { deleteRole, getRole, transferRole } from "@/utils/roles.be"; +import { doesEntityAllow } from "@/utils/permissions"; +import { findBy } from "@/utils"; +import { requestUser } from "@/utils/api"; + +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); + if (req.method === "DELETE") return await del(req, res); +} + +async function get(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {id} = req.query as {id: string}; + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + res.status(200).json(role); +} + +async function del(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const { id } = req.query as { id: string }; + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + if (role.isDefault) return res.status(403).json({ok: false}) + + const entity = await getEntityWithRoles(role.entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "delete_entity_role")) return res.status(403).json({ok: false}) + + const defaultRole = findBy(entity.roles, 'isDefault', true)! + + await transferRole(role.id, defaultRole.id) + await deleteRole(role.id) + + return res.status(200).json({ok: true}); +} + +async function patch(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const { id } = req.query as { id: string }; + const {label, permissions} = req.body as {label?: string, permissions?: string} + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + const entity = await getEntityWithRoles(role.entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "rename_entity_role") && !!label) return res.status(403).json({ok: false}) + if (!doesEntityAllow(user, entity, "edit_role_permissions") && !!permissions) return res.status(403).json({ok: false}) + + if (!!label) await db.collection("roles").updateOne({ id }, { $set: {label} }); + if (!!permissions) await db.collection("roles").updateOne({ id }, { $set: {permissions} }); + + return res.status(200).json({ok: true}); +} diff --git a/src/pages/api/roles/[id]/users.ts b/src/pages/api/roles/[id]/users.ts new file mode 100644 index 00000000..8a1b7efc --- /dev/null +++ b/src/pages/api/roles/[id]/users.ts @@ -0,0 +1,40 @@ +// 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 {getEntityWithRoles} from "@/utils/entities.be"; +import client from "@/lib/mongodb"; +import {Entity} from "@/interfaces/entity"; +import { assignRoleToUsers, deleteRole, getRole, transferRole } from "@/utils/roles.be"; +import { doesEntityAllow } from "@/utils/permissions"; +import { findBy } from "@/utils"; +import { getUser } from "@/utils/users.be"; + +const db = client.db(process.env.MONGODB_DB); + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method === "POST") return await post(req, res); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) return res.status(401).json({ ok: false }) + + const user = await getUser(req.session.user.id); + if (!user) return res.status(401).json({ ok: false }) + + const { id } = req.query as { id: string }; + const {users} = req.body as {users: string[]} + + const role = await getRole(id) + if (!role) return res.status(404).json({ok: false}) + + const entity = await getEntityWithRoles(role.entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "assign_to_role")) return res.status(403).json({ok: false}) + + const result = await assignRoleToUsers(users, entity.id, role.id) + return res.status(200).json({ok: result.acknowledged}); +} diff --git a/src/pages/api/roles/index.ts b/src/pages/api/roles/index.ts new file mode 100644 index 00000000..5ffe94d2 --- /dev/null +++ b/src/pages/api/roles/index.ts @@ -0,0 +1,50 @@ +// 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, getEntity, getEntityWithRoles} from "@/utils/entities.be"; +import {Entity} from "@/interfaces/entity"; +import {v4} from "uuid"; +import { createRole, getRoles, getRolesByEntity } from "@/utils/roles.be"; +import { mapBy } from "@/utils"; +import { RolePermission } from "@/resources/entityPermissions"; +import { doesEntityAllow } from "@/utils/permissions"; +import { User } from "@/interfaces/user"; +import { requestUser } from "@/utils/api"; + +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) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + if (["admin", "developer"].includes(user.type)) return res.status(200).json(await getRoles()); + res.status(200).json(await getRoles(mapBy(user.entities, 'role'))); +} + +async function post(req: NextApiRequest, res: NextApiResponse) { + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + + const {entityID, label, permissions} = req.body as {entityID: string, label: string, permissions: RolePermission[]} + + const entity = await getEntityWithRoles(entityID) + if (!entity) return res.status(404).json({ok: false}) + + if (!doesEntityAllow(user, entity, "create_entity_role")) return res.status(403).json({ok: false}) + + const role = { + id: v4(), + entityID, + label, + permissions + } + + await createRole(role) + return res.status(200).json(role); +} diff --git a/src/pages/api/stats/index.ts b/src/pages/api/stats/index.ts index 41cb6d77..4b150553 100644 --- a/src/pages/api/stats/index.ts +++ b/src/pages/api/stats/index.ts @@ -6,6 +6,7 @@ import { sessionOptions } from "@/lib/session"; import { Stat } from "@/interfaces/user"; import { Assignment } from "@/interfaces/results"; import { groupBy } from "lodash"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); @@ -17,20 +18,17 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { } async function get(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); + const snapshot = await db.collection("stats").find({}).toArray(); res.status(200).json(snapshot); } async function post(req: NextApiRequest, res: NextApiResponse) { - if (!req.session.user) { - res.status(401).json({ ok: false }); - return; - } + const user = await requestUser(req, res) + if (!user) return res.status(401).json({ ok: false }); const stats = req.body as Stat[]; stats.forEach(async (stat) => await db.collection("stats").updateOne( @@ -59,7 +57,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) { $set: { results: [ ...assignmentSnapshot ? assignmentSnapshot.results : [], - { user: req.session.user?.id, type: req.session.user?.focus, stats: assignmentStats }, + { user: user.id, type: user.focus, stats: assignmentStats }, ], } } diff --git a/src/pages/api/stats/update.ts b/src/pages/api/stats/update.ts index 11c76017..f3d27fbb 100644 --- a/src/pages/api/stats/update.ts +++ b/src/pages/api/stats/update.ts @@ -10,21 +10,23 @@ import client from "@/lib/mongodb"; import {withIronSessionApiRoute} from "iron-session/next"; import {groupBy} from "lodash"; import {NextApiRequest, NextApiResponse} from "next"; +import { requestUser } from "@/utils/api"; const db = client.db(process.env.MONGODB_DB); export default withIronSessionApiRoute(update, sessionOptions); async function update(req: NextApiRequest, res: NextApiResponse) { - if (req.session.user) { - const docUser = await db.collection("users").findOne({ id: req.session.user.id }); + const user = await requestUser(req, res) + if (user) { + const docUser = await db.collection("users").findOne({ id: user.id }); if (!docUser) { res.status(401).json(undefined); return; } - const stats = await db.collection("stats").find({ user: req.session.user.id }).toArray(); + const stats = await db.collection("stats").find({ user: user.id }).toArray(); const groupedStats = groupBySession(stats); const sessionLevels: {[key in Module]: {correct: number; total: number}}[] = Object.keys(groupedStats).map((key) => { @@ -91,18 +93,18 @@ async function update(req: NextApiRequest, res: NextApiResponse) { const levels = { - reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", req.session.user.focus), - listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", req.session.user.focus), - writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", req.session.user.focus), - speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", req.session.user.focus), - level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", req.session.user.focus), + reading: calculateBandScore(readingLevel.correct, readingLevel.total, "reading", user.focus), + listening: calculateBandScore(listeningLevel.correct, listeningLevel.total, "listening", user.focus), + writing: calculateBandScore(writingLevel.correct, writingLevel.total, "writing", user.focus), + speaking: calculateBandScore(speakingLevel.correct, speakingLevel.total, "speaking", user.focus), + level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", user.focus), }; await db.collection("users").updateOne( - { id: req.session.user.id}, + { id: user.id}, { $set: {levels} } ); - + res.status(200).json({ok: true}); } else { res.status(401).json(undefined); diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts index c1e8ceac..7ee2cdca 100644 --- a/src/pages/api/user.ts +++ b/src/pages/api/user.ts @@ -7,7 +7,9 @@ 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"; +import { getUser } from "@/utils/users.be"; const auth = getAuth(adminApp); const db = client.db(process.env.MONGODB_DB); @@ -41,20 +43,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,22 +50,18 @@ 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}); } async function get(req: NextApiRequest, res: NextApiResponse) { if (req.session.user) { - const user = await db.collection("users").findOne({id: req.session.user.id}); - if (!user) { - res.status(401).json(undefined); - return; - } + const user = await getUser(req.session.user.id) + if (!user) return res.status(401).json(undefined); await db.collection("users").updateOne({id: user.id}, {$set: {lastLogin: new Date().toISOString()}}); diff --git a/src/pages/assignments/[id].tsx b/src/pages/assignments/[id].tsx new file mode 100644 index 00000000..6ce4e703 --- /dev/null +++ b/src/pages/assignments/[id].tsx @@ -0,0 +1,453 @@ +import Button from "@/components/Low/Button"; +import ProgressBar from "@/components/Low/ProgressBar"; +import Modal from "@/components/Modal"; +import useUsers from "@/hooks/useUsers"; +import {Module} from "@/interfaces"; +import {Assignment} from "@/interfaces/results"; +import {Group, Stat, User} from "@/interfaces/user"; +import useExamStore from "@/stores/examStore"; +import {getExamById} from "@/utils/exams"; +import {sortByModule} from "@/utils/moduleUtils"; +import {calculateBandScore} from "@/utils/score"; +import {convertToUserSolutions} from "@/utils/stats"; +import {getUserName} from "@/utils/users"; +import axios from "axios"; +import clsx from "clsx"; +import {capitalize, uniqBy} from "lodash"; +import moment from "moment"; +import {useRouter} from "next/router"; +import {BsBook, BsBuilding, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen} from "react-icons/bs"; +import {toast} from "react-toastify"; +import {futureAssignmentFilter} from "@/utils/assignments"; +import {withIronSessionSsr} from "iron-session/next"; +import {checkAccess, doesEntityAllow} from "@/utils/permissions"; +import {mapBy, redirect, serialize} from "@/utils"; +import {getAssignment} from "@/utils/assignments.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"; +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) + if (!user) return redirect("/login") + + if (!checkAccess(user, ["admin", "developer", "corporate", "teacher", "mastercorporate"])) + return redirect("/assignments") + + res.setHeader("Cache-Control", "public, s-maxage=10, stale-while-revalidate=59"); + + const {id} = params as {id: string}; + + const assignment = await getAssignment(id); + if (!assignment) return redirect("/assignments") + + const entity = await getEntityWithRoles(assignment.entity || "") + if (!entity){ + const users = await getUsers() + return {props: serialize({user, users, assignment})}; + } + + 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; + entity?: EntityWithRoles +} + +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); + const setSelectedModules = useExamStore((state) => state.setSelectedModules); + + const router = useRouter(); + + const deleteAssignment = async () => { + if (!canDeleteAssignment) return + if (!confirm("Are you sure you want to delete this assignment?")) return; + + axios + .delete(`/api/assignments/${assignment?.id}`) + .then(() => toast.success(`Successfully deleted the assignment "${assignment?.name}".`)) + .catch(() => toast.error("Something went wrong, please try again later.")) + .finally(() => router.push("/assignments")); + }; + + const startAssignment = () => { + 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) => { + const date = moment(parseInt(timestamp)); + const formatter = "YYYY/MM/DD - HH:mm"; + + return date.format(formatter); + }; + + const calculateAverageModuleScore = (module: Module) => { + if (!assignment) return -1; + + const resultModuleBandScores = assignment.results.map((r) => { + const moduleStats = r.stats.filter((s) => s.module === module); + + const correct = moduleStats.reduce((acc, curr) => acc + curr.score.correct, 0); + const total = moduleStats.reduce((acc, curr) => acc + curr.score.total, 0); + return calculateBandScore(correct, total, module, r.type); + }); + + return resultModuleBandScores.length === 0 ? -1 : resultModuleBandScores.reduce((acc, curr) => acc + curr, 0) / assignment.results.length; + }; + + const aggregateScoresByModule = (stats: Stat[]): {module: Module; total: number; missing: number; correct: number}[] => { + const scores: { + [key in Module]: {total: number; missing: number; correct: number}; + } = { + reading: { + total: 0, + correct: 0, + missing: 0, + }, + listening: { + total: 0, + correct: 0, + missing: 0, + }, + writing: { + total: 0, + correct: 0, + missing: 0, + }, + speaking: { + total: 0, + correct: 0, + missing: 0, + }, + level: { + total: 0, + correct: 0, + missing: 0, + }, + }; + + stats.forEach((x) => { + scores[x.module!] = { + total: scores[x.module!].total + x.score.total, + correct: scores[x.module!].correct + x.score.correct, + missing: scores[x.module!].missing + x.score.missing, + }; + }); + + return Object.keys(scores) + .filter((x) => scores[x as Module].total > 0) + .map((x) => ({module: x as Module, ...scores[x as Module]})); + }; + + const customContent = (stats: Stat[], user: string, focus: "academic" | "general") => { + const correct = stats.reduce((accumulator, current) => accumulator + current.score.correct, 0); + const total = stats.reduce((accumulator, current) => accumulator + current.score.total, 0); + const aggregatedScores = aggregateScoresByModule(stats).filter((x) => x.total > 0); + + const aggregatedLevels = aggregatedScores.map((x) => ({ + module: x.module, + level: calculateBandScore(x.correct, x.total, x.module, focus), + })); + + const timeSpent = stats[0].timeSpent; + + const selectExam = () => { + const examPromises = uniqBy(stats, "exam").map((stat) => getExamById(stat.module, stat.exam)); + + Promise.all(examPromises).then((exams) => { + if (exams.every((x) => !!x)) { + setUserSolutions(convertToUserSolutions(stats)); + setShowSolutions(true); + setExams(exams.map((x) => x!).sort(sortByModule)); + setSelectedModules( + exams + .map((x) => x!) + .sort(sortByModule) + .map((x) => x!.module), + ); + router.push("/exam"); + } + }); + }; + + const content = ( + <> +
+
+ {formatTimestamp(stats[0].date.toString())} + {timeSpent && ( + <> + + {Math.floor(timeSpent / 60)} minutes + + )} +
+ = 0.7 && "text-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "text-mti-red", + correct / total < 0.3 && "text-mti-rose", + )}> + Level{" "} + {(aggregatedLevels.reduce((accumulator, current) => accumulator + current.level, 0) / aggregatedLevels.length).toFixed(1)} + +
+ +
+
+ {aggregatedLevels.map(({module, level}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {level.toFixed(1)} +
+ ))} +
+
+ + ); + + return ( +
+ + {(() => { + const student = users.find((u) => u.id === user); + return `${student?.name} (${student?.email})`; + })()} + +
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + onClick={selectExam} + role="button"> + {content} +
+
= 0.7 && "hover:border-mti-purple", + correct / total >= 0.3 && correct / total < 0.7 && "hover:border-mti-red", + correct / total < 0.3 && "hover:border-mti-rose", + )} + data-tip="Your screen size is too small to view previous exams." + role="button"> + {content} +
+
+ ); + }; + + const shouldRenderStart = () => { + if (assignment) { + if (futureAssignmentFilter(assignment)) { + return true; + } + } + + return false; + }; + + const removeInactiveAssignees = () => { + const mappedResults = mapBy(assignment.results, 'user') + const inactiveAssignees = assignment.assignees.filter((a) => !mappedResults.includes(a)) + const activeAssignees = assignment.assignees.filter((a) => mappedResults.includes(a)) + + if (!confirm(`Are you sure you want to remove ${inactiveAssignees.length} assignees?`)) return + + axios + .patch(`/api/assignments/${assignment.id}`, {assignees: activeAssignees}) + .then(() => { + toast.success(`The assignment "${assignment.name}" has been updated successfully!`); + router.replace(router.asPath); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }); + } + + 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 ( + <> + + {assignment.name} | EnCoach + + + + + +
+
+
+ + + +

{assignment.name}

+
+ {!!entity && ( + + {entity.label} + + )} +
+ +
+
+ +
+
+ Start Date: {moment(assignment?.startDate).format("DD/MM/YY, HH:mm")} + End Date: {moment(assignment?.endDate).format("DD/MM/YY, HH:mm")} +
+
+ + Assignees:{" "} + {users + .filter((u) => assignment?.assignees.includes(u.id)) + .map((u) => `${u.name} (${u.email})`) + .join(", ")} + + Assigner: {getUserName(users.find((x) => x.id === assignment?.assigner))} +
+
+ + {assignment.assignees.length !== 0 && assignment.results.length !== assignment.assignees.length && ( + + )} + +
+ Average Scores +
+ {assignment && + uniqBy(assignment.exams, (x) => x.module).map(({module}) => ( +
+ {module === "reading" && } + {module === "listening" && } + {module === "writing" && } + {module === "speaking" && } + {module === "level" && } + {calculateAverageModuleScore(module) > -1 && ( + {calculateAverageModuleScore(module).toFixed(1)} + )} +
+ ))} +
+
+
+ + Results ({assignment?.results.length}/{assignment?.assignees.length}) + +
+ {assignment && assignment?.results.length > 0 && ( +
+ {assignment.results.map((r) => customContent(r.stats, r.user, r.type))} +
+ )} + {assignment && assignment?.results.length === 0 && No results yet...} +
+
+ +
+ + {assignment && + (assignment.results.length === assignment.assignees.length || moment().isAfter(moment(assignment.endDate))) && ( + + )} + {/** if the assignment is not deemed as active yet, display start */} + {shouldRenderStart() && ( + + )} + +
+
+
+ + ); +} diff --git a/src/pages/assignments/creator/[id].tsx b/src/pages/assignments/creator/[id].tsx new file mode 100644 index 00000000..4f3e8bca --- /dev/null +++ b/src/pages/assignments/creator/[id].tsx @@ -0,0 +1,615 @@ +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 ProgressBar from "@/components/Low/ProgressBar"; +import Select from "@/components/Low/Select"; +import Separator from "@/components/Low/Separator"; +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 {InstructorGender, Variant} from "@/interfaces/exam"; +import {Assignment} from "@/interfaces/results"; +import {Group, User} from "@/interfaces/user"; +import {sessionOptions} from "@/lib/session"; +import {mapBy, redirect, serialize} from "@/utils"; +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, doesEntityAllow, findAllowedEntities} from "@/utils/permissions"; +import {calculateAverageLevel} from "@/utils/score"; +import {getEntitiesUsers, getUsers} from "@/utils/users.be"; +import axios from "axios"; +import clsx from "clsx"; +import {withIronSessionSsr} from "iron-session/next"; +import {capitalize} from "lodash"; +import moment from "moment"; +import Head from "next/head"; +import Link from "next/link"; +import {useRouter} from "next/router"; +import {generate} from "random-words"; +import {useEffect, useMemo, useState} from "react"; +import ReactDatePicker from "react-datepicker"; +import {BsBook, BsCheckCircle, BsChevronLeft, BsClipboard, BsHeadphones, BsMegaphone, BsPen, BsXCircle} from "react-icons/bs"; +import {toast} from "react-toastify"; + +export const getServerSideProps = withIronSessionSsr(async ({req, res, params}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + 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("/assignments") + + const entities = await (checkAccess(user, ["developer", "admin"]) ? getEntitiesWithRoles() : getEntitiesWithRoles(entityIDS)); + const entity = entities.find((e) => e.id === assignment.entity) + + 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 { + assignment: Assignment; + groups: Group[]; + user: User; + users: User[]; + entities: EntityWithRoles[]; +} + +const SIZE = 9; + +export default function AssignmentsPage({assignment, user, users, entities, groups}: Props) { + const [selectedModules, setSelectedModules] = useState(assignment.exams.map((e) => e.module)); + const [assignees, setAssignees] = useState(assignment.assignees); + const [teachers, setTeachers] = useState(assignment.teachers || []); + const [entity, setEntity] = useState(assignment.entity || entities[0]?.id); + const [name, setName] = useState(assignment.name); + const [isLoading, setIsLoading] = useState(false); + + const [startDate, setStartDate] = useState(moment(assignment.startDate).toDate()); + const [endDate, setEndDate] = useState(moment(assignment.endDate).toDate()); + + const [variant, setVariant] = useState("full"); + const [instructorGender, setInstructorGender] = useState(assignment?.instructorGender || "varied"); + + const [generateMultiple, setGenerateMultiple] = useState(false); + const [released, setReleased] = useState(assignment.released || false); + + const [autoStart, setAutostart] = useState(assignment.autoStart || false); + const [autoStartDate, setAutoStartDate] = useState(moment(assignment.autoStartDate).toDate()); + + const [useRandomExams, setUseRandomExams] = useState(true); + const [examIDs, setExamIDs] = useState<{id: string; module: Module}[]>([]); + + const {exams} = useExams(); + const router = useRouter(); + + const classrooms = useMemo(() => groups.filter((e) => e.entity === entity), [entity, groups]); + + const userStudents = useMemo(() => users.filter((x) => x.type === "student"), [users]); + const userTeachers = useMemo(() => users.filter((x) => x.type === "teacher"), [users]); + + const {rows: filteredStudentsRows, renderSearch: renderStudentSearch} = useListSearch([["name"], ["email"]], userStudents); + const {rows: filteredTeachersRows, renderSearch: renderTeacherSearch} = useListSearch([["name"], ["email"]], userTeachers); + + const {items: studentRows, renderMinimal: renderStudentPagination} = usePagination(filteredStudentsRows, SIZE); + const {items: teacherRows, renderMinimal: renderTeacherPagination} = usePagination(filteredTeachersRows, SIZE); + + useEffect(() => { + setExamIDs((prev) => prev.filter((x) => selectedModules.includes(x.module))); + }, [selectedModules]); + + useEffect(() => { + setAssignees([]); + setTeachers([]); + }, [entity]); + + const toggleModule = (module: Module) => { + const modules = selectedModules.filter((x) => x !== module); + setSelectedModules((prev) => (prev.includes(module) ? modules : [...modules, module])); + }; + + const toggleAssignee = (user: User) => { + setAssignees((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); + }; + + const toggleTeacher = (user: User) => { + setTeachers((prev) => (prev.includes(user.id) ? prev.filter((a) => a !== user.id) : [...prev, user.id])); + }; + + const createAssignment = () => { + setIsLoading(true); + + (assignment ? axios.patch : axios.post)(`/api/assignments${assignment.id}`, { + assignees, + name, + startDate, + examIDs: !useRandomExams ? examIDs : undefined, + endDate, + selectedModules, + generateMultiple, + entity, + teachers, + variant, + instructorGender, + released, + autoStart, + autoStartDate, + }) + .then(() => { + toast.success(`The assignment "${name}" has been updated successfully!`); + router.push(`/assignments/${assignment.id}`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + }; + + const deleteAssignment = () => { + if (!confirm(`Are you sure you want to delete the "${assignment.name}" assignment?`)) return; + console.log("GOT HERE"); + setIsLoading(true); + + axios + .delete(`/api/assignments/${assignment.id}`) + .then(() => { + toast.success(`The assignment "${name}" has been deleted successfully!`); + router.push("/assignments"); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(false)); + }; + + const startAssignment = () => { + if (assignment) { + setIsLoading(true); + + axios + .post(`/api/assignments/${assignment.id}/start`) + .then(() => { + toast.success(`The assignment "${name}" has been started successfully!`); + router.push(`/assignments/${assignment.id}`); + }) + .catch((e) => { + console.log(e); + toast.error("Something went wrong, please try again later!"); + }) + .finally(() => setIsLoading(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 ( + <> + + Edit {assignment.name} | EnCoach + + + + + +
+
+ + + +

Edit {assignment.name}

+
+ +
+
+
+
toggleModule("reading") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("reading") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Reading + {!selectedModules.includes("reading") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("reading") && } +
+
toggleModule("listening") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("listening") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Listening + {!selectedModules.includes("listening") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("listening") && } +
+
toggleModule("level") + : undefined + } + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("level") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Level + {!selectedModules.includes("level") && selectedModules.length === 0 && ( +
+ )} + {!selectedModules.includes("level") && selectedModules.length > 0 && } + {selectedModules.includes("level") && } +
+
toggleModule("writing") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("writing") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Writing + {!selectedModules.includes("writing") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("writing") && } +
+
toggleModule("speaking") : undefined} + className={clsx( + "w-52 relative max-w-xs flex items-center bg-mti-white-alt transition duration-300 ease-in-out border p-5 rounded-xl gap-8 cursor-pointer", + selectedModules.includes("speaking") ? "border-mti-purple-light" : "border-mti-gray-platinum", + )}> +
+ +
+ Speaking + {!selectedModules.includes("speaking") && !selectedModules.includes("level") && ( +
+ )} + {selectedModules.includes("level") && } + {selectedModules.includes("speaking") && } +
+
+ +
+ setName(e)} defaultValue={name} label="Assignment Name" required /> + (value ? setInstructorGender(value.value as InstructorGender) : null)} + disabled={!selectedModules.includes("speaking") || !!assignment} + options={[ + {value: "male", label: "Male"}, + {value: "female", label: "Female"}, + {value: "varied", label: "Varied"}, + ]} + /> +
+ )} + + {selectedModules.length > 0 && ( +
+ + Random Exams + + {!useRandomExams && ( +
+ {selectedModules.map((module) => ( +
+ + setName(e)} defaultValue={name} label="Assignment Name" required /> + (value ? setInstructorGender(value.value as InstructorGender) : null)} + disabled={!selectedModules.includes("speaking")} + options={[ + {value: "male", label: "Male"}, + {value: "female", label: "Female"}, + {value: "varied", label: "Varied"}, + ]} + /> +
+ )} + + {selectedModules.length > 0 && ( +
+ + Random Exams + + {!useRandomExams && ( +
+ {selectedModules.map((module) => ( +
+ + +
+
+ Entity: + +
+ +
+ Members ({selectedUsers.length} selected): +
+
+ {renderSearch()} + {renderMinimal()} +
+ + +
+ {items.map((u) => ( + + ))} +
+ + + ); +} diff --git a/src/pages/entities/index.tsx b/src/pages/entities/index.tsx new file mode 100644 index 00000000..2ae539a2 --- /dev/null +++ b/src/pages/entities/index.tsx @@ -0,0 +1,106 @@ +/* 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, findAllowedEntities, 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"; +import { requestUser } from "@/utils/api"; +import { mapBy, redirect, serialize } from "@/utils"; + +type EntitiesWithCount = {entity: EntityWithRoles; users: User[]; count: number}; + +export const getServerSideProps = withIronSessionSsr(async ({req, res}) => { + const user = await requestUser(req, res) + if (!user) return redirect("/login") + + if (shouldRedirectHome(user)) return redirect("/") + + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(["admin", "developer"].includes(user.type) ? undefined : entityIDs); + const allowedEntities = findAllowedEntities(user, entities, 'view_entities') + + const entitiesWithCount = await Promise.all( + allowedEntities.map(async (e) => ({entity: e, count: await countEntityUsers(e.id), users: await getEntityUsers(e.id, 5)})), + ); + + return { + props: serialize({user, entities: 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 + + + + + + +
+
+

Entities

+ +
+ + + 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..887409d8 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -6,38 +6,106 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ExamPage from "./(exam)/ExamPage"; import Head from "next/head"; import {User} from "@/interfaces/user"; +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(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { + const user = await requestUser(req, res) + 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") + + if (filterBy(assignment.results, 'user', user.id).length > 0) + return redirect("/exam") + + const exams = await getExamsByIds(uniqBy(assignment.exams, "id")) + const session = await getSessionByAssignment(assignmentID) - if (!user) { return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; + props: serialize({user, assignment, exams, session: session ?? undefined}) + } } return { - props: {user: req.session.user}, + props: serialize({user}), }; }, sessionOptions); 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) { + if (moment(assignment.startDate).isAfter(moment()) || moment(assignment.endDate).isBefore(moment())) return + + 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 ( <> @@ -49,7 +117,7 @@ export default function Page({user}: Props) { - + ); } diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx index 983fdfeb..b8e43261 100644 --- a/src/pages/exercises.tsx +++ b/src/pages/exercises.tsx @@ -6,42 +6,111 @@ import {shouldRedirectHome} from "@/utils/navigation.disabled"; import ExamPage from "./(exam)/ExamPage"; import Head from "next/head"; import {User} from "@/interfaces/user"; +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(({req, res}) => { - const user = req.session.user; +export const getServerSideProps = withIronSessionSsr(async ({req, res, query}) => { + const user = await requestUser(req, res) + 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 (!["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("/exam") - if (!user) { return { - redirect: { - destination: "/login", - permanent: false, - }, - }; - } - - if (shouldRedirectHome(user)) { - return { - redirect: { - destination: "/", - permanent: false, - }, - }; + props: serialize({user, assignment, exams, session}) + } } return { - props: {user: req.session.user}, + props: serialize({user}), }; }, sessionOptions); 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/generation.tsx b/src/pages/generation.tsx index 7a4a21b4..5d37c93f 100644 --- a/src/pages/generation.tsx +++ b/src/pages/generation.tsx @@ -16,30 +16,18 @@ import useExamEditorStore from "@/stores/examEditor"; import ExamEditorStore from "@/stores/examEditor/types"; import ExamEditor from "@/components/ExamEditor"; import MultipleAudioUploader from "@/components/ExamEditor/Shared/AudioEdit"; +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/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/index.tsx b/src/pages/index.tsx index ef4f7bd9..abefdaad 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,213 +1,16 @@ -/* 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 {User} from "@/interfaces/user"; 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, 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 {getUserCorporate} from "@/utils/groups.be"; -import {getUsers} from "@/utils/users.be"; +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; + const user = await requestUser(req, res) + if (!user) return redirect("/login") - 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, - }, - }; - } - - const linkedCorporate = (await getUserCorporate(user.id)) || null; - - return { - props: {user, envVariables, linkedCorporate}, - }; + return redirect(`/dashboard/${user.type}`) }, sessionOptions); -interface Props { - user: User; - envVariables: {[key: string]: string}; - linkedCorporate?: CorporateUser | MasterCorporateUser; -} - -export default function Home({user: propsUser, linkedCorporate}: Props) { - const [user, setUser] = useState(propsUser); - const [showDiagnostics, setShowDiagnostics] = useState(false); - const [showDemographicInput, setShowDemographicInput] = useState(false); - const [selectedScreen, setSelectedScreen] = useState("admin"); - - const {mutateUser} = useUser({redirectTo: "/login"}); - const router = useRouter(); - - useEffect(() => { - if (user) { - setShowDemographicInput(!user.demographicInformation || !user.demographicInformation.country || !user.demographicInformation.phone); - setShowDiagnostics(user.isFirstLogin && user.type === "student"); - } - }, [user]); - - const checkIfUserExpired = () => { - const expirationDate = user!.subscriptionExpirationDate; - - if (expirationDate === null || expirationDate === undefined) return false; - if (moment(expirationDate).isAfter(moment(new Date()))) return false; - - return true; - }; - - if (user && (user.status === "paymentDue" || user.status === "disabled" || checkIfUserExpired())) { - return ( - <> - - EnCoach - - - - - {user.status === "disabled" && ( - -
- Your account has been disabled! - Please contact an administrator if you believe this to be a mistake. -
-
- )} - {(user.status === "paymentDue" || checkIfUserExpired()) && } - - ); - } - - if (user && showDemographicInput) { - return ( - <> - - EnCoach - - - - - - { - setUser(user); - mutateUser(user); - }} - user={user} - /> - - - ); - } - - if (user && showDiagnostics) { - return ( - <> - - EnCoach - - - - - - setShowDiagnostics(false)} /> - - - ); - } - - return ( - <> - - EnCoach - - - - - - {user && ( - - {checkAccess(user, ["student"]) && } - {checkAccess(user, ["teacher"]) && } - {checkAccess(user, ["corporate"]) && } - {checkAccess(user, ["mastercorporate"]) && } - {checkAccess(user, ["agent"]) && } - {checkAccess(user, ["admin"]) && } - {checkAccess(user, ["developer"]) && ( - <> - ({ + value: u, + label: USER_TYPE_LABELS[u], + }))} + value={{ + value: selectedScreen, + label: USER_TYPE_LABELS[selectedScreen], + }} + onChange={(value) => (value ? setSelectedScreen(value.value) : setSelectedScreen("admin"))} + /> + + {selectedScreen === "student" && } + {selectedScreen === "teacher" && } + {selectedScreen === "corporate" && ( + + )} + {selectedScreen === "mastercorporate" && } + {selectedScreen === "agent" && } + {selectedScreen === "admin" && } + + )} + + )} + + ); +} diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts new file mode 100644 index 00000000..5d431895 --- /dev/null +++ b/src/resources/entityPermissions.ts @@ -0,0 +1,112 @@ +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" | + "view_reading" | + "delete_reading" | + "generate_listening" | + "view_listening" | + "delete_listening" | + "generate_writing" | + "view_writing" | + "delete_writing" | + "generate_speaking" | + "view_speaking" | + "delete_speaking" | + "generate_level" | + "view_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" + +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", + "view_reading", + "delete_reading", + "generate_listening", + "view_listening", + "delete_listening", + "generate_writing", + "view_writing", + "delete_writing", + "generate_speaking", + "view_speaking", + "delete_speaking", + "generate_level", + "view_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/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/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/assignments.be.ts b/src/utils/assignments.be.ts index 261f04b6..4a21dc3f 100644 --- a/src/utils/assignments.be.ts +++ b/src/utils/assignments.be.ts @@ -18,10 +18,22 @@ 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 getAssignment = async (id: string) => { + return await db.collection("assignments").findOne({id}); +}; + +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 +49,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..93b8badd --- /dev/null +++ b/src/utils/entities.be.ts @@ -0,0 +1,98 @@ +import {Entity, EntityWithRoles, Role} from "@/interfaces/entity"; +import client from "@/lib/mongodb"; +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); + +export const getEntityWithRoles = async (id: string): Promise => { + 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(); +}; + +export const createEntity = async (entity: Entity) => { + await db.collection("entities").insertOne(entity) + + 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}) + + await db.collection("users").updateMany( + {"entities.id": entity.id}, + { + // @ts-expect-error + $pull: { + entities: {id: entity.id}, + }, + }, + ); +} diff --git a/src/utils/exams.be.ts b/src/utils/exams.be.ts index 70bcd685..dedc4d4c 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"; @@ -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); @@ -29,6 +30,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: mapBy(groupedByModule[m], 'id')}}) + .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 4c344dc8..d5018ef1 100644 --- a/src/utils/groups.be.ts +++ b/src/utils/groups.be.ts @@ -1,112 +1,175 @@ -import {app} from "@/firebase"; -import {Assignment} from "@/interfaces/results"; -import {CorporateUser, Group, 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 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(); + 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[], + }); + 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(); + 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 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 892cafbc..fcd8f64c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -43,3 +43,16 @@ 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/invites.be.ts b/src/utils/invites.be.ts new file mode 100644 index 00000000..091a73b1 --- /dev/null +++ b/src/utils/invites.be.ts @@ -0,0 +1,19 @@ +import {Session} from "@/hooks/useSessions"; +import { Entity } from "@/interfaces/entity"; +import {Invite, InviteWithEntity} 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 convertInvitersToEntity = async (invite: Invite): Promise => ({ + ...invite, + entity: (await db.collection("entities").findOne({id: invite.entity })) ?? undefined, +}); diff --git a/src/utils/navigation.disabled.ts b/src/utils/navigation.disabled.ts index 8ec28df8..720b559b 100644 --- a/src/utils/navigation.disabled.ts +++ b/src/utils/navigation.disabled.ts @@ -9,8 +9,8 @@ export const preventNavigation = (navDisabled: boolean, focusMode: boolean): boo export const shouldRedirectHome = (user: User) => { if (user.status === "disabled") return true; - if (user.isFirstLogin && user.type === "student") return true; - if (!user.demographicInformation) return true; + // if (user.isFirstLogin && user.type === "student") return true; + // if (!user.demographicInformation) return true; if (user.subscriptionExpirationDate && moment(new Date()).isAfter(user.subscriptionExpirationDate)) return true; return false; diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index da3d2d59..71059a57 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,32 @@ 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 findAllowedEntitiesSomePermissions(user: User, entities: EntityWithRoles[], permissions: RolePermission[]) { + if (["admin", "developer"].includes(user?.type)) return entities + + const allowedEntities = entities.filter((e) => permissions.some((p) => doesEntityAllow(user, e, p))) + 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 new file mode 100644 index 00000000..0dea6694 --- /dev/null +++ b/src/utils/roles.be.ts @@ -0,0 +1,34 @@ +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 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/search.ts b/src/utils/search.ts index 87b2cfc1..b9cf1b8f 100644 --- a/src/utils/search.ts +++ b/src/utils/search.ts @@ -3,19 +3,33 @@ ['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; }; -export const search = (text: string, fields: string[][], rows: any[]) => { +export const search = (text: string, fields: string[][], rows: T[]) => { const searchText = text.toLowerCase(); 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/sessions.be.ts b/src/utils/sessions.be.ts new file mode 100644 index 00000000..ef4e710c --- /dev/null +++ b/src/utils/sessions.be.ts @@ -0,0 +1,16 @@ +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(); + +export const getSessionByAssignment = async (assignmentID: string) => + await db + .collection("sessions") + .findOne({"assignment.id": assignmentID}) 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 31ecbe05..b684a1b5 100644 --- a/src/utils/users.be.ts +++ b/src/utils/users.be.ts @@ -1,94 +1,156 @@ -import {CorporateUser, Group, Type, User} from "@/interfaces/user"; -import {getGroupsForUser, getParticipantGroups, getUserGroups, getUsersGroups} from "./groups.be"; -import {last, uniq, uniqBy} from "lodash"; -import {getUserCodes} from "./codes.be"; -import moment from "moment"; +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 { EntityWithRoles, WithEntities } from "@/interfaces/entity"; +import { getEntity } from "./entities.be"; +import { getRole } from "./roles.be"; +import { findAllowedEntities } from "./permissions"; +import { mapBy } from "."; 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; + + 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; + 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, filter?: object) { + return await db + .collection("users") + .find({ "entities.id": id, ...(filter || {}) }) + .limit(limit || 0) + .toArray(); +} + +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) { + 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 } }); } 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(); - const total = await db.collection("users").countDocuments(filters); - return {users, total}; - } + 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 adminGroups = await getUserGroups(userID); - const groups = await getUsersGroups(adminGroups.flatMap((x) => x.participants)); - const belongingGroups = await getParticipantGroups(userID); + const total = await db.collection("users").countDocuments(filters); + return { users, total }; + } - const participants = uniq([ - ...adminGroups.flatMap((x) => x.participants), - ...(userType === "mastercorporate" ? groups.flat().flatMap((x) => x.participants) : []), - ...(userType === "teacher" ? belongingGroups.flatMap((x) => x.participants) : []), - ]); + const adminGroups = await getUserGroups(userID); + const groups = await getUsersGroups(adminGroups.flatMap((x) => x.participants)); + const belongingGroups = await getParticipantGroups(userID); - // ⨯ [FirebaseError: Invalid Query. A non-empty array is required for 'in' filters.] { - if (participants.length === 0) return {users: [], total: 0}; + const participants = uniq([ + ...adminGroups.flatMap((x) => x.participants), + ...(userType === "mastercorporate" ? groups.flat().flatMap((x) => x.participants) : []), + ...(userType === "teacher" ? belongingGroups.flatMap((x) => x.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}}); + // ⨯ [FirebaseError: Invalid Query. A non-empty array is required for 'in' filters.] { + if (participants.length === 0) return { users: [], total: 0 }; - return {users, total}; + 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 }; } 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 + ); +} + +export const filterAllowedUsers = async (user: User, entities: EntityWithRoles[]) => { + const studentsAllowedEntities = findAllowedEntities(user, entities, 'view_students') + const teachersAllowedEntities = findAllowedEntities(user, entities, 'view_teachers') + const corporateAllowedEntities = findAllowedEntities(user, entities, 'view_corporates') + const masterCorporateAllowedEntities = findAllowedEntities(user, entities, 'view_mastercorporates') + + const students = await getEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), {type: "student"}) + const teachers = await getEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), {type: "teacher"}) + const corporates = await getEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), {type: "corporate"}) + const masterCorporates = await getEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), {type: "mastercorporate"}) + + return [...students, ...teachers, ...corporates, ...masterCorporates] } diff --git a/src/utils/users.ts b/src/utils/users.ts index bdcb262e..659c0bd0 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -1,45 +1,51 @@ -import {Group, User} from "@/interfaces/user"; -import {getUserCompanyName, USER_TYPE_LABELS} from "@/resources/user"; -import {capitalize} from "lodash"; +import { EntityWithRoles, 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"; +import { mapBy } from "."; +import { findAllowedEntities } from "./permissions"; +import { getEntitiesUsers } from "./users.be"; 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; }; + +export const isAdmin = (user: User) => ["admin", "developer"].includes(user.type)