├── .gitignore ├── client ├── .env ├── next.config.js ├── public │ ├── favicon.ico │ ├── vercel.svg │ ├── thirteen.svg │ └── next.svg ├── src │ ├── assets │ │ ├── Trackit_Plain.png │ │ ├── Trackit_Text.png │ │ ├── Trackit_Background.png │ │ └── empty.svg │ ├── pages │ │ ├── index.jsx │ │ ├── projects │ │ │ ├── [...params].jsx │ │ │ └── index.jsx │ │ ├── 404.jsx │ │ ├── _app.jsx │ │ ├── auth.jsx │ │ ├── administration.jsx │ │ └── tickets.jsx │ ├── util │ │ ├── GetObjectProperty.js │ │ ├── Constants.js │ │ ├── Utils.js │ │ └── ValidationSchemas.js │ ├── components │ │ ├── others │ │ │ ├── Loading.jsx │ │ │ ├── TooltipAvatar.jsx │ │ │ ├── PermissionsRender.jsx │ │ │ ├── EmptyData.jsx │ │ │ ├── AlertModal.jsx │ │ │ ├── StatCard.jsx │ │ │ ├── SearchBar.jsx │ │ │ ├── navigationBar │ │ │ │ ├── NavItem.jsx │ │ │ │ └── Navbar.jsx │ │ │ ├── DemoLoginInfoModal.jsx │ │ │ └── Table.jsx │ │ ├── editor │ │ │ └── RichTextEditor.jsx │ │ ├── layout.jsx │ │ ├── administration │ │ │ ├── ManageRoles.jsx │ │ │ ├── ManageUsers.jsx │ │ │ ├── ManageTicketTypes.jsx │ │ │ ├── CreateRole.jsx │ │ │ ├── CreateTicketType.jsx │ │ │ └── UpdateUser.jsx │ │ ├── authentication │ │ │ ├── Login.jsx │ │ │ └── SignUp.jsx │ │ ├── tickets │ │ │ ├── CommentSection.jsx │ │ │ ├── Comment.jsx │ │ │ ├── TicketInfo.jsx │ │ │ └── CreateTicket.jsx │ │ └── projects │ │ │ ├── ViewProject.jsx │ │ │ ├── Dashboard.jsx │ │ │ └── AddProject.jsx │ ├── hooks │ │ ├── usePermissions.js │ │ ├── useAuth.js │ │ └── useApi.js │ ├── services │ │ ├── comment-service.js │ │ ├── auth-service.js │ │ ├── project-service.js │ │ ├── ticket-service.js │ │ └── miscellaneous-service.js │ ├── styles │ │ ├── globals.scss │ │ └── theme.js │ └── store │ │ └── store.js ├── .eslintrc.json ├── jsconfig.json ├── .prettierrc ├── .gitignore └── package.json ├── server ├── .gitignore ├── util │ ├── constants.js │ └── utils.js ├── tests │ ├── utils.js │ ├── auth.test.js │ ├── app.test.js │ ├── data.js │ ├── project.test.js │ └── role.test.js ├── routes │ ├── seed.route.js │ ├── auth.route.js │ ├── user.route.js │ ├── ticketType.route.js │ ├── role.route.js │ ├── comment.route.js │ ├── project.route.js │ └── ticket.route.js ├── vercel.json ├── .env.EXAMPLE ├── models │ ├── role.model.js │ ├── ticketType.model.js │ ├── user.model.js │ ├── comment.model.js │ ├── project.model.js │ └── ticket.model.js ├── server.js ├── package.json ├── config │ └── config.js ├── app.js ├── controllers │ ├── auth.controller.js │ ├── seed.controller.js │ ├── role.controller.js │ ├── comment.controller.js │ ├── ticketType.controller.js │ ├── user.controller.js │ └── ticket.controller.js ├── seed │ ├── seedData.js │ └── seedDB.js ├── schema │ └── validation.schema.js └── middleware │ └── middleware.js ├── screenshots ├── login.png ├── add_project.png ├── all_projects.png ├── my_tickets.png ├── view_project.png ├── view_ticket.png ├── admin_create_role.png ├── project_overview.png ├── ticket_comments.png ├── admin_manage_roles.png ├── admin_manage_users.png ├── add_project_contributors.png ├── admin_create_ticket_types.png └── admin_manage_ticket_types.png ├── Notes.txt ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .next -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_API_ENDPOINT=http://localhost:5000 -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .env 3 | generateRoute.js -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true 3 | }; -------------------------------------------------------------------------------- /screenshots/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/login.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /screenshots/add_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/add_project.png -------------------------------------------------------------------------------- /screenshots/all_projects.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/all_projects.png -------------------------------------------------------------------------------- /screenshots/my_tickets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/my_tickets.png -------------------------------------------------------------------------------- /screenshots/view_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/view_project.png -------------------------------------------------------------------------------- /screenshots/view_ticket.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/view_ticket.png -------------------------------------------------------------------------------- /screenshots/admin_create_role.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/admin_create_role.png -------------------------------------------------------------------------------- /screenshots/project_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/project_overview.png -------------------------------------------------------------------------------- /screenshots/ticket_comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/ticket_comments.png -------------------------------------------------------------------------------- /client/src/assets/Trackit_Plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/client/src/assets/Trackit_Plain.png -------------------------------------------------------------------------------- /client/src/assets/Trackit_Text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/client/src/assets/Trackit_Text.png -------------------------------------------------------------------------------- /screenshots/admin_manage_roles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/admin_manage_roles.png -------------------------------------------------------------------------------- /screenshots/admin_manage_users.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/admin_manage_users.png -------------------------------------------------------------------------------- /client/src/assets/Trackit_Background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/client/src/assets/Trackit_Background.png -------------------------------------------------------------------------------- /screenshots/add_project_contributors.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/add_project_contributors.png -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next"], 3 | "rules": { 4 | "react-hooks/exhaustive-deps": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/admin_create_ticket_types.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/admin_create_ticket_types.png -------------------------------------------------------------------------------- /screenshots/admin_manage_ticket_types.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jenil-Vekaria/Trackit/HEAD/screenshots/admin_manage_ticket_types.png -------------------------------------------------------------------------------- /client/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /client/src/pages/index.jsx: -------------------------------------------------------------------------------- 1 | import ViewAllProjects from "./projects"; 2 | 3 | const Root = () => { 4 | return ; 5 | }; 6 | 7 | export default Root; 8 | -------------------------------------------------------------------------------- /server/util/constants.js: -------------------------------------------------------------------------------- 1 | export const MANAGE_TICKET = "PERMISSION_MANAGE_TICKET"; 2 | export const MANAGE_PROJECT = "PERMISSION_MANAGE_PROJECT"; 3 | export const MANAGE_ADMIN_PAGE = "PERMISSION_MANAGE_ADMIN_PAGE"; -------------------------------------------------------------------------------- /server/tests/utils.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | 3 | export const generateAccessToken = (email, id) => { 4 | return jwt.sign({ email, id }, process.env.SECRET_KEY, { expiresIn: process.env.JWT_TOKEN_EXPIRATION }); 5 | }; 6 | -------------------------------------------------------------------------------- /server/routes/seed.route.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { seedDatabase } from "../controllers/seed.controller.js"; 3 | 4 | const router = express.Router(); 5 | 6 | router.post("/", seedDatabase); 7 | 8 | export default router; -------------------------------------------------------------------------------- /server/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "builds": [ 4 | { 5 | "src": "server.js", 6 | "use": "@vercel/node" 7 | } 8 | ], 9 | "routes": [ 10 | { 11 | "src": "/(.*)", 12 | "dest": "server.js" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /server/.env.EXAMPLE: -------------------------------------------------------------------------------- 1 | MONGO_DB_CONNECTION= 2 | SECRET_KEY="bugtracker" 3 | PASSWORD_SALT=12 4 | NODE_ENV="development" if want to do local development or "production" if you have remote mongoDB Connection URL 5 | JWT_TOKEN_EXPIRATION="1h" -------------------------------------------------------------------------------- /client/src/util/GetObjectProperty.js: -------------------------------------------------------------------------------- 1 | export const getFieldValue = (object, key) => { 2 | const keys = key.split("."); 3 | let value = object; 4 | 5 | keys.forEach(nestedKey => { 6 | value = value[nestedKey]; 7 | }); 8 | 9 | return value; 10 | }; -------------------------------------------------------------------------------- /client/src/components/others/Loading.jsx: -------------------------------------------------------------------------------- 1 | import { Center, Spinner } from "@chakra-ui/react"; 2 | 3 | const Loading = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default Loading; 12 | -------------------------------------------------------------------------------- /client/src/components/others/TooltipAvatar.jsx: -------------------------------------------------------------------------------- 1 | import { Avatar, Tooltip } from "@chakra-ui/react"; 2 | import React from "react"; 3 | 4 | const TooltipAvatar = (props) => { 5 | return ( 6 | 7 | 8 | 9 | ); 10 | }; 11 | 12 | export default TooltipAvatar; 13 | -------------------------------------------------------------------------------- /server/models/role.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const RoleSchema = mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true 7 | }, 8 | permissions: { 9 | type: [String], 10 | required: true 11 | } 12 | }); 13 | 14 | export default mongoose.model("Role", RoleSchema); -------------------------------------------------------------------------------- /client/src/pages/projects/[...params].jsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import ViewProject from "@/components/projects/ViewProject"; 3 | 4 | const Project = () => { 5 | const router = useRouter(); 6 | const { params = [] } = router.query; 7 | 8 | return ; 9 | }; 10 | 11 | export default Project; 12 | -------------------------------------------------------------------------------- /client/src/components/others/PermissionsRender.jsx: -------------------------------------------------------------------------------- 1 | import { usePermissions } from "../../hooks/usePermissions"; 2 | 3 | const PermissionsRender = ({ permissionCheck, children }) => { 4 | const canRender = usePermissions(permissionCheck); 5 | 6 | if (canRender) { 7 | return children; 8 | } 9 | return null; 10 | }; 11 | 12 | export default PermissionsRender; 13 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "importOrder": [ 3 | "^next/(.*)$", 4 | "", 5 | "^@/components/(.*)$", 6 | "^@/services/(.*)$", 7 | "^@/features/(.*)$", 8 | "^@/hooks/(.*)$", 9 | "^@/util/(.*)$", 10 | "^@/assets/(.*)$", 11 | "^[./]" 12 | ], 13 | "importOrderSeparation": false, 14 | "importOrderSortSpecifiers": true 15 | } 16 | -------------------------------------------------------------------------------- /server/routes/auth.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { login } from '../controllers/auth.controller.js'; 3 | import { validateResource } from "../middleware/middleware.js"; 4 | import { loginSchema } from "../schema/validation.schema.js"; 5 | 6 | const router = express.Router(); 7 | 8 | router.post("/login", validateResource(loginSchema), login); 9 | 10 | export default router; -------------------------------------------------------------------------------- /server/models/ticketType.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const TicketType = mongoose.Schema({ 4 | name: { 5 | type: String, 6 | required: true 7 | }, 8 | iconName: { 9 | type: String, 10 | required: true 11 | }, 12 | colour: { 13 | type: String, 14 | default: "#000000" 15 | } 16 | }); 17 | 18 | export default mongoose.model("TicketType", TicketType); -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | .next 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /client/src/components/others/EmptyData.jsx: -------------------------------------------------------------------------------- 1 | import { Center, Image, Text } from "@chakra-ui/react"; 2 | import React from "react"; 3 | import empty from "@/assets/empty.svg"; 4 | 5 | const EmptyData = () => { 6 | return ( 7 |
8 | Empty Data 9 | 10 | No Data 11 | 12 |
13 | ); 14 | }; 15 | 16 | export default EmptyData; 17 | -------------------------------------------------------------------------------- /client/src/pages/404.jsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Image from "next/image"; 3 | import { Center } from "@chakra-ui/react"; 4 | import React from "react"; 5 | import pageNotFound from "@/assets/page_not_found.svg"; 6 | 7 | const PageNotFound = () => { 8 | return ( 9 |
10 | 11 | Page not found 12 | 13 | 404 Page Not Found 14 |
15 | ); 16 | }; 17 | 18 | export default PageNotFound; 19 | -------------------------------------------------------------------------------- /client/src/hooks/usePermissions.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import AuthService from "../services/auth-service"; 3 | import useAuthStore from "./useAuth"; 4 | 5 | 6 | export const usePermissions = (permissionCheck) => { 7 | const [permissionsList, setPermissionsList] = useState([]); 8 | const useAuth = useAuthStore(); 9 | 10 | useEffect(() => { 11 | const user = useAuth.userProfile; 12 | 13 | setPermissionsList(user?.roleId.permissions); 14 | }, []); 15 | 16 | return permissionCheck(permissionsList); 17 | }; -------------------------------------------------------------------------------- /server/routes/user.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { getAllUsers, updateUser, createUser } from '../controllers/user.controller.js'; 3 | import { validateResource } from '../middleware/middleware.js'; 4 | import { createUserSchema } from '../schema/validation.schema.js'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get("/all", getAllUsers); 9 | router.patch("/update", updateUser); 10 | router.patch("/updateMyProfile", validateResource(createUserSchema), updateUser); 11 | router.post("/create", validateResource(createUserSchema), createUser); 12 | 13 | export default router; -------------------------------------------------------------------------------- /client/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/models/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const UserSchema = mongoose.Schema({ 4 | firstName: { 5 | type: String, 6 | required: true 7 | }, 8 | lastName: { 9 | type: String, 10 | required: true 11 | }, 12 | email: { 13 | type: String, 14 | required: true 15 | }, 16 | roleId: { 17 | type: mongoose.Types.ObjectId, 18 | ref: "Role", 19 | required: true 20 | }, 21 | password: { 22 | type: String, 23 | required: true 24 | } 25 | }); 26 | 27 | export default mongoose.model("User", UserSchema); -------------------------------------------------------------------------------- /server/models/comment.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const CommentSchema = mongoose.Schema({ 4 | userId: { 5 | required: true, 6 | ref: "User", 7 | type: mongoose.Types.ObjectId 8 | }, 9 | text: { 10 | required: true, 11 | type: String 12 | }, 13 | ticketId: { 14 | required: true, 15 | type: mongoose.Types.ObjectId 16 | }, 17 | createdOn: { 18 | required: true, 19 | type: Date 20 | }, 21 | updatedOn: { 22 | required: true, 23 | type: Date 24 | } 25 | }); 26 | 27 | export default mongoose.model("Comment", CommentSchema); -------------------------------------------------------------------------------- /client/src/hooks/useAuth.js: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist, createJSONStorage } from 'zustand/middleware'; 3 | 4 | const useAuthStore = create(persist( 5 | (set, _) => ({ 6 | accessToken: null, 7 | userProfile: null, 8 | setUserProfile: (userProfile) => set((state) => ({ ...state, userProfile })), 9 | setAccessToken: (accessToken) => set((state) => ({ ...state, accessToken })), 10 | clear: () => set(() => ({ accessToken: null, userProfile: null })) 11 | }), 12 | { 13 | name: 'auth', 14 | storage: createJSONStorage(() => sessionStorage), 15 | } 16 | )); 17 | 18 | export default useAuthStore; 19 | -------------------------------------------------------------------------------- /client/src/pages/_app.jsx: -------------------------------------------------------------------------------- 1 | import { theme } from "@/styles/theme.js"; 2 | import { ChakraProvider } from "@chakra-ui/provider"; 3 | import "@inovua/reactdatagrid-community/index.css"; 4 | import "@inovua/reactdatagrid-community/style/base.scss"; 5 | import "react-quill/dist/quill.snow.css"; 6 | import { SWRDevTools } from "swr-devtools"; 7 | import Layout from "@/components/layout.jsx"; 8 | import "../styles/globals.scss"; 9 | 10 | const App = ({ Component, pageProps }) => { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import app from './app.js'; 3 | import { PORT, MONGO_DB_CONNECTION, CURRENT_ENVIRONMENT } from "./config/config.js"; 4 | 5 | //Connect to DB 6 | 7 | try { 8 | await mongoose.connect(MONGO_DB_CONNECTION); 9 | app.listen(PORT, () => { 10 | console.log("\n"); 11 | console.log("=========================================="); 12 | console.info(`🚀 Running Environment: ${CURRENT_ENVIRONMENT}`); 13 | console.info(`✅ Server Running On: http://localhost:${PORT}`); 14 | console.info(`🔗 MongoDB Connection URL: ${MONGO_DB_CONNECTION}`); 15 | console.log("=========================================="); 16 | }); 17 | } catch (error) { 18 | console.error(error); 19 | } 20 | -------------------------------------------------------------------------------- /server/routes/ticketType.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { addTicketType, deleteTicketType, updateTicketType, getTicketType } from '../controllers/ticketType.controller.js'; 3 | import { checkUserPermissions, validateResource } from "../middleware/middleware.js"; 4 | import { createTicketTypeSchema } from '../schema/validation.schema.js'; 5 | import { Permissions } from '../util/utils.js'; 6 | 7 | const router = express.Router(); 8 | const validation = [checkUserPermissions("ticket type", Permissions.canManageAdminPage), validateResource(createTicketTypeSchema)]; 9 | 10 | router.get("/", getTicketType); 11 | router.post("/", validation, addTicketType); 12 | router.patch("/", validation, updateTicketType); 13 | router.delete("/:ticketTypeId", deleteTicketType); 14 | 15 | export default router; -------------------------------------------------------------------------------- /client/src/components/editor/RichTextEditor.jsx: -------------------------------------------------------------------------------- 1 | import dynamic from "next/dynamic"; 2 | 3 | const ReactQuill = dynamic(() => import("react-quill"), { 4 | ssr: false, 5 | }); 6 | 7 | const RichTextEditor = ({ content, setContent, disabled = false }) => { 8 | const modules = { 9 | toolbar: [ 10 | ["bold", "italic", "underline", "strike"], 11 | ["blockquote", "code-block"], 12 | [{ list: "ordered" }, { list: "bullet" }], 13 | [{ header: [1, 2, 3, 4, 5, 6, false] }], 14 | [{ color: [] }, { background: [] }], 15 | ["clean"], 16 | ], 17 | }; 18 | 19 | return ( 20 | 27 | ); 28 | }; 29 | 30 | export default RichTextEditor; 31 | -------------------------------------------------------------------------------- /server/models/project.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const ProjectSchema = mongoose.Schema({ 4 | title: { 5 | type: String, 6 | required: true 7 | }, 8 | description: { 9 | type: "String", 10 | default: "--No Project Description--" 11 | }, 12 | authorId: { 13 | type: mongoose.Types.ObjectId, 14 | ref: "User", 15 | required: true 16 | }, 17 | assignees: { 18 | type: [mongoose.Types.ObjectId], 19 | ref: "User", 20 | required: true, 21 | default: [] 22 | }, 23 | createdOn: { 24 | type: Date, 25 | default: Date.now() 26 | }, 27 | updatedOn: { 28 | type: Date, 29 | default: Date.now() 30 | } 31 | }); 32 | 33 | export default mongoose.model("Project", ProjectSchema); -------------------------------------------------------------------------------- /client/src/components/others/AlertModal.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Modal, 4 | ModalBody, 5 | ModalCloseButton, 6 | ModalContent, 7 | ModalFooter, 8 | ModalHeader, 9 | ModalOverlay, 10 | } from "@chakra-ui/react"; 11 | import React from "react"; 12 | 13 | const AlertModal = ({ title, body, onCTA, onClose, isOpen }) => { 14 | return ( 15 | 16 | 17 | 18 | {title} 19 | 20 | {body} 21 | 22 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default AlertModal; 32 | -------------------------------------------------------------------------------- /client/src/components/others/StatCard.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardBody, 4 | Center, 5 | Icon, 6 | Stat, 7 | StatLabel, 8 | StatNumber, 9 | useColorModeValue, 10 | } from "@chakra-ui/react"; 11 | import React from "react"; 12 | 13 | const StatCard = ({ iconBackground, iconColor, icon, name, value }) => { 14 | const bg = useColorModeValue(iconBackground, iconBackground); 15 | return ( 16 | 17 |
18 | 19 |
20 | 21 | 22 | {name} 23 | {value} 24 | 25 | 26 |
27 | ); 28 | }; 29 | 30 | export default StatCard; 31 | -------------------------------------------------------------------------------- /client/src/components/layout.jsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import Auth from "@/pages/auth"; 3 | import { Flex } from "@chakra-ui/react"; 4 | import { useEffect, useState } from "react"; 5 | import AuthService from "@/services/auth-service"; 6 | import Navbar from "./others/navigationBar/Navbar"; 7 | 8 | const Layout = ({ children }) => { 9 | const [isAuthorized, setIsAuthorized] = useState(false); 10 | 11 | const router = useRouter(); 12 | 13 | useEffect(() => { 14 | const authorized = AuthService.isAuthorized(); 15 | setIsAuthorized(authorized); 16 | 17 | if (!authorized) { 18 | router.replace("/"); 19 | } 20 | }, []); 21 | 22 | if (!isAuthorized) { 23 | return ; 24 | } 25 | 26 | return ( 27 | 28 | 29 | {children} 30 | 31 | ); 32 | }; 33 | 34 | export default Layout; 35 | -------------------------------------------------------------------------------- /client/src/components/others/SearchBar.jsx: -------------------------------------------------------------------------------- 1 | import { SearchIcon } from "@chakra-ui/icons"; 2 | import { Flex, Input, InputGroup, InputLeftElement } from "@chakra-ui/react"; 3 | import React from "react"; 4 | 5 | const SearchBar = ({ placeholder, handleSearchInputChange }) => { 6 | return ( 7 | 15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default React.memo(SearchBar); 30 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watchAll --detectOpenHandles --verbose", 9 | "seed": "node ./seed/seedDB.js", 10 | "start": "nodemon server.js" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "bcrypt": "^5.1.0", 16 | "body-parser": "^1.20.2", 17 | "cors": "^2.8.5", 18 | "dotenv": "^16.0.3", 19 | "express": "^4.18.2", 20 | "express-validator": "^6.15.0", 21 | "jsonwebtoken": "^9.0.0", 22 | "moment": "^2.29.4", 23 | "mongoose": "^7.0.2", 24 | "nodemon": "^2.0.21", 25 | "yup": "^1.0.2" 26 | }, 27 | "devDependencies": { 28 | "jest": "^29.5.0", 29 | "supertest": "^6.3.3" 30 | }, 31 | "jest": { 32 | "testEnvironment": "node" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/routes/role.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { getRoles, addRole, deleteRole, updateRole } from '../controllers/role.controller.js'; 3 | import { checkUserPermissions, validateParamId, validateResource } from "../middleware/middleware.js"; 4 | import { createRoleSchema } from "../schema/validation.schema.js"; 5 | import { Permissions } from '../util/utils.js'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get("/", getRoles); 10 | router.post("/", [checkUserPermissions("role", Permissions.canManageAdminPage), validateResource(createRoleSchema)], addRole); 11 | router.patch("/:roleId", [checkUserPermissions("role", Permissions.canManageAdminPage), validateResource(createRoleSchema), validateParamId("roleId")], updateRole); 12 | router.delete("/:roleId", [checkUserPermissions("role", Permissions.canManageAdminPage), validateParamId("roleId")], deleteRole); 13 | 14 | export default router; -------------------------------------------------------------------------------- /client/src/services/comment-service.js: -------------------------------------------------------------------------------- 1 | const getTicketComments = (ticketId) => { 2 | return { 3 | url: `/comment/${ticketId}`, 4 | method: "get" 5 | }; 6 | }; 7 | 8 | const createTicketComment = (ticketId, ticketData) => { 9 | return { 10 | url: `/comment/${ticketId}`, 11 | method: "post", 12 | data: ticketData 13 | }; 14 | 15 | }; 16 | 17 | const updateTicketComment = (commentId, ticketData) => { 18 | return { 19 | url: `/comment/${commentId}`, 20 | method: "patch", 21 | data: ticketData 22 | }; 23 | 24 | }; 25 | 26 | const deleteTicketComment = (commentId) => { 27 | return { 28 | url: `/comment/${commentId}`, 29 | method: "delete" 30 | }; 31 | }; 32 | 33 | const CommentService = { 34 | getTicketComments, 35 | createTicketComment, 36 | updateTicketComment, 37 | deleteTicketComment, 38 | }; 39 | 40 | export default CommentService; -------------------------------------------------------------------------------- /client/src/services/auth-service.js: -------------------------------------------------------------------------------- 1 | import decode from 'jwt-decode'; 2 | import useAuthStore from "@/hooks/useAuth.js"; 3 | 4 | 5 | const login = (data) => { 6 | return { 7 | url: "/auth/login", 8 | method: "post", 9 | data 10 | }; 11 | }; 12 | 13 | 14 | const isAuthorized = () => { 15 | const authStore = useAuthStore.getState(); 16 | 17 | const token = authStore.accessToken; 18 | 19 | if (authStore.accessToken === null || authStore.userProfile === null) { 20 | authStore.clear(); 21 | return false; 22 | } 23 | 24 | if (token) { 25 | const decodeToken = decode(token); 26 | 27 | if (decodeToken.exp * 1000 < new Date().getTime()) { 28 | authStore.clear(); 29 | return false; 30 | } 31 | else { 32 | return true; 33 | } 34 | } 35 | 36 | return false; 37 | }; 38 | 39 | const AuthService = { 40 | login, 41 | isAuthorized 42 | }; 43 | 44 | export default AuthService; -------------------------------------------------------------------------------- /server/config/config.js: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | 3 | dotenv.config(); 4 | 5 | const Environment = { 6 | PRODUCTION: 'production', 7 | DEVELOPMENT: 'development', 8 | }; 9 | 10 | export const SECRET_KEY = process.env.SECRET_KEY; 11 | export const PASSWORD_SALT = process.env.PASSWORD_SALT; 12 | export const JWT_TOKEN_EXPIRATION = process.env.JWT_TOKEN_EXPIRATION; 13 | 14 | 15 | export const PORT = process.env.PORT || 5000; 16 | export const isProduction = process.env.NODE_ENV === Environment.PRODUCTION || false; 17 | export const isDevelopment = process.env.NODE_ENV === Environment.DEVELOPMENT || true; 18 | 19 | 20 | const DEV_DB_NAME = "dev-bugtracker"; 21 | const DEV_DB_PORT = 27017; 22 | 23 | const PROD_CONNECTION = process.env.MONGO_DB_CONNECTION; 24 | const DEV_CONNECTION = `mongodb://127.0.0.1:${DEV_DB_PORT}/${DEV_DB_NAME}`; 25 | 26 | export const MONGO_DB_CONNECTION = isProduction ? PROD_CONNECTION : DEV_CONNECTION; 27 | export const CURRENT_ENVIRONMENT = process.env.NODE_ENV || Environment.DEVELOPMENT; -------------------------------------------------------------------------------- /server/tests/auth.test.js: -------------------------------------------------------------------------------- 1 | import request from './app.test.js'; 2 | 3 | describe("Authentication", () => { 4 | 5 | it("POST /auth/login - should return 200 status code", async () => { 6 | await request.post("/auth/login") 7 | .send({ 8 | email: "robert.smith@bugtracker.com", 9 | password: "random" 10 | }) 11 | .expect(200); 12 | }); 13 | 14 | it("POST /auth/login - should return 400 status code - User not found", async () => { 15 | await request.post("/auth/login") 16 | .send({ 17 | email: "bob@bugtracker.com", 18 | password: "random" 19 | }) 20 | .expect(400); 21 | }); 22 | 23 | it("POST /auth/login - should return 400 status code - Incorrect password", async () => { 24 | await request.post("/auth/login") 25 | .send({ 26 | email: "james.smith@bugtracker.com", 27 | password: "randommm" 28 | }) 29 | .expect(400); 30 | }); 31 | 32 | }); 33 | -------------------------------------------------------------------------------- /Notes.txt: -------------------------------------------------------------------------------- 1 | Difficulty on this projects? 2 | 3 | #1 4 | 5 | I needed a way to efficiently render all the tickets without needing to fetch data everytime I create or update a ticket. 6 | What I did was to incorporate redux toolkit to store all the fetched data on local storage and update the data in the redux storage accordingly whenever a 7 | ticket was modified. Additionally, I added redux persist on top of it so that the data is presisted whenever the page reloads. 8 | 9 | #2 10 | 11 | In the project there were some common data that was needed by components across the application and I wanted avoid passing down those data down as props into 12 | child components. What I did was to incorporate redux toolkit to store all common data on local storage, so that components can directly access this global state 13 | without needing to access passdown props. 14 | 15 | So issue was solved, however the data was persisting whenever the page reloaded. What I did was to add redux-persist on top of it to persist all my data. As a result 16 | I was able to access all my global state, as well as persist them -------------------------------------------------------------------------------- /server/routes/comment.route.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { getComments, createComment, updateComment, deleteComment } from "../controllers/comment.controller.js"; 3 | import { checkUserPermissions, validateParamId, validateResource } from "../middleware/middleware.js"; 4 | import { createCommentSchema } from "../schema/validation.schema.js"; 5 | import { Permissions } from '../util/utils.js'; 6 | const router = express.Router(); 7 | 8 | //Add comment validation 9 | router.get("/:ticketId", validateParamId("ticketId"), getComments); 10 | router.post("/:ticketId", 11 | [checkUserPermissions("comment", Permissions.canManageTickets), validateResource(createCommentSchema), validateParamId("ticketId")], 12 | createComment); 13 | router.patch("/:commentId", 14 | [checkUserPermissions("comment", Permissions.canManageTickets), validateResource(createCommentSchema), validateParamId("commentId")], 15 | updateComment); 16 | router.delete("/:commentId", 17 | [checkUserPermissions("comment", Permissions.canManageTickets), validateParamId("commentId")], 18 | deleteComment); 19 | 20 | export default router; -------------------------------------------------------------------------------- /client/public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jenil Vekaria 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/src/services/project-service.js: -------------------------------------------------------------------------------- 1 | const getMyProjects = () => { 2 | return { 3 | url: `/project`, 4 | method: "get" 5 | }; 6 | }; 7 | 8 | const createProject = (data) => { 9 | return { 10 | url: "/project", 11 | data, 12 | method: "post" 13 | }; 14 | }; 15 | 16 | const updateProject = (data, projectId) => { 17 | return { 18 | url: `/project/${projectId}`, 19 | data, 20 | method: "patch" 21 | }; 22 | }; 23 | 24 | const getProjectInfo = (projectId) => { 25 | return { 26 | method: "get", 27 | url: `/project/${projectId}` 28 | }; 29 | }; 30 | 31 | const deleteProject = (projectId) => { 32 | return { 33 | method: "delete", 34 | url: `/project/${projectId}` 35 | }; 36 | }; 37 | 38 | const getProjectStats = (projectId) => { 39 | return { 40 | method: "get", 41 | url: `/project/stat/${projectId}` 42 | }; 43 | }; 44 | 45 | const ProjectService = { 46 | getMyProjects, 47 | createProject, 48 | getProjectInfo, 49 | updateProject, 50 | deleteProject, 51 | getProjectStats 52 | }; 53 | 54 | export default ProjectService; -------------------------------------------------------------------------------- /server/routes/project.route.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { addProject, deleteProject, getUserProjects, updateProject, getProjectInfo, getProjectStat } from "../controllers/project.controller.js"; 3 | import { checkUserPermissions, validateParamId, validateResource } from "../middleware/middleware.js"; 4 | import { createProjectSchema } from "../schema/validation.schema.js"; 5 | import { Permissions } from "../util/utils.js"; 6 | 7 | const router = express.Router(); 8 | 9 | router.get("/", getUserProjects); 10 | router.get("/stat/:projectId", [validateParamId("projectId")], getProjectStat); 11 | router.get("/:projectId", validateParamId("projectId"), getProjectInfo); 12 | 13 | router.post("/", 14 | [checkUserPermissions("projects", Permissions.canManageProjects), validateResource(createProjectSchema)], 15 | addProject); 16 | 17 | router.patch("/:projectId", 18 | [checkUserPermissions("projects", Permissions.canManageProjects), validateResource(createProjectSchema), validateParamId("projectId")], 19 | updateProject); 20 | 21 | router.delete("/:projectId", 22 | [checkUserPermissions("projects", Permissions.canManageProjects), validateParamId("projectId")], 23 | deleteProject); 24 | 25 | export default router; -------------------------------------------------------------------------------- /client/src/pages/auth.jsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { 3 | Button, 4 | Heading, 5 | Icon, 6 | Link, 7 | VStack, 8 | useDisclosure, 9 | } from "@chakra-ui/react"; 10 | import { AiFillGithub } from "react-icons/ai"; 11 | import { Login } from "@/components/authentication/Login"; 12 | import DemoLoginInfoModal from "@/components/others/DemoLoginInfoModal"; 13 | import logo from "@/assets/Trackit_Plain.png"; 14 | 15 | export const Auth = () => { 16 | const { isOpen, onOpen, onClose } = useDisclosure(); 17 | 18 | return ( 19 | <> 20 | 21 | 26 | 27 | 28 | logo 29 | 30 | Sign in to your account 31 | 32 | 33 |
34 | 35 | 36 |
37 | 38 | ); 39 | }; 40 | 41 | export default Auth; 42 | -------------------------------------------------------------------------------- /client/src/styles/globals.scss: -------------------------------------------------------------------------------- 1 | $INOVUA_DATAGRID_ACCENT_COLOR: #151c29 !default; 2 | $INOVUA_DATAGRID_BORDER_COLOR: #ffffff00 !default; 3 | $INOVUA_DATAGRID_HEADER_BG: #334154 !default; 4 | $INOVUA_DATAGRID_BG_COLOR: $INOVUA_DATAGRID_ACCENT_COLOR !default; 5 | 6 | $INOVUA_DATAGRID_ROW_ODD_BG_COLOR: $INOVUA_DATAGRID_ACCENT_COLOR !default; 7 | $INOVUA_DATAGRID_ROW_EVEN_BG_COLOR: $INOVUA_DATAGRID_ACCENT_COLOR !default; 8 | 9 | $INOVUA_DATAGRID_CELL_BORDER_COLOR: #28374e !default; 10 | 11 | .InovuaReactDataGrid__header { 12 | background: $INOVUA_DATAGRID_HEADER_BG; 13 | border-radius: 10px 10px 0 0; 14 | border: $INOVUA_DATAGRID_HEADER_BG; 15 | } 16 | 17 | 18 | 19 | 20 | .ql-toolbar.ql-snow, .ql-container.ql-snow { 21 | border: 1px solid #3d4551; 22 | } 23 | 24 | .ql-toolbar.ql-snow{ 25 | border-radius: 0.375rem 0.375rem 0 0; 26 | } 27 | 28 | .ql-container.ql-snow { 29 | border-radius: 0 0 0.375rem 0.375rem; 30 | } 31 | 32 | .ql-snow.ql-toolbar button svg, 33 | .ql-snow .ql-toolbar button svg, 34 | .ql-picker-label { 35 | filter: brightness(100) saturate(20) 36 | } 37 | 38 | .ql-container{ 39 | min-height: 230px; 40 | font-size: 1rem; 41 | } 42 | @import '@inovua/reactdatagrid-community/style/theme/default-dark/index.scss'; -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import cors from 'cors'; 3 | import authRoutes from './routes/auth.route.js'; 4 | import roleRoutes from './routes/role.route.js'; 5 | import projectRoutes from './routes/project.route.js'; 6 | import ticketTypeRoutes from './routes/ticketType.route.js'; 7 | import userRoutes from "./routes/user.route.js"; 8 | import ticketRoutes from "./routes/ticket.route.js"; 9 | import commentRoutes from "./routes/comment.route.js"; 10 | import { handleError, routeNotFound, authMiddleware } from './middleware/middleware.js'; 11 | 12 | const app = express(); 13 | 14 | //preconfigure express app 15 | app.use(express.json()); 16 | app.use(express.urlencoded({ extended: true })); 17 | app.use(cors()); 18 | 19 | //Middleware 20 | app.get("/", (_, res) => res.send("Welcome to TrackIt API")); 21 | app.use('/auth', authRoutes); 22 | app.use('/user', authMiddleware, userRoutes); 23 | app.use('/role', authMiddleware, roleRoutes); 24 | app.use('/project', authMiddleware, projectRoutes); 25 | app.use('/comment', authMiddleware, commentRoutes); 26 | app.use('/ticket', authMiddleware, ticketRoutes); 27 | app.use('/ticketType', authMiddleware, ticketTypeRoutes); 28 | 29 | app.use(handleError); 30 | app.use(routeNotFound); 31 | 32 | export default app; -------------------------------------------------------------------------------- /server/routes/ticket.route.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import { getUserTickets, getProjectTickets, getTicketInfo, createTicket, updateTicket, deleteTicket } from "../controllers/ticket.controller.js"; 3 | import { checkUserPermissions, validateParamId, validateResource } from "../middleware/middleware.js"; 4 | import { createTicketSchema } from "../schema/validation.schema.js"; 5 | import { Permissions } from "../util/utils.js"; 6 | 7 | const router = express.Router(); 8 | 9 | router.get("/user/:userId", getUserTickets); 10 | 11 | router.get("/project/:projectId", 12 | validateParamId("projectId"), 13 | getProjectTickets); 14 | 15 | router.get("/:ticketId", 16 | validateParamId("ticketId"), 17 | getTicketInfo); 18 | 19 | router.post("/project/:projectId", 20 | [validateResource(createTicketSchema), validateParamId("projectId")], 21 | createTicket); 22 | 23 | router.patch("/project/:projectId", 24 | [checkUserPermissions("tickets", Permissions.canManageTickets), validateResource(createTicketSchema), validateParamId("projectId")], 25 | updateTicket); 26 | 27 | router.delete("/:ticketId", 28 | [checkUserPermissions("tickets", Permissions.canManageTickets), validateParamId("ticketId")], 29 | deleteTicket); 30 | 31 | export default router; -------------------------------------------------------------------------------- /client/src/util/Constants.js: -------------------------------------------------------------------------------- 1 | import { Icon } from "@chakra-ui/react"; 2 | import * as BsIcons from "react-icons/bs"; 3 | 4 | export const TICKET_STATUS = ["Open", "In-Progress", "Done", "Archived"]; 5 | 6 | export const MANAGE_TICKET = "PERMISSION_MANAGE_TICKET"; 7 | export const MANAGE_PROJECT = "PERMISSION_MANAGE_PROJECT"; 8 | export const MANAGE_ADMIN_PAGE = "PERMISSION_MANAGE_ADMIN_PAGE"; 9 | 10 | // export const ADD_COMMENT = "PERMISSION_ADD_COMMENT"; 11 | // export const MANAGE_ROLE = "PERMISSION_MANAGE_ROLE"; 12 | // export const UPDATE_USER_PROFILE = "PERMISSION_UPDATE_USER_PROFILE"; 13 | 14 | export const DEFINED_ROLES = ["Admin", "Project Manager", "Developer", "Submitter"]; 15 | 16 | export const BS_ICONS = Object.keys(BsIcons).map(icon => { return { name: icon, icon: }; }); 17 | 18 | export const DEMO_LOGIN_INFO = [ 19 | { 20 | email: "james.smith@bugtracker.com", 21 | password: "password", 22 | role: "Admin" 23 | }, 24 | { 25 | email: "michael.smith@bugtracker.com", 26 | password: "password", 27 | role: "Developer" 28 | }, 29 | { 30 | email: "robert.smith@bugtracker.com", 31 | password: "password", 32 | role: "Submitter" 33 | } 34 | ]; -------------------------------------------------------------------------------- /client/src/components/others/navigationBar/NavItem.jsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Box, Center, Flex, Icon, Text, Tooltip } from "@chakra-ui/react"; 3 | import React from "react"; 4 | 5 | const NavItem = ({ navSize, name, icon, path, active }) => { 6 | return ( 7 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 28 | {name} 29 | 30 | 31 |
32 |
33 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default NavItem; 40 | -------------------------------------------------------------------------------- /server/util/utils.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import Role from "../models/role.model.js"; 3 | import * as Constants from './constants.js'; 4 | 5 | export const getUserRole = async (roleId) => { 6 | 7 | try { 8 | const role = await Role.findOne({ _id: roleId }); 9 | 10 | if (!role) 11 | throw "Role not found"; 12 | 13 | return role; 14 | } catch (error) { 15 | console.error(error); 16 | } 17 | }; 18 | 19 | 20 | export const canPerformAction = async (permissionCheck, user) => { 21 | const roleId = user.roleId; 22 | const roleObject = await getUserRole(roleId); 23 | 24 | return permissionCheck(roleObject.permissions); 25 | }; 26 | 27 | export const validateObjectId = (id, message, res) => { 28 | if (!mongoose.Types.ObjectId.isValid(id)) { 29 | return res.status(403).json({ message }); 30 | } 31 | }; 32 | 33 | 34 | 35 | const canManageTickets = (permissionsList) => permissionsList.includes(Constants.MANAGE_TICKET); 36 | const canManageProjects = (permissionsList) => permissionsList.includes(Constants.MANAGE_PROJECT); 37 | const canManageAdminPage = (permissionsList) => permissionsList.includes(Constants.MANAGE_ADMIN_PAGE); 38 | 39 | export const Permissions = { 40 | canManageTickets, 41 | canManageProjects, 42 | canManageAdminPage 43 | }; -------------------------------------------------------------------------------- /client/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/services/ticket-service.js: -------------------------------------------------------------------------------- 1 | import useAuthStore from "@/hooks/useAuth"; 2 | 3 | const getUserTickets = () => { 4 | const userProfile = useAuthStore.getState().userProfile; 5 | return { 6 | method: "get", 7 | url: `/ticket/user/${userProfile._id}` 8 | }; 9 | }; 10 | 11 | const getProjectTickets = (projectId) => { 12 | return { 13 | method: "get", 14 | url: `/ticket/project/${projectId}` 15 | }; 16 | }; 17 | 18 | const getTicketInfo = (ticketId) => { 19 | return { 20 | method: "get", 21 | url: `/ticket/${ticketId}` 22 | }; 23 | }; 24 | 25 | const createTicket = (projectId, data) => { 26 | return { 27 | method: "post", 28 | url: `/ticket/project/${projectId}`, 29 | data 30 | }; 31 | }; 32 | 33 | const updateTicket = (projectId, data) => { 34 | return { 35 | method: "patch", 36 | url: `/ticket/project/${projectId}`, 37 | data 38 | }; 39 | }; 40 | 41 | const deleteTicket = (ticketId) => { 42 | return { 43 | method: "delete", 44 | url: `/ticket/${ticketId}` 45 | }; 46 | }; 47 | 48 | 49 | const TicketService = { 50 | getProjectTickets, 51 | getTicketInfo, 52 | getUserTickets, 53 | createTicket, 54 | updateTicket, 55 | deleteTicket 56 | }; 57 | 58 | export default TicketService; -------------------------------------------------------------------------------- /client/src/store/store.js: -------------------------------------------------------------------------------- 1 | // This file will globally hold all the reducers 2 | import { configureStore } from "@reduxjs/toolkit"; 3 | import { 4 | persistStore, 5 | persistCombineReducers, 6 | FLUSH, 7 | REHYDRATE, 8 | PAUSE, 9 | PERSIST, 10 | PURGE, 11 | REGISTER, 12 | } from 'redux-persist'; 13 | import storage from 'redux-persist/lib/storage'; 14 | import authReducer from "../features/authSlice"; 15 | import miscellaneousSlice from "../features/miscellaneousSlice"; 16 | import projectSlice from "../features/projectSlice"; 17 | import ticketSlice from "../features/ticketSlice"; 18 | 19 | const persistConfig = { 20 | key: 'root', 21 | storage, 22 | version: 1 23 | }; 24 | 25 | // ? Add the reducers here 26 | const rootReducer = { 27 | auth: authReducer, 28 | project: projectSlice, 29 | miscellaneous: miscellaneousSlice, 30 | ticket: ticketSlice, 31 | }; 32 | 33 | const persistCombinedReducers = persistCombineReducers(persistConfig, rootReducer); 34 | 35 | let store = configureStore({ 36 | reducer: persistCombinedReducers, 37 | middleware: (getDefaultMiddleware) => 38 | getDefaultMiddleware({ 39 | serializableCheck: { 40 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], 41 | }, 42 | }), 43 | }); 44 | let persistor = persistStore(store); 45 | 46 | export { store, persistor }; -------------------------------------------------------------------------------- /server/models/ticket.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const TicketSchema = mongoose.Schema({ 4 | projectId: { 5 | type: mongoose.Types.ObjectId, 6 | ref: "Project", 7 | required: true 8 | }, 9 | title: { 10 | type: String, 11 | required: true 12 | }, 13 | type: { 14 | type: mongoose.Types.ObjectId, 15 | ref: "TicketType", 16 | required: true 17 | }, 18 | description: { 19 | type: String, 20 | default: "" 21 | }, 22 | status: { 23 | type: String, 24 | enum: ["Open", "In-Progress", "Done", "Archived"], 25 | default: "Open" 26 | }, 27 | estimatedTime: { 28 | type: Number, 29 | required: true 30 | }, 31 | estimatedTimeUnit: { 32 | type: String, 33 | enum: ["h", "d"], 34 | default: "h" 35 | }, 36 | tags: { 37 | type: [String], 38 | default: [] 39 | }, 40 | assignees: { 41 | type: [mongoose.Types.ObjectId], 42 | ref: "User" 43 | }, 44 | createdBy: { 45 | type: mongoose.Types.ObjectId, 46 | ref: "User", 47 | required: true 48 | }, 49 | createdOn: { 50 | type: Date, 51 | default: Date.now() 52 | }, 53 | updatedOn: { 54 | type: Date, 55 | default: Date.now() 56 | } 57 | }); 58 | 59 | export default mongoose.model("Ticket", TicketSchema); -------------------------------------------------------------------------------- /server/controllers/auth.controller.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | import jwt from "jsonwebtoken"; 3 | import User from "../models/user.model.js"; 4 | 5 | export const login = async (req, res) => { 6 | const { email, password } = req.body; 7 | 8 | try { 9 | const existingUser = await User.findOne({ email }).populate("roleId"); 10 | 11 | if (!existingUser) { 12 | return res.status(400).json({ message: "Please provide a valid email address and password" }); 13 | } 14 | 15 | const isPasswordCorrect = await bcrypt.compare(password, existingUser.password); 16 | 17 | if (!isPasswordCorrect) { 18 | return res.status(400).json({ message: "Please provide a valid email address and password" }); 19 | } 20 | 21 | const accessToken = jwt.sign({ id: existingUser._id }, process.env.SECRET_KEY, { expiresIn: process.env.JWT_TOKEN_EXPIRATION }); 22 | 23 | return res.status(200).json({ 24 | userProfile: { 25 | firstName: existingUser.firstName, 26 | lastName: existingUser.lastName, 27 | email: existingUser.email, 28 | _id: existingUser._id, 29 | roleId: existingUser.roleId, 30 | }, 31 | accessToken 32 | }); 33 | 34 | } catch (error) { 35 | console.log(error); 36 | return res.status(500).json({ message: "Internal server issue" }); 37 | } 38 | }; 39 | 40 | -------------------------------------------------------------------------------- /client/src/util/Utils.js: -------------------------------------------------------------------------------- 1 | import * as Constants from "./Constants.js"; 2 | 3 | const canManageTickets = (permissionsList) => permissionsList.includes(Constants.MANAGE_TICKET); 4 | const canManageProjects = (permissionsList) => permissionsList.includes(Constants.MANAGE_PROJECT); 5 | const canManageAdminPage = (permissionsList) => permissionsList.includes(Constants.MANAGE_ADMIN_PAGE); 6 | 7 | export const Permissions = { 8 | canManageTickets, 9 | canManageProjects, 10 | canManageAdminPage 11 | }; 12 | 13 | export const createTicketTypeSelectOptions = (ticketTypes) => { 14 | return ticketTypes.map((ticketType) => ( 15 | 18 | )); 19 | }; 20 | 21 | export const createTicketStatusSelectOptions = () => { 22 | return Constants.TICKET_STATUS.map((status, index) => ( 23 | 26 | )); 27 | }; 28 | 29 | export const hexToRgb = (hex, opacity) => { 30 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 31 | 32 | if (!result) { 33 | return `rgba(0, 0, 0, ${opacity})`; 34 | } 35 | 36 | const r = parseInt(result[1], 16); 37 | const g = parseInt(result[2], 16); 38 | const b = parseInt(result[3], 16); 39 | 40 | return `rgba(${r}, ${g}, ${b}, ${opacity})`; 41 | }; 42 | 43 | export const getUserFullname = (object) => { 44 | return object?.firstName + " " + object?.lastName; 45 | }; 46 | -------------------------------------------------------------------------------- /client/src/components/administration/ManageRoles.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Spacer, useDisclosure } from "@chakra-ui/react"; 2 | import React, { useState } from "react"; 3 | import MiscellaneousService from "@/services/miscellaneous-service"; 4 | import useApi from "@/hooks/useApi"; 5 | import { MANAGE_ROLES } from "../../util/TableDataDisplay"; 6 | import Table from "../others/Table"; 7 | import CreateRole from "./CreateRole"; 8 | 9 | const ManageRoles = () => { 10 | const allRolesSWR = useApi(MiscellaneousService.getRoles()); 11 | const [viewRole, setViewRole] = useState(null); 12 | const { isOpen, onClose, onOpen } = useDisclosure(); 13 | 14 | const onRoleClick = (rowProps, _) => { 15 | setViewRole(rowProps.data); 16 | onOpen(); 17 | }; 18 | 19 | const closeModal = () => { 20 | setViewRole(null); 21 | onClose(); 22 | }; 23 | 24 | return ( 25 | <> 26 | 34 |
35 | 36 | 37 | 40 | 41 | 42 | 48 | 49 | ); 50 | }; 51 | 52 | export default ManageRoles; 53 | -------------------------------------------------------------------------------- /client/src/components/administration/ManageUsers.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Spacer, useDisclosure } from "@chakra-ui/react"; 2 | import React, { useState } from "react"; 3 | import MiscellaneousService from "@/services/miscellaneous-service"; 4 | import useApi from "@/hooks/useApi"; 5 | import { MANAGE_USERS_COLUMNS } from "@/util/TableDataDisplay"; 6 | import Table from "../others/Table"; 7 | import UpdateUser from "./UpdateUser"; 8 | 9 | const ManageUsers = () => { 10 | const allUsersSWR = useApi(MiscellaneousService.getUsers("excludeUser=true")); 11 | const [viewUser, setViewUser] = useState(null); 12 | const { isOpen, onOpen, onClose } = useDisclosure(); 13 | 14 | const onUserClick = (rowProps, _) => { 15 | setViewUser(rowProps.data); 16 | onOpen(); 17 | }; 18 | 19 | const closeModal = async () => { 20 | setViewUser(null); 21 | onClose(); 22 | }; 23 | 24 | return ( 25 | <> 26 |
34 |
35 | 36 | 37 | 40 | 41 | 47 | 48 | ); 49 | }; 50 | 51 | export default ManageUsers; 52 | -------------------------------------------------------------------------------- /client/src/components/administration/ManageTicketTypes.jsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Spacer, useDisclosure } from "@chakra-ui/react"; 2 | import React, { useState } from "react"; 3 | import MiscellaneousService from "@/services/miscellaneous-service"; 4 | import useApi from "@/hooks/useApi"; 5 | import { MANAGE_TICKET_TYPES_COLUMNS } from "../../util/TableDataDisplay"; 6 | import Table from "../others/Table"; 7 | import CreateTicketType from "./CreateTicketType"; 8 | 9 | const ManageTicketTypes = () => { 10 | const allTicketTypesSWR = useApi(MiscellaneousService.getAllTicketType()); 11 | const [viewTicketType, setViewTicketType] = useState(null); 12 | const { isOpen, onOpen, onClose } = useDisclosure(); 13 | 14 | const onTicketTypeClick = (rowProps, _) => { 15 | setViewTicketType(rowProps.data); 16 | onOpen(); 17 | }; 18 | 19 | const closeModal = () => { 20 | setViewTicketType(null); 21 | onClose(); 22 | }; 23 | 24 | return ( 25 | <> 26 |
33 |
34 | 35 | 36 | 39 | 40 | 41 | 47 | 48 | ); 49 | }; 50 | 51 | export default ManageTicketTypes; 52 | -------------------------------------------------------------------------------- /client/src/components/others/DemoLoginInfoModal.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Modal, 4 | ModalBody, 5 | ModalCloseButton, 6 | ModalContent, 7 | ModalFooter, 8 | ModalHeader, 9 | ModalOverlay, 10 | Table, 11 | TableContainer, 12 | Tbody, 13 | Td, 14 | Th, 15 | Thead, 16 | Tr, 17 | } from "@chakra-ui/react"; 18 | import React from "react"; 19 | import { DEMO_LOGIN_INFO } from "@/util/Constants"; 20 | 21 | const DemoLoginInfoModal = ({ isOpen, onClose }) => { 22 | return ( 23 | 24 | 25 | 26 | Demo Login Info 27 | 28 | 29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | {DEMO_LOGIN_INFO.map((loginInfo, index) => ( 40 | 41 | 42 | 43 | 44 | 45 | ))} 46 | 47 |
EmailPasswordRole
{loginInfo.email}{loginInfo.password}{loginInfo.role}
48 | 49 | 50 | 51 | 52 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default DemoLoginInfoModal; 62 | -------------------------------------------------------------------------------- /server/seed/seedData.js: -------------------------------------------------------------------------------- 1 | import * as Permissions from "../util/constants.js"; 2 | 3 | export const DBUsers = [ 4 | { 5 | firstName: "James", 6 | lastName: "Smith", 7 | email: "james.smith@bugtracker.com", 8 | password: "password" 9 | }, 10 | { 11 | firstName: "Michael", 12 | lastName: "Smith", 13 | email: "michael.smith@bugtracker.com", 14 | password: "password" 15 | }, 16 | { 17 | firstName: "Robert", 18 | lastName: "Smith", 19 | email: "robert.smith@bugtracker.com", 20 | password: "password" 21 | } 22 | ]; 23 | 24 | export const DBRole = [ 25 | { 26 | name: "Admin", 27 | permissions: [ 28 | Permissions.MANAGE_TICKET, 29 | Permissions.MANAGE_PROJECT, 30 | Permissions.MANAGE_ADMIN_PAGE 31 | ] 32 | }, 33 | { 34 | name: "Developer", 35 | permissions: [ 36 | Permissions.MANAGE_TICKET, 37 | Permissions.MANAGE_PROJECT, 38 | ] 39 | }, 40 | { 41 | name: "Submitter", 42 | permissions: [ 43 | Permissions.MANAGE_TICKET, 44 | ] 45 | }, 46 | ]; 47 | 48 | export const DBTicketType = [ 49 | { 50 | name: "Feature", 51 | iconName: "BsPlusLg", 52 | colour: "#4ab577" 53 | }, 54 | { 55 | name: "Bug", 56 | iconName: "BsBugFill", 57 | colour: "#e25555" 58 | }, 59 | { 60 | name: "Documentation", 61 | iconName: "BsFileEarmarkText", 62 | colour: "#ED8936", 63 | }, 64 | { 65 | name: "Support", 66 | iconName: "BsQuestion", 67 | colour: "#4299E1", 68 | } 69 | ]; -------------------------------------------------------------------------------- /server/controllers/seed.controller.js: -------------------------------------------------------------------------------- 1 | import User from "../models/user.model.js"; 2 | import Role from "../models/role.model.js"; 3 | import TicketType from "../models/ticketType.model.js"; 4 | import mongoose from "mongoose"; 5 | import { DBTicketType, DBRole, DBUsers } from "../seed/seedData.js"; 6 | import bcrypt from 'bcrypt'; 7 | 8 | const getHashedPassword = async () => { 9 | const hasedPasswordPromises = DBUsers.map(user => bcrypt.hash(user.password, +process.env.PASSWORD_SALT)); 10 | 11 | return Promise.all(hasedPasswordPromises); 12 | }; 13 | 14 | const seedUsers = async (role, hashedPasswords) => { 15 | const usersPromises = DBUsers.map((user, index) => { 16 | return User.create({ ...user, password: hashedPasswords[index], roleId: role[index]._id }); 17 | }); 18 | 19 | return usersPromises; 20 | }; 21 | 22 | const populate = async () => { 23 | try { 24 | await Promise.all([ 25 | TicketType.create(DBTicketType), 26 | Role.create(DBRole) 27 | ]).then(async ([_, role]) => { 28 | const hashedPasswords = await getHashedPassword(); 29 | await seedUsers(role, hashedPasswords); 30 | }); 31 | 32 | } catch (error) { 33 | throw error; 34 | } 35 | }; 36 | export const seedDatabase = async () => { 37 | try { 38 | if (mongoose.connection?.readyState === 1) { 39 | console.log('❌ Clearing database...'); 40 | mongoose.connection.dropDatabase(); 41 | } 42 | 43 | console.log('🌱 Seeding database...'); 44 | 45 | 46 | setTimeout(() => { 47 | console.log('✅ Seeding successful'); 48 | }, 10000); 49 | } catch (error) { 50 | return console.log(error); 51 | } 52 | }; -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@chakra-ui/icons": "^2.0.17", 7 | "@chakra-ui/react": "^2.5.1", 8 | "@emotion/styled": "^11.10.6", 9 | "@inovua/reactdatagrid-community": "^5.8.5", 10 | "@testing-library/jest-dom": "^5.16.5", 11 | "@testing-library/react": "^14.0.0", 12 | "@testing-library/user-event": "^14.4.3", 13 | "axios": "^1.3.4", 14 | "chart.js": "^4.2.1", 15 | "eslint": "^8.36.0", 16 | "eslint-config-next": "^13.2.4", 17 | "formik": "^2.2.9", 18 | "framer-motion": "^10.5.0", 19 | "jwt-decode": "^3.1.2", 20 | "moment": "^2.29.4", 21 | "next": "^13.2.4", 22 | "react": "18.2.0", 23 | "react-chartjs-2": "^5.2.0", 24 | "react-dom": "18.2.0", 25 | "react-icons": "^4.8.0", 26 | "react-persist": "^1.0.2", 27 | "react-quill": "^2.0.0", 28 | "react-scripts": "^5.0.1", 29 | "sass": "^1.59.3", 30 | "swr": "^2.2.4", 31 | "web-vitals": "^3.3.0", 32 | "yup": "^1.0.2", 33 | "zustand": "^4.4.6" 34 | }, 35 | "scripts": { 36 | "dev": "next dev", 37 | "analyze": "cross-env ANALYZE=true next build", 38 | "build": "next build", 39 | "start": "next start" 40 | }, 41 | "eslintConfig": { 42 | "extends": [ 43 | "react-app", 44 | "react-app/jest" 45 | ] 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@next/bundle-analyzer": "^14.0.1", 61 | "@trivago/prettier-plugin-sort-imports": "^4.1.1", 62 | "cross-env": "^7.0.3", 63 | "swr-devtools": "^1.3.2" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/src/pages/administration.jsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { 3 | Flex, 4 | Heading, 5 | Tab, 6 | TabList, 7 | TabPanel, 8 | TabPanels, 9 | Tabs, 10 | } from "@chakra-ui/react"; 11 | import { useEffect } from "react"; 12 | import ManageRoles from "@/components/administration/ManageRoles"; 13 | import ManageTicketTypes from "@/components/administration/ManageTicketTypes"; 14 | import ManageUsers from "@/components/administration/ManageUsers"; 15 | import MiscellaneousService from "@/services/miscellaneous-service"; 16 | import { usePermissions } from "@/hooks/usePermissions"; 17 | import { Permissions } from "@/util/Utils"; 18 | import PageNotFound from "./404"; 19 | 20 | const Administration = () => { 21 | const canManageAdminPage = usePermissions(Permissions.canManageAdminPage); 22 | 23 | if (!canManageAdminPage) { 24 | return ; 25 | } 26 | 27 | return ( 28 | 29 | 30 | Administration 31 | 32 | 33 | 34 | Administration 35 | 36 | 37 |
38 | 39 | 40 | 41 | Manage Users 42 | Manage Roles 43 | Manage Ticket Type 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
61 | ); 62 | }; 63 | 64 | export default Administration; 65 | -------------------------------------------------------------------------------- /client/src/pages/tickets.jsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import { Flex, Heading, useDisclosure } from "@chakra-ui/react"; 3 | import React, { useState } from "react"; 4 | import Loading from "@/components/others/Loading"; 5 | import useApi from "@/hooks/useApi"; 6 | import Table from "../components/others/Table"; 7 | import CreateTicket from "../components/tickets/CreateTicket"; 8 | import TicketService from "../services/ticket-service"; 9 | import { 10 | MY_TICKETS_COLUMNS, 11 | TICKETS_DEFAULT_SORT, 12 | } from "../util/TableDataDisplay"; 13 | 14 | const Tickets = () => { 15 | const myTicketsSWR = useApi(TicketService.getUserTickets()); 16 | 17 | const [viewTicket, setViewTicket] = useState(null); 18 | const { isOpen, onOpen, onClose } = useDisclosure(); 19 | 20 | const onTicketClick = (rowProps, _) => { 21 | setViewTicket(rowProps.data); 22 | onOpen(); 23 | }; 24 | 25 | const onModalClose = () => { 26 | setViewTicket(null); 27 | onClose(); 28 | }; 29 | 30 | if (myTicketsSWR.isLoading) { 31 | return ; 32 | } 33 | 34 | return ( 35 | 36 | 37 | Tickets 38 | 39 | 40 | 41 | My Tickets 42 | 43 | 44 | 45 |
46 | 47 | 56 | 57 | 63 | 64 | ); 65 | }; 66 | 67 | export default Tickets; 68 | -------------------------------------------------------------------------------- /server/schema/validation.schema.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | export const createUserSchema = yup.object().shape({ 4 | firstName: yup.string().trim().required("First name required"), 5 | lastName: yup.string().trim().required("Last name required"), 6 | email: yup.string().trim().email("Invalid email").required("Email required"), 7 | password: yup.string() 8 | .min(6, "Password must be at least 6 characters long") 9 | .required("Password required"), 10 | confirmPassword: yup.string().oneOf( 11 | [yup.ref("password"), null], 12 | "Passwords must match", 13 | ), 14 | }); 15 | 16 | export const loginSchema = yup.object().shape({ 17 | email: yup.string().email("Invalid email").required("Required"), 18 | password: yup.string() 19 | .min(6, "Password must be at least 6 characters long") 20 | .required("Required"), 21 | }); 22 | 23 | export const createTicketTypeSchema = yup.object().shape({ 24 | name: yup.string().required("Ticket type name is required"), 25 | iconName: yup.string().required("Ticket type icon name is required"), 26 | colour: yup.string() 27 | }); 28 | 29 | export const createProjectSchema = yup.object().shape({ 30 | title: yup.string().trim().required("Project title required"), 31 | description: yup.string(), 32 | assignees: yup.array().required("Assignees required"), 33 | }); 34 | 35 | export const createTicketSchema = yup.object().shape({ 36 | title: yup.string().trim().required("Ticket title required"), 37 | status: yup.string().trim().required("Ticket status required"), 38 | type: yup.string().required("Ticket type required"), 39 | estimatedTime: yup.number().required("Ticket estimated time required"), 40 | estimatedTimeUnit: yup.string().required("Ticket estimated time unit required") 41 | }); 42 | 43 | export const createRoleSchema = yup.object().shape({ 44 | name: yup.string().min(1, "Role name cannot be empty").required("Required"), 45 | permissions: yup.array() 46 | }); 47 | 48 | export const createCommentSchema = yup.object().shape({ 49 | text: yup.string().trim().min(1, "Comment cannot be empty") 50 | }); -------------------------------------------------------------------------------- /client/src/pages/projects/index.jsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import { Button, Flex, Heading, Spacer, useDisclosure } from "@chakra-ui/react"; 5 | import PermissionsRender from "@/components/others/PermissionsRender"; 6 | import Table from "@/components/others/Table"; 7 | import AddProject from "@/components/projects/AddProject"; 8 | import ProjectService from "@/services/project-service"; 9 | import useApi from "@/hooks/useApi"; 10 | import { PROJECTS_COLUMNS } from "../../util/TableDataDisplay"; 11 | import { Permissions } from "../../util/Utils"; 12 | 13 | const ViewAllProjects = () => { 14 | const router = useRouter(); 15 | const { isOpen, onOpen, onClose } = useDisclosure(); 16 | const projectsSWR = useApi(ProjectService.getMyProjects()); 17 | 18 | const handleRowClick = (rowData) => { 19 | const projectId = rowData.data._id; 20 | router.push(`/projects/${projectId}`); 21 | }; 22 | 23 | return ( 24 | 25 | 26 | Projects 27 | 28 | 29 | 30 | Projects 31 | 32 | 33 | 34 | 42 | 43 | 44 | 45 |
46 | 47 |
54 | 55 | 60 | 61 | ); 62 | }; 63 | 64 | export default ViewAllProjects; 65 | -------------------------------------------------------------------------------- /server/seed/seedDB.js: -------------------------------------------------------------------------------- 1 | import User from "../models/user.model.js"; 2 | import Role from "../models/role.model.js"; 3 | import TicketType from "../models/ticketType.model.js"; 4 | import mongoose from "mongoose"; 5 | import { DBTicketType, DBRole, DBUsers } from "./seedData.js"; 6 | import bcrypt from 'bcrypt'; 7 | import { MONGO_DB_CONNECTION } from "../config/config.js"; 8 | 9 | const getHashedPassword = async () => { 10 | const hasedPasswordPromises = DBUsers.map(user => bcrypt.hash(user.password, +process.env.PASSWORD_SALT)); 11 | 12 | return Promise.all(hasedPasswordPromises); 13 | }; 14 | 15 | const seedUsers = async (role, hashedPasswords) => { 16 | const usersPromises = DBUsers.map((user, index) => { 17 | return User.create({ ...user, password: hashedPasswords[index], roleId: role[index]._id }); 18 | }); 19 | 20 | return usersPromises; 21 | }; 22 | 23 | const populate = async () => { 24 | try { 25 | await Promise.all([ 26 | TicketType.create(DBTicketType), 27 | Role.create(DBRole) 28 | ]).then(async ([_, role]) => { 29 | const hashedPasswords = await getHashedPassword(); 30 | await seedUsers(role, hashedPasswords); 31 | }); 32 | 33 | } catch (error) { 34 | console.log(error); 35 | throw error; 36 | } 37 | }; 38 | const seedDatabase = async () => { 39 | try { 40 | if (mongoose.connection?.readyState === 0) { 41 | await mongoose.connect(MONGO_DB_CONNECTION); 42 | } 43 | 44 | if (mongoose.connection?.readyState === 1) { 45 | console.log('❌ Clearing database...'); 46 | mongoose.connection.dropDatabase(); 47 | } 48 | else { 49 | return console.log("Unable to connect to DB"); 50 | } 51 | 52 | 53 | 54 | 55 | console.log('🌱 Seeding database...'); 56 | 57 | await populate(); 58 | 59 | setTimeout(() => { 60 | console.log('✅ Seeding successful'); 61 | mongoose.connection.close(); 62 | }, 5000); 63 | } catch (error) { 64 | return console.log(error); 65 | } 66 | }; 67 | 68 | 69 | await seedDatabase(); -------------------------------------------------------------------------------- /server/controllers/role.controller.js: -------------------------------------------------------------------------------- 1 | import Role from "../models/role.model.js"; 2 | import User from "../models/user.model.js"; 3 | 4 | export const getRoles = async (req, res) => { 5 | try { 6 | const roles = await Role.find({}); 7 | 8 | return res.json(roles); 9 | } catch (error) { 10 | console.log(error); 11 | return res.status(500).json({ message: "Internal server issue" }); 12 | } 13 | }; 14 | 15 | export const addRole = async (req, res) => { 16 | const { name, permissions } = req.body; 17 | 18 | try { 19 | const exisitingRole = await Role.findOne({ name }); 20 | 21 | if (exisitingRole) { 22 | return res.status(400).json({ message: "Role already exist" }); 23 | } 24 | 25 | const role = await Role.create({ name, permissions }); 26 | 27 | return res.json(role); 28 | } catch (error) { 29 | console.log(error); 30 | return res.status(500).json({ message: "Internal server issue" }); 31 | } 32 | }; 33 | 34 | export const deleteRole = async (req, res) => { 35 | const { roleId } = req.params; 36 | 37 | try { 38 | const totalUserWithThisRole = await User.find({ roleId }).count(); 39 | 40 | if (totalUserWithThisRole > 0) { 41 | return res.status(405).json({ message: `Forbidden: ${totalUserWithThisRole} user(s) is associated with this role. ` }); 42 | } 43 | 44 | await Role.deleteOne({ _id: roleId }); 45 | 46 | return res.sendStatus(200); 47 | } catch (error) { 48 | console.log(error); 49 | return res.status(500).json({ message: "Internal server issue" }); 50 | } 51 | }; 52 | 53 | export const updateRole = async (req, res) => { 54 | const { roleId } = req.params; 55 | const { name, permissions } = req.body; 56 | 57 | try { 58 | const updatedRole = await Role.findOneAndUpdate({ _id: roleId }, { name, permissions }, { new: true }); 59 | 60 | return res.json(updatedRole); 61 | 62 | } catch (error) { 63 | console.error(error.message); 64 | console.log(error); 65 | return res.status(500).json({ message: "Internal server issue" }); 66 | } 67 | 68 | }; -------------------------------------------------------------------------------- /server/tests/app.test.js: -------------------------------------------------------------------------------- 1 | import supertest from "supertest"; 2 | import app from "../app"; 3 | import mongoose from "mongoose"; 4 | import User from "../models/user.model"; 5 | import Role from "../models/role.model"; 6 | import bcrypt from 'bcrypt'; 7 | // import UserRole from "../models/userRole.model"; 8 | 9 | import { projectPayload, sampleRoles, sampleUsers } from "./data"; 10 | import Project from "../models/project.model"; 11 | 12 | 13 | const request = supertest(app); 14 | 15 | const seedDatabase = async () => { 16 | //Seed Role 17 | await Role.insertMany(sampleRoles); 18 | 19 | let index = 0; 20 | 21 | // //Seed user 22 | for (const user of sampleUsers) { 23 | const hashedPassword = await bcrypt.hash(user.password, 12); 24 | user.password = hashedPassword; 25 | const newUser = await User.create(user); 26 | 27 | let role; 28 | 29 | if (index == 0) 30 | role = await Role.findOne({ name: "admin" }); 31 | else if (index == 1) 32 | role = await Role.findOne({ name: "project manager" }); 33 | else if (index == 2) 34 | role = await Role.findOne({ name: "developer" }); 35 | else 36 | role = await Role.findOne({ name: "submitter" }); 37 | 38 | // await UserRole.create({ userId: newUser._id, roleId: role._id }); 39 | // console.log(`Created ${user.firstName} ${user.lastName} (${newUser._id}): ${role.name} (${role._id})`); 40 | index++; 41 | } 42 | 43 | //Seed Project 44 | await Project.insertMany(projectPayload); 45 | 46 | }; 47 | 48 | const removeAllCollections = async () => { 49 | const collections = Object.keys(mongoose.connection.collections); 50 | 51 | for (const collectionName of collections) { 52 | const collection = mongoose.connection.collections[collectionName]; 53 | await collection.deleteMany(); 54 | } 55 | }; 56 | 57 | //connect to mongoDB 58 | beforeAll(async () => { 59 | const url = `mongodb://127.0.0.1/avengers`; 60 | await mongoose.connect(url, { useNewUrlParser: true }); 61 | await seedDatabase(); 62 | }); 63 | 64 | afterAll(async () => { 65 | await removeAllCollections(); 66 | }); 67 | 68 | describe("App Test", () => { 69 | it("Run Test", () => { }); 70 | }); 71 | 72 | export default request; -------------------------------------------------------------------------------- /server/controllers/comment.controller.js: -------------------------------------------------------------------------------- 1 | import Comment from "../models/comment.model.js"; 2 | 3 | export const getComments = async (req, res) => { 4 | const { ticketId } = req.params; 5 | 6 | try { 7 | const comments = await Comment.find({ ticketId }, { _id: 1, text: 1, userId: 1, updatedOn: 1, createdOn: 1 }) 8 | .populate([ 9 | { path: "userId", select: { firstName: 1, lastName: 1 } } 10 | ]) 11 | .sort({ createdOn: "desc" }); 12 | 13 | return res.json(comments); 14 | 15 | } catch (error) { 16 | console.log(error); 17 | return res.status(500).json({ message: "Internal server issue" }); 18 | } 19 | }; 20 | 21 | export const createComment = async (req, res) => { 22 | const { ticketId } = req.params; 23 | const { text } = req.body; 24 | 25 | try { 26 | const userId = req.user._id; 27 | 28 | let newComment = await Comment.create({ ticketId, userId, text, createdOn: Date.now(), updatedOn: Date.now() }); 29 | newComment = await newComment.populate([ 30 | { path: "userId", select: { firstName: 1, lastName: 1 } } 31 | ]); 32 | return res.json(newComment); 33 | 34 | } catch (error) { 35 | console.log(error); 36 | return res.status(500).json({ message: "Internal server issue" }); 37 | } 38 | }; 39 | 40 | export const updateComment = async (req, res) => { 41 | const { commentId } = req.params; 42 | const { text } = req.body; 43 | 44 | try { 45 | 46 | let updatedComment = await Comment.findOneAndUpdate({ _id: commentId }, { text, updatedOn: Date.now() }); 47 | updatedComment = await updatedComment.populate([ 48 | { path: "userId", select: { firstName: 1, lastName: 1 } } 49 | ]); 50 | return res.json(updatedComment); 51 | 52 | } catch (error) { 53 | console.log(error); 54 | return res.status(500).json({ message: "Internal server issue" }); 55 | } 56 | }; 57 | 58 | export const deleteComment = async (req, res) => { 59 | const { commentId } = req.params; 60 | 61 | try { 62 | await Comment.deleteOne({ _id: commentId }); 63 | 64 | return res.sendStatus(200); 65 | 66 | } catch (error) { 67 | console.log(error); 68 | return res.status(500).json({ message: "Internal server issue" }); 69 | } 70 | }; 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /client/src/services/miscellaneous-service.js: -------------------------------------------------------------------------------- 1 | import useAuthStore from "@/hooks/useAuth"; 2 | 3 | const updateUserProfile = (data) => { 4 | return { 5 | url: "/user/update", 6 | method: "patch", 7 | data 8 | }; 9 | }; 10 | 11 | const updateMyProfile = (data) => { 12 | return { 13 | url: "/user/updateMyProfile", 14 | method: "patch", 15 | data 16 | }; 17 | }; 18 | 19 | const getUsers = (query = "") => { 20 | return { 21 | method: "get", 22 | url: `/user/all${"?" + query}` 23 | }; 24 | }; 25 | 26 | const createUser = (data) => { 27 | return { 28 | method: "post", 29 | url: "/user/create", 30 | data 31 | }; 32 | }; 33 | 34 | 35 | const getAllTicketType = () => { 36 | return { 37 | url: "/ticketType", 38 | method: "get" 39 | }; 40 | }; 41 | 42 | const createTicketType = (data) => { 43 | return { 44 | url: "/ticketType", 45 | method: "post", 46 | data 47 | }; 48 | }; 49 | 50 | 51 | const updateTicketType = (data) => { 52 | return { 53 | url: `/ticketType`, 54 | method: "patch", 55 | data 56 | }; 57 | }; 58 | 59 | const deleteTicketType = (ticketTypeId) => { 60 | return { 61 | url: `/ticketType/${ticketTypeId}`, 62 | method: "delete" 63 | }; 64 | }; 65 | 66 | const getRoles = () => { 67 | return { 68 | url: "/role", 69 | method: "get" 70 | }; 71 | }; 72 | 73 | const createRole = (data) => { 74 | return { 75 | url: "/role", 76 | method: "post", 77 | data 78 | }; 79 | }; 80 | 81 | const updateRole = (data) => { 82 | return { 83 | url: `/role/${data._id}`, 84 | method: "patch", 85 | data 86 | }; 87 | }; 88 | 89 | const deleteRole = (roleId) => { 90 | return { 91 | url: `/role/${roleId}`, 92 | method: "delete" 93 | }; 94 | }; 95 | 96 | const MiscellaneousService = { 97 | updateMyProfile, 98 | getUsers, 99 | createUser, 100 | getAllTicketType, 101 | createTicketType, 102 | updateTicketType, 103 | deleteTicketType, 104 | getRoles, 105 | updateUserProfile, 106 | createRole, 107 | updateRole, 108 | deleteRole 109 | }; 110 | 111 | export default MiscellaneousService; 112 | -------------------------------------------------------------------------------- /client/src/styles/theme.js: -------------------------------------------------------------------------------- 1 | import { extendTheme } from "@chakra-ui/react"; 2 | 3 | export const theme = extendTheme({ 4 | config: { 5 | initialColorMode: "dark", 6 | useSystemColorMode: false, 7 | }, 8 | styles: { 9 | global: { 10 | "*": { 11 | boxSizing: "border-box", 12 | margin: 0, 13 | padding: 0, 14 | fontFamily: `"Roboto", "Sans-sarif", "Times New Roman"`, 15 | }, 16 | "html, body": { 17 | backgroundColor: "#182130", 18 | maxHeight: "100vh", 19 | height: "100vh", 20 | overflowY: "hidden", 21 | }, 22 | }, 23 | }, 24 | colors: { 25 | primary: "#182130", 26 | secondary: "#151c29", 27 | hover: "#435572", 28 | inputLabel: "#A0AEC0", 29 | }, 30 | components: { 31 | Button: { 32 | baseStyle: { 33 | fontWeight: "500", 34 | } 35 | }, 36 | FormLabel: { 37 | baseStyle: { 38 | fontWeight: "bold", 39 | fontSize: "sm", 40 | color: "inputLabel", 41 | }, 42 | }, 43 | Input: { 44 | defaultProps: { 45 | size: "md", 46 | }, 47 | }, 48 | Select: { 49 | defaultProps: { 50 | size: "md", 51 | }, 52 | }, 53 | Textarea: { 54 | defaultProps: { 55 | size: "md", 56 | }, 57 | }, 58 | InputGroup: { 59 | defaultProps: { 60 | size: "md", 61 | }, 62 | }, 63 | Modal: { 64 | sizes: { 65 | lg: { 66 | dialog: { 67 | maxWidth: "70%", 68 | maxHeight: "85%", 69 | minHeight: "85%", 70 | backgroundColor: "#182130", 71 | }, 72 | body: { 73 | overflowY: "auto", 74 | }, 75 | }, 76 | md: { 77 | dialog: { 78 | minWidth: "40%", 79 | minHeight: "50%", 80 | backgroundColor: "#182130", 81 | }, 82 | body: { 83 | overflowY: "auto", 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }); -------------------------------------------------------------------------------- /client/src/assets/empty.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/middleware/middleware.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import mongoose from "mongoose"; 3 | import User from '../models/user.model.js'; 4 | import { getUserRole } from "../util/utils.js"; 5 | 6 | export const authMiddleware = async (req, res, next) => { 7 | const token = req.headers['x-access-token']; 8 | if (token) { 9 | jwt.verify(token, process.env.SECRET_KEY, (error, decoded) => { 10 | if (error) { 11 | return res.status(500).json({ error }); 12 | } 13 | //Check if the user exists 14 | User.findOne({ _id: decoded.id }).then((user) => { 15 | if (user) { 16 | req.user = user.toJSON(); 17 | return next(); 18 | } 19 | }).catch((error) => { 20 | return res.status(403).json({ message: "User not found" }); 21 | }); 22 | 23 | }); 24 | } 25 | else { 26 | console.log("authMiddleware issue"); 27 | return res.sendStatus(403); 28 | } 29 | }; 30 | 31 | 32 | export const handleError = async (error, req, res, next) => { 33 | const statusCode = res.statusCode !== 200 ? res.statusCode : 500; 34 | res.status(statusCode); 35 | res.json({ 36 | message: error.message 37 | }); 38 | }; 39 | 40 | 41 | export const routeNotFound = async (req, res) => { 42 | console.log("Route not found"); 43 | res.status(404).json({ message: "Something went wrong" }); 44 | }; 45 | 46 | 47 | export const validateResource = (resourceSchema) => { 48 | return (req, res, next) => { 49 | resourceSchema.validate(req.body) 50 | .then((valid) => { 51 | next(); 52 | }) 53 | .catch((err) => { 54 | res.status(400).json({ message: err.errors[0] }); 55 | }); 56 | }; 57 | }; 58 | 59 | export const validateParamId = (paramId) => { 60 | return (req, res, next) => { 61 | if (!mongoose.Types.ObjectId.isValid(req.params[paramId])) { 62 | return res.status(404).json({ message: `Invalid ${paramId}` }); 63 | } 64 | 65 | next(); 66 | }; 67 | }; 68 | 69 | export const checkUserPermissions = (objectName, permissionCheck) => async (req, res, next) => { 70 | const roleId = req.user.roleId; 71 | const roleObject = await getUserRole(roleId); 72 | 73 | const isPermitted = permissionCheck(roleObject.permissions); 74 | 75 | if (isPermitted) { 76 | next(); 77 | } 78 | else { 79 | return res.status(403).json({ message: `Not permitted to create/modify ${objectName}` }); 80 | } 81 | } 82 | 83 | -------------------------------------------------------------------------------- /server/controllers/ticketType.controller.js: -------------------------------------------------------------------------------- 1 | import TicketType from "../models/ticketType.model.js"; 2 | import Ticket from "../models/ticket.model.js"; 3 | 4 | export const getTicketType = async (req, res) => { 5 | try { 6 | 7 | const ticketType = await TicketType.find({}); 8 | 9 | return res.json(ticketType); 10 | } catch (error) { 11 | console.log(error); 12 | return res.status(500).json({ message: "Internal server issue" }); 13 | } 14 | }; 15 | 16 | export const addTicketType = async (req, res) => { 17 | const { name, iconName, colour } = req.body; 18 | 19 | try { 20 | //ensure no duplication 21 | const existingTicketType = await TicketType.findOne({ name }); 22 | 23 | if (existingTicketType) { 24 | return res.status(403).json({ message: "Ticket type already exist with name as " + name }); 25 | } 26 | 27 | const ticketType = await TicketType.create({ name, iconName, colour }); 28 | 29 | return res.json(ticketType); 30 | } catch (error) { 31 | console.log(error); 32 | return res.status(500).json({ message: "Internal server issue" }); 33 | } 34 | }; 35 | 36 | export const updateTicketType = async (req, res) => { 37 | const { _id, name, iconName, colour } = req.body; 38 | 39 | try { 40 | const ticketType = await TicketType.findOne({ _id }); 41 | 42 | if (!ticketType) { 43 | return res.status(403).json({ message: "Ticket not found" }); 44 | } 45 | 46 | ticketType.name = name; 47 | ticketType.iconName = iconName; 48 | ticketType.colour = colour; 49 | 50 | const updatedTicketType = await ticketType.save(); 51 | 52 | return res.json(updatedTicketType); 53 | 54 | } catch (error) { 55 | console.log(error); 56 | return res.status(500).json({ message: "Internal server issue" }); 57 | } 58 | }; 59 | 60 | export const deleteTicketType = async (req, res) => { 61 | const { ticketTypeId } = req.params; 62 | 63 | try { 64 | const ticketType = await TicketType.findById(ticketTypeId); 65 | 66 | if (!ticketType) { 67 | return res.status(403).json({ message: "Ticket type not found" }); 68 | } 69 | 70 | const totalTicketsWithThisTicketType = await Ticket.find({ type: ticketTypeId }).count(); 71 | 72 | if (totalTicketsWithThisTicketType > 0) { 73 | return res.status(405).json({ message: `Forbidden: ${totalTicketsWithThisTicketType} ticket(s) is associated with ticket type "${ticketType.name}"` }); 74 | } 75 | 76 | await TicketType.deleteOne({ _id: ticketTypeId }); 77 | 78 | return res.sendStatus(200); 79 | } catch (error) { 80 | console.log(error); 81 | return res.status(500).json({ message: "Internal server issue" }); 82 | } 83 | }; -------------------------------------------------------------------------------- /client/src/components/others/Table.jsx: -------------------------------------------------------------------------------- 1 | import ReactDataGrid from "@inovua/reactdatagrid-community"; 2 | import { useEffect, useState } from "react"; 3 | import React from "react"; 4 | import { getFieldValue } from "@/util/GetObjectProperty"; 5 | import SearchBar from "./SearchBar"; 6 | 7 | const Table = ({ 8 | tableData = [], 9 | columns, 10 | searchPlaceholder, 11 | onRowClick, 12 | defaultSortInfo, 13 | hasCheckboxColumn = false, 14 | sortable = true, 15 | selectedRowIds, 16 | onSelectionChange, 17 | height = 400, 18 | rowHeight = 45, 19 | disableCheckBox = false, 20 | isLoading = false, 21 | }) => { 22 | const [dataSource, setDataSource] = useState([]); 23 | const [selectedRow, setSelectedRow] = useState({}); 24 | const [dataFields, setDataFields] = useState([]); 25 | const gridStyle = { minHeight: height }; 26 | 27 | const getDataSourceFields = () => { 28 | const result = []; 29 | 30 | columns.forEach((column) => { 31 | if (column.searchInField) { 32 | result.push(...column.searchInField); 33 | } 34 | }); 35 | 36 | setDataFields(result); 37 | }; 38 | 39 | const handleSearchInputChange = ({ target: { value } }) => { 40 | const lowerSearchText = value.toLowerCase(); 41 | 42 | const newData = tableData.filter((data) => { 43 | return dataFields.some((key) => { 44 | const value = getFieldValue(data, key); 45 | return value.toLowerCase().includes(lowerSearchText); 46 | }); 47 | }); 48 | 49 | setDataSource(newData); 50 | }; 51 | 52 | useEffect(() => { 53 | getDataSourceFields(); 54 | }, []); 55 | 56 | useEffect(() => { 57 | if (tableData) { 58 | setDataSource(tableData); 59 | } 60 | }, [tableData]); 61 | 62 | useEffect(() => { 63 | if (selectedRowIds) { 64 | const selectedRowData = {}; 65 | 66 | selectedRowIds.forEach((id) => (selectedRowData[id] = true)); 67 | 68 | setSelectedRow(selectedRowData); 69 | } 70 | }, [selectedRowIds]); 71 | 72 | return ( 73 | <> 74 | 78 | 79 | 97 | 98 | ); 99 | }; 100 | 101 | export default Table; 102 | -------------------------------------------------------------------------------- /client/src/util/ValidationSchemas.js: -------------------------------------------------------------------------------- 1 | import * as Yup from "yup"; 2 | 3 | export const SignupSchema = Yup.object().shape({ 4 | firstName: Yup.string().required("Required"), 5 | lastName: Yup.string().required("Required"), 6 | email: Yup.string().email("Invalid email").required("Required"), 7 | password: Yup.string() 8 | .min(6, "Must be minimum 6 characters long") 9 | .required("Required"), 10 | confirmPassword: Yup.string().oneOf( 11 | [Yup.ref("password"), null], 12 | "Passwords must match", 13 | ), 14 | roleId: Yup.string().required("Role is required") 15 | }); 16 | 17 | export const SignUpData = { 18 | firstName: "", 19 | lastName: "", 20 | email: "", 21 | password: "", 22 | confirmPassword: "", 23 | roleId: "" 24 | }; 25 | 26 | 27 | export const LoginSchema = Yup.object().shape({ 28 | email: Yup.string().email("Invalid email"), 29 | password: Yup.string().required("Required") 30 | }); 31 | 32 | export const LoginData = { 33 | email: "", 34 | password: "", 35 | }; 36 | 37 | export const ManageUserSchema = Yup.object().shape({ 38 | firstName: Yup.string().required("Required"), 39 | lastName: Yup.string().required("Required"), 40 | email: Yup.string().email("Invalid email").required("Required") 41 | }); 42 | 43 | 44 | export const CreateProjectSchema = Yup.object().shape({ 45 | title: Yup.string().required("Required"), 46 | }); 47 | 48 | export const CreateProjectData = { 49 | title: "", 50 | description: "", 51 | assignees: [] 52 | }; 53 | 54 | export const CreateTicketSchema = Yup.object().shape({ 55 | title: Yup.string().required("Required"), 56 | status: Yup.string().required("Required"), 57 | type: Yup.string().required("Required"), 58 | estimatedTime: Yup.number().required("Required"), 59 | estimatedTimeUnit: Yup.string().required("Required") 60 | }); 61 | 62 | export const CreateTicketData = { 63 | title: "", 64 | type: "", 65 | description: "", 66 | status: "", 67 | estimatedTime: 0, 68 | estimatedTimeUnit: "", 69 | assignees: [] 70 | }; 71 | 72 | export const CreateRoleSchema = Yup.object().shape({ 73 | name: Yup.string().min(1, "Cannot be empty").required("Required"), 74 | permissions: Yup.array() 75 | }); 76 | 77 | export const CreateRoleData = { 78 | name: "", 79 | permissions: [] 80 | }; 81 | 82 | export const CreateTicketTypeSchema = Yup.object().shape({ 83 | name: Yup.string().required("Required"), 84 | iconName: Yup.string(), 85 | colour: Yup.string().required("Required"), 86 | }); 87 | 88 | export const CreateTicketTypeData = { 89 | name: "", 90 | iconName: "", 91 | colour: "#000000", 92 | }; 93 | 94 | export const CreateCommentSchema = Yup.object().shape({ 95 | text: Yup.string().trim().min(1, "Cannot be empty") 96 | }); 97 | 98 | export const CreateCommentData = { 99 | text: "" 100 | }; -------------------------------------------------------------------------------- /client/src/components/authentication/Login.jsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { 3 | Alert, 4 | Box, 5 | Button, 6 | FormControl, 7 | FormErrorMessage, 8 | FormLabel, 9 | Input, 10 | } from "@chakra-ui/react"; 11 | import { Field, Form, Formik } from "formik"; 12 | import { useEffect, useState } from "react"; 13 | import AuthService from "@/services/auth-service"; 14 | import MiscellaneousService from "@/services/miscellaneous-service"; 15 | import useApi from "@/hooks/useApi"; 16 | import useAuthStore from "@/hooks/useAuth"; 17 | import { LoginData, LoginSchema } from "@/util/ValidationSchemas"; 18 | 19 | export const Login = () => { 20 | const [error, seterror] = useState(""); 21 | const [isLogging, setisLogging] = useState(false); 22 | const router = useRouter(); 23 | const loginSWR = useApi(null); 24 | const authStore = useAuthStore(); 25 | 26 | useEffect(() => { 27 | if (loginSWR.data) { 28 | authStore.setAccessToken(loginSWR.data.accessToken); 29 | authStore.setUserProfile(loginSWR.data.userProfile); 30 | router.reload(); 31 | } 32 | }, [loginSWR.data]); 33 | 34 | const onHandleFormSubmit = async (values) => { 35 | seterror(""); 36 | setisLogging(true); 37 | 38 | try { 39 | await loginSWR.mutateServer(AuthService.login(values)); 40 | } catch (error) { 41 | seterror(error); 42 | } 43 | setisLogging(false); 44 | }; 45 | 46 | return ( 47 | 56 | 61 | {({ errors, touched }) => ( 62 |
63 | {error && ( 64 | 65 | {error} 66 | 67 | )} 68 | 69 | 70 | Email 71 | 72 | {errors.email} 73 | 74 | 75 | 76 | Password 77 | 78 | {errors.password} 79 | 80 | 81 | 91 | 92 | )} 93 |
94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /server/controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import User from "../models/user.model.js"; 3 | import bcrypt from 'bcrypt'; 4 | 5 | export const getAllUsers = async (req, res) => { 6 | const excludeUser = req.query.excludeUser; 7 | 8 | try { 9 | let findCondition = {}; 10 | 11 | if (excludeUser === "true") { 12 | const userId = req.user._id; 13 | findCondition = { _id: { $ne: userId } }; 14 | } 15 | 16 | const users = await User.find(findCondition, { _id: 1, firstName: 1, lastName: 1, email: 1, roleId: 1 }) 17 | .populate({ path: "roleId", select: { name: 1 } }); 18 | return res.json(users); 19 | } catch (error) { 20 | console.log(error); 21 | return res.status(500).json({ message: "Internal server issue" }); 22 | } 23 | }; 24 | 25 | export const updateUser = async (req, res) => { 26 | const userData = req.body; 27 | 28 | try { 29 | //ensure email is not a duplicate 30 | let existingUser = await User.findOne({ email: userData.email, _id: { $ne: userData._id } }); 31 | 32 | if (existingUser) { 33 | return res.status(400).json({ message: "Email already exists, please try again" }); 34 | } 35 | 36 | let user = await User.findOne({ _id: userData._id }); 37 | 38 | user.firstName = userData.firstName; 39 | user.lastName = userData.lastName; 40 | user.email = userData.email; 41 | user.roleId = userData.roleId; 42 | 43 | if (userData.password) { 44 | //Hash the password 45 | const hashedPassword = await bcrypt.hash(userData.password, +process.env.PASSWORD_SALT); 46 | user.password = hashedPassword; 47 | } 48 | 49 | let updatedUser = await user.save({ new: true }); 50 | updatedUser = await updatedUser.populate([{ path: "roleId" }]); 51 | 52 | updatedUser = updatedUser.toJSON(); 53 | delete updatedUser.password; 54 | 55 | return res.json(updatedUser); 56 | 57 | } catch (error) { 58 | console.log(error); 59 | return res.status(500).json({ message: "Internal server issue" }); 60 | } 61 | }; 62 | 63 | export const createUser = async (req, res) => { 64 | const { firstName, lastName, email, password, confirmPassword, roleId } = req.body; 65 | 66 | try { 67 | //Search if user exists in database 68 | const existingUser = await User.findOne({ email }); 69 | 70 | if (existingUser) { 71 | return res.status(400).json({ message: "Email you've provided already exist" }); 72 | } 73 | 74 | if (password !== confirmPassword) { 75 | return res.status(400).json({ message: "Password don't match" }); 76 | } 77 | 78 | //Hash the password 79 | const hashedPassword = await bcrypt.hash(password, +process.env.PASSWORD_SALT); 80 | 81 | //Create user in database 82 | let newUser = await User.create({ firstName, lastName, email, password: hashedPassword, roleId: new mongoose.Types.ObjectId(roleId) }); 83 | newUser = await newUser.populate([{ path: "roleId", select: { name: 1 } }]); 84 | 85 | newUser = newUser.toJSON(); 86 | delete newUser.password; 87 | 88 | return res.json(newUser); 89 | 90 | } catch (error) { 91 | console.log(error); 92 | return res.status(500).json({ message: "Internal server issue" }); 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /client/src/components/tickets/CommentSection.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Center, 4 | Flex, 5 | FormControl, 6 | FormErrorMessage, 7 | IconButton, 8 | Input, 9 | InputGroup, 10 | InputRightElement, 11 | Text, 12 | } from "@chakra-ui/react"; 13 | import { Field, Form, Formik } from "formik"; 14 | import React, { useEffect, useState } from "react"; 15 | import { IoMdSend } from "react-icons/io"; 16 | import CommentService from "@/services/comment-service"; 17 | import useApi from "@/hooks/useApi"; 18 | import { Permissions } from "@/util/Utils"; 19 | import { 20 | CreateCommentData, 21 | CreateCommentSchema, 22 | } from "@/util/ValidationSchemas"; 23 | import PermissionsRender from "../others/PermissionsRender"; 24 | import Comment from "./Comment"; 25 | 26 | const CommentSection = ({ ticketId }) => { 27 | const [error, setError] = useState(""); 28 | const commentsSWR = useApi(CommentService.getTicketComments(ticketId)); 29 | 30 | const onComment = async (values, { resetForm }) => { 31 | try { 32 | await commentsSWR.mutateServer( 33 | CommentService.createTicketComment(ticketId, values) 34 | ); 35 | resetForm(); 36 | setError(""); 37 | } catch (error) { 38 | console.log(error); 39 | setError(error); 40 | } 41 | }; 42 | 43 | return ( 44 | 45 | 53 | {commentsSWR.data ? ( 54 | commentsSWR.data?.map((comment) => ( 55 | 61 | )) 62 | ) : ( 63 |
64 | No Comments 65 |
66 | )} 67 |
68 | {error && ( 69 | 70 | {error} 71 | 72 | )} 73 | 74 | 79 | {({ errors, touched }) => ( 80 |
81 | 82 | 83 | 90 | 91 | } 96 | type="submit" 97 | /> 98 | 99 | 100 | {errors.text} 101 | 102 | 103 | )} 104 |
105 |
106 |
107 | ); 108 | }; 109 | 110 | export default CommentSection; 111 | -------------------------------------------------------------------------------- /client/src/hooks/useApi.js: -------------------------------------------------------------------------------- 1 | import AuthService from "@/services/auth-service"; 2 | import axios from "axios"; 3 | import useSWR from "swr"; 4 | import useAuthStore from "./useAuth"; 5 | 6 | const API = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_ENDPOINT }); 7 | 8 | API.interceptors.request.use((req) => { 9 | const accessToken = useAuthStore.getState().accessToken; 10 | 11 | if (accessToken) 12 | req.headers["x-access-token"] = accessToken; 13 | 14 | return req; 15 | }); 16 | 17 | 18 | const useApi = (apiRequestInfo, shouldFetch = true, revalidateIfStale = true) => { 19 | 20 | let key = ""; 21 | let fetcher = () => null; 22 | 23 | if (apiRequestInfo) { 24 | key = apiRequestInfo.method + "-" + apiRequestInfo.url; //getApiRequestInfo is a function and name will be used as unique key 25 | fetcher = () => api(apiRequestInfo); 26 | } 27 | else { 28 | key = "api-request"; 29 | } 30 | 31 | 32 | const swr = useSWR(shouldFetch ? key : null, fetcher, { 33 | shouldRetryOnError: false, 34 | revalidateOnFocus: false, 35 | revalidateOnReconnect: false, 36 | revalidateIfStale 37 | }); 38 | 39 | const api = async (requestInfo) => { 40 | const res = await API(requestInfo); 41 | return res.data; 42 | }; 43 | 44 | const mutateServer = async (mutationApiRequestInfo) => { 45 | try { 46 | await swr.mutate(getMutation(mutationApiRequestInfo, swr.data), getMutationOptions(mutationApiRequestInfo, swr.data)); 47 | } catch (error) { 48 | console.error(error); 49 | throw error.response.data.message; 50 | } 51 | }; 52 | 53 | const getMutationOptions = (mutationApiRequestInfo, oldData) => { 54 | let optimisticData; 55 | const mutateData = mutationApiRequestInfo.data; 56 | const isArray = Array.isArray(oldData); 57 | 58 | switch (mutationApiRequestInfo.method) { 59 | case "post": 60 | optimisticData = isArray ? [mutateData, ...oldData] : mutateData; 61 | case "patch": 62 | optimisticData = isArray ? oldData.map(data => data._id === mutateData._id ? mutateData : data) : mutateData; 63 | case "delete": 64 | const splitUrl = mutationApiRequestInfo.url.split("/"); 65 | const id = splitUrl.pop(); 66 | optimisticData = isArray ? oldData.filter(data => data._id !== id) : mutateData; 67 | default: 68 | optimisticData = oldData; 69 | } 70 | 71 | return { 72 | optimisticData, 73 | populateCache: true, 74 | revalidate: false, 75 | rollbackOnError: true, 76 | }; 77 | }; 78 | 79 | const getMutation = async (mutationApiRequestInfo, oldData) => { 80 | const responseData = await api(mutationApiRequestInfo); 81 | const isArray = Array.isArray(oldData); 82 | 83 | switch (mutationApiRequestInfo.method) { 84 | case "post": 85 | return isArray ? [responseData, ...oldData] : responseData; 86 | case "patch": 87 | return isArray ? oldData.map(data => data._id === responseData._id ? responseData : data) : responseData; 88 | case "delete": 89 | const splitUrl = mutationApiRequestInfo.url.split("/"); 90 | const id = splitUrl.pop(); 91 | return isArray ? oldData.filter(data => data._id !== id) : oldData; 92 | default: 93 | return oldData; 94 | } 95 | 96 | }; 97 | 98 | return { ...swr, mutateServer }; 99 | }; 100 | 101 | export default useApi; -------------------------------------------------------------------------------- /server/tests/data.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | /* 4 | 0: Jame Smith - admin, 5 | 1: Michael Smith - project manager, 6 | 2: Robert Smith - developer, 7 | 3: Maria Garcia - submitter 8 | */ 9 | 10 | const _ids = []; 11 | 12 | for (let x = 0; x < 4; x++) { 13 | 14 | _ids.push(new mongoose.Types.ObjectId().toString()); 15 | } 16 | 17 | export const sampleUsers = [ 18 | { 19 | _id: _ids[0], 20 | firstName: "James", 21 | lastName: "Smith", 22 | email: "james.smith@bugtracker.com", 23 | password: "random" 24 | }, 25 | { 26 | _id: _ids[1], 27 | firstName: "Michael", 28 | lastName: "Smith", 29 | email: "michael.smith@bugtracker.com", 30 | password: "random" 31 | }, 32 | { 33 | _id: _ids[2], 34 | firstName: "Robert", 35 | lastName: "Smith", 36 | email: "robert.smith@bugtracker.com", 37 | password: "random" 38 | }, 39 | { 40 | _id: _ids[3], 41 | firstName: "Maria", 42 | lastName: "Garcia", 43 | email: "maria.garcia@bugtracker.com", 44 | password: "random" 45 | } 46 | ]; 47 | 48 | 49 | export const sampleRoles = [ 50 | { 51 | _id: new mongoose.Types.ObjectId().toString(), 52 | name: "admin", 53 | permissions: ["PERMISSION_ADD_TICKET", "PERMISSION_ADD_PROJECT", "PERMISSION_ADD_MEMBER_TO_PROJECT", "PERMISSION_ADD_COMMENT", "PERMISSION_MANAGE_ROLE", "PERMISSION_UPDATE_USER_PROFILE"] 54 | }, 55 | { 56 | _id: new mongoose.Types.ObjectId().toString(), 57 | name: "project manager", 58 | permissions: ["PERMISSION_ADD_TICKET", "PERMISSION_ADD_PROJECT", "PERMISSION_ADD_MEMBER_TO_PROJECT", "PERMISSION_ADD_COMMENT", "PERMISSION_MANAGE_ROLE"] 59 | }, 60 | { 61 | _id: new mongoose.Types.ObjectId().toString(), 62 | name: "developer", 63 | permissions: ["PERMISSION_ADD_TICKET", "PERMISSION_ADD_PROJECT", "PERMISSION_ADD_MEMBER_TO_PROJECT", "PERMISSION_ADD_COMMENT"] 64 | }, 65 | { 66 | _id: new mongoose.Types.ObjectId().toString(), 67 | name: "submitter", 68 | permissions: ["PERMISSION_ADD_TICKET", "PERMISSION_ADD_COMMENT"] 69 | }, 70 | ]; 71 | 72 | /* 73 | Project 1: James Smith 74 | Project 2: James Smith 75 | Project 3: Michael Smith 76 | Project 4: Michael Smith 77 | Project 5: Michael Smith 78 | Project 6: Robert Smith 79 | */ 80 | 81 | export const projectPayload = [ 82 | { 83 | _id: new mongoose.Types.ObjectId().toString(), 84 | title: "Sample project #1", 85 | description: "This is a sample project #1 description", 86 | authorId: _ids[0] 87 | }, 88 | { 89 | _id: new mongoose.Types.ObjectId().toString(), 90 | title: "Sample project #2", 91 | description: "This is a sample project #2 description", 92 | authorId: _ids[0] 93 | }, 94 | { 95 | _id: new mongoose.Types.ObjectId().toString(), 96 | title: "Sample project #3", 97 | description: "This is a sample project #3 description", 98 | authorId: _ids[1] 99 | }, 100 | { 101 | _id: new mongoose.Types.ObjectId().toString(), 102 | title: "Sample project #4", 103 | description: "This is a sample project #3 description", 104 | authorId: _ids[1] 105 | }, 106 | { 107 | _id: new mongoose.Types.ObjectId().toString(), 108 | title: "Sample project #5", 109 | description: "This is a sample project #3 description", 110 | authorId: _ids[1] 111 | }, 112 | { 113 | _id: new mongoose.Types.ObjectId().toString(), 114 | title: "Sample project #6", 115 | description: "This is a sample project #3 description", 116 | authorId: _ids[2] 117 | }, 118 | ]; -------------------------------------------------------------------------------- /client/src/components/tickets/Comment.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Avatar, 3 | Box, 4 | Button, 5 | Flex, 6 | IconButton, 7 | Input, 8 | Popover, 9 | PopoverBody, 10 | PopoverContent, 11 | PopoverTrigger, 12 | Spacer, 13 | Text, 14 | useDisclosure, 15 | } from "@chakra-ui/react"; 16 | import moment from "moment"; 17 | import React, { useState } from "react"; 18 | import { BsThreeDotsVertical } from "react-icons/bs"; 19 | import AuthService from "@/services/auth-service"; 20 | import CommentService from "@/services/comment-service"; 21 | import useAuthStore from "@/hooks/useAuth"; 22 | import { Permissions, getUserFullname } from "@/util/Utils"; 23 | import PermissionsRender from "../others/PermissionsRender"; 24 | 25 | const Comment = ({ mutateServer, commentData, setError }) => { 26 | const useAuth = useAuthStore(); 27 | const [isEditing, setisEditing] = useState(false); 28 | const [comment, setcomment] = useState(commentData.text); 29 | const { isOpen, onToggle, onClose } = useDisclosure(); 30 | 31 | const signedInUserId = useAuth.userProfile._id; 32 | const isMyComment = commentData.userId._id === signedInUserId; 33 | const isCommentEdited = commentData.createdOn !== commentData.updatedOn; 34 | 35 | const onCommentEditSaveClick = async () => { 36 | setisEditing((prev) => !prev); 37 | onClose(); 38 | 39 | if (isEditing) { 40 | try { 41 | await mutateServer( 42 | CommentService.updateTicketComment(commentData._id, { 43 | text: comment, 44 | }) 45 | ); 46 | onClose(); 47 | setError(""); 48 | } catch (error) { 49 | setError(error); 50 | } 51 | } 52 | }; 53 | 54 | const onCommentDeleteClick = async () => { 55 | try { 56 | onClose(); 57 | await mutateServer(CommentService.deleteTicketComment(commentData._id)); 58 | } catch (error) { 59 | setError(error); 60 | } 61 | }; 62 | 63 | const getCommentDateTime = () => { 64 | const now = moment(); 65 | const end = moment(commentData.updatedOn); 66 | const duration = moment.duration(now.diff(end)); 67 | const days = duration.asDays(); 68 | 69 | if (days >= 1) { 70 | return end.format("MMM D, YYYY hh:mm A"); 71 | } else { 72 | return end.fromNow(); 73 | } 74 | }; 75 | 76 | return ( 77 | 85 | 86 | 87 | 88 | 89 | {getUserFullname(commentData.userId)} 90 | 91 | 92 | {getCommentDateTime()} {isCommentEdited ? "(Edited)" : ""} 93 | 94 | 95 | {isEditing ? ( 96 | setcomment(e.target.value)} 100 | /> 101 | ) : ( 102 | {comment} 103 | )} 104 | 105 | 106 | 107 | 108 | {isMyComment ? ( 109 | 110 | 111 | } 116 | /> 117 | 118 | 119 | 120 | 121 | 124 | 127 | 128 | 129 | 130 | 131 | ) : null} 132 | 133 | 134 | ); 135 | }; 136 | 137 | export default Comment; 138 | -------------------------------------------------------------------------------- /server/tests/project.test.js: -------------------------------------------------------------------------------- 1 | import { generateAccessToken } from "./utils"; 2 | import request from './app.test.js'; 3 | import User from "../models/user.model"; 4 | import { projectPayload, sampleUsers } from "./data"; 5 | import Project from "../models/project.model"; 6 | 7 | const getUser = async (email) => { 8 | return await User.findOne({ email }); 9 | }; 10 | 11 | describe("Project", () => { 12 | 13 | describe("create project", () => { 14 | 15 | it("given user is not logged in --> should return a 403", async () => { 16 | const response = await request.post("/project").send(projectPayload[0]); 17 | 18 | expect(response.statusCode).toBe(403); 19 | }); 20 | 21 | 22 | it("given invalid payload --> should return 403", async () => { 23 | const user = sampleUsers[0]; 24 | const token = generateAccessToken(user.email, user._id); 25 | 26 | const response = await request.post("/project") 27 | .set("x-access-token", token) 28 | .send({}); 29 | 30 | expect(response.statusCode).toEqual(400); 31 | expect(response.body.error).toEqual("project title required"); 32 | }); 33 | }); 34 | 35 | describe("add project assignee", () => { 36 | 37 | it("given no projectId --> should return 404", async () => { 38 | const user = sampleUsers[0]; 39 | const token = generateAccessToken(user.email, user._id); 40 | 41 | const response = await request.post("/project/addAssignee") 42 | .set("x-access-token", token) 43 | .send({ 44 | assigneeId: user._id 45 | }); 46 | 47 | expect(response.statusCode).toEqual(404); 48 | }); 49 | 50 | it("given projectId --> should return 200", async () => { 51 | const user = sampleUsers[0]; 52 | const token = generateAccessToken(user.email, user._id); 53 | const project1 = projectPayload[0]._id; 54 | 55 | const response = await request.post(`/project/addAssignee/${project1}`) 56 | .set("x-access-token", token) 57 | .send({ 58 | assigneeId: sampleUsers[1]._id 59 | }); 60 | 61 | expect(response.statusCode).toEqual(200); 62 | }); 63 | 64 | it("given user is not the project author --> should return 403", async () => { 65 | const user = sampleUsers[1]; 66 | const token = generateAccessToken(user.email, user._id); 67 | const project1 = projectPayload[0]._id; 68 | 69 | const response = await request.post(`/project/addAssignee/${project1}`) 70 | .set("x-access-token", token) 71 | .send({ 72 | assigneeId: user._id 73 | }); 74 | 75 | expect(response.statusCode).toEqual(403); 76 | }); 77 | }); 78 | 79 | describe("remove project assignee", () => { 80 | it("given projectId --> should return 200", async () => { 81 | const user = sampleUsers[0]; 82 | const token = generateAccessToken(user.email, user._id); 83 | const project1 = projectPayload[0]._id; 84 | 85 | const response = await request.delete(`/project/removeAssignee/${project1}`) 86 | .set("x-access-token", token) 87 | .send({ 88 | assigneeId: sampleUsers[1]._id 89 | }); 90 | 91 | expect(response.statusCode).toEqual(200); 92 | }); 93 | }); 94 | 95 | describe("update project", () => { 96 | it("given projectId --> should return 200", async () => { 97 | const user = sampleUsers[0]; 98 | const token = generateAccessToken(user.email, user._id); 99 | const project1 = projectPayload[0]._id; 100 | 101 | const response = await request.patch(`/project/${project1}`) 102 | .set("x-access-token", token) 103 | .send({ 104 | title: "Change project title #1", 105 | description: "Sample project #1 description" 106 | }); 107 | 108 | expect(response.statusCode).toEqual(200); 109 | 110 | //Verify 111 | const project = await Project.findOne({ _id: project1 }); 112 | 113 | expect(project.title).toEqual("Change project title #1"); 114 | expect(project.description).toEqual("Sample project #1 description"); 115 | }); 116 | }); 117 | }); -------------------------------------------------------------------------------- /client/src/components/projects/ViewProject.jsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import PageNotFound from "@/pages/404"; 5 | import { ArrowBackIcon } from "@chakra-ui/icons"; 6 | import { 7 | Button, 8 | Flex, 9 | Heading, 10 | IconButton, 11 | Spacer, 12 | Tab, 13 | TabList, 14 | TabPanel, 15 | TabPanels, 16 | Tabs, 17 | useDisclosure, 18 | } from "@chakra-ui/react"; 19 | import React, { useState } from "react"; 20 | import PermissionsRender from "@/components/others/PermissionsRender"; 21 | import Table from "@/components/others/Table"; 22 | import Dashboard from "@/components/projects/Dashboard"; 23 | import ProjectService from "@/services/project-service"; 24 | import TicketService from "@/services/ticket-service"; 25 | import useApi from "@/hooks/useApi"; 26 | import { TICKETS_COLUMNS, TICKETS_DEFAULT_SORT } from "@/util/TableDataDisplay"; 27 | import { Permissions, apifetch } from "@/util/Utils"; 28 | import Loading from "../others/Loading"; 29 | import CreateTicket from "../tickets/CreateTicket"; 30 | import AddProject from "./AddProject"; 31 | 32 | const ViewProject = ({ projectId }) => { 33 | const { isOpen, onOpen, onClose } = useDisclosure(); 34 | const projectInfoDiscloure = useDisclosure(); 35 | 36 | const router = useRouter(); 37 | 38 | const projectTicketsSWR = useApi(TicketService.getProjectTickets(projectId)); 39 | const projectInfoSWR = useApi(ProjectService.getProjectInfo(projectId)); 40 | 41 | const [viewTicket, setViewTicket] = useState(null); 42 | 43 | const onModalClose = () => { 44 | setViewTicket(null); 45 | onClose(); 46 | }; 47 | 48 | const onTicketClick = (rowProps, _) => { 49 | setViewTicket(rowProps.data); 50 | onOpen(); 51 | }; 52 | 53 | const navigateBack = () => { 54 | router.replace("/projects"); 55 | }; 56 | 57 | if (projectInfoSWR.error?.response.status) { 58 | return ; 59 | } 60 | 61 | if (projectInfoSWR.isLoading || projectTicketsSWR.isLoading) { 62 | return ; 63 | } 64 | 65 | return ( 66 | 67 | 68 | {projectInfoSWR.data?.title || "Projects"} 69 | 70 | 71 | 72 | } 74 | variant="link" 75 | size="lg" 76 | colorScheme="black" 77 | onClick={navigateBack} 78 | /> 79 | {projectInfoSWR.data?.title} 80 | 81 | 82 | 83 | 84 | 85 | 88 | 89 | 90 | 96 | 97 | 98 | 99 | 100 | Tickets 101 | Overview 102 | 103 | 104 | 105 | 106 |
114 | 115 | 116 | {projectId ? : null} 117 | 118 | 119 | 120 |
121 | 122 | {projectInfoSWR.data ? ( 123 | 130 | ) : null} 131 | 132 | 138 | 139 | ); 140 | }; 141 | 142 | export default ViewProject; 143 | -------------------------------------------------------------------------------- /client/src/components/authentication/SignUp.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Box, 4 | Button, 5 | Flex, 6 | FormControl, 7 | FormErrorMessage, 8 | FormLabel, 9 | Input, 10 | InputGroup, 11 | InputRightElement, 12 | useToast, 13 | } from "@chakra-ui/react"; 14 | import { Field, Form, Formik } from "formik"; 15 | import React, { useState } from "react"; 16 | import AuthService from "@/services/auth-service"; 17 | import { SignUpData, SignupSchema } from "@/util/ValidationSchemas"; 18 | 19 | export const SignUp = () => { 20 | const toast = useToast(); 21 | const [showPassword, setShowPassword] = useState(false); 22 | const [error, seterror] = useState(""); 23 | const handleClick = () => setShowPassword((prevState) => !prevState); 24 | 25 | const onHandleFormSubmit = (values, action) => { 26 | seterror(""); 27 | 28 | AuthService.signup(values) 29 | .then((result) => { 30 | toast({ 31 | title: "Account created", 32 | description: "We've created your account for you", 33 | status: "success", 34 | duration: 9000, 35 | isClosable: true, 36 | }); 37 | action.resetForm(); 38 | }) 39 | .catch((error) => { 40 | seterror(error.response.data.message); 41 | }); 42 | }; 43 | 44 | return ( 45 | 54 | 59 | {({ errors, touched }) => ( 60 |
61 | {error && ( 62 | 63 | {error} 64 | 65 | )} 66 | 67 | 68 | First Name 69 | 70 | {errors.firstName} 71 | 72 | 73 | 74 | Last Name 75 | 76 | {errors.lastName} 77 | 78 | 79 | 80 | 81 | Email 82 | 83 | {errors.email} 84 | 85 | 86 | 87 | Password 88 | 94 | {errors.password} 95 | 96 | 97 | 101 | Confirm Password 102 | 103 | 109 | 110 | 113 | 114 | 115 | {errors.confirmPassword} 116 | 117 | 118 | 121 | 122 | )} 123 |
124 |
125 | ); 126 | }; 127 | -------------------------------------------------------------------------------- /client/src/components/others/navigationBar/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { useRouter } from "next/router"; 3 | import { 4 | Avatar, 5 | Divider, 6 | Flex, 7 | Heading, 8 | Menu, 9 | MenuButton, 10 | MenuItem, 11 | MenuList, 12 | Text, 13 | useDisclosure, 14 | } from "@chakra-ui/react"; 15 | import React, { useEffect, useState } from "react"; 16 | import { FiFileText, FiLayers, FiUser } from "react-icons/fi"; 17 | import AuthService from "@/services/auth-service"; 18 | import useApi from "@/hooks/useApi"; 19 | import useAuthStore from "@/hooks/useAuth"; 20 | import { usePermissions } from "@/hooks/usePermissions"; 21 | import { Permissions, getUserFullname } from "@/util/Utils"; 22 | import logo from "@/assets/Trackit_Plain.png"; 23 | import UpdateUser from "../../administration/UpdateUser"; 24 | import NavItem from "./NavItem"; 25 | 26 | const Navbar = () => { 27 | const [navSize, setNavSize] = useState("large"); 28 | const router = useRouter(); 29 | const { isOpen, onOpen, onClose } = useDisclosure(); 30 | const canManageAdminPage = usePermissions(Permissions.canManageAdminPage); 31 | const useAuth = useAuthStore(); 32 | 33 | const myUserProfileSWR = useApi(null); 34 | 35 | useEffect(() => { 36 | if (myUserProfileSWR.data) { 37 | useAuth.setUserProfile(myUserProfileSWR.data); 38 | } 39 | }, [myUserProfileSWR.data]); 40 | 41 | const menuItems = [ 42 | { 43 | path: "/projects", 44 | name: "Projects", 45 | icon: FiLayers, 46 | }, 47 | { 48 | path: "/tickets", 49 | name: "Tickets", 50 | icon: FiFileText, 51 | }, 52 | { 53 | path: "/administration", 54 | name: "Administration", 55 | icon: FiUser, 56 | }, 57 | ]; 58 | 59 | const onProfileClick = () => { 60 | onOpen(); 61 | }; 62 | 63 | const onLogout = () => { 64 | useAuth.clear(); 65 | router.reload(); 66 | }; 67 | 68 | return ( 69 | <> 70 | 79 | 85 | 86 | 87 | {menuItems.map((item, index) => { 88 | if (item.name === "Administration" && !canManageAdminPage) { 89 | return ; 90 | } 91 | 92 | return ( 93 | 104 | ); 105 | })} 106 | 107 | 108 | 109 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | {useAuth.userProfile.firstName}{" "} 121 | {useAuth.userProfile.lastName} 122 | 123 | 124 | {useAuth.userProfile.roleId.name || "No Data"} 125 | 126 | 127 | 128 | 129 | 130 | Profile 131 | Logout 132 | 133 | 134 | 135 | 136 | 137 | 144 | 145 | ); 146 | }; 147 | 148 | export default Navbar; 149 | -------------------------------------------------------------------------------- /client/src/components/tickets/TicketInfo.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | AlertIcon, 4 | Flex, 5 | FormControl, 6 | FormErrorMessage, 7 | FormLabel, 8 | Input, 9 | Select, 10 | Text, 11 | } from "@chakra-ui/react"; 12 | import { Field, Form, Formik } from "formik"; 13 | import moment from "moment/moment"; 14 | import React from "react"; 15 | import MiscellaneousService from "@/services/miscellaneous-service"; 16 | import useApi from "@/hooks/useApi"; 17 | import { usePermissions } from "@/hooks/usePermissions"; 18 | import { 19 | Permissions, 20 | createTicketStatusSelectOptions, 21 | createTicketTypeSelectOptions, 22 | } from "@/util/Utils"; 23 | import { CreateTicketSchema } from "@/util/ValidationSchemas"; 24 | import RichTextEditor from "../editor/RichTextEditor"; 25 | 26 | const TicketInfo = ({ 27 | ticketInfo, 28 | onHandleFormSubmit, 29 | formRef, 30 | ticketDescription, 31 | setTicketDescription, 32 | }) => { 33 | const canManageTickets = usePermissions(Permissions.canManageTickets); 34 | const ticketTypesSWR = useApi(MiscellaneousService.getAllTicketType()); 35 | 36 | return ( 37 | 44 | {({ errors, touched, handleChange }) => ( 45 |
46 | 47 | 48 | 49 | Title 50 | 57 | {errors.title} 58 | 59 | 60 | 61 | Description 62 | 67 | 68 | 69 | 70 | 71 | 72 | Type 73 | 80 | 83 | {createTicketTypeSelectOptions(ticketTypesSWR.data || [])} 84 | 85 | {errors.type} 86 | 87 | 88 | Status 89 | 96 | 99 | {createTicketStatusSelectOptions()} 100 | 101 | {errors.status} 102 | 103 | 104 | 107 | Estimated time 108 | 115 | {errors.estimatedTime} 116 | 117 | 118 | 123 | Estimated Time Unit 124 | 131 | 134 | 135 | 136 | 137 | 138 | {errors.estimatedTimeUnit} 139 | 140 | 141 | 142 | 143 | 144 | 145 | {ticketInfo.createdOn 146 | ? "Created " + moment(ticketInfo.createdOn).fromNow() 147 | : ""} 148 | 149 | 150 | {ticketInfo.updatedOn 151 | ? "Updated " + moment(ticketInfo.updatedOn).fromNow() 152 | : ""} 153 | 154 | 155 | 156 | 157 | 158 | )} 159 |
160 | ); 161 | }; 162 | 163 | export default TicketInfo; 164 | -------------------------------------------------------------------------------- /client/src/components/projects/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Center, 4 | Flex, 5 | Heading, 6 | Spinner, 7 | useColorModeValue, 8 | } from "@chakra-ui/react"; 9 | import { 10 | ArcElement, 11 | Chart as ChartJS, 12 | Colors, 13 | Legend, 14 | Tooltip, 15 | } from "chart.js"; 16 | import React, { useEffect, useState } from "react"; 17 | import { Pie } from "react-chartjs-2"; 18 | import { 19 | BsFillFileTextFill, 20 | BsPersonCheckFill, 21 | BsPersonFill, 22 | BsQuestionLg, 23 | } from "react-icons/bs"; 24 | import ProjectService from "@/services/project-service"; 25 | import useApi from "@/hooks/useApi"; 26 | import { hexToRgb } from "@/util/Utils"; 27 | import StatCard from "../others/StatCard"; 28 | 29 | ChartJS.register(ArcElement, Tooltip, Legend, Colors); 30 | 31 | const Dashboard = ({ projectId }) => { 32 | const [projectStats, setProjectStats] = useState([]); 33 | const [ticketTypeChartData, setTicketTypeChartData] = useState(null); 34 | const [ticketStatusChartData, setTicketStatusChartData] = useState(null); 35 | const iconColor = useColorModeValue("white", "white"); 36 | const projectStatsSWR = useApi(ProjectService.getProjectStats(projectId)); 37 | 38 | const iconBackgroundColor = [ 39 | "purple.300", 40 | "green.300", 41 | "red.300", 42 | "blue.300", 43 | ]; 44 | 45 | const createStatInfo = (stat) => { 46 | return [ 47 | { 48 | name: "Total Tickets", 49 | icon: BsFillFileTextFill, 50 | iconBackground: iconBackgroundColor[0], 51 | iconColor, 52 | value: stat.ticketCount, 53 | }, 54 | { 55 | name: "My Tickets", 56 | icon: BsPersonFill, 57 | iconBackground: iconBackgroundColor[1], 58 | iconColor, 59 | value: stat.myTicketCount, 60 | }, 61 | { 62 | name: "Unassigned Tickets", 63 | icon: BsQuestionLg, 64 | iconBackground: iconBackgroundColor[2], 65 | iconColor, 66 | value: stat.unassignedTicketCount, 67 | }, 68 | { 69 | name: "Assigned Tickets", 70 | icon: BsPersonCheckFill, 71 | iconBackground: iconBackgroundColor[3], 72 | iconColor, 73 | value: stat.assignedTicketCount, 74 | }, 75 | ]; 76 | }; 77 | 78 | const createTicketTypeChartData = (stat) => { 79 | const data = { 80 | labels: [], 81 | datasets: [ 82 | { 83 | label: "Ticket Type", 84 | data: [], 85 | backgroundColor: [], 86 | borderColor: [], 87 | }, 88 | ], 89 | }; 90 | 91 | stat.ticketTypeCount.forEach((ticketTypeCountStat) => { 92 | data.datasets[0].data.push(ticketTypeCountStat.value); 93 | 94 | const ticketTypeInfo = ticketTypeCountStat.ticketTypeInfo; 95 | const backgroundColour = hexToRgb(ticketTypeInfo.colour, 1); 96 | 97 | data.labels.push(ticketTypeInfo.name); 98 | data.datasets[0].backgroundColor.push(backgroundColour); 99 | data.datasets[0].borderColor.push("rgba(255,255,255,1)"); 100 | }); 101 | 102 | return data; 103 | }; 104 | 105 | const createTicketStatusChartData = (stat) => { 106 | const data = { 107 | labels: [], 108 | datasets: [ 109 | { 110 | label: "Ticket Status", 111 | data: [], 112 | backgroundColor: [], 113 | borderColor: [], 114 | }, 115 | ], 116 | }; 117 | 118 | stat.ticketStatusCount.forEach((ticketStatus, index) => { 119 | data.datasets[0].data.push(ticketStatus.value); 120 | data.labels.push(ticketStatus._id); 121 | 122 | let backgroundColour = ""; 123 | 124 | switch (ticketStatus._id) { 125 | case "Open": 126 | backgroundColour = hexToRgb("#FBD38D", 1); 127 | break; 128 | case "In-Progress": 129 | backgroundColour = hexToRgb("#90CDF4", 1); 130 | break; 131 | case "Done": 132 | backgroundColour = hexToRgb("#68D391", 1); 133 | break; 134 | case "Archived": 135 | backgroundColour = hexToRgb("#E2E8F0", 1); 136 | break; 137 | default: 138 | backgroundColour = hexToRgb("#FBD38D", 1); 139 | break; 140 | } 141 | 142 | data.datasets[0].backgroundColor.push(backgroundColour); 143 | data.datasets[0].borderColor.push("rgba(255,255,255,1)"); 144 | }); 145 | 146 | return data; 147 | }; 148 | 149 | useEffect(() => { 150 | if (projectStatsSWR.data) { 151 | setProjectStats(createStatInfo(projectStatsSWR.data)); 152 | setTicketTypeChartData(createTicketTypeChartData(projectStatsSWR.data)); 153 | setTicketStatusChartData( 154 | createTicketStatusChartData(projectStatsSWR.data) 155 | ); 156 | } 157 | }, [projectStatsSWR.data]); 158 | 159 | if (projectStatsSWR.isLoading) { 160 | return ( 161 |
162 | 163 |
164 | ); 165 | } 166 | return ( 167 | 168 | 169 | {projectStats.map((stat, index) => ( 170 | 171 | ))} 172 | 173 |
174 | 175 | 176 | {ticketTypeChartData ? ( 177 | 178 | 179 | Ticket Type 180 | 181 | 185 | 186 | ) : null} 187 | 188 | {ticketStatusChartData ? ( 189 | 190 | 191 | Ticket Status 192 | 193 | 197 | 198 | ) : null} 199 | 200 |
201 | ); 202 | }; 203 | 204 | export default Dashboard; 205 | -------------------------------------------------------------------------------- /server/controllers/ticket.controller.js: -------------------------------------------------------------------------------- 1 | import Project from "../models/project.model.js"; 2 | import Ticket from "../models/ticket.model.js"; 3 | import { validateObjectId } from "../util/utils.js"; 4 | 5 | export const getUserTickets = async (req, res) => { 6 | 7 | const { userId } = req.params; 8 | 9 | try { 10 | 11 | const tickets = await Ticket.find({ assignees: userId }) 12 | .populate({ path: "projectId", select: { title: 1 } }) 13 | .populate({ path: "type", select: { __v: 0 } }) 14 | .populate({ path: "createdBy", select: { firstName: 1, lastName: 1 } }) 15 | .populate({ path: "assignees", select: { firstName: 1, lastName: 1 } }); 16 | 17 | return res.json(tickets); 18 | 19 | } catch (error) { 20 | console.log(error); 21 | return res.status(500).json({ message: "Internal server issue" }); 22 | } 23 | 24 | }; 25 | 26 | export const getProjectTickets = async (req, res) => { 27 | const { projectId } = req.params; 28 | 29 | try { 30 | const userId = req.user._id; 31 | 32 | // Ensure the user belongs to the project 33 | const project = await Project.findById(projectId); 34 | 35 | if (!project.assignees.includes(userId)) { 36 | return res.status(403).json({ message: "Not authorized to get project tickets" }); 37 | } 38 | 39 | const tickets = await Ticket.find({ projectId }) 40 | .populate({ path: "projectId", select: { title: 1 } }) 41 | .populate({ path: "createdBy", select: { firstName: 1, lastName: 1 } }) 42 | .populate({ path: "type", select: { __v: 0 } }) 43 | .populate({ path: "assignees", select: { firstName: 1, lastName: 1 }, populate: { path: "roleId", select: { _id: 0, name: 1 } } }); 44 | 45 | return res.json(tickets); 46 | } catch (error) { 47 | console.log(error); 48 | return res.status(500).json({ message: "Internal server issue" }); 49 | } 50 | }; 51 | 52 | export const getTicketInfo = async (req, res) => { 53 | const { ticketId } = req.params; 54 | 55 | try { 56 | const userId = req.user._id; 57 | 58 | // Ensure the ticket exist 59 | const ticket = await Ticket.findOne({ _id: ticketId }) 60 | .populate({ path: "projectId", select: { title: 1 } }) 61 | .populate({ path: "type", select: { __v: 0 } }) 62 | .populate({ path: "createdBy", select: { firstName: 1, lastName: 1 } }) 63 | .populate({ path: "assignees", select: { firstName: 1, lastName: 1 } }); 64 | 65 | // Ensure the user belongs to the project 66 | const project = await Project.findById({ _id: ticket.projectId }); 67 | 68 | if (!project.assignees.includes(userId)) { 69 | return res.status(403).json({ message: "Not authorized to view the ticket" }); 70 | } 71 | 72 | if (!ticket) { 73 | return res.status(403).json({ message: "Ticket does not exist" }); 74 | } 75 | 76 | return res.json(ticket); 77 | 78 | } catch (error) { 79 | console.log(error); 80 | return res.status(500).json({ message: "Internal server issue" }); 81 | } 82 | }; 83 | 84 | export const createTicket = async (req, res) => { 85 | const { projectId } = req.params; 86 | const { 87 | type, 88 | title, 89 | description, 90 | status, 91 | assignees, 92 | estimatedTime, 93 | estimatedTimeUnit 94 | } = req.body; 95 | 96 | try { 97 | const userId = req.user._id; 98 | 99 | // Ensure the user belongs to the project 100 | const project = await Project.findOne({ _id: projectId }); 101 | 102 | if (!project.assignees.includes(userId)) { 103 | return res.status(403).json({ message: "Not authorized to add tickets to a project" }); 104 | } 105 | 106 | const newTicket = await Ticket.create({ projectId, type, title, description, status, assignees, estimatedTime, estimatedTimeUnit, createdBy: userId }); 107 | 108 | const ticket = await Ticket.findById(newTicket._id) 109 | .populate({ path: "projectId", select: { title: 1 } }) 110 | .populate({ path: "type", select: { __v: 0 } }) 111 | .populate({ path: "createdBy", select: { firstName: 1, lastName: 1 } }) 112 | .populate({ path: "assignees", select: { firstName: 1, lastName: 1 } }); 113 | 114 | return res.json(ticket); 115 | } catch (error) { 116 | console.log(error); 117 | return res.status(500).json({ message: "Internal server issue" }); 118 | } 119 | }; 120 | 121 | export const updateTicket = async (req, res) => { 122 | const { projectId } = req.params; 123 | 124 | try { 125 | const userId = req.user._id; 126 | 127 | validateObjectId(req.body._id, "Invalid ticket id", res); 128 | 129 | // Ensure the user belongs to the project 130 | const project = await Project.findById(projectId); 131 | 132 | if (!project.assignees.includes(userId)) { 133 | return res.status(403).json({ message: "Not authorized to add tickets to a project" }); 134 | } 135 | 136 | 137 | await Ticket.findOneAndUpdate({ _id: req.body._id }, { ...req.body, updatedOn: Date.now() }); 138 | 139 | const updatedTicket = await Ticket.findById(req.body._id) 140 | .populate({ path: "projectId", select: { title: 1 } }) 141 | .populate({ path: "type", select: { __v: 0 } }) 142 | .populate({ path: "createdBy", select: { firstName: 1, lastName: 1 } }) 143 | .populate({ path: "assignees", select: { firstName: 1, lastName: 1 } }); 144 | 145 | return res.json(updatedTicket); 146 | 147 | 148 | } catch (error) { 149 | console.log(error); 150 | return res.status(500).json({ message: "Internal server issue" }); 151 | } 152 | }; 153 | 154 | export const deleteTicket = async (req, res) => { 155 | const { ticketId } = req.params; 156 | 157 | try { 158 | const result = await Ticket.deleteOne({ _id: ticketId }); 159 | 160 | if (result.deletedCount === 0) { 161 | return res.status(403).json({ message: "Ticket does not exist" }); 162 | } 163 | 164 | return res.sendStatus(200); 165 | } catch (error) { 166 | console.log(error); 167 | return res.status(500).json({ message: "Internal server issue" }); 168 | } 169 | }; -------------------------------------------------------------------------------- /server/tests/role.test.js: -------------------------------------------------------------------------------- 1 | import { generateAccessToken } from "./utils"; 2 | import * as permission from "../util/permissions"; 3 | import request from './app.test.js'; 4 | import User from "../models/user.model"; 5 | import Role from "../models/role.model"; 6 | import { sampleUsers } from "./data"; 7 | 8 | /* 9 | 0: Jame Smith - admin, 10 | 1: Michael Smith - project manager, 11 | 2: Robert Smith - developer, 12 | 3: Maria Garcia - submitter 13 | */ 14 | 15 | const getRole = async (name) => { 16 | return await Role.find({ name }); 17 | }; 18 | 19 | describe("Roles", () => { 20 | it("POST /role --> Admin can add role", async () => { 21 | const user = sampleUsers[0]; 22 | const token = generateAccessToken(user.email, user._id); 23 | 24 | const response = await request.post("/role") 25 | .set("x-access-token", token) 26 | .send({ 27 | name: "custom role", 28 | permissions: [permission.ADD_COMMENT, permission.ADD_MEMBER_TO_PROJECT, permission.ADD_PROJECT] 29 | }); 30 | 31 | expect(response.status).toEqual(200); 32 | 33 | //Verify the change 34 | const role = await Role.findOne({ name: "custom role" }); 35 | 36 | expect(role.name).toEqual("custom role"); 37 | expect(role.permissions).toEqual([permission.ADD_COMMENT, permission.ADD_MEMBER_TO_PROJECT, permission.ADD_PROJECT]); 38 | 39 | }); 40 | 41 | it("POST /role --> Non-Admin cannot add role", async () => { 42 | const user = sampleUsers[3]; 43 | const token = generateAccessToken(user.email, user._id); 44 | 45 | const response = await request.post("/role") 46 | .set("x-access-token", token) 47 | .send({ 48 | name: "custom role", 49 | permissions: [permission.ADD_COMMENT, permission.ADD_MEMBER_TO_PROJECT, permission.ADD_PROJECT] 50 | }); 51 | 52 | expect(response.status).toEqual(403); 53 | expect(response.body.error).toEqual("Not authorized to add roles"); 54 | 55 | }); 56 | 57 | it("POST /role --> Cannot add duplicate role name", async () => { 58 | const user = sampleUsers[0]; 59 | const token = generateAccessToken(user.email, user._id); 60 | 61 | const response = await request.post("/role") 62 | .set("x-access-token", token) 63 | .send({ 64 | name: "admin", 65 | permissions: [permission.ADD_COMMENT, permission.ADD_MEMBER_TO_PROJECT, permission.ADD_PROJECT] 66 | }); 67 | 68 | expect(response.status).toEqual(400); 69 | expect(response.body.error).toEqual("Role already exist"); 70 | 71 | }); 72 | 73 | 74 | it("PATCH /role --> Non-admin cannot modify roles", async () => { 75 | const user = sampleUsers[2]; 76 | const token = generateAccessToken(user.email, user._id); 77 | 78 | const response = await request.patch("/role") 79 | .set("x-access-token", token) 80 | .send({ 81 | name: "custom role", 82 | permission: [permission.ADD_MEMBER_TO_PROJECT, permission.ADD_COMMENT] 83 | }); 84 | 85 | expect(response.status).toEqual(403); 86 | expect(response.body.error).toEqual("Not authorized to modify roles"); 87 | }); 88 | 89 | it.skip("PATCH /role --> Admin can modify role", async () => { 90 | const user = sampleUsers[0]; 91 | const token = generateAccessToken(user.email, user._id); 92 | const role = await getRole("custom role"); 93 | 94 | const response = await request.patch("/role") 95 | .set("x-access-token", token) 96 | .send({ 97 | roleId: role._id, 98 | name: "sample role", 99 | permissions: [permission.ADD_TICKET] 100 | }); 101 | 102 | expect(response.status).toEqual(200); 103 | 104 | //Verify the change 105 | const newRole = await Role.findOne({ name: "sample role" }); 106 | 107 | expect(newRole.name).toEqual("sample role"); 108 | expect(newRole.permissions).toEqual([permission.ADD_TICKET]); 109 | 110 | }); 111 | 112 | it.skip("PATCH /role --> Updating with invalid id", async () => { 113 | const user = sampleUsers[0]; 114 | const token = generateAccessToken(user.email, user._id); 115 | const role = await getRole("sample role"); 116 | 117 | const response = await request.patch("/role") 118 | .set("x-access-token", token) 119 | .send({ 120 | roleId: role._id + "abc", 121 | name: "sample role", 122 | permissions: [permission.ADD_TICKET] 123 | }); 124 | 125 | expect(response.status).toEqual(404); 126 | expect(response.body.error).toEqual("No roles found with that id"); 127 | }); 128 | 129 | 130 | it("DELETE /role --> Non-admin cannot delete roles", async () => { 131 | const user = sampleUsers[2]; 132 | const token = generateAccessToken(user.email, user._id); 133 | 134 | const response = await request.delete("/role") 135 | .set("x-access-token", token) 136 | .send({ 137 | name: "sample role" 138 | }); 139 | 140 | expect(response.status).toEqual(403); 141 | expect(response.body.error).toEqual("Not authorized to delete roles"); 142 | }); 143 | 144 | it.skip("DELETE /role --> Delete role with invalid id", async () => { 145 | const user = sampleUsers[0]; 146 | const token = generateAccessToken(user.email, user._id); 147 | const role = await getRole("sample role"); 148 | 149 | const response = await request.delete("/role") 150 | .set("x-access-token", token) 151 | .send({ roleId: role._id + "abc" }); 152 | 153 | expect(response.status).toEqual(404); 154 | expect(response.body.error).toEqual("No roles found with that id"); 155 | 156 | }); 157 | 158 | it.skip("DELETE /role --> Admin can delete role", async () => { 159 | const user = sampleUsers[0]; 160 | const token = generateAccessToken(user.email, user._id); 161 | const role = await getRole("sample role"); 162 | 163 | const response = await request.delete("/role") 164 | .set("x-access-token", token) 165 | .send({ roleId: role._id }); 166 | 167 | expect(response.status).toEqual(200); 168 | 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /client/src/components/administration/CreateRole.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Box, 4 | Button, 5 | Flex, 6 | FormControl, 7 | FormErrorMessage, 8 | FormHelperText, 9 | FormLabel, 10 | Input, 11 | Modal, 12 | ModalBody, 13 | ModalCloseButton, 14 | ModalContent, 15 | ModalFooter, 16 | ModalHeader, 17 | ModalOverlay, 18 | Spacer, 19 | Switch, 20 | useDisclosure, 21 | } from "@chakra-ui/react"; 22 | import { Field, Formik } from "formik"; 23 | import React, { useEffect, useRef, useState } from "react"; 24 | import MiscellaneousService from "@/services/miscellaneous-service"; 25 | import * as Constants from "@/util/Constants"; 26 | import { CreateRoleData, CreateRoleSchema } from "@/util/ValidationSchemas"; 27 | import AlertModal from "../others/AlertModal"; 28 | 29 | const CreateRole = ({ isOpen, onClose, role, mutateServer }) => { 30 | const isNewRole = !role; 31 | const alertDialogDisclosure = useDisclosure(); 32 | const formRef = useRef(null); 33 | const [permissions, setPermissions] = useState([]); 34 | const [roleData, setRoleData] = useState(CreateRoleData); 35 | const [error, setError] = useState(null); 36 | 37 | useEffect(() => { 38 | if (isOpen && role) { 39 | setRoleData(role); 40 | console.log(role.permissions); 41 | setPermissions(role.permissions); 42 | } 43 | }, [isOpen]); 44 | 45 | const displayPermissions = [ 46 | { 47 | name: "Manage Tickets", 48 | helperText: "Allows user to create, delete, and modify tickets", 49 | value: Constants.MANAGE_TICKET, 50 | }, 51 | { 52 | name: "Manage Projects", 53 | helperText: 54 | "Allows users to create, delete, and modify their own projects", 55 | value: Constants.MANAGE_PROJECT, 56 | }, 57 | { 58 | name: "Manage Admin Page", 59 | helperText: "Allows user to access the admin page", 60 | value: Constants.MANAGE_ADMIN_PAGE, 61 | }, 62 | ]; 63 | 64 | const onPermissionToggle = ({ target: { checked, value } }) => { 65 | if (checked) { 66 | //add permission 67 | setPermissions([...permissions, value]); 68 | } else { 69 | //remove permission 70 | const updatedPermissions = permissions.filter( 71 | (permission) => permission !== value 72 | ); 73 | setPermissions(updatedPermissions); 74 | } 75 | }; 76 | 77 | const closeModal = () => { 78 | setRoleData(CreateRoleData); 79 | setPermissions([]); 80 | setError(""); 81 | onClose(); 82 | }; 83 | 84 | const onRoleDelete = async () => { 85 | alertDialogDisclosure.onClose(); 86 | try { 87 | const apiRequestInfo = MiscellaneousService.deleteRole(role._id); 88 | await mutateServer(apiRequestInfo); 89 | closeModal(); 90 | } catch (error) { 91 | setError(error); 92 | } 93 | }; 94 | 95 | const onFormSubmit = async (data) => { 96 | try { 97 | const roleDataCopy = { ...data, permissions }; 98 | 99 | let apiRequestInfo = {}; 100 | 101 | if (isNewRole) { 102 | apiRequestInfo = MiscellaneousService.createRole(roleDataCopy); 103 | } else { 104 | apiRequestInfo = MiscellaneousService.updateRole(roleDataCopy); 105 | } 106 | 107 | await mutateServer(apiRequestInfo); 108 | 109 | closeModal(); 110 | } catch (error) { 111 | setError(error); 112 | } 113 | }; 114 | 115 | return ( 116 | 117 | 118 | 119 | Create New Role 120 | 121 | 122 | 123 | 130 | {({ errors, touched }) => ( 131 | <> 132 | {error && ( 133 | 139 | {error} 140 | 141 | )} 142 | 143 | Role Name 144 | 145 | {errors.name} 146 | 147 | 148 | {displayPermissions.map((permission, index) => ( 149 | 150 | 151 | {permission.name} 152 | 153 | 160 | 161 | 162 | {permission.helperText} 163 | 164 | 165 | ))} 166 | 167 | )} 168 | 169 | 170 | 171 | 172 | 173 | {role ? ( 174 | 177 | ) : ( 178 | 187 | )} 188 | 194 | 195 | 196 | 197 | 204 | 205 | ); 206 | }; 207 | 208 | export default CreateRole; 209 | -------------------------------------------------------------------------------- /client/src/components/tickets/CreateTicket.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | AlertIcon, 4 | Button, 5 | Heading, 6 | Modal, 7 | ModalBody, 8 | ModalCloseButton, 9 | ModalContent, 10 | ModalFooter, 11 | ModalHeader, 12 | ModalOverlay, 13 | Tab, 14 | TabList, 15 | TabPanel, 16 | TabPanels, 17 | Tabs, 18 | Text, 19 | useDisclosure, 20 | } from "@chakra-ui/react"; 21 | import React, { useEffect, useState } from "react"; 22 | import { useRef } from "react"; 23 | import ProjectService from "@/services/project-service"; 24 | import TicketService from "@/services/ticket-service"; 25 | import useApi from "@/hooks/useApi"; 26 | import { usePermissions } from "@/hooks/usePermissions"; 27 | import { PROJECT_ASSIGNEES_COLUMNS } from "@/util/TableDataDisplay"; 28 | import { Permissions } from "@/util/Utils"; 29 | import { CreateTicketData } from "@/util/ValidationSchemas"; 30 | import AlertModal from "../others/AlertModal"; 31 | import PermissionsRender from "../others/PermissionsRender"; 32 | import Table from "../others/Table"; 33 | import CommentSection from "./CommentSection"; 34 | import TicketInfo from "./TicketInfo"; 35 | 36 | const CreateTicket = ({ 37 | isOpen, 38 | onClose, 39 | ticket, 40 | mutateServer, 41 | projectInfo, 42 | }) => { 43 | const isNewTicket = ticket ? false : true; 44 | 45 | const [selectedAssigneeIds, setSelectedAssigneeIds] = useState([]); 46 | const [ticketDescription, setTicketDescription] = useState(""); 47 | const [ticketInfo, setTicketInfo] = useState(CreateTicketData); 48 | const [error, setError] = useState(""); 49 | const [project, setProject] = useState(projectInfo); 50 | 51 | const projectSWR = useApi( 52 | ProjectService.getProjectInfo(ticket?.projectId._id), 53 | !projectInfo && ticket 54 | ); 55 | 56 | const canManageTickets = usePermissions(Permissions.canManageTickets); 57 | 58 | const alertModalDisclosure = useDisclosure(); 59 | const formRef = useRef(); 60 | 61 | useEffect(() => { 62 | if (projectSWR.data) { 63 | setProject(projectSWR.data); 64 | } 65 | }, [projectSWR]); 66 | 67 | useEffect(() => { 68 | if (isOpen && ticket) { 69 | const ticketCopy = { ...ticket }; 70 | 71 | ticketCopy._id = ticket._id; 72 | ticketCopy.projectId = ticket.projectId._id; 73 | ticketCopy.type = ticket.type._id; 74 | ticketCopy.assignees = ticket.assignees.map((assignee) => assignee._id); 75 | 76 | setTicketInfo(ticketCopy); 77 | setSelectedAssigneeIds(ticketCopy.assignees); 78 | setTicketDescription(ticket.description); 79 | } 80 | }, [isOpen]); 81 | 82 | const onTicketAssigneeClick = ({ selected }) => { 83 | setSelectedAssigneeIds(Object.keys(selected)); 84 | }; 85 | 86 | const onTicketDelete = async () => { 87 | alertModalDisclosure.onClose(); 88 | 89 | try { 90 | const apiRequestInfo = TicketService.deleteTicket(ticket._id); 91 | 92 | await mutateServer(apiRequestInfo); 93 | 94 | closeTicketModal(); 95 | } catch (error) { 96 | setError(error); 97 | } 98 | }; 99 | 100 | const closeTicketModal = () => { 101 | setSelectedAssigneeIds([]); 102 | setTicketDescription(""); 103 | setTicketInfo(CreateTicketData); 104 | setError(""); 105 | onClose(); 106 | }; 107 | 108 | const onHandleFormSubmit = async (data) => { 109 | try { 110 | const ticketData = { ...data }; 111 | ticketData.assignees = selectedAssigneeIds; 112 | ticketData.description = ticketDescription; 113 | 114 | let apiRequestInfo = {}; 115 | 116 | if (isNewTicket) { 117 | apiRequestInfo = TicketService.createTicket(project._id, ticketData); 118 | } else { 119 | apiRequestInfo = TicketService.updateTicket(project._id, ticketData); 120 | } 121 | 122 | await mutateServer(apiRequestInfo); 123 | closeTicketModal(); 124 | } catch (error) { 125 | setError(error); 126 | } 127 | }; 128 | 129 | return ( 130 | 136 | 137 | 138 | 139 | 140 | {!isNewTicket ? "Edit" : "Create"} Ticket 141 | 142 | 143 | Project: {project?.title} 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | Ticket Info 153 | {!isNewTicket && Comments} 154 | Assignees 155 | 156 | 157 | {error && ( 158 | 159 | 160 | {error} 161 | 162 | )} 163 | 164 | 165 | 166 | 174 | 175 | 176 | {!isNewTicket && ( 177 | 178 | 179 | 180 | )} 181 | 182 | 183 |
194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 209 | {!isNewTicket ? ( 210 | 216 | ) : ( 217 | 218 | )} 219 | 220 | 221 | 222 | 223 | 230 | 231 | ); 232 | }; 233 | 234 | export default React.memo(CreateTicket); 235 | -------------------------------------------------------------------------------- /client/src/components/administration/CreateTicketType.jsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { 3 | Alert, 4 | Button, 5 | Flex, 6 | FormControl, 7 | FormErrorMessage, 8 | FormLabel, 9 | Icon, 10 | Input, 11 | Modal, 12 | ModalBody, 13 | ModalCloseButton, 14 | ModalContent, 15 | ModalFooter, 16 | ModalHeader, 17 | ModalOverlay, 18 | Text, 19 | useDisclosure, 20 | } from "@chakra-ui/react"; 21 | import { Field, Formik } from "formik"; 22 | import React, { useEffect, useRef, useState } from "react"; 23 | import MiscellaneousService from "@/services/miscellaneous-service"; 24 | import { 25 | CreateTicketTypeData, 26 | CreateTicketTypeSchema, 27 | } from "@/util/ValidationSchemas"; 28 | import AlertModal from "../others/AlertModal"; 29 | import SearchBar from "../others/SearchBar"; 30 | 31 | const CreateTicketType = ({ isOpen, onClose, ticketType, mutateServer }) => { 32 | const isNewTicketType = !ticketType; 33 | 34 | const [ticketTypeData, setTicketTypeData] = useState(CreateTicketTypeData); 35 | const [iconColour, setIconColour] = useState("#000000"); 36 | const [selectedIcon, setSelectedIcon] = useState(null); 37 | const [iconName, setIconName] = useState(null); 38 | const [error, setError] = useState(""); 39 | 40 | const formRef = useRef(null); 41 | const alertDialogDisclosure = useDisclosure(); 42 | 43 | let bsIcons = null; 44 | 45 | useEffect(() => { 46 | if (isOpen && ticketType) { 47 | setTicketTypeData(ticketType); 48 | setIconColour(ticketType.colour); 49 | getIcon(ticketType.iconName); 50 | } 51 | }, [isOpen]); 52 | 53 | const getIcon = async (iconName) => { 54 | try { 55 | bsIcons = await import("react-icons/bs"); 56 | if (bsIcons[iconName]) { 57 | setSelectedIcon(() => bsIcons[iconName]); 58 | setIconName(iconName); 59 | } 60 | } catch (error) { 61 | console.log(error); 62 | } 63 | }; 64 | 65 | const onIconSearch = ({ target: { value } }) => { 66 | const trimmedValue = value.trim(); 67 | getIcon(trimmedValue); 68 | }; 69 | 70 | const onColourChange = ({ target: { value } }) => { 71 | setIconColour(value); 72 | }; 73 | 74 | const closeModal = () => { 75 | setError(""); 76 | setIconColour("#000000"); 77 | setSelectedIcon(null); 78 | setTicketTypeData(CreateTicketTypeData); 79 | onClose(); 80 | }; 81 | 82 | const deleteTicketType = async () => { 83 | alertDialogDisclosure.onClose(); 84 | 85 | try { 86 | const apiRequestInfo = MiscellaneousService.deleteTicketType( 87 | ticketTypeData._id 88 | ); 89 | await mutateServer(apiRequestInfo); 90 | closeModal(); 91 | } catch (error) { 92 | setError(error); 93 | } 94 | }; 95 | 96 | const onFormSubmit = async (data) => { 97 | console.log("FORM"); 98 | if (!iconName) { 99 | setError("Must select an icon"); 100 | return; 101 | } 102 | 103 | try { 104 | const ticketTypeCopy = { ...data, iconName, colour: iconColour }; 105 | let apiRequestInfo; 106 | 107 | if (isNewTicketType) { 108 | apiRequestInfo = MiscellaneousService.createTicketType(ticketTypeCopy); 109 | } else { 110 | apiRequestInfo = MiscellaneousService.updateTicketType(ticketTypeCopy); 111 | } 112 | 113 | await mutateServer(apiRequestInfo); 114 | 115 | closeModal(); 116 | } catch (error) { 117 | setError(error); 118 | } 119 | }; 120 | 121 | return ( 122 | 123 | 124 | 125 | 126 | {isNewTicketType ? "Update" : "Create"} Ticket Type 127 | 128 | 129 | 130 | 131 | 132 | Preview: 133 | {selectedIcon ? ( 134 | 143 | ) : null} 144 | 145 | 146 | 153 | {({ errors, touched }) => ( 154 | <> 155 | {error && ( 156 | 162 | {error} 163 | 164 | )} 165 | 166 | 167 | Ticket Type Name 168 | 169 | {errors.name} 170 | 171 | 172 | 173 | Colour (select icon colour) 174 | 181 | {errors.colour} 182 | 183 | 184 | 185 | 186 | 187 | Select an Icon 188 | 193 | (Click Here) 194 | 195 | 196 | 201 | 202 | 203 | )} 204 | 205 | 206 | 207 | 208 | 209 | {!isNewTicketType ? ( 210 | 213 | ) : ( 214 | 217 | )} 218 | 227 | 228 | 229 | 230 | 237 | 238 | ); 239 | }; 240 | 241 | export default CreateTicketType; 242 | -------------------------------------------------------------------------------- /client/src/components/administration/UpdateUser.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | Button, 4 | Flex, 5 | FormControl, 6 | FormErrorMessage, 7 | FormLabel, 8 | Input, 9 | InputGroup, 10 | InputRightElement, 11 | Modal, 12 | ModalBody, 13 | ModalCloseButton, 14 | ModalContent, 15 | ModalFooter, 16 | ModalHeader, 17 | ModalOverlay, 18 | Select, 19 | Tooltip, 20 | useBoolean, 21 | } from "@chakra-ui/react"; 22 | import { Field, Form, Formik } from "formik"; 23 | import React, { useEffect, useRef, useState } from "react"; 24 | import MiscellaneousService from "@/services/miscellaneous-service"; 25 | import useApi from "@/hooks/useApi"; 26 | import { 27 | ManageUserSchema, 28 | SignUpData, 29 | SignupSchema, 30 | } from "@/util/ValidationSchemas"; 31 | 32 | const UpdateUser = ({ 33 | isOpen, 34 | closeModal, 35 | viewUser, 36 | isUpdateMyProfile = false, 37 | mutateServer, 38 | }) => { 39 | const allRolesSWR = useApi(MiscellaneousService.getRoles()); 40 | const formRef = useRef(null); 41 | const [error, setError] = useState(null); 42 | const [userInfo, setUserInfo] = useState(SignUpData); 43 | const [showPassword, setShowPassword] = useBoolean(); 44 | const isUpdatingUserProfile = !isUpdateMyProfile && viewUser; 45 | 46 | const modalTitle = isUpdateMyProfile 47 | ? "My Profile" 48 | : viewUser 49 | ? "Update User" 50 | : "Create User"; 51 | 52 | useEffect(() => { 53 | if (isOpen && viewUser) { 54 | const userInfoCopy = { 55 | _id: viewUser._id, 56 | firstName: viewUser.firstName, 57 | lastName: viewUser.lastName, 58 | roleId: viewUser.roleId?._id, 59 | email: viewUser.email, 60 | }; 61 | 62 | if (isUpdateMyProfile) { 63 | userInfoCopy.password = ""; 64 | userInfoCopy.confirmPassword = ""; 65 | } 66 | 67 | setUserInfo(userInfoCopy); 68 | } 69 | }, [isOpen]); 70 | 71 | const onUpdateUser = async (data) => { 72 | try { 73 | let apiRequestInfo; 74 | 75 | if (viewUser) { 76 | apiRequestInfo = isUpdateMyProfile 77 | ? MiscellaneousService.updateMyProfile(data) 78 | : MiscellaneousService.updateUserProfile(data); 79 | } else { 80 | apiRequestInfo = MiscellaneousService.createUser(data); 81 | } 82 | 83 | await mutateServer(apiRequestInfo); 84 | 85 | setError(""); 86 | onCloseModal(); 87 | } catch (error) { 88 | console.log("ERROR: ", error); 89 | setError(error); 90 | } 91 | }; 92 | 93 | const onCloseModal = () => { 94 | setShowPassword.off(); 95 | setUserInfo(SignUpData); 96 | setError(""); 97 | closeModal(); 98 | }; 99 | 100 | const createRoleTypeOption = () => { 101 | return allRolesSWR.data?.map((role) => ( 102 | 105 | )); 106 | }; 107 | 108 | return ( 109 | 110 | 111 | 112 | {modalTitle} 113 | 114 | 115 | 124 | {({ errors, touched }) => ( 125 |
126 | {error && ( 127 | 133 | {error} 134 | 135 | )} 136 | 137 | 140 | First Name 141 | 142 | {errors.firstName} 143 | 144 | 145 | 146 | Last Name 147 | 148 | {errors.lastName} 149 | 150 | 151 | 152 | 153 | Email 154 | 155 | {errors.email} 156 | 157 | {!isUpdateMyProfile ? ( 158 | 162 | Role 163 | 164 | 167 | {createRoleTypeOption()} 168 | 169 | {errors.roleId} 170 | 171 | ) : null} 172 | 173 | {!isUpdatingUserProfile ? ( 174 | <> 175 | 179 | Password 180 | 186 | {errors.password} 187 | 188 | 189 | 195 | Confirm Password 196 | 197 | 203 | 204 | 207 | 208 | 209 | 210 | {errors.confirmPassword} 211 | 212 | 213 | 214 | ) : null} 215 | 216 | )} 217 |
218 |
219 | 220 | 221 | {viewUser ? ( 222 | 223 | 224 | 225 | ) : null} 226 | 227 | 233 | 234 |
235 |
236 | ); 237 | }; 238 | 239 | export default UpdateUser; 240 | -------------------------------------------------------------------------------- /client/src/components/projects/AddProject.jsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | import { 3 | Alert, 4 | AlertIcon, 5 | Box, 6 | Button, 7 | Flex, 8 | FormControl, 9 | FormErrorMessage, 10 | FormLabel, 11 | Input, 12 | Modal, 13 | ModalBody, 14 | ModalCloseButton, 15 | ModalContent, 16 | ModalFooter, 17 | ModalHeader, 18 | ModalOverlay, 19 | Tab, 20 | TabList, 21 | TabPanel, 22 | TabPanels, 23 | Tabs, 24 | useDisclosure, 25 | } from "@chakra-ui/react"; 26 | import { Field, Form, Formik } from "formik"; 27 | import React, { useEffect, useMemo, useRef, useState } from "react"; 28 | import AuthService from "@/services/auth-service"; 29 | import MiscellaneousService from "@/services/miscellaneous-service"; 30 | import ProjectService from "@/services/project-service"; 31 | import useApi from "@/hooks/useApi"; 32 | import useAuthStore from "@/hooks/useAuth"; 33 | import { 34 | PROJECT_ASSIGNEES_COLUMNS, 35 | USERS_COLUMNS, 36 | } from "@/util/TableDataDisplay"; 37 | import { 38 | CreateProjectData, 39 | CreateProjectSchema, 40 | } from "@/util/ValidationSchemas"; 41 | import RichTextEditor from "../editor/RichTextEditor"; 42 | import AlertModal from "../others/AlertModal"; 43 | import Table from "../others/Table"; 44 | 45 | const AddProject = ({ isOpen, onClose, projectInfo, mutateServer }) => { 46 | const useAuth = useAuthStore(); 47 | const isNewProject = projectInfo === undefined; 48 | 49 | const router = useRouter(); 50 | const formRef = useRef(); 51 | const deleteProjectDisclosure = useDisclosure(); 52 | 53 | const [error, setError] = useState(""); 54 | const [selectedAssigneeIds, setSelectedAssigneeIds] = useState([]); 55 | const [projectDescription, setProjectDescription] = useState(""); 56 | const [projectInfoData, setProjectInfoData] = useState(CreateProjectData); 57 | const [isProjectAuthor, setIsProjectAuthor] = useState(isNewProject); 58 | 59 | const allUsersSWR = useApi(MiscellaneousService.getUsers(), isOpen); 60 | 61 | useEffect(() => { 62 | if (isOpen && projectInfo) { 63 | setProjectInfoData({ 64 | title: projectInfo.title, 65 | description: projectInfo.description, 66 | assignees: projectInfo.assignees.map((assignee) => assignee._id) || [], 67 | }); 68 | 69 | setProjectDescription(projectInfo.description); 70 | 71 | setSelectedAssigneeIds( 72 | projectInfo.assignees.map((assignee) => assignee._id) 73 | ); 74 | 75 | setIsProjectAuthor(useAuth.userProfile?._id === projectInfo.authorId._id); 76 | } 77 | }, [isOpen]); 78 | 79 | const onAssigneeClick = ({ selected }) => { 80 | setSelectedAssigneeIds(Object.keys(selected)); 81 | }; 82 | 83 | const onProjectDelete = async () => { 84 | try { 85 | await mutateServer(ProjectService.deleteProject(projectInfo._id)); 86 | onCloseModal(); 87 | router.back(); 88 | } catch (error) { 89 | setError(error); 90 | deleteProjectDisclosure.onClose(); 91 | } 92 | }; 93 | 94 | const onCloseModal = () => { 95 | setError(""); 96 | setProjectInfoData(CreateProjectData); 97 | setProjectDescription(""); 98 | setSelectedAssigneeIds([]); 99 | onClose(); 100 | }; 101 | 102 | const onHandleFormSubmit = async (data) => { 103 | try { 104 | const projectData = { ...data }; 105 | projectData.assignees = selectedAssigneeIds; 106 | projectData.description = projectDescription; 107 | 108 | let apiRequestInfo = {}; 109 | 110 | if (isNewProject) { 111 | apiRequestInfo = ProjectService.createProject(projectData); 112 | } else { 113 | projectData._id = projectInfo._id; 114 | apiRequestInfo = ProjectService.updateProject( 115 | projectData, 116 | projectInfo._id 117 | ); 118 | } 119 | 120 | await mutateServer(apiRequestInfo); 121 | 122 | onClose(); 123 | onCloseModal(); 124 | } catch (error) { 125 | console.log(error); 126 | setError(error); 127 | } 128 | }; 129 | 130 | return ( 131 | 137 | 138 | 139 | {isNewProject ? "Create" : "Update"} Project 140 | 141 | 142 | 143 | 144 | 145 | Project Info 146 | Contributors 147 | 148 | 149 | {error && ( 150 | 151 | 152 | {error} 153 | 154 | )} 155 | 156 | 157 | 158 | 165 | {({ errors, touched }) => ( 166 | 167 |
168 | 169 | 170 | 173 | Title 174 | 181 | 182 | {errors.title} 183 | 184 | 185 | 186 | 187 | Description 188 | 193 | 194 | 195 | 196 | 197 |
198 | )} 199 |
200 |
201 | 202 |
220 | 221 | 222 | 223 | 224 | 225 | 226 | {!isNewProject && isProjectAuthor ? ( 227 | 230 | ) : null} 231 | 232 | {isProjectAuthor ? ( 233 | 241 | ) : null} 242 | 243 | {!isProjectAuthor ? : null} 244 | 245 | 246 | 253 | 254 | 255 | ); 256 | }; 257 | 258 | export default AddProject; 259 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | 5 | Trackit 6 | 7 | 8 |

