├── .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 |
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 |
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 | onCTA(onClose)}>
23 | Delete
24 |
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 |
29 |
30 | Sign in to your account
31 |
32 |
33 |
34 | Demo Login Info
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 |
16 | {ticketType.name}
17 |
18 | ));
19 | };
20 |
21 | export const createTicketStatusSelectOptions = () => {
22 | return Constants.TICKET_STATUS.map((status, index) => (
23 |
24 | {status}
25 |
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 | onOpen()}>
38 | Add Role
39 |
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 | onOpen()}>
38 | Add New User
39 |
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 | onOpen()}>
37 | Add Ticket Type
38 |
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 | Email
34 | Password
35 | Role
36 |
37 |
38 |
39 | {DEMO_LOGIN_INFO.map((loginInfo, index) => (
40 |
41 | {loginInfo.email}
42 | {loginInfo.password}
43 | {loginInfo.role}
44 |
45 | ))}
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | Close
54 |
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 |
40 | Add Project
41 |
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 |
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 |
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 |
122 | {isEditing ? "Save" : "Edit"}
123 |
124 |
125 | Delete
126 |
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 | onOpen()}>
86 | Add Ticket
87 |
88 |
89 |
90 | projectInfoDiscloure.onOpen()}
93 | >
94 | Project Info
95 |
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 |
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 |
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 |
175 | Delete
176 |
177 | ) : (
178 | {
181 | setPermissions([]);
182 | onClose();
183 | }}
184 | >
185 | Cancel
186 |
187 | )}
188 | formRef.current?.handleSubmit()}
191 | >
192 | {isNewRole ? "Create" : "Save"}
193 |
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 | formRef.current?.handleSubmit()}
206 | >
207 | {!isNewTicket ? "Save" : "Create"}
208 |
209 | {!isNewTicket ? (
210 | alertModalDisclosure.onOpen()}
213 | >
214 | Delete
215 |
216 | ) : (
217 | Cancel
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 |
211 | Delete
212 |
213 | ) : (
214 |
215 | Cancel
216 |
217 | )}
218 | {
221 | console.log("create ticket type", formRef.current);
222 | formRef.current?.handleSubmit();
223 | }}
224 | >
225 | {isNewTicketType ? "Create" : "Save"}
226 |
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 |
103 | {role.name}
104 |
105 | ));
106 | };
107 |
108 | return (
109 |
110 |
111 |
112 | {modalTitle}
113 |
114 |
115 |
124 | {({ errors, touched }) => (
125 |
216 | )}
217 |
218 |
219 |
220 |
221 | {viewUser ? (
222 |
223 | Delete
224 |
225 | ) : null}
226 |
227 | formRef.current?.handleSubmit()}
230 | >
231 | {!viewUser ? "Create" : "Save"}
232 |
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 |
197 |
198 | )}
199 |
200 |
201 |
202 |
220 |
221 |
222 |
223 |
224 |
225 |
226 | {!isNewProject && isProjectAuthor ? (
227 |
228 | Delete Project
229 |
230 | ) : null}
231 |
232 | {isProjectAuthor ? (
233 | {
236 | formRef.current?.submitForm();
237 | }}
238 | >
239 | {isNewProject ? "Create" : "Save Changes"}
240 |
241 | ) : null}
242 |
243 | {!isProjectAuthor ? Close : null}
244 |
245 |
246 |
253 |
254 |
255 | );
256 | };
257 |
258 | export default AddProject;
259 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
--------------------------------------------------------------------------------