From f1d97aa6c9f7b1359d37a977e410724e8c66b34f Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 23 Dec 2024 09:38:49 +0000 Subject: [PATCH 01/12] ENCOA-292 --- src/pages/entities/[id]/roles/[role].tsx | 1 + src/resources/entityPermissions.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/pages/entities/[id]/roles/[role].tsx b/src/pages/entities/[id]/roles/[role].tsx index ec8f445c..26bb26be 100644 --- a/src/pages/entities/[id]/roles/[role].tsx +++ b/src/pages/entities/[id]/roles/[role].tsx @@ -75,6 +75,7 @@ const CLASSROOM_MANAGEMENT: PermissionLayout[] = [ { label: "Create Classrooms", key: "create_classroom" }, { label: "Rename Classrooms", key: "rename_classrooms" }, { label: "Add to Classroom", key: "add_to_classroom" }, + { label: "Upload to Classroom", key: "upload_classroom" }, { label: "Remove from Classroom", key: "remove_from_classroom" }, { label: "Delete Classroom", key: "delete_classroom" }, ] diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts index ddcfbf5a..0a2d85ce 100644 --- a/src/resources/entityPermissions.ts +++ b/src/resources/entityPermissions.ts @@ -59,7 +59,8 @@ export type RolePermission = "view_statistics" | "download_statistics_report" | "edit_grading_system" | - "view_student_performance" + "view_student_performance" | + "upload_classroom" export const DEFAULT_PERMISSIONS: RolePermission[] = [ "view_students", @@ -132,5 +133,6 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [ "view_statistics", "download_statistics_report", "edit_grading_system", - "view_student_performance" + "view_student_performance", + "upload_classroom" ] From 9cf13e3f26da89fa752ab04989ab8b423ed174df Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 23 Dec 2024 09:55:03 +0000 Subject: [PATCH 02/12] ENCOA-294 & ENCOA-293 --- src/pages/(admin)/Lists/UserList.tsx | 12 ++++++++---- src/pages/dashboard/corporate.tsx | 1 - src/pages/dashboard/mastercorporate.tsx | 1 - src/pages/dashboard/teacher.tsx | 1 - src/pages/entities/[id]/roles/[role].tsx | 1 + src/resources/entityPermissions.ts | 6 ++++-- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/pages/(admin)/Lists/UserList.tsx b/src/pages/(admin)/Lists/UserList.tsx index f32611f8..63ac86e9 100644 --- a/src/pages/(admin)/Lists/UserList.tsx +++ b/src/pages/(admin)/Lists/UserList.tsx @@ -24,6 +24,7 @@ import { WithLabeledEntities } from "@/interfaces/entity"; import Table from "@/components/High/Table"; import useEntities from "@/hooks/useEntities"; import { useAllowedEntities } from "@/hooks/useEntityPermissions"; +import { findAllowedEntities } from "@/utils/permissions"; const columnHelper = createColumnHelper>(); const searchFields = [["name"], ["email"], ["entities", ""]]; @@ -45,8 +46,6 @@ export default function UserList({ const { users, reload } = useEntitiesUsers(type) const { entities } = useEntities() - const { balance } = useUserBalance(); - const isAdmin = useMemo(() => ["admin", "developer"].includes(user?.type), [user?.type]) const entitiesViewStudents = useAllowedEntities(user, entities, "view_students") @@ -65,6 +64,8 @@ export default function UserList({ const entitiesEditMasterCorporates = useAllowedEntities(user, entities, "edit_mastercorporates") const entitiesDeleteMasterCorporates = useAllowedEntities(user, entities, "delete_mastercorporates") + const entitiesDownloadUsers = useAllowedEntities(user, entities, "download_user_list") + const appendUserFilters = useFilterStore((state) => state.appendUserFilter); const router = useRouter(); @@ -342,7 +343,10 @@ export default function UserList({ ]; const downloadExcel = (rows: WithLabeledEntities[]) => { - const csv = exportListToExcel(rows); + if (entitiesDownloadUsers.length === 0) return toast.error("You are not allowed to download the user list.") + + const allowedRows = rows.filter(r => mapBy(r.entities, 'id').some(e => mapBy(entitiesDownloadUsers, 'id').includes(e))) + const csv = exportListToExcel(allowedRows); const element = document.createElement("a"); const file = new Blob([csv], { type: "text/csv" }); @@ -437,7 +441,7 @@ export default function UserList({ data={displayUsers} columns={(!showDemographicInformation ? defaultColumns : demographicColumns) as any} searchFields={searchFields} - onDownload={downloadExcel} + onDownload={entitiesDownloadUsers.length > 0 ? downloadExcel : undefined} /> diff --git a/src/pages/dashboard/corporate.tsx b/src/pages/dashboard/corporate.tsx index 40b9c8a2..817658b4 100644 --- a/src/pages/dashboard/corporate.tsx +++ b/src/pages/dashboard/corporate.tsx @@ -124,7 +124,6 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen value={`${entities.length} - ${totalCount}/${totalLicenses}`} color="purple" /> - {allowedEntityStatistics.length > 0 && ( router.push("/statistical")} diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx index d34f9e1c..ef8cfb52 100644 --- a/src/pages/dashboard/mastercorporate.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -133,7 +133,6 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen value={`${entities.length} - ${totalCount}/${totalLicenses}`} color="purple" /> - {allowedStudentPerformance.length > 0 && ( router.push("/users/performance")} diff --git a/src/pages/dashboard/teacher.tsx b/src/pages/dashboard/teacher.tsx index 0d3a880c..56fd56da 100644 --- a/src/pages/dashboard/teacher.tsx +++ b/src/pages/dashboard/teacher.tsx @@ -96,7 +96,6 @@ export default function Dashboard({ user, users, entities, assignments, stats, g value={groups.length} color="purple" /> - {allowedStudentPerformance.length > 0 && ( router.push("/users/performance")} diff --git a/src/pages/entities/[id]/roles/[role].tsx b/src/pages/entities/[id]/roles/[role].tsx index 26bb26be..40c8f37c 100644 --- a/src/pages/entities/[id]/roles/[role].tsx +++ b/src/pages/entities/[id]/roles/[role].tsx @@ -47,6 +47,7 @@ const USER_MANAGEMENT: PermissionLayout[] = [ { label: "Create Users in Batch", key: "create_user_batch" }, { label: "Create a Single Code", key: "create_code" }, { label: "Create Codes in Batch", key: "create_code_batch" }, + { label: "Download User List", key: "download_user_list" }, { label: "View Code List", key: "view_code_list" }, { label: "Delete Code", key: "delete_code" }, ] diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts index 0a2d85ce..9bbeab9d 100644 --- a/src/resources/entityPermissions.ts +++ b/src/resources/entityPermissions.ts @@ -60,7 +60,8 @@ export type RolePermission = "download_statistics_report" | "edit_grading_system" | "view_student_performance" | - "upload_classroom" + "upload_classroom" | + "download_user_list" export const DEFAULT_PERMISSIONS: RolePermission[] = [ "view_students", @@ -134,5 +135,6 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [ "download_statistics_report", "edit_grading_system", "view_student_performance", - "upload_classroom" + "upload_classroom", + "download_user_list" ] From e9c961e633d42e6d2746c201c2087fcb47a0f313 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 23 Dec 2024 10:18:52 +0000 Subject: [PATCH 03/12] ENCOA-267 --- src/components/Medium/RecordFilter.tsx | 273 ++++++++++++----------- src/components/Medium/StatGridItem.tsx | 4 +- src/pages/entities/[id]/roles/[role].tsx | 2 + src/pages/record.tsx | 9 +- src/resources/entityPermissions.ts | 8 +- src/utils/groups.be.ts | 3 +- 6 files changed, 157 insertions(+), 142 deletions(-) diff --git a/src/components/Medium/RecordFilter.tsx b/src/components/Medium/RecordFilter.tsx index a8e5ce89..d3cbdc31 100644 --- a/src/components/Medium/RecordFilter.tsx +++ b/src/components/Medium/RecordFilter.tsx @@ -8,163 +8,166 @@ import useGroups from "@/hooks/useGroups"; import useRecordStore from "@/stores/recordStore"; import { EntityWithRoles } from "@/interfaces/entity"; import { mapBy } from "@/utils"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; type TimeFilter = "months" | "weeks" | "days"; type Filter = TimeFilter | "assignments" | undefined; interface Props { - user: User; - entities: EntityWithRoles[] - users: User[] - filterState: { - filter: Filter, - setFilter: React.Dispatch> - }, - assignments?: boolean; - children?: ReactNode + user: User; + entities: EntityWithRoles[] + users: User[] + filterState: { + filter: Filter, + setFilter: React.Dispatch> + }, + assignments?: boolean; + children?: ReactNode } const defaultSelectableCorporate = { - value: "", - label: "All", + value: "", + label: "All", }; const RecordFilter: React.FC = ({ - user, - entities, - users, - filterState, - assignments = true, - children + user, + entities, + users, + filterState, + assignments = true, + children }) => { - const { filter, setFilter } = filterState; + const { filter, setFilter } = filterState; - const [entity, setEntity] = useState() + const [entity, setEntity] = useState() - const [, setStatsUserId] = useRecordStore((state) => [ - state.selectedUser, - state.setSelectedUser - ]); + const [, setStatsUserId] = useRecordStore((state) => [ + state.selectedUser, + state.setSelectedUser + ]); - const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity]) + const allowedViewEntities = useAllowedEntities(user, entities, 'view_student_record') - useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]) + const entityUsers = useMemo(() => !entity ? users : users.filter(u => mapBy(u.entities, 'id').includes(entity)), [users, entity]) - const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { - setFilter((prev) => (prev === value ? undefined : value)); - }; + useEffect(() => setStatsUserId(user.id), [setStatsUserId, user.id]) - return ( -
-
- {checkAccess(user, ["developer", "admin", "mastercorporate"]) && !children && ( - <> -
- + const toggleFilter = (value: "months" | "weeks" | "days" | "assignments") => { + setFilter((prev) => (prev === value ? undefined : value)); + }; - ({ - value: x.id, - label: `${x.name} - ${x.email}`, - }))} - defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} - onChange={(value) => setStatsUserId(value?.value!)} - styles={{ - menuPortal: (base) => ({ ...base, zIndex: 9999 }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} - /> -
- - )} - {(user.type === "corporate" || user.type === "teacher") && !children && ( -
- + ({ - value: x.id, - label: `${x.name} - ${x.email}`, - }))} - defaultValue={{value: user.id, label: `${user.name} - ${user.email}`}} - onChange={(value) => setStatsUserId(value?.value!)} - styles={{ - menuPortal: (base) => ({ ...base, zIndex: 9999 }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", - color: state.isFocused ? "black" : styles.color, - }), - }} - /> -
- )} - {children} -
-
- {assignments && ( - - )} - - - -
-
- ); + ({ + value: x.id, + label: `${x.name} - ${x.email}`, + }))} + defaultValue={{ value: user.id, label: `${user.name} - ${user.email}` }} + onChange={(value) => setStatsUserId(value?.value!)} + styles={{ + menuPortal: (base) => ({ ...base, zIndex: 9999 }), + option: (styles, state) => ({ + ...styles, + backgroundColor: state.isFocused ? "#D5D9F0" : state.isSelected ? "#7872BF" : "white", + color: state.isFocused ? "black" : styles.color, + }), + }} + /> + + )} + {children} + +
+ {assignments && ( + + )} + + + +
+ + ); } export default RecordFilter; diff --git a/src/components/Medium/StatGridItem.tsx b/src/components/Medium/StatGridItem.tsx index aa025517..976edd99 100644 --- a/src/components/Medium/StatGridItem.tsx +++ b/src/components/Medium/StatGridItem.tsx @@ -82,7 +82,7 @@ interface StatsGridItemProps { selectedTrainingExams?: string[]; maxTrainingExams?: number; setSelectedTrainingExams?: React.Dispatch>; - renderPdfIcon: (session: string, color: string, textColor: string) => React.ReactNode; + renderPdfIcon?: (session: string, color: string, textColor: string) => React.ReactNode; } const StatsGridItem: React.FC = ({ @@ -236,7 +236,7 @@ const StatsGridItem: React.FC = ({ {renderLevelScore()} )} - {shouldRenderPDFIcon() && renderPdfIcon(session, textColor, textColor)} + {shouldRenderPDFIcon() && renderPdfIcon && renderPdfIcon(session, textColor, textColor)} {examNumber === undefined ? ( <> diff --git a/src/pages/entities/[id]/roles/[role].tsx b/src/pages/entities/[id]/roles/[role].tsx index 40c8f37c..037fb571 100644 --- a/src/pages/entities/[id]/roles/[role].tsx +++ b/src/pages/entities/[id]/roles/[role].tsx @@ -79,6 +79,8 @@ const CLASSROOM_MANAGEMENT: PermissionLayout[] = [ { label: "Upload to Classroom", key: "upload_classroom" }, { label: "Remove from Classroom", key: "remove_from_classroom" }, { label: "Delete Classroom", key: "delete_classroom" }, + { label: "View Student Record", key: "view_student_record" }, + { label: "Download Student Report", key: "download_student_record" }, ] const ENTITY_MANAGEMENT: PermissionLayout[] = [ diff --git a/src/pages/record.tsx b/src/pages/record.tsx index 557ebc86..6366a742 100644 --- a/src/pages/record.tsx +++ b/src/pages/record.tsx @@ -22,7 +22,7 @@ import { Assignment } from "@/interfaces/results"; import { getEntitiesUsers, getUsers } from "@/utils/users.be"; import { getAssignments, getEntitiesAssignments } from "@/utils/assignments.be"; import useGradingSystem from "@/hooks/useGrading"; -import { mapBy, redirect, serialize } from "@/utils"; +import { findBy, mapBy, redirect, serialize } from "@/utils"; import { getEntitiesWithRoles } from "@/utils/entities.be"; import { checkAccess } from "@/utils/permissions"; import { getGroups, getGroupsByEntities } from "@/utils/groups.be"; @@ -31,6 +31,7 @@ import { Grading } from "@/interfaces"; import { EntityWithRoles } from "@/interfaces/entity"; import CardList from "@/components/High/CardList"; import { requestUser } from "@/utils/api"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) @@ -74,6 +75,7 @@ export default function History({ user, users, assignments, entities, gradingSys const [filter, setFilter] = useState(); const { data: stats, isLoading: isStatsLoading } = useFilterRecordsByUser(statsUserId || user?.id); + const allowedDownloadEntities = useAllowedEntities(user, entities, 'download_student_record') const renderPdfIcon = usePDFDownload("stats"); @@ -155,6 +157,9 @@ export default function History({ user, users, assignments, entities, gradingSys const customContent = (timestamp: string) => { const dateStats = groupedStats[timestamp]; + const statUser = findBy(users, 'id', dateStats[0]?.user) + + const canDownload = mapBy(statUser?.entities, 'id').some(e => mapBy(allowedDownloadEntities, 'id').includes(e)) return ( ); }; diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts index 9bbeab9d..484672d1 100644 --- a/src/resources/entityPermissions.ts +++ b/src/resources/entityPermissions.ts @@ -61,7 +61,9 @@ export type RolePermission = "edit_grading_system" | "view_student_performance" | "upload_classroom" | - "download_user_list" + "download_user_list" | + "view_student_record" | + "download_student_record" export const DEFAULT_PERMISSIONS: RolePermission[] = [ "view_students", @@ -136,5 +138,7 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [ "edit_grading_system", "view_student_performance", "upload_classroom", - "download_user_list" + "download_user_list", + "view_student_record", + "download_student_record" ] diff --git a/src/utils/groups.be.ts b/src/utils/groups.be.ts index 0c0a3536..fcfd3e67 100644 --- a/src/utils/groups.be.ts +++ b/src/utils/groups.be.ts @@ -3,6 +3,7 @@ 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 { uniq } from "lodash"; import moment from "moment"; import { getLinkedUsers, getUser } from "./users.be"; import { getSpecificUsers } from "./users.be"; @@ -116,7 +117,7 @@ export const getUsersGroups = async (ids: string[]) => { 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[], + participants: uniq(group.participants).map((p) => users.find((u) => u.id === p)).filter((x) => !!x) as User[], }); export const getAllAssignersByCorporate = async (corporateID: string, type: Type): Promise => { From 2146f5794158dc5fb8fb663c53e97a75fcd6f1eb Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 23 Dec 2024 15:09:35 +0000 Subject: [PATCH 04/12] Solved a bug --- src/pages/dashboard/admin.tsx | 3 +-- src/pages/dashboard/developer.tsx | 1 - src/pages/dashboard/mastercorporate.tsx | 4 +++- src/utils/users.be.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pages/dashboard/admin.tsx b/src/pages/dashboard/admin.tsx index 2e53d579..ab5e7931 100644 --- a/src/pages/dashboard/admin.tsx +++ b/src/pages/dashboard/admin.tsx @@ -139,7 +139,6 @@ export default function Dashboard({ value={entities.length} color="purple" /> - router.push("/statistical")} label="Entity Statistics" @@ -149,7 +148,7 @@ export default function Dashboard({ router.push("/users/performance")} label="Student Performance" - value={students.length} + value={usersCount.student} color="purple" /> - router.push("/statistical")} label="Entity Statistics" diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx index ef8cfb52..6afa9b0e 100644 --- a/src/pages/dashboard/mastercorporate.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -78,7 +78,9 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) - const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + curr.licenses, 0), [entities]) + + console.log(userCounts) + const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) const router = useRouter(); diff --git a/src/utils/users.be.ts b/src/utils/users.be.ts index 92b3b249..7997aef1 100644 --- a/src/utils/users.be.ts +++ b/src/utils/users.be.ts @@ -171,7 +171,7 @@ export const countAllowedUsers = async (user: User, entities: EntityWithRoles[]) const student = await countEntitiesUsers(mapBy(studentsAllowedEntities, 'id'), { type: "student" }) const teacher = await countEntitiesUsers(mapBy(teachersAllowedEntities, 'id'), { type: "teacher" }) const corporate = await countEntitiesUsers(mapBy(corporateAllowedEntities, 'id'), { type: "corporate" }) - const masterCorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" }) + const mastercorporate = await countEntitiesUsers(mapBy(masterCorporateAllowedEntities, 'id'), { type: "mastercorporate" }) - return { student, teacher, corporate, masterCorporate } + return { student, teacher, corporate, mastercorporate } } From 408cfbb500834f3cdb2278fce933d07b3efc4883 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 23 Dec 2024 15:11:20 +0000 Subject: [PATCH 05/12] Bug solved with the Practice Modal --- src/exams/Level/index.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/exams/Level/index.tsx b/src/exams/Level/index.tsx index bfd8d739..eb535c80 100644 --- a/src/exams/Level/index.tsx +++ b/src/exams/Level/index.tsx @@ -25,6 +25,7 @@ import ProgressButtons from "../components/ProgressButtons"; import useExamNavigation from "../Navigation/useExamNavigation"; import { calculateExerciseIndex } from "../utils/calculateExerciseIndex"; import { defaultExamUserSolutions } from "@/utils/exams"; +import PracticeModal from "@/components/PracticeModal"; const Level: React.FC> = ({ exam, showSolutions = false, preview = false }) => { @@ -66,6 +67,12 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr const [continueAnyways, setContinueAnyways] = useState(false); const [textRender, setTextRender] = useState(false); + const hasPractice = useMemo(() => { + if (partIndex > -1 && partIndex < exam.parts.length) { + return exam.parts[partIndex].exercises.some(e => e.isPractice) + } + return false + }, [partIndex, exam.parts]) const [questionModalKwargs, setQuestionModalKwargs] = useState<{ type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined; @@ -337,6 +344,7 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr return ( <>
+ > = ({ exam, showSolutions = false, pr (!showPartDivider && !startNow) && } - {(showPartDivider || (startNow && partIndex === 0)) ? + {(showPartDivider || (startNow && partIndex === 0)) ? Date: Mon, 23 Dec 2024 16:34:01 +0000 Subject: [PATCH 06/12] Solved another bug --- src/pages/api/hello.ts | 7 +++++++ src/pages/dashboard/corporate.tsx | 2 +- src/pages/dashboard/mastercorporate.tsx | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts index 76280a02..0a75b094 100644 --- a/src/pages/api/hello.ts +++ b/src/pages/api/hello.ts @@ -1,6 +1,13 @@ // Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import { CorporateUser, User } from "@/interfaces/user"; import client from "@/lib/mongodb"; +import { getLinkedUsers } from "@/utils/users.be"; +import { uniqBy } from "lodash"; import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; +import fs from 'fs' +import { findBy, mapBy } from "@/utils"; +import { addUsersToEntity, getEntitiesWithRoles } from "@/utils/entities.be"; const db = client.db(process.env.MONGODB_DB); diff --git a/src/pages/dashboard/corporate.tsx b/src/pages/dashboard/corporate.tsx index 817658b4..2504646a 100644 --- a/src/pages/dashboard/corporate.tsx +++ b/src/pages/dashboard/corporate.tsx @@ -70,7 +70,7 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) - const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + curr.licenses, 0), [entities]) + const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) const allowedEntityStatistics = useAllowedEntities(user, entities, 'view_entity_statistics') const allowedStudentPerformance = useAllowedEntities(user, entities, 'view_student_performance') diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx index 6afa9b0e..a0c4d0d6 100644 --- a/src/pages/dashboard/mastercorporate.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -79,7 +79,6 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) - console.log(userCounts) const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) const router = useRouter(); From 770056e0c4ed3875e3969a7e63afa75d56bd0172 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 24 Dec 2024 10:31:52 +0000 Subject: [PATCH 07/12] Improved part of the performance of the dashboards --- src/interfaces/user.ts | 1 + src/pages/(exam)/ExamPage.tsx | 1 - src/pages/api/stats/update.ts | 48 +++++++++++++------------ src/pages/dashboard/corporate.tsx | 36 +++++++++++-------- src/pages/dashboard/developer.tsx | 14 ++++---- src/pages/dashboard/mastercorporate.tsx | 36 +++++++++++-------- src/pages/exam.tsx | 2 +- src/utils/entities.be.ts | 43 +++++++++++++--------- src/utils/roles.be.ts | 14 ++++---- 9 files changed, 110 insertions(+), 85 deletions(-) diff --git a/src/interfaces/user.ts b/src/interfaces/user.ts index 8de4bf56..b1ed48bc 100644 --- a/src/interfaces/user.ts +++ b/src/interfaces/user.ts @@ -28,6 +28,7 @@ export interface BasicUser { export interface StudentUser extends BasicUser { type: "student"; studentID?: string; + averageLevel?: number preferredGender?: InstructorGender; demographicInformation?: DemographicInformation; preferredTopics?: string[]; diff --git a/src/pages/(exam)/ExamPage.tsx b/src/pages/(exam)/ExamPage.tsx index 9a041a35..5ca4ef24 100644 --- a/src/pages/(exam)/ExamPage.tsx +++ b/src/pages/(exam)/ExamPage.tsx @@ -60,7 +60,6 @@ export default function ExamPage({ page, user, destination = "/", hideSidebar = setFlags, setShuffles, evaluated, - setEvaluated, } = useExamStore(); const [isFetchingExams, setIsFetchingExams] = useState(false); diff --git a/src/pages/api/stats/update.ts b/src/pages/api/stats/update.ts index f3d27fbb..eeff1855 100644 --- a/src/pages/api/stats/update.ts +++ b/src/pages/api/stats/update.ts @@ -1,15 +1,15 @@ -import {MODULES} from "@/constants/ielts"; -import {app} from "@/firebase"; -import {Module} from "@/interfaces"; -import {Stat, User} from "@/interfaces/user"; -import {sessionOptions} from "@/lib/session"; -import {calculateBandScore} from "@/utils/score"; -import {groupByModule, groupBySession} from "@/utils/stats"; +import { MODULES } from "@/constants/ielts"; +import { app } from "@/firebase"; +import { Module } from "@/interfaces"; +import { Stat, User } from "@/interfaces/user"; +import { sessionOptions } from "@/lib/session"; +import { calculateAverageLevel, calculateBandScore } from "@/utils/score"; +import { groupByModule, groupBySession } from "@/utils/stats"; import { MODULE_ARRAY } from "@/utils/moduleUtils"; import client from "@/lib/mongodb"; -import {withIronSessionApiRoute} from "iron-session/next"; -import {groupBy} from "lodash"; -import {NextApiRequest, NextApiResponse} from "next"; +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); @@ -29,8 +29,8 @@ async function update(req: NextApiRequest, res: NextApiResponse) { 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) => { - const sessionStats = groupedStats[key].map((stat) => ({module: stat.module, correct: stat.score.correct, total: stat.score.total})); + const sessionLevels: { [key in Module]: { correct: number; total: number } }[] = Object.keys(groupedStats).map((key) => { + const sessionStats = groupedStats[key].map((stat) => ({ module: stat.module, correct: stat.score.correct, total: stat.score.total })); const sessionLevels = { reading: { correct: 0, @@ -59,8 +59,8 @@ async function update(req: NextApiRequest, res: NextApiResponse) { if (moduleStats.length === 0) return; const moduleScore = moduleStats.reduce( - (accumulator, current) => ({correct: accumulator.correct + current.correct, total: accumulator.total + current.total}), - {correct: 0, total: 0}, + (accumulator, current) => ({ correct: accumulator.correct + current.correct, total: accumulator.total + current.total }), + { correct: 0, total: 0 }, ); sessionLevels[module] = moduleScore; @@ -72,24 +72,24 @@ async function update(req: NextApiRequest, res: NextApiResponse) { const readingLevel = sessionLevels .map((x) => x.reading) .filter((x) => x.total > 0) - .reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); + .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 }); const listeningLevel = sessionLevels .map((x) => x.listening) .filter((x) => x.total > 0) - .reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); + .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 }); const writingLevel = sessionLevels .map((x) => x.writing) .filter((x) => x.total > 0) - .reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); + .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 }); const speakingLevel = sessionLevels .map((x) => x.speaking) .filter((x) => x.total > 0) - .reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); + .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 }); - const levelLevel = sessionLevels + const levelLevel = sessionLevels .map((x) => x.level) .filter((x) => x.total > 0) - .reduce((acc, cur) => ({total: acc.total + cur.total, correct: acc.correct + cur.correct}), {total: 0, correct: 0}); + .reduce((acc, cur) => ({ total: acc.total + cur.total, correct: acc.correct + cur.correct }), { total: 0, correct: 0 }); const levels = { @@ -100,12 +100,14 @@ async function update(req: NextApiRequest, res: NextApiResponse) { level: calculateBandScore(levelLevel.correct, levelLevel.total, "level", user.focus), }; + const averageLevel = calculateAverageLevel(levels) + await db.collection("users").updateOne( - { id: user.id}, - { $set: {levels} } + { id: user.id }, + { $set: { levels, averageLevel } } ); - res.status(200).json({ok: true}); + res.status(200).json({ ok: true }); } else { res.status(401).json(undefined); } diff --git a/src/pages/dashboard/corporate.tsx b/src/pages/dashboard/corporate.tsx index 2504646a..3eb71ca5 100644 --- a/src/pages/dashboard/corporate.tsx +++ b/src/pages/dashboard/corporate.tsx @@ -3,18 +3,18 @@ import Layout from "@/components/High/Layout"; import UserDisplayList from "@/components/UserDisplayList"; import IconCard from "@/components/IconCard"; import { EntityWithRoles } from "@/interfaces/entity"; -import { Stat, Type, User } from "@/interfaces/user"; +import { Stat, StudentUser, Type, User } from "@/interfaces/user"; import { sessionOptions } from "@/lib/session"; import { dateSorter, filterBy, mapBy, redirect, serialize } from "@/utils"; import { requestUser } from "@/utils/api"; import { countEntitiesAssignments } from "@/utils/assignments.be"; import { getEntitiesWithRoles } from "@/utils/entities.be"; import { countGroupsByEntities } from "@/utils/groups.be"; -import { checkAccess } from "@/utils/permissions"; +import { checkAccess, findAllowedEntities } from "@/utils/permissions"; import { calculateAverageLevel } from "@/utils/score"; import { groupByExam } from "@/utils/stats"; import { getStatsByUsers } from "@/utils/stats.be"; -import { countAllowedUsers, filterAllowedUsers } from "@/utils/users.be"; +import { countAllowedUsers, filterAllowedUsers, getUsers } from "@/utils/users.be"; import { withIronSessionSsr } from "iron-session/next"; import { uniqBy } from "lodash"; import moment from "moment"; @@ -37,7 +37,9 @@ import { isAdmin } from "@/utils/users"; interface Props { user: User; - users: User[]; + students: StudentUser[] + latestStudents: User[] + latestTeachers: User[] userCounts: { [key in Type]: number } entities: EntityWithRoles[]; assignmentsCount: number; @@ -53,21 +55,25 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const entityIDS = mapBy(user.entities, "id") || []; const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); - const users = await filterAllowedUsers(user, entities) + + const allowedStudentEntities = findAllowedEntities(user, entities, "view_students") + const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers") + + const students = + await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 }); + const latestStudents = + await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 }) + const latestTeachers = + await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 }) const userCounts = await countAllowedUsers(user, entities) const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } }); const groupsCount = await countGroupsByEntities(mapBy(entities, "id")); - const stats = await getStatsByUsers(users.map((u) => u.id)); - - return { props: serialize({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }) }; + return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) }; }, sessionOptions); -export default function Dashboard({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); - +export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) { const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) const totalLicenses = useMemo(() => entities.reduce((acc, curr) => acc + parseInt(curr.licenses.toString()), 0), [entities]) @@ -159,15 +165,15 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
dateSorter(a, b, "desc", "registrationDate"))} + users={latestStudents} title="Latest Students" /> dateSorter(a, b, "desc", "registrationDate"))} + users={latestTeachers} title="Latest Teachers" /> calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + users={students} title="Highest level students" /> { if (!checkAccess(user, ["admin", "developer"])) return redirect("/") - const students = await getUsers({ type: 'student' }); + const students = await getUsers({ type: 'student' }, 10, { averageLevel: -1 }); const usersCount = { student: await countUsers({ type: "student" }), teacher: await countUsers({ type: "teacher" }), @@ -66,20 +66,18 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const assignmentsCount = await countEntitiesAssignments(mapBy(entities, 'id'), { archived: { $ne: true } }); const groupsCount = await countGroups(); - const stats = await getStatsByUsers(mapBy(students, 'id')); - - return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, stats, groupsCount }) }; + return { props: serialize({ user, students, latestStudents, latestTeachers, usersCount, entities, assignmentsCount, groupsCount }) }; }, sessionOptions); export default function Dashboard({ user, - students, + students = [], latestStudents, latestTeachers, usersCount, entities, assignmentsCount, - stats, + stats = [], groupsCount }: Props) { const router = useRouter(); @@ -170,7 +168,7 @@ export default function Dashboard({ title="Latest Teachers" /> calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + users={students} title="Highest level students" /> { const user = await requestUser(req, res) if (!user || !user.isVerified) return redirect("/login") - if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) - return redirect("/") + if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/") const entityIDS = mapBy(user.entities, "id") || []; const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); - const users = await filterAllowedUsers(user, entities) + + const allowedStudentEntities = findAllowedEntities(user, entities, "view_students") + const allowedTeacherEntities = findAllowedEntities(user, entities, "view_teachers") + + const students = + await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { averageLevel: -1 }); + const latestStudents = + await getUsers({ type: 'student', "entities.id": { $in: mapBy(allowedStudentEntities, 'id') } }, 10, { registrationDate: -1 }) + const latestTeachers = + await getUsers({ type: 'teacher', "entities.id": { $in: mapBy(allowedTeacherEntities, 'id') } }, 10, { registrationDate: -1 }) const userCounts = await countAllowedUsers(user, entities) const assignmentsCount = await countEntitiesAssignments(mapBy(entities, "id"), { archived: { $ne: true } }); const groupsCount = await countGroupsByEntities(mapBy(entities, "id")); - const stats = await getStatsByUsers(users.map((u) => u.id)); - - return { props: serialize({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }) }; + return { props: serialize({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, groupsCount }) }; }, sessionOptions); -export default function Dashboard({ user, users, userCounts, entities, assignmentsCount, stats, groupsCount }: Props) { - const students = useMemo(() => users.filter((u) => u.type === "student"), [users]); - const teachers = useMemo(() => users.filter((u) => u.type === "teacher"), [users]); +export default function Dashboard({ user, students, latestStudents, latestTeachers, userCounts, entities, assignmentsCount, stats = [], groupsCount }: Props) { const totalCount = useMemo(() => userCounts.corporate + userCounts.mastercorporate + userCounts.student + userCounts.teacher, [userCounts]) @@ -168,15 +174,15 @@ export default function Dashboard({ user, users, userCounts, entities, assignmen
dateSorter(a, b, "desc", "registrationDate"))} + users={latestStudents} title="Latest Students" /> dateSorter(a, b, "desc", "registrationDate"))} + users={latestTeachers} title="Latest Teachers" /> calculateAverageLevel(b.levels) - calculateAverageLevel(a.levels))} + users={students} title="Highest level students" /> { - return (await db.collection("entities").findOne({id})) ?? undefined; + 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}} : {}) + .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) || []})); + 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}} : {}) + .find(ids ? { id: { $in: ids } } : {}) .toArray(); }; @@ -57,41 +57,52 @@ export const createEntity = async (entity: Entity) => { await db.collection("roles").insertOne(defaultRole) await db.collection("roles").insertOne(adminRole) - return {default: defaultRole, admin: adminRole} + return { default: defaultRole, admin: adminRole } } export const addUserToEntity = async (user: string, entity: string, role: string) => await db.collection("users").updateOne( - {id: user}, + { id: user }, { // @ts-expect-error $push: { - entities: {id: entity, role}, + entities: { id: entity, role }, }, }, ); export const addUsersToEntity = async (users: string[], entity: string, role: string) => await db.collection("users").updateMany( - {id: {$in: users}}, + { id: { $in: users } }, { // @ts-expect-error $push: { - entities: {id: entity, role}, + entities: { id: entity, role }, + }, + }, + ); + +export const removeUsersFromEntity = async (users: string[], entity: string) => + await db.collection("users").updateMany( + { id: { $in: users } }, + { + // @ts-expect-error + $pull: { + entities: { id: entity }, }, }, ); 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("entities").deleteOne({ id: entity.id }) + await db.collection("roles").deleteMany({ entityID: entity.id }) await db.collection("users").updateMany( - {"entities.id": entity.id}, + { "entities.id": entity.id }, { // @ts-expect-error $pull: { - entities: {id: entity.id}, + entities: { id: entity.id }, }, }, ); diff --git a/src/utils/roles.be.ts b/src/utils/roles.be.ts index 0dea6694..dd3a3b6b 100644 --- a/src/utils/roles.be.ts +++ b/src/utils/roles.be.ts @@ -1,4 +1,4 @@ -import {Role} from "@/interfaces/entity"; +import { Role } from "@/interfaces/entity"; import client from "@/lib/mongodb"; const db = client.db(process.env.MONGODB_DB); @@ -6,16 +6,18 @@ const db = client.db(process.env.MONGODB_DB); export const getRolesByEntities = async (entityIDs: string[]) => await db .collection("roles") - .find({entityID: {$in: entityIDs}}) + .find({ entityID: { $in: entityIDs } }) .toArray(); -export const getRolesByEntity = async (entityID: string) => await db.collection("roles").find({entityID}).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 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 getDefaultRole = async (entityID: string) => await db.collection("roles").findOne({ isDefault: true, entityID }) 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 deleteRole = async (id: string) => await db.collection("roles").deleteOne({ id }) export const transferRole = async (previousRole: string, newRole: string) => await db.collection("users") From 7b5d021bf3fb5ff2ba6ef22ea38126ae471f9eda Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Tue, 24 Dec 2024 12:40:02 +0000 Subject: [PATCH 08/12] Hotfix --- src/pages/dashboard/mastercorporate.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/dashboard/mastercorporate.tsx b/src/pages/dashboard/mastercorporate.tsx index f06c78c6..33044718 100644 --- a/src/pages/dashboard/mastercorporate.tsx +++ b/src/pages/dashboard/mastercorporate.tsx @@ -58,7 +58,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) if (!user || !user.isVerified) return redirect("/login") - if (!checkAccess(user, ["admin", "developer", "corporate"])) return redirect("/") + if (!checkAccess(user, ["admin", "developer", "mastercorporate"])) return redirect("/") const entityIDS = mapBy(user.entities, "id") || []; const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDS); From b52259794e3c793fddb74ed6b0e27a6e65c38ed1 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 30 Dec 2024 11:11:44 +0000 Subject: [PATCH 09/12] ENCOA-298 --- src/exams/Level/index.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/exams/Level/index.tsx b/src/exams/Level/index.tsx index eb535c80..008897ae 100644 --- a/src/exams/Level/index.tsx +++ b/src/exams/Level/index.tsx @@ -67,13 +67,6 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr const [continueAnyways, setContinueAnyways] = useState(false); const [textRender, setTextRender] = useState(false); - const hasPractice = useMemo(() => { - if (partIndex > -1 && partIndex < exam.parts.length) { - return exam.parts[partIndex].exercises.some(e => e.isPractice) - } - return false - }, [partIndex, exam.parts]) - const [questionModalKwargs, setQuestionModalKwargs] = useState<{ type?: "module" | "blankQuestions" | "submit"; unanswered?: boolean | undefined; onClose: (next?: boolean) => void | undefined; }>({ @@ -108,6 +101,14 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr } ); + const hasPractice = useMemo(() => { + if (partIndex > -1 && partIndex < exam.parts.length && !showPartDivider) { + console.log(exam.parts[partIndex].exercises.some(e => e.isPractice)) + return exam.parts[partIndex].exercises.some(e => e.isPractice) + } + return false + }, [partIndex, showPartDivider, exam.parts]) + const registerSolution = useCallback((updateSolution: () => UserSolution) => { userSolutionRef.current = updateSolution; setSolutionWasUpdated(true); @@ -344,7 +345,7 @@ const Level: React.FC> = ({ exam, showSolutions = false, pr return ( <>
- + Date: Mon, 30 Dec 2024 15:36:20 +0000 Subject: [PATCH 10/12] Updated the expiry date to be based on the expiry date --- src/interfaces/entity.ts | 5 ++ src/interfaces/paypal.ts | 1 + src/pages/api/entities/[id]/index.ts | 21 ++++- src/pages/entities/[id]/index.tsx | 103 ++++++++++++++++++++++- src/pages/entities/[id]/roles/[role].tsx | 4 +- src/pages/payment-record.tsx | 36 +++++--- src/pages/payment.tsx | 4 +- src/resources/entityPermissions.ts | 8 +- 8 files changed, 164 insertions(+), 18 deletions(-) diff --git a/src/interfaces/entity.ts b/src/interfaces/entity.ts index da1e8e5e..b10d547e 100644 --- a/src/interfaces/entity.ts +++ b/src/interfaces/entity.ts @@ -4,6 +4,11 @@ export interface Entity { id: string; label: string; licenses: number; + expiryDate?: Date | null + payment?: { + currency: string + price: number + } } export interface Role { diff --git a/src/interfaces/paypal.ts b/src/interfaces/paypal.ts index b1b3e8b0..d7c7c902 100644 --- a/src/interfaces/paypal.ts +++ b/src/interfaces/paypal.ts @@ -32,6 +32,7 @@ export type DurationUnit = "weeks" | "days" | "months" | "years"; export interface Payment { id: string; corporate: string; + entity?: string agent?: string; agentCommission: number; agentValue: number; diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts index c329f832..2f438f20 100644 --- a/src/pages/api/entities/[id]/index.ts +++ b/src/pages/api/entities/[id]/index.ts @@ -6,9 +6,11 @@ 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 { getEntityUsers, getUser } from "@/utils/users.be"; import { requestUser } from "@/utils/api"; import { isAdmin } from "@/utils/users"; +import { filterBy, mapBy } from "@/utils"; +import { User } from "@/interfaces/user"; const db = client.db(process.env.MONGODB_DB); @@ -66,5 +68,22 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return res.status(200).json({ ok: entity.acknowledged }); } + if (req.body.expiryDate !== undefined) { + const entity = await getEntity(id) + const result = await db.collection("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } }); + + const users = await getEntityUsers(id, 0, { + subscriptionExpirationDate: entity?.expiryDate, + $and: [ + { type: { $ne: "admin" } }, + { type: { $ne: "developer" } }, + ] + }) + + await db.collection("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } }) + + return res.status(200).json({ ok: result.acknowledged }); + } + return res.status(200).json({ ok: true }); } diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index 1546d933..324c2227 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -2,6 +2,7 @@ import CardList from "@/components/High/CardList"; import Layout from "@/components/High/Layout"; import Select from "@/components/Low/Select"; +import Checkbox from "@/components/Low/Checkbox"; import Tooltip from "@/components/Low/Tooltip"; import { useEntityPermission } from "@/hooks/useEntityPermissions"; import { useListSearch } from "@/hooks/useListSearch"; @@ -27,7 +28,10 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { Divider } from "primereact/divider"; import { useEffect, useMemo, useState } from "react"; +import ReactDatePicker from "react-datepicker"; + import { + BsCheck, BsChevronLeft, BsClockFill, BsEnvelopeFill, @@ -43,6 +47,15 @@ import { } from "react-icons/bs"; import { toast } from "react-toastify"; +const expirationDateColor = (date: Date) => { + const momentDate = moment(date); + const today = moment(new Date()); + + if (today.add(1, "days").isAfter(momentDate)) return "!bg-mti-red-ultralight border-mti-red-light"; + if (today.add(3, "days").isAfter(momentDate)) return "!bg-mti-rose-ultralight border-mti-rose-light"; + if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; +}; + export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const user = req.session.user as User; @@ -88,6 +101,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const [isAdding, setIsAdding] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); + const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) const router = useRouter(); @@ -99,6 +113,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const canRemoveMembers = useEntityPermission(user, entity, "remove_from_entity") const canAssignRole = useEntityPermission(user, entity, "assign_to_role") + const canPay = useEntityPermission(user, entity, 'pay_entity') const toggleUser = (u: User) => setSelectedUsers((prev) => (prev.includes(u.id) ? prev.filter((p) => p !== u.id) : [...prev, u.id])); @@ -166,6 +181,23 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { .finally(() => setIsLoading(false)); }; + const updateExpiryDate = () => { + if (!isAdmin(user)) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, { expiryDate }) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + const editLicenses = () => { if (!isAdmin(user)) return; @@ -289,7 +321,7 @@ export default function Home({ user, entity, users, linkedUsers }: Props) {
-
+

{entity.label} {isAdmin(user) && `- ${entity.licenses || 0} licenses`}

+ + {!isAdmin(user) && canPay && ( + + {!entity.expiryDate && "Unlimited"} + {entity.expiryDate && moment(entity.expiryDate).format("DD/MM/YYYY")} + + )}
+ + {isAdmin(user) && ( + <> + + +
+
+ {!!expiryDate && ( + moment(date).isAfter(new Date())} + dateFormat="dd/MM/yyyy" + selected={expiryDate ? moment(expiryDate).toDate() : null} + onChange={(date) => setExpiryDate(date)} + /> + )} + + {!expiryDate && ( +
+ Unlimited +
+ )} + + setExpiryDate(checked ? entity.expiryDate || new Date() : null)} + > + Enable expiry date + +
+ + + +
+ + )} +
Members ({users.length}) diff --git a/src/pages/entities/[id]/roles/[role].tsx b/src/pages/entities/[id]/roles/[role].tsx index 037fb571..5c41954f 100644 --- a/src/pages/entities/[id]/roles/[role].tsx +++ b/src/pages/entities/[id]/roles/[role].tsx @@ -98,7 +98,9 @@ const ENTITY_MANAGEMENT: PermissionLayout[] = [ { label: "Delete Entity Role", key: "delete_entity_role" }, { label: "Download Statistics Report", key: "download_statistics_report" }, { label: "Edit Grading System", key: "edit_grading_system" }, - { label: "View Student Performance", key: "view_student_performance" } + { label: "View Student Performance", key: "view_student_performance" }, + { label: "Pay for Entity", key: "pay_entity" }, + { label: "View Payment Record", key: "view_payment_record" } ] const ASSIGNMENT_MANAGEMENT: PermissionLayout[] = [ diff --git a/src/pages/payment-record.tsx b/src/pages/payment-record.tsx index 2be5369a..daf277ce 100644 --- a/src/pages/payment-record.tsx +++ b/src/pages/payment-record.tsx @@ -30,9 +30,12 @@ import { toFixedNumber } from "@/utils/number"; import { CSVLink } from "react-csv"; import { Tab } from "@headlessui/react"; import { useListSearch } from "@/hooks/useListSearch"; -import { checkAccess, getTypesOfUser } from "@/utils/permissions"; +import { checkAccess, findAllowedEntities, getTypesOfUser } from "@/utils/permissions"; import { requestUser } from "@/utils/api"; -import { redirect } from "@/utils"; +import { mapBy, redirect, serialize } from "@/utils"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; +import { isAdmin } from "@/utils/users"; +import { Entity, EntityWithRoles } from "@/interfaces/entity"; export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { const user = await requestUser(req, res) @@ -42,8 +45,13 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { return redirect("/") } + const entityIDs = mapBy(user.entities, 'id') + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs) + + const allowedEntities = findAllowedEntities(user, entities, "view_payment_record") + return { - props: { user }, + props: serialize({ user, entities: allowedEntities }), }; }, sessionOptions); @@ -273,7 +281,13 @@ interface PaypalPaymentWithUserData extends PaypalPayment { } const paypalFilterRows = [["email"], ["name"], ["orderId"], ["value"]]; -export default function PaymentRecord() { + +interface Props { + user: User + entities: EntityWithRoles[] +} + +export default function PaymentRecord({ user, entities }: Props) { const [selectedCorporateUser, setSelectedCorporateUser] = useState(); const [selectedAgentUser, setSelectedAgentUser] = useState(); const [isCreatingPayment, setIsCreatingPayment] = useState(false); @@ -281,9 +295,9 @@ export default function PaymentRecord() { const [displayPayments, setDisplayPayments] = useState([]); const [corporate, setCorporate] = useState(); + const [entity, setEntity] = useState(); const [agent, setAgent] = useState(); - const { user } = useUser({ redirectTo: "/login" }); const { users, reload: reloadUsers } = useUsers(); const { payments: originalPayments, reload: reloadPayment } = usePayments(); const { payments: paypalPayments, reload: reloadPaypalPayment } = usePaypalPayments(); @@ -341,17 +355,17 @@ export default function PaymentRecord() { useEffect(() => { setFilters((prev) => [ - ...prev.filter((x) => x.id !== "corporate-filter"), - ...(!corporate + ...prev.filter((x) => x.id !== "entity-filter"), + ...(!entity ? [] : [ { - id: "corporate-filter", - filter: (p: Payment) => p.corporate === corporate.id, + id: "entity-filter", + filter: (p: Payment) => p.entity === entity.id, }, ]), ]); - }, [corporate]); + }, [entity]); useEffect(() => { setFilters((prev) => [ @@ -675,7 +689,7 @@ export default function PaymentRecord() { { - if (user?.type === agent || user?.type === "corporate" || value) return null; + if (user?.type === "agent" || user?.type === "corporate" || value) return null; if (!info.row.original.commissionTransfer || !info.row.original.corporateTransfer) return alert("All files need to be uploaded to consider it paid!"); if (!confirm(`Are you sure you want to consider this payment paid?`)) return null; diff --git a/src/pages/payment.tsx b/src/pages/payment.tsx index 49854d22..579a88db 100644 --- a/src/pages/payment.tsx +++ b/src/pages/payment.tsx @@ -7,7 +7,7 @@ import PaymentDue from "./(status)/PaymentDue"; import { useRouter } from "next/router"; import { requestUser } from "@/utils/api"; import { mapBy, redirect, serialize } from "@/utils"; -import { getEntities } from "@/utils/entities.be"; +import { getEntities, getEntitiesWithRoles } from "@/utils/entities.be"; import { isAdmin } from "@/utils/users"; import { EntityWithRoles } from "@/interfaces/entity"; import { User } from "@/interfaces/user"; @@ -17,7 +17,7 @@ export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => { if (!user) return redirect("/login") const entityIDs = mapBy(user.entities, 'id') - const entities = await getEntities(isAdmin(user) ? undefined : entityIDs) + const entities = await getEntitiesWithRoles(isAdmin(user) ? undefined : entityIDs) return { props: serialize({ user, entities }), diff --git a/src/resources/entityPermissions.ts b/src/resources/entityPermissions.ts index 484672d1..b9c6c165 100644 --- a/src/resources/entityPermissions.ts +++ b/src/resources/entityPermissions.ts @@ -63,7 +63,9 @@ export type RolePermission = "upload_classroom" | "download_user_list" | "view_student_record" | - "download_student_record" + "download_student_record" | + "pay_entity" | + "view_payment_record" export const DEFAULT_PERMISSIONS: RolePermission[] = [ "view_students", @@ -140,5 +142,7 @@ export const ADMIN_PERMISSIONS: RolePermission[] = [ "upload_classroom", "download_user_list", "view_student_record", - "download_student_record" + "download_student_record", + "pay_entity", + "view_payment_record" ] From f64b50df9e74abf39f37f8412b6d18fe5bd76170 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Mon, 30 Dec 2024 18:39:02 +0000 Subject: [PATCH 11/12] Updated part of the payment --- src/components/High/Layout.tsx | 12 +- src/components/Low/Input.tsx | 5 +- src/components/Navbar.tsx | 10 +- src/components/PaymobPayment.tsx | 17 +- src/interfaces/paymob.ts | 2 +- src/pages/(status)/PaymentDue.tsx | 338 ++++++++++++++------------- src/pages/api/entities/[id]/index.ts | 5 + src/pages/api/paymob/webhook.ts | 64 ++--- src/pages/entities/[id]/index.tsx | 56 +++++ src/pages/payment.tsx | 17 +- src/stores/examEditor/defaults.ts | 288 +++++++++++------------ 11 files changed, 457 insertions(+), 357 deletions(-) diff --git a/src/components/High/Layout.tsx b/src/components/High/Layout.tsx index c29d62c4..502384e1 100644 --- a/src/components/High/Layout.tsx +++ b/src/components/High/Layout.tsx @@ -1,8 +1,8 @@ import useEntities from "@/hooks/useEntities"; import { EntityWithRoles } from "@/interfaces/entity"; -import {User} from "@/interfaces/user"; +import { User } from "@/interfaces/user"; import clsx from "clsx"; -import {useRouter} from "next/router"; +import { useRouter } from "next/router"; import { ToastContainer } from "react-toastify"; import Navbar from "../Navbar"; import Sidebar from "../Sidebar"; @@ -23,19 +23,19 @@ export default function Layout({ user, children, className, - bgColor="bg-white", + bgColor = "bg-white", hideSidebar, navDisabled = false, focusMode = false, onFocusLayerMouseEnter }: Props) { const router = useRouter(); - const {entities} = useEntities() + const { entities } = useEntities() return (
- {!hideSidebar && ( + {!hideSidebar && user && ( )}
- {!hideSidebar && ( + {!hideSidebar && user && ( void; } @@ -29,6 +30,7 @@ export default function Input({ className, roundness = "full", disabled = false, + thin = false, min, onChange, }: Props) { @@ -95,9 +97,10 @@ export default function Input({ min={type === "number" ? (min ?? 0) : undefined} placeholder={placeholder} className={clsx( - "px-8 py-6 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", + "px-8 text-sm font-normal bg-white border border-mti-gray-platinum focus:outline-none", "placeholder:text-mti-gray-cool disabled:bg-mti-gray-platinum/40 disabled:text-mti-gray-dim disabled:cursor-not-allowed", roundness === "full" ? "rounded-full" : "rounded-xl", + thin ? 'py-4' : 'py-6' )} required={required} defaultValue={defaultValue} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index f2bc5a42..a18f11e5 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -65,28 +65,28 @@ export default function Navbar({ user, path, navDisabled = false, focusMode = fa { module: "reading", icon: () => , - achieved: user.levels?.reading || 0 >= user.desiredLevels?.reading || 9, + achieved: user?.levels?.reading || 0 >= user?.desiredLevels?.reading || 9, }, { module: "listening", icon: () => , - achieved: user.levels?.listening || 0 >= user.desiredLevels?.listening || 9, + achieved: user?.levels?.listening || 0 >= user?.desiredLevels?.listening || 9, }, { module: "writing", icon: () => , - achieved: user.levels?.writing || 0 >= user.desiredLevels?.writing || 9, + achieved: user?.levels?.writing || 0 >= user?.desiredLevels?.writing || 9, }, { module: "speaking", icon: () => , - achieved: user.levels?.speaking || 0 >= user.desiredLevels?.speaking || 9, + achieved: user?.levels?.speaking || 0 >= user?.desiredLevels?.speaking || 9, }, { module: "level", icon: () => , - achieved: user.levels?.level || 0 >= user.desiredLevels?.level || 9, + achieved: user?.levels?.level || 0 >= user?.desiredLevels?.level || 9, }, ]; diff --git a/src/components/PaymobPayment.tsx b/src/components/PaymobPayment.tsx index 47cec9d6..9df12935 100644 --- a/src/components/PaymobPayment.tsx +++ b/src/components/PaymobPayment.tsx @@ -1,15 +1,17 @@ -import {PaymentIntention} from "@/interfaces/paymob"; -import {DurationUnit} from "@/interfaces/paypal"; -import {User} from "@/interfaces/user"; +import { Entity } from "@/interfaces/entity"; +import { PaymentIntention } from "@/interfaces/paymob"; +import { DurationUnit } from "@/interfaces/paypal"; +import { User } from "@/interfaces/user"; import axios from "axios"; -import {useRouter} from "next/router"; -import {useState} from "react"; +import { useRouter } from "next/router"; +import { useState } from "react"; import Button from "./Low/Button"; import Input from "./Low/Input"; import Modal from "./Modal"; interface Props { user: User; + entity?: Entity currency: string; price: number; setIsPaymentLoading: (v: boolean) => void; @@ -18,7 +20,7 @@ interface Props { onSuccess: (duration: number, duration_unit: DurationUnit) => void; } -export default function PaymobPayment({user, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess}: Props) { +export default function PaymobPayment({ user, entity, price, setIsPaymentLoading, currency, duration, duration_unit, onSuccess }: Props) { const [isLoading, setIsLoading] = useState(false); const router = useRouter(); @@ -56,10 +58,11 @@ export default function PaymobPayment({user, price, setIsPaymentLoading, currenc userID: user.id, duration, duration_unit, + entity: entity?.id }, }; - const response = await axios.post<{iframeURL: string}>(`/api/paymob`, paymentIntention); + const response = await axios.post<{ iframeURL: string }>(`/api/paymob`, paymentIntention); router.push(response.data.iframeURL); } catch (error) { diff --git a/src/interfaces/paymob.ts b/src/interfaces/paymob.ts index f75445a4..13ed8693 100644 --- a/src/interfaces/paymob.ts +++ b/src/interfaces/paymob.ts @@ -28,7 +28,7 @@ interface Customer { extras: IntentionExtras; } -type IntentionExtras = {[key: string]: string | number}; +type IntentionExtras = { [key: string]: string | number | undefined }; export interface IntentionResult { payment_keys: PaymentKeysItem[]; diff --git a/src/pages/(status)/PaymentDue.tsx b/src/pages/(status)/PaymentDue.tsx index 82a081dc..5dfc9842 100644 --- a/src/pages/(status)/PaymentDue.tsx +++ b/src/pages/(status)/PaymentDue.tsx @@ -5,8 +5,8 @@ import usePackages from "@/hooks/usePackages"; import useUsers from "@/hooks/useUsers"; import { User } from "@/interfaces/user"; import clsx from "clsx"; -import { capitalize } from "lodash"; -import { useEffect, useState } from "react"; +import { capitalize, sortBy } from "lodash"; +import { useEffect, useMemo, useState } from "react"; import useInvites from "@/hooks/useInvites"; import { BsArrowRepeat } from "react-icons/bs"; import InviteCard from "@/components/Medium/InviteCard"; @@ -16,46 +16,50 @@ import useDiscounts from "@/hooks/useDiscounts"; import PaymobPayment from "@/components/PaymobPayment"; import moment from "moment"; import { EntityWithRoles } from "@/interfaces/entity"; +import { Discount, Package } from "@/interfaces/paypal"; +import { isAdmin } from "@/utils/users"; +import { useAllowedEntities } from "@/hooks/useEntityPermissions"; +import Select from "@/components/Low/Select"; interface Props { - user: User; + user: User + discounts: Discount[] + packages: Package[] entities: EntityWithRoles[] hasExpired?: boolean; reload: () => void; } -export default function PaymentDue({ user, hasExpired = false, reload }: Props) { +export default function PaymentDue({ user, discounts = [], entities = [], packages = [], hasExpired = false, reload }: Props) { const [isLoading, setIsLoading] = useState(false); - const [appliedDiscount, setAppliedDiscount] = useState(0); + const [entity, setEntity] = useState() const router = useRouter(); - const { packages } = usePackages(); - const { discounts } = useDiscounts(); const { users } = useUsers(); - const { groups } = useGroups({}); const { invites, isLoading: isInvitesLoading, reload: reloadInvites } = useInvites({ to: user?.id }); - useEffect(() => { - const userDiscounts = discounts.filter((x) => user.email.endsWith(`@${x.domain}`)); - if (userDiscounts.length === 0) return; - - const biggestDiscount = [...userDiscounts].sort((a, b) => b.percentage - a.percentage).shift(); - if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) return; - - setAppliedDiscount(biggestDiscount.percentage); - }, [discounts, user]); - - const isIndividual = () => { - if (user?.type === "developer") return true; + const isIndividual = useMemo(() => { + if (isAdmin(user)) return false; if (user?.type !== "student") return false; - const userGroups = groups.filter((g) => g.participants.includes(user?.id)); - if (userGroups.length === 0) return true; + return user.entities.length === 0 + }, [user]) - const userGroupsAdminTypes = userGroups.map((g) => users?.find((u) => u.id === g.admin)?.type).filter((t) => !!t); - return userGroupsAdminTypes.every((t) => t !== "corporate"); - }; + const appliedDiscount = useMemo(() => { + const biggestDiscount = [...discounts].sort((a, b) => b.percentage - a.percentage).shift(); + + if (!biggestDiscount || (biggestDiscount.validUntil && moment(biggestDiscount.validUntil).isBefore(moment()))) + return 0; + + return biggestDiscount.percentage + }, [discounts]) + + const entitiesThatCanBePaid = useAllowedEntities(user, entities, 'pay_entity') + + useEffect(() => { + if (entitiesThatCanBePaid.length > 0) setEntity(entitiesThatCanBePaid[0]) + }, [entitiesThatCanBePaid]) return ( <> @@ -74,169 +78,185 @@ export default function PaymentDue({ user, hasExpired = false, reload }: Props)
)} - {user ? ( - - {invites.length > 0 && ( -
-
-
- Invites - -
+ + {invites.length > 0 && ( +
+
+
+ Invites +
- - {invites.map((invite) => ( - { - reloadInvites(); - router.reload(); - }} - /> - ))} - -
- )} +
+ + {invites.map((invite) => ( + { + reloadInvites(); + router.reload(); + }} + /> + ))} + +
+ )} -
- {hasExpired && You do not have time credits for your account type!} - {isIndividual() && ( -
- - To add to your use of EnCoach, please purchase one of the time packages available below: - -
- {packages.map((p) => ( -
-
- EnCoach's Logo - - EnCoach - {p.duration}{" "} - {capitalize( - p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, - )} - -
-
- {appliedDiscount === 0 && ( - - {p.price} {p.currency} - - )} - {appliedDiscount > 0 && ( -
- - {p.price} {p.currency} - - - {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} - -
- )} - { - setTimeout(reload, 500); - }} - currency={p.currency} - duration={p.duration} - duration_unit={p.duration_unit} - price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} - /> -
-
- This includes: -
    -
  • - Train your abilities for the IELTS exam
  • -
  • - Gain insights into your weaknesses and strengths
  • -
  • - Allow yourself to correctly prepare for the exam
  • -
-
-
- ))} -
-
- )} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - user?.corporateInformation.payment && ( -
- - To add to your use of EnCoach and that of your students and teachers, please pay your designated package - below: - -
+
+ {hasExpired && You do not have time credits for your account type!} + {isIndividual && ( +
+ + To add to your use of EnCoach, please purchase one of the time packages available below: + +
+ {packages.map((p) => ( +
EnCoach's Logo - EnCoach - {12} Months + EnCoach - {p.duration}{" "} + {capitalize( + p.duration === 1 ? p.duration_unit.slice(0, p.duration_unit.length - 1) : p.duration_unit, + )}
- - {user.corporateInformation.payment.value} {user.corporateInformation.payment.currency} - + {appliedDiscount === 0 && ( + + {p.price} {p.currency} + + )} + {appliedDiscount > 0 && ( +
+ + {p.price} {p.currency} + + + {(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} {p.currency} + +
+ )} { - setIsLoading(false); setTimeout(reload, 500); }} + currency={p.currency} + duration={p.duration} + duration_unit={p.duration_unit} + price={+(p.price - p.price * (appliedDiscount / 100)).toFixed(2)} />
This includes:
    -
  • - - Allow a total of 0 students and teachers to use EnCoach -
  • -
  • - Train their abilities for the IELTS exam
  • -
  • - Gain insights into your students' weaknesses and strengths
  • -
  • - Allow them to correctly prepare for the exam
  • +
  • - Train your abilities for the IELTS exam
  • +
  • - Gain insights into your weaknesses and strengths
  • +
  • - Allow yourself to correctly prepare for the exam
+ ))} +
+
+ )} + + {!isIndividual && entitiesThatCanBePaid.length > 0 && + entity?.payment && ( +
+
+ + ({ value: e.id, label: e.label, entity: e }))} + onChange={(e) => e?.value ? setEntity(e?.entity) : null} + className="!w-full max-w-[400px] self-center" + /> +
+ + An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users + you desire and your expected monthly duration. - If you believe this to be a mistake, please contact the platform's administration, thank you for your - patience. + Please try again later or contact your agent or an admin, thank you for your patience.
)} - {!isIndividual() && - (user?.type === "corporate" || user?.type === "mastercorporate") && - !user.corporateInformation.payment && ( -
- - An admin nor your agent have yet set the price intended to your requirements in terms of the amount of users - you desire and your expected monthly duration. - - - Please try again later or contact your agent or an admin, thank you for your patience. - -
- )} -
- - ) : ( -
- )} +
+ ); } diff --git a/src/pages/api/entities/[id]/index.ts b/src/pages/api/entities/[id]/index.ts index 2f438f20..0323ac05 100644 --- a/src/pages/api/entities/[id]/index.ts +++ b/src/pages/api/entities/[id]/index.ts @@ -68,6 +68,11 @@ async function patch(req: NextApiRequest, res: NextApiResponse) { return res.status(200).json({ ok: entity.acknowledged }); } + if (req.body.payment) { + const entity = await db.collection("entities").updateOne({ id }, { $set: { payment: req.body.payment } }); + return res.status(200).json({ ok: entity.acknowledged }); + } + if (req.body.expiryDate !== undefined) { const entity = await getEntity(id) const result = await db.collection("entities").updateOne({ id }, { $set: { expiryDate: req.body.expiryDate } }); diff --git a/src/pages/api/paymob/webhook.ts b/src/pages/api/paymob/webhook.ts index 7b428c6c..0fd4b416 100644 --- a/src/pages/api/paymob/webhook.ts +++ b/src/pages/api/paymob/webhook.ts @@ -1,15 +1,19 @@ // 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 {Group, User} from "@/interfaces/user"; -import {DurationUnit, Package, Payment} from "@/interfaces/paypal"; -import {v4} from "uuid"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { withIronSessionApiRoute } from "iron-session/next"; +import { sessionOptions } from "@/lib/session"; +import { Group, User } from "@/interfaces/user"; +import { DurationUnit, Package, Payment } from "@/interfaces/paypal"; +import { v4 } from "uuid"; import ShortUniqueId from "short-unique-id"; import axios from "axios"; -import {IntentionResult, PaymentIntention, TransactionResult} from "@/interfaces/paymob"; +import { IntentionResult, PaymentIntention, TransactionResult } from "@/interfaces/paymob"; import moment from "moment"; import client from "@/lib/mongodb"; +import { getEntity } from "@/utils/entities.be"; +import { Entity } from "@/interfaces/entity"; +import { getEntityUsers } from "@/utils/users.be"; +import { mapBy } from "@/utils"; const db = client.db(process.env.MONGODB_DB); @@ -22,21 +26,22 @@ async function post(req: NextApiRequest, res: NextApiResponse) { const authToken = await authenticatePaymob(); console.log("WEBHOOK: ", transactionResult); - if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ok: false}); - if (!transactionResult.transaction.success) return res.status(400).json({ok: false}); + if (!checkTransaction(authToken, transactionResult.transaction.order.id)) return res.status(404).json({ ok: false }); + if (!transactionResult.transaction.success) return res.status(400).json({ ok: false }); - const {userID, duration, duration_unit} = transactionResult.intention.extras.creation_extras as { + const { userID, duration, duration_unit, entity: entityID } = transactionResult.intention.extras.creation_extras as { userID: string; duration: number; duration_unit: DurationUnit; + entity: string }; const user = await db.collection("users").findOne({ id: userID as string }); - if (!user || !duration || !duration_unit) return res.status(404).json({ok: false}); + if (!user || !duration || !duration_unit) return res.status(404).json({ ok: false }); const subscriptionExpirationDate = user.subscriptionExpirationDate; - if (!subscriptionExpirationDate) return res.status(200).json({ok: false}); + if (!subscriptionExpirationDate) return res.status(200).json({ ok: false }); const initialDate = moment(subscriptionExpirationDate).isAfter(moment()) ? moment(subscriptionExpirationDate) : moment(); @@ -44,8 +49,8 @@ async function post(req: NextApiRequest, res: NextApiResponse) { await db.collection("users").updateOne( { id: userID as string }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); + { $set: { subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active" } } + ); await db.collection("paypalpayments").insertOne({ id: v4(), @@ -60,22 +65,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { value: transactionResult.transaction.amount_cents / 1000, }); - if (user.type === "corporate") { - const groups = await db.collection("groups").find({ admin: user.id }).toArray(); + if (entityID) { + const entity = await getEntity(entityID) + await db.collection("entities").updateOne({ id: entityID }, { $set: { expiryDate: req.body.expiryDate } }); - const participants = (await Promise.all( - groups.flatMap((x) => x.participants).map(async (x) => ({...(await db.collection("users").findOne({ id: x}))})), - )) as User[]; - const sameExpiryDateParticipants = participants.filter( - (x) => x.subscriptionExpirationDate === subscriptionExpirationDate && x.status !== "disabled", - ); + const users = await getEntityUsers(entityID, 0, { + subscriptionExpirationDate: entity?.expiryDate, + $and: [ + { type: { $ne: "admin" } }, + { type: { $ne: "developer" } }, + ] + }) - for (const participant of sameExpiryDateParticipants) { - await db.collection("users").updateOne( - { id: participant.id }, - { $set: {subscriptionExpirationDate: updatedSubscriptionExpirationDate, status: "active"} } - ); - } + await db.collection("users").updateMany({ id: { $in: mapBy(users, 'id') } }, { $set: { subscriptionExpirationDate: req.body.expiryDate } }) } res.status(200).json({ @@ -84,19 +86,19 @@ async function post(req: NextApiRequest, res: NextApiResponse) { } const authenticatePaymob = async () => { - const response = await axios.post<{token: string}>( + const response = await axios.post<{ token: string }>( "https://oman.paymob.com/api/auth/tokens", { api_key: process.env.PAYMOB_API_KEY, }, - {headers: {Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}`}}, + { headers: { Authorization: `Bearer ${process.env.PAYMOB_SECRET_KEY}` } }, ); return response.data.token; }; const checkTransaction = async (token: string, orderID: number) => { - const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", {auth_token: token, order_id: orderID}); + const response = await axios.post("https://oman.paymob.com/api/ecommerce/orders/transaction_inquiry", { auth_token: token, order_id: orderID }); return response.status === 200; }; diff --git a/src/pages/entities/[id]/index.tsx b/src/pages/entities/[id]/index.tsx index 324c2227..34b35978 100644 --- a/src/pages/entities/[id]/index.tsx +++ b/src/pages/entities/[id]/index.tsx @@ -2,6 +2,7 @@ import CardList from "@/components/High/CardList"; import Layout from "@/components/High/Layout"; import Select from "@/components/Low/Select"; +import Input from "@/components/Low/Input"; import Checkbox from "@/components/Low/Checkbox"; import Tooltip from "@/components/Low/Tooltip"; import { useEntityPermission } from "@/hooks/useEntityPermissions"; @@ -29,6 +30,7 @@ import { useRouter } from "next/router"; import { Divider } from "primereact/divider"; import { useEffect, useMemo, useState } from "react"; import ReactDatePicker from "react-datepicker"; +import { CURRENCIES } from "@/resources/paypal"; import { BsCheck, @@ -56,6 +58,11 @@ const expirationDateColor = (date: Date) => { if (today.add(7, "days").isAfter(momentDate)) return "!bg-mti-orange-ultralight border-mti-orange-light"; }; +const CURRENCIES_OPTIONS = CURRENCIES.map(({ label, currency }) => ({ + value: currency, + label, +})); + export const getServerSideProps = withIronSessionSsr(async ({ req, params }) => { const user = req.session.user as User; @@ -102,6 +109,8 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { const [isLoading, setIsLoading] = useState(false); const [selectedUsers, setSelectedUsers] = useState([]); const [expiryDate, setExpiryDate] = useState(entity?.expiryDate) + const [paymentPrice, setPaymentPrice] = useState(entity?.payment?.price) + const [paymentCurrency, setPaymentCurrency] = useState(entity?.payment?.currency) const router = useRouter(); @@ -198,6 +207,23 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { .finally(() => setIsLoading(false)); }; + const updatePayment = () => { + if (!isAdmin(user)) return; + + setIsLoading(true); + axios + .patch(`/api/entities/${entity.id}`, { payment: { price: paymentPrice, currency: paymentCurrency } }) + .then(() => { + toast.success("The entity has been updated successfully!"); + router.replace(router.asPath); + }) + .catch((e) => { + console.error(e); + toast.error("Something went wrong!"); + }) + .finally(() => setIsLoading(false)); + }; + const editLicenses = () => { if (!isAdmin(user)) return; @@ -430,6 +456,36 @@ export default function Home({ user, entity, users, linkedUsers }: Props) { Apply Change
+ + + +
+
+ setPaymentPrice(e ? parseInt(e) : undefined)} + type="number" + defaultValue={entity.payment?.price || 0} + thin + /> +