From 9c59af97be772e6475c28a49c45fdbb0038c8816 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Fri, 13 Mar 2026 13:29:01 +0000 Subject: [PATCH] 1 --- .../1773403308528-create-files-table.js | 128 +++++++++++++ ...9-create-users-custom-permissions-table.js | 50 +++++ frontend/src/components/AsideMenuList.tsx | 2 + frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/helpers/roleVisibility.ts | 16 ++ frontend/src/interfaces/index.ts | 1 + frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 31 +++- frontend/src/pages/dashboard.tsx | 175 +++++++++++++++++- 9 files changed, 390 insertions(+), 19 deletions(-) create mode 100644 backend/src/db/migrations/1773403308528-create-files-table.js create mode 100644 backend/src/db/migrations/1773403308529-create-users-custom-permissions-table.js create mode 100644 frontend/src/helpers/roleVisibility.ts diff --git a/backend/src/db/migrations/1773403308528-create-files-table.js b/backend/src/db/migrations/1773403308528-create-files-table.js new file mode 100644 index 0000000..b20d270 --- /dev/null +++ b/backend/src/db/migrations/1773403308528-create-files-table.js @@ -0,0 +1,128 @@ +module.exports = { + /** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('sequelize').Sequelize} Sequelize + */ + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rawTables = await queryInterface.showAllTables({ transaction }); + const tableNames = rawTables.map((table) => { + if (typeof table === 'string') { + return table; + } + + return table.tableName || table.name; + }); + + if (!tableNames.includes('files')) { + await queryInterface.createTable( + 'files', + { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + belongsTo: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + }, + belongsToId: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + }, + belongsToColumn: { + type: Sequelize.DataTypes.STRING(255), + allowNull: true, + }, + name: { + type: Sequelize.DataTypes.STRING(2083), + allowNull: false, + }, + sizeInBytes: { + type: Sequelize.DataTypes.INTEGER, + allowNull: true, + }, + privateUrl: { + type: Sequelize.DataTypes.STRING(2083), + allowNull: true, + }, + publicUrl: { + type: Sequelize.DataTypes.STRING(2083), + allowNull: false, + }, + createdById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + }, + updatedById: { + type: Sequelize.DataTypes.UUID, + allowNull: true, + references: { + model: 'users', + key: 'id', + }, + }, + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + deletedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: true, + }, + }, + { transaction }, + ); + + await queryInterface.addIndex('files', ['belongsTo', 'belongsToId', 'belongsToColumn'], { + name: 'files_belongs_to_idx', + transaction, + }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + /** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('sequelize').Sequelize} Sequelize + */ + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rawTables = await queryInterface.showAllTables({ transaction }); + const tableNames = rawTables.map((table) => { + if (typeof table === 'string') { + return table; + } + + return table.tableName || table.name; + }); + + if (tableNames.includes('files')) { + await queryInterface.dropTable('files', { transaction }); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/1773403308529-create-users-custom-permissions-table.js b/backend/src/db/migrations/1773403308529-create-users-custom-permissions-table.js new file mode 100644 index 0000000..0d73e64 --- /dev/null +++ b/backend/src/db/migrations/1773403308529-create-users-custom-permissions-table.js @@ -0,0 +1,50 @@ +module.exports = { + /** + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import('sequelize').Sequelize} Sequelize + * @returns {Promise} + */ + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.sequelize.query( + ` + CREATE TABLE IF NOT EXISTS "usersCustom_permissionsPermissions" ( + "createdAt" TIMESTAMPTZ NOT NULL, + "updatedAt" TIMESTAMPTZ NOT NULL, + "users_custom_permissionsId" UUID NOT NULL, + "permissionId" UUID NOT NULL, + PRIMARY KEY ("users_custom_permissionsId", "permissionId") + ); + `, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + /** + * @param {import('sequelize').QueryInterface} queryInterface + * @returns {Promise} + */ + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.sequelize.query( + 'DROP TABLE IF EXISTS "usersCustom_permissionsPermissions";', + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/frontend/src/components/AsideMenuList.tsx b/frontend/src/components/AsideMenuList.tsx index 9e33ea1..35ca4f9 100644 --- a/frontend/src/components/AsideMenuList.tsx +++ b/frontend/src/components/AsideMenuList.tsx @@ -3,6 +3,7 @@ import { MenuAsideItem } from '../interfaces' import AsideMenuItem from './AsideMenuItem' import {useAppSelector} from "../stores/hooks"; import {hasPermission} from "../helpers/userPermissions"; +import { userHasAnyRole } from '../helpers/roleVisibility'; type Props = { menu: MenuAsideItem[] @@ -20,6 +21,7 @@ export default function AsideMenuList({ menu, isDropdownList = false, className {menu.map((item, index) => { if (!hasPermission(currentUser, item.permissions)) return null; + if (!userHasAnyRole(currentUser, item.roles)) return null; return (
diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/helpers/roleVisibility.ts b/frontend/src/helpers/roleVisibility.ts new file mode 100644 index 0000000..fdee23c --- /dev/null +++ b/frontend/src/helpers/roleVisibility.ts @@ -0,0 +1,16 @@ +const ADMIN_ROLE_NAMES = ['Administrator', 'Platform Owner', 'Academic Director']; +const INSTRUCTOR_ROLE_NAMES = ['Lead Instructor', 'Teaching Assistant']; +const LEARNER_ROLE_NAMES = ['Learner']; + +export const LMS_ROLE_GROUPS = { + admin: ADMIN_ROLE_NAMES, + instructor: INSTRUCTOR_ROLE_NAMES, + learner: LEARNER_ROLE_NAMES, + instructorOrAdmin: [...ADMIN_ROLE_NAMES, ...INSTRUCTOR_ROLE_NAMES], + allLms: [...ADMIN_ROLE_NAMES, ...INSTRUCTOR_ROLE_NAMES, ...LEARNER_ROLE_NAMES], +}; + +export const userHasAnyRole = (user: any, roleNames?: string[]) => { + if (!roleNames?.length) return true; + return roleNames.includes(user?.app_role?.name); +}; diff --git a/frontend/src/interfaces/index.ts b/frontend/src/interfaces/index.ts index 0c7dd74..85a619d 100644 --- a/frontend/src/interfaces/index.ts +++ b/frontend/src/interfaces/index.ts @@ -14,6 +14,7 @@ export type MenuAsideItem = { withDevider?: boolean; menu?: MenuAsideItem[] permissions?: string | string[] + roles?: string[] } export type MenuNavBarItem = { diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index d0bb84b..2cafd41 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -1,5 +1,6 @@ import * as icon from '@mdi/js'; import { MenuAsideItem } from './interfaces' +import { LMS_ROLE_GROUPS } from './helpers/roleVisibility'; const menuAside: MenuAsideItem[] = [ { @@ -14,7 +15,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiAccountGroup ?? icon.mdiTable, - permissions: 'READ_USERS' + permissions: 'READ_USERS', + roles: LMS_ROLE_GROUPS.instructorOrAdmin, }, { href: '/roles/roles-list', @@ -22,7 +24,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable, - permissions: 'READ_ROLES' + permissions: 'READ_ROLES', + roles: LMS_ROLE_GROUPS.admin, }, { href: '/permissions/permissions-list', @@ -30,7 +33,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: icon.mdiShieldAccountOutline ?? icon.mdiTable, - permissions: 'READ_PERMISSIONS' + permissions: 'READ_PERMISSIONS', + roles: LMS_ROLE_GROUPS.admin, }, { href: '/courses/courses-list', @@ -38,7 +42,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiSchool' in icon ? icon['mdiSchool' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_COURSES' + permissions: 'READ_COURSES', + roles: LMS_ROLE_GROUPS.allLms, }, { href: '/lessons/lessons-list', @@ -46,7 +51,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_LESSONS' + permissions: 'READ_LESSONS', + roles: LMS_ROLE_GROUPS.allLms, }, { href: '/enrollments/enrollments-list', @@ -54,7 +60,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiAccountSchool' in icon ? icon['mdiAccountSchool' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_ENROLLMENTS' + permissions: 'READ_ENROLLMENTS', + roles: LMS_ROLE_GROUPS.allLms, }, { href: '/lesson_progress/lesson_progress-list', @@ -62,7 +69,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiProgressCheck' in icon ? icon['mdiProgressCheck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_LESSON_PROGRESS' + permissions: 'READ_LESSON_PROGRESS', + roles: LMS_ROLE_GROUPS.allLms, }, { href: '/announcements/announcements-list', @@ -70,7 +78,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiBullhorn' in icon ? icon['mdiBullhorn' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_ANNOUNCEMENTS' + permissions: 'READ_ANNOUNCEMENTS', + roles: LMS_ROLE_GROUPS.allLms, }, { href: '/course_resources/course_resources-list', @@ -78,7 +87,8 @@ const menuAside: MenuAsideItem[] = [ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore icon: 'mdiFolderOpen' in icon ? icon['mdiFolderOpen' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, - permissions: 'READ_COURSE_RESOURCES' + permissions: 'READ_COURSE_RESOURCES', + roles: LMS_ROLE_GROUPS.allLms, }, { href: '/profile', @@ -92,7 +102,8 @@ const menuAside: MenuAsideItem[] = [ target: '_blank', label: 'Swagger API', icon: icon.mdiFileCode, - permissions: 'READ_API_DOCS' + permissions: 'READ_API_DOCS', + roles: LMS_ROLE_GROUPS.admin, }, ] diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 40a0a7e..d5c9e40 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -11,11 +11,22 @@ import { getPageTitle } from '../config' import Link from "next/link"; import { hasPermission } from "../helpers/userPermissions"; +import { LMS_ROLE_GROUPS, userHasAnyRole } from '../helpers/roleVisibility'; import { fetchWidgets } from '../stores/roles/rolesSlice'; import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; import { SmartWidget } from '../components/SmartWidget/SmartWidget'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; + +type LearnerMetricsState = { + enrolledCourses: string | number | null; + completedCourses: string | number | null; + avgCourseProgress: string | number | null; + lessonsInProgress: string | number | null; + lessonsCompleted: string | number | null; + hoursSpent: string | number | null; +}; + const Dashboard = () => { const dispatch = useAppDispatch(); const iconsColor = useAppSelector((state) => state.style.iconsColor); @@ -34,6 +45,14 @@ const Dashboard = () => { const [lesson_progress, setLesson_progress] = React.useState(loadingMessage); const [announcements, setAnnouncements] = React.useState(loadingMessage); const [course_resources, setCourse_resources] = React.useState(loadingMessage); + const [learnerMetrics, setLearnerMetrics] = React.useState({ + enrolledCourses: loadingMessage, + completedCourses: loadingMessage, + avgCourseProgress: loadingMessage, + lessonsInProgress: loadingMessage, + lessonsCompleted: loadingMessage, + hoursSpent: loadingMessage, + }); const [widgetsRole, setWidgetsRole] = React.useState({ @@ -43,6 +62,10 @@ const Dashboard = () => { const { isFetchingQuery } = useAppSelector((state) => state.openAi); const { rolesWidgets, loading } = useAppSelector((state) => state.roles); + const isAdmin = userHasAnyRole(currentUser, LMS_ROLE_GROUPS.admin); + const isInstructor = userHasAnyRole(currentUser, LMS_ROLE_GROUPS.instructor); + const isLearner = userHasAnyRole(currentUser, LMS_ROLE_GROUPS.learner); + const overviewTitle = isLearner ? 'My Learning Overview' : isInstructor ? 'Instructor Overview' : 'Platform Overview'; async function loadData() { @@ -70,6 +93,79 @@ const Dashboard = () => { }); }); } + + async function loadLearnerMetrics() { + if (!currentUser?.id || !isLearner) return; + + try { + const canReadEnrollments = hasPermission(currentUser, 'READ_ENROLLMENTS'); + const canReadLessonProgress = hasPermission(currentUser, 'READ_LESSON_PROGRESS'); + + const enrollmentsResponse = canReadEnrollments + ? await axios.get('/enrollments', { + params: { + page: 0, + limit: 500, + student: currentUser.id, + }, + }) + : { data: { rows: [] } }; + + const enrollmentRows = Array.isArray(enrollmentsResponse?.data?.rows) + ? enrollmentsResponse.data.rows + : []; + const enrolledCourses = enrollmentRows.length; + const completedCourses = enrollmentRows.filter((item) => item?.status === 'completed').length; + const avgCourseProgress = enrollmentRows.length + ? Math.round( + enrollmentRows.reduce((sum, item) => sum + Number(item?.progress_percent || 0), 0) / + enrollmentRows.length, + ) + : 0; + + const enrollmentIds = enrollmentRows.map((item) => item?.id).filter(Boolean); + const lessonProgressResponse = + canReadLessonProgress && enrollmentIds.length + ? await axios.get('/lesson_progress', { + params: { + page: 0, + limit: 1000, + enrollment: enrollmentIds.join('|'), + }, + }) + : { data: { rows: [] } }; + const lessonProgressRows = Array.isArray(lessonProgressResponse?.data?.rows) + ? lessonProgressResponse.data.rows + : []; + + const lessonsInProgress = lessonProgressRows.filter((item) => item?.status === 'in_progress').length; + const lessonsCompleted = lessonProgressRows.filter((item) => item?.status === 'completed').length; + const hoursSpent = + Math.round( + (lessonProgressRows.reduce((sum, item) => sum + Number(item?.time_spent_seconds || 0), 0) / 3600) * + 10, + ) / 10; + + setLearnerMetrics({ + enrolledCourses, + completedCourses, + avgCourseProgress: `${avgCourseProgress}%`, + lessonsInProgress, + lessonsCompleted, + hoursSpent, + }); + } catch (error) { + console.error('Failed to load learner metrics:', error); + setLearnerMetrics({ + enrolledCourses: 'Error', + completedCourses: 'Error', + avgCourseProgress: 'Error', + lessonsInProgress: 'Error', + lessonsCompleted: 'Error', + hoursSpent: 'Error', + }); + } + } async function getWidgets(roleId) { await dispatch(fetchWidgets(roleId)); @@ -77,8 +173,9 @@ const Dashboard = () => { React.useEffect(() => { if (!currentUser) return; loadData().then(); + loadLearnerMetrics().then(); setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } }); - }, [currentUser]); + }, [currentUser, isLearner]); React.useEffect(() => { if (!currentUser || !widgetsRole?.role?.value) return; @@ -95,7 +192,7 @@ const Dashboard = () => { {''} @@ -140,11 +237,79 @@ const Dashboard = () => {
{!!rolesWidgets.length &&
} + + {isLearner && ( + <> +
My Courses & Progress
+
+ +
+
My courses
+
{learnerMetrics.enrolledCourses}
+
+ + +
+
+ Completed courses +
+
{learnerMetrics.completedCourses}
+
+ + +
+
+ Avg course progress +
+
{learnerMetrics.avgCourseProgress}
+
+ + +
+
+ Lessons in progress +
+
+ {learnerMetrics.lessonsInProgress} +
+
+ + +
+
+ Lessons completed +
+
+ {learnerMetrics.lessonsCompleted} +
+
+ + +
+
Hours spent
+
{learnerMetrics.hoursSpent}
+
+ +
+ + )}
- {hasPermission(currentUser, 'READ_USERS') && + {hasPermission(currentUser, 'READ_USERS') && !isLearner &&
@@ -172,7 +337,7 @@ const Dashboard = () => {
} - {hasPermission(currentUser, 'READ_ROLES') && + {hasPermission(currentUser, 'READ_ROLES') && isAdmin &&
@@ -200,7 +365,7 @@ const Dashboard = () => {
} - {hasPermission(currentUser, 'READ_PERMISSIONS') && + {hasPermission(currentUser, 'READ_PERMISSIONS') && isAdmin &&