Issue and Project Tracking System (DEMO)

9 | 10 |

11 | Use Trackit! Tracking system that allows team members to collaborate, discuss and manage projects effectively 12 |
13 |
14 |

15 |

16 | 17 | 18 | 19 | ## ✨ Features 20 | 21 | - Team management 22 | - Project management 23 | - Ticket management 24 | - User assignment 25 | - Project statistics 26 | - Advanced searching 27 | - Commenting 28 | - Role based organization (Create custom permissions) 29 | - Custom field creation 30 | - Attachments (Not done yte) 31 | - Change tracker (Not done yet) 32 | 33 | 34 | 35 | ## 🛠️Technologies 36 | 37 | | **Front-end** |


NextJs

|


Chakra UI

|


Axios

|


Zustand

| 38 | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | 39 | | **Back-end** |


NodeJs

|


ExpressJS

|


MongoDB

|


Jest

| 40 | 41 | ##### Project will be dockerized soon 42 | 43 | ## 🚀 Quick start 44 | 45 | Start developing locally. 46 | 47 | ### Step 1: Download Node.js and MongoDB 48 | 49 | Download: [MongoDB](https://www.mongodb.com/try/download/community) 50 | Download: [Node.js - 18.14.0](https://nodejs.org/en/) 51 | 52 | ### Step 2: Clone the repo 53 | 54 | Fork the repository then clone it locally by doing 55 | 56 | ```sh 57 | git clone https://github.com/Jenil-Vekaria/Trackit.git 58 | ``` 59 | 60 | ### Step 2: Install Dependencies 61 | 62 | cd into the client and server directory, and install the dependencies 63 | 64 | ```sh 65 | cd client & npm install 66 | ``` 67 | 68 | ```sh 69 | cd server & npm install 70 | ``` 71 | 72 | ### Step 3: Setup .env 73 | 74 | To run the server, you will need the `.env` variables 75 | 76 | Rename [.env.EXAMPLE](./server/.env.EXAMPLE) file to **.env** 77 | 78 | ### Step 4: Seed database 79 | 80 | Execute the following command to seed the database 81 | 82 | ```sh 83 | npm run seed 84 | ``` 85 | 86 | ##### Login Info 87 | 88 | | Email | Password | Role | Permissions | 89 | | ---------------------------- | -------- | --------- | ---------------------------------- | 90 | | james.smith@bugtracker.com | password | Admin | Manage admin page/projects/tickets | 91 | | michael.smith@bugtracker.com | password | Developer | Manage projects/tickets | 92 | | robert.smith@bugtracker.com | password | Submitter | Manage tickets | 93 | 94 | #### You are all setup! 95 | 96 | Run client application 97 | 98 | ```sh 99 | npm run dev 100 | ``` 101 | 102 | Run server application 103 | 104 | ```sh 105 | npm run start 106 | ``` 107 | 108 | **Setup Issue?** 109 | Create an issue in this repository 110 | 111 | ### Give a ⭐, if you liked the project 112 | 113 | ## 📸 Screenshots 114 | 115 |
116 |

Login

117 |

Log into the application with your credentials. If you don't have an account, click Sign Up to create a new account. Once you have logged in, you will be directed to projects page

118 | 119 |
120 | 121 |
122 |

View All Projects

123 |

You will find all the projects you have created or belong to. You can also search and sort the projects. Click on Add Project to create new project

124 | If your permissions doesn't allow you to manage project, "Add Project" will not be displayed 125 | 126 |
127 | 128 |
129 |

Add Project

130 |

Enter your project information here (Title and description)

131 | 132 |
133 | 134 |
135 |

Add Project (Contributor)

136 |

Select all the project contributors. You will also see what type of role the user belong to.

137 | 138 |
139 | 140 |
141 |

View Project Info

142 |

Once you have created your project, you will see all your project tickets (intially none). You create new tickets, view project info and edit exisiting ticket.

143 | If your permissions doesn't allow you to manage tickets, "Add Ticket" will not be displayed 144 | 145 |
146 | 147 |
148 |

Project Overview

149 |

Click on Overview to see the project statistics

150 | 151 |
152 | 153 |
154 |

View Ticket Info

155 |

Click on the existing ticket, you can edit the ticket info, add comment or update the ticket assignee

156 | If your permission doesn't allow you to manage tickets, all the fields, comments, assigness will be disabled 157 | 158 |
159 | 160 |
161 |

View Ticket Comments

162 |

Click on comments tab, you will see all the ticket comments and you can also comment on it.

163 | If your permission doesn't allow you to manage comments, you will not be able to comment 164 | 165 |
166 | 167 |
168 |

My Tickets

169 |

Click on Tickets tab to see all your tickets regarless of what project it belongs to. Clicking on the ticket will allow you to edit it

170 | 171 |
172 | 173 |
174 |

Admin - Manage Users

175 |

Click on Admin to manage the organization (Users, Roles, Custom Ticket Type)

176 |

Click on Manage User to manage all the users and their roles. Clicking on the user will allow you to update their role

177 | This tab will only be displayed if you are the admin 178 | 179 |
180 | 181 |
182 |

Admin - Manage Roles

183 |

Manage Roles tab will display all the roles and their respective permissions. To create custom role, click on Add New Role

184 | 185 |
186 | 187 |
188 |

Admin - Manage Roles (Add)

189 |

You can create your custom role by giving a role name and selecting the types of allowed actions

190 | 191 |
192 | 193 |
194 |

Admin - Manage Ticket Types

195 |

You will see all the ticket types here. There are some pre-defined ticket types (Feature, Bug, Documentation, Support), but you may create custom ticket types by clicking on Add New Ticket Type

196 | 197 |
198 | 199 |
200 |

Admin - Manage Ticket Types (Add)

201 |

Create custom ticket type by giving ticket type name, selecting an icon, and the icon colour

202 | 203 |
204 | 205 | ## Author 206 | 207 | - Github: [@Jenil-Vekara](https://github.com/Jenil-Vekaria) 208 | - Portfolio: [Jenil-Vekaria.netlify.app](https://jenil-vekaria.netlify.app/) 209 | - LinkedIn: [@JenilVekaria](https://www.linkedin.com/in/jenilvekaria/) 210 | --------------------------------------------------------------------------------