├── client
├── .env.example
├── src
│ ├── vite-env.d.ts
│ ├── features
│ │ ├── categories
│ │ │ ├── index.ts
│ │ │ ├── apis
│ │ │ │ └── category-api.ts
│ │ │ └── components
│ │ │ │ ├── DeleteCategoryAlert.tsx
│ │ │ │ ├── AddCategorySheet.tsx
│ │ │ │ ├── CategoriesList.tsx
│ │ │ │ └── EditCategorySheet.tsx
│ │ ├── auth
│ │ │ ├── index.ts
│ │ │ ├── routes
│ │ │ │ ├── LoginPage.tsx
│ │ │ │ └── SignupPage.tsx
│ │ │ ├── apis
│ │ │ │ └── auth-api.ts
│ │ │ └── components
│ │ │ │ └── LoginCard.tsx
│ │ ├── users
│ │ │ ├── index.ts
│ │ │ ├── routes
│ │ │ │ ├── TasksPage.tsx
│ │ │ │ └── SettingsPage.tsx
│ │ │ └── apis
│ │ │ │ └── user-api.ts
│ │ ├── comments
│ │ │ ├── index.ts
│ │ │ ├── apis
│ │ │ │ └── comment-api.ts
│ │ │ └── components
│ │ │ │ ├── DeleteCommentAlert.tsx
│ │ │ │ ├── CommentCard.tsx
│ │ │ │ ├── CommentSheet.tsx
│ │ │ │ └── EditCommentSheet.tsx
│ │ ├── projects
│ │ │ ├── index.ts
│ │ │ ├── routes
│ │ │ │ ├── AllProjectsPage.tsx
│ │ │ │ ├── ProjectPage.tsx
│ │ │ │ └── DashboardPage.tsx
│ │ │ ├── components
│ │ │ │ ├── ProjectChart.tsx
│ │ │ │ ├── ProjectCard.tsx
│ │ │ │ ├── ChartBoard.tsx
│ │ │ │ ├── DeleteProjectAlert.tsx
│ │ │ │ ├── RemoveMemberAlert.tsx
│ │ │ │ ├── MembersList.tsx
│ │ │ │ └── AddMemberSheet.tsx
│ │ │ └── apis
│ │ │ │ └── project-api.ts
│ │ └── issues
│ │ │ ├── index.ts
│ │ │ ├── components
│ │ │ ├── TablePagination.tsx
│ │ │ ├── DeleteIssueAlert.tsx
│ │ │ └── IssueActionsDropdown.tsx
│ │ │ └── apis
│ │ │ └── issue-api.ts
│ ├── assets
│ │ ├── logo-dark.png
│ │ └── logo-light.png
│ ├── App.tsx
│ ├── main.tsx
│ ├── lib
│ │ ├── react-query.ts
│ │ └── axios.ts
│ ├── components
│ │ ├── ui
│ │ │ ├── collapsible.tsx
│ │ │ ├── label.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── input.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── spinner.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── card.tsx
│ │ │ ├── button.tsx
│ │ │ └── table.tsx
│ │ ├── UserAvatar.tsx
│ │ ├── layout
│ │ │ ├── RootLayout.tsx
│ │ │ ├── ButtomMenu.tsx
│ │ │ └── SideMenu.tsx
│ │ ├── AlertMassage.tsx
│ │ ├── ModeToggle.tsx
│ │ ├── ProtectedRoutes.tsx
│ │ └── Authorization.tsx
│ ├── utils
│ │ └── index.ts
│ ├── providers
│ │ ├── index.tsx
│ │ └── theme-provider.tsx
│ ├── routes
│ │ └── index.tsx
│ └── index.css
├── public
│ ├── ERD.png
│ ├── logo-light.png
│ ├── favicon-dark.png
│ ├── favicon-light.png
│ └── screenshots
│ │ ├── postman.png
│ │ ├── issuezy-demo1.gif
│ │ ├── issuezy-demo2.gif
│ │ ├── issuezy-demo3.gif
│ │ ├── issuezy-demo4.gif
│ │ ├── issuezy-demo5.gif
│ │ ├── issuezy_mobile.png
│ │ └── issuezy_desktop.png
├── netlify.toml
├── postcss.config.js
├── prettier.config.cjs
├── vite.config.ts
├── tsconfig.node.json
├── components.json
├── .gitignore
├── .eslintrc.cjs
├── index.html
├── tsconfig.json
├── package.json
└── tailwind.config.js
└── server
├── .env.example
├── helpers
└── error-helper.js
├── .sequelizerc
├── .gitignore
├── migrations
├── 20230909171510-rename-user-name-to-firstname.js
├── 20230909171816-add-lastname-to-users-table.js
├── 20230914113812-change-issue-priority-to-string.js
├── 20230829091856-create-user.js
├── 20230830114812-create-membership.js
├── 20230831085418-create-category.js
├── 20230902195109-create-comment.js
├── 20230829142038-create-project.js
└── 20230901130919-create-issue.js
├── middlewares
├── auth.js
└── error-handler.js
├── .eslintrc.json
├── config
├── cors.js
├── passport.js
└── index.js
├── seeders
├── 20230928135226-categories-seed-file.js
├── 20230928132342-projects-seed-file.js
├── 20230928134227-memberships-seed-file.js
├── 20230928131712-users-seed-file.js
├── 20230928135909-issues-seed-file.js
└── 20230928140236-comments-seed-file.js
├── models
├── membership.js
├── category.js
├── comment.js
├── project.js
├── index.js
├── user.js
└── issue.js
├── app.js
├── controllers
├── comment-controller.js
├── category-controller.js
├── user-controller.js
├── issue-controller.js
└── project-controller.js
├── package.json
├── routes
└── index.js
└── services
├── comment-service.js
├── user-service.js
└── category-service.js
/client/.env.example:
--------------------------------------------------------------------------------
1 | VITE_API_ENDPOINT="http://localhost:3000/api"
--------------------------------------------------------------------------------
/client/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/features/categories/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./components/CategoriesList.tsx";
2 |
--------------------------------------------------------------------------------
/client/public/ERD.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/ERD.png
--------------------------------------------------------------------------------
/client/netlify.toml:
--------------------------------------------------------------------------------
1 | [[redirects]]
2 | from = "/*"
3 | to = "/index.html"
4 | status = 200
5 |
--------------------------------------------------------------------------------
/client/public/logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/logo-light.png
--------------------------------------------------------------------------------
/client/public/favicon-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/favicon-dark.png
--------------------------------------------------------------------------------
/client/public/favicon-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/favicon-light.png
--------------------------------------------------------------------------------
/client/src/assets/logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/src/assets/logo-dark.png
--------------------------------------------------------------------------------
/client/src/assets/logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/src/assets/logo-light.png
--------------------------------------------------------------------------------
/client/src/features/auth/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./routes/LoginPage.tsx";
2 | export * from "./routes/SignupPage.tsx";
3 |
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/client/public/screenshots/postman.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/postman.png
--------------------------------------------------------------------------------
/client/src/features/users/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./routes/SettingsPage.tsx";
2 | export * from "./routes/TasksPage.tsx";
3 |
--------------------------------------------------------------------------------
/client/src/features/comments/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./components/CommentCard.tsx";
2 | export * from "./components/CommentSheet.tsx";
3 |
--------------------------------------------------------------------------------
/client/public/screenshots/issuezy-demo1.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/issuezy-demo1.gif
--------------------------------------------------------------------------------
/client/public/screenshots/issuezy-demo2.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/issuezy-demo2.gif
--------------------------------------------------------------------------------
/client/public/screenshots/issuezy-demo3.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/issuezy-demo3.gif
--------------------------------------------------------------------------------
/client/public/screenshots/issuezy-demo4.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/issuezy-demo4.gif
--------------------------------------------------------------------------------
/client/public/screenshots/issuezy-demo5.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/issuezy-demo5.gif
--------------------------------------------------------------------------------
/client/public/screenshots/issuezy_mobile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/issuezy_mobile.png
--------------------------------------------------------------------------------
/client/public/screenshots/issuezy_desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/KellyCHI22/Issuezy/HEAD/client/public/screenshots/issuezy_desktop.png
--------------------------------------------------------------------------------
/client/src/App.tsx:
--------------------------------------------------------------------------------
1 | import AppProviders from "./providers";
2 |
3 | function App() {
4 | return ;
5 | }
6 |
7 | export default App;
8 |
--------------------------------------------------------------------------------
/client/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /*eslint-env node*/
2 | module.exports = {
3 | plugins: ['prettier-plugin-tailwindcss'],
4 | tailwindConfig: './tailwind.config.js',
5 | };
6 |
--------------------------------------------------------------------------------
/client/src/features/projects/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./routes/AllProjectsPage.tsx";
2 | export * from "./routes/DashboardPage.tsx";
3 | export * from "./routes/ProjectPage.tsx";
4 | export * from "./routes/IssuePage.tsx";
5 |
--------------------------------------------------------------------------------
/server/.env.example:
--------------------------------------------------------------------------------
1 | PORT=MY_SERVER_PORT
2 | JWT_SECRET=MY_SECRET
3 | SEED_PASSWORD=MY_SEED_PASSWORD
4 | DB_USERNAME=MY_DB_USERNAME
5 | DB_PASSWORD=MY_DB_PASSWORD
6 | DB_NAME=MY_DB_NAME
7 | DB_HOST=MY_DB_HOST
8 | DB_PORT=MY_DB_PORT
--------------------------------------------------------------------------------
/server/helpers/error-helper.js:
--------------------------------------------------------------------------------
1 | const customError = (statusCode, message) => {
2 | const error = new Error(message);
3 | error.status = statusCode;
4 | return error;
5 | };
6 |
7 | module.exports = {
8 | customError,
9 | };
10 |
--------------------------------------------------------------------------------
/server/.sequelizerc:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | config: path.resolve('config', 'index.js'),
5 | 'migrations-path': path.resolve('migrations'),
6 | 'models-path': path.resolve('models'),
7 | 'seeders-path': path.resolve('seeders'),
8 | };
9 |
--------------------------------------------------------------------------------
/client/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 | import "./index.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/client/src/lib/react-query.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from "@tanstack/react-query";
2 |
3 | export const queryClient = new QueryClient({
4 | defaultOptions: {
5 | queries: {
6 | staleTime: 10 * (60 * 1000), // 10 mins
7 | cacheTime: 15 * (60 * 1000), // 15 mins
8 | },
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/client/src/features/issues/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./components/AssignIssueSheet.tsx";
2 | export * from "./components/DeleteIssueAlert.tsx";
3 | export * from "./components/EditIssueSheet.tsx";
4 | export * from "./components/IssueSheet.tsx";
5 | export * from "./components/IssuesTable.tsx";
6 | export * from "./components/issueColumns.tsx";
7 |
--------------------------------------------------------------------------------
/client/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig({
7 | plugins: [react()],
8 | resolve: {
9 | alias: {
10 | '@': path.resolve(__dirname, './src'),
11 | },
12 | },
13 | });
14 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.js
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | # logs
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
24 | .env
--------------------------------------------------------------------------------
/client/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "baseUrl": ".",
9 | "paths": {
10 | "@/*": ["./src/*"]
11 | }
12 | },
13 | "include": ["vite.config.ts"]
14 | }
15 |
--------------------------------------------------------------------------------
/client/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
2 |
3 | const Collapsible = CollapsiblePrimitive.Root
4 |
5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
6 |
7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
8 |
9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
10 |
--------------------------------------------------------------------------------
/client/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | .env
15 | .env.development
16 | .env.production
17 |
18 | # Editor directories and files
19 | .vscode/*
20 | !.vscode/extensions.json
21 | .idea
22 | .DS_Store
23 | *.suo
24 | *.ntvs*
25 | *.njsproj
26 | *.sln
27 | *.sw?
28 |
--------------------------------------------------------------------------------
/server/migrations/20230909171510-rename-user-name-to-firstname.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /** @type {import('sequelize-cli').Migration} */
4 | module.exports = {
5 | async up(queryInterface, Sequelize) {
6 | return queryInterface.renameColumn('Users', 'name', 'firstname');
7 | },
8 |
9 | async down(queryInterface, Sequelize) {
10 | return queryInterface.renameColumn('Users', 'firstname', 'name');
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/server/middlewares/auth.js:
--------------------------------------------------------------------------------
1 | const passport = require('../config/passport');
2 |
3 | const authenticated = (req, res, next) => {
4 | passport.authenticate('jwt', { session: false }, (err, user) => {
5 | if (err || !user) {
6 | return res.status(401).json({ status: 'error', message: 'unauthorized' });
7 | }
8 |
9 | next();
10 | })(req, res, next);
11 | };
12 |
13 | module.exports = {
14 | authenticated,
15 | };
16 |
--------------------------------------------------------------------------------
/server/middlewares/error-handler.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | apiErrorHandler(err, req, res, next) {
3 | if (err instanceof Error) {
4 | res.status(err.status || 500).json({
5 | status: 'error',
6 | message: `${err.message}`,
7 | });
8 | } else {
9 | res.status(500).json({
10 | status: 'error',
11 | message: 'Internal server error',
12 | });
13 | }
14 | next(err);
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/server/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "node": true,
5 | "commonjs": true,
6 | "es2021": true
7 | },
8 | "extends": "eslint:recommended",
9 | "parserOptions": {
10 | "ecmaVersion": "latest"
11 | },
12 | "rules": {
13 | "indent": ["error", 2],
14 | "linebreak-style": [0],
15 | "quotes": ["error", "single"],
16 | "semi": ["error", "always"],
17 | "no-unused-vars": "off"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/server/migrations/20230909171816-add-lastname-to-users-table.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /** @type {import('sequelize-cli').Migration} */
4 | module.exports = {
5 | async up(queryInterface, Sequelize) {
6 | return queryInterface.addColumn('Users', 'lastname', {
7 | type: Sequelize.STRING,
8 | allowNull: false,
9 | });
10 | },
11 |
12 | async down(queryInterface, Sequelize) {
13 | return queryInterface.removeColumn('Users', 'lastname');
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/client/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function formatTime(isoString: string) {
9 | const date = new Date(isoString);
10 | const americanDate = new Intl.DateTimeFormat("en-US", {
11 | day: "numeric",
12 | month: "short",
13 | year: "numeric",
14 | }).format(date);
15 | return americanDate;
16 | }
17 |
--------------------------------------------------------------------------------
/client/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/server/config/cors.js:
--------------------------------------------------------------------------------
1 | const whitelist = [
2 | 'http://localhost:5173',
3 | 'http://localhost:4173',
4 | 'https://issuezy.netlify.app',
5 | ];
6 |
7 | const corsOptionsDelegate = (req, callback) => {
8 | let corsOptions = {};
9 | if (whitelist.indexOf(req.header('Origin')) !== -1) {
10 | corsOptions = {
11 | origin: true,
12 | exposedHeaders: ['authorization'],
13 | };
14 | } else {
15 | corsOptions = { origin: false };
16 | }
17 | callback(null, corsOptions);
18 | };
19 |
20 | module.exports = { corsOptionsDelegate };
21 |
--------------------------------------------------------------------------------
/client/src/features/users/routes/TasksPage.tsx:
--------------------------------------------------------------------------------
1 | export function TasksPage() {
2 | return (
3 |
4 |
5 |
6 |
7 |
My tasks
8 |
Work in progress 🛠️
9 |
10 |
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/server/migrations/20230914113812-change-issue-priority-to-string.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /** @type {import('sequelize-cli').Migration} */
4 | module.exports = {
5 | async up(queryInterface, Sequelize) {
6 | return queryInterface.changeColumn('Issues', 'priority', {
7 | type: Sequelize.STRING,
8 | defaultValue: '1',
9 | allowNull: false,
10 | });
11 | },
12 | async down(queryInterface, Sequelize) {
13 | return queryInterface.changeColumn('Issues', 'priority', {
14 | type: Sequelize.INTEGER,
15 | defaultValue: 1,
16 | allowNull: false,
17 | });
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/client/src/lib/axios.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | const baseURL = import.meta.env.VITE_API_ENDPOINT;
4 |
5 | const axiosInstance = axios.create({
6 | baseURL,
7 | });
8 |
9 | export interface ErrorResponseData {
10 | status: "error";
11 | message: string;
12 | }
13 |
14 | axiosInstance.interceptors.request.use(
15 | (config) => {
16 | const token = localStorage.getItem("token");
17 | if (token) {
18 | config.headers.Authorization = `Bearer ${token}`;
19 | }
20 | return config;
21 | },
22 | (error) => {
23 | console.error(error);
24 | },
25 | );
26 |
27 | export { baseURL, axiosInstance };
28 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
12 |
18 |
19 | issuezy
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/client/src/components/UserAvatar.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback } from "@/components/ui/avatar";
2 | import { cn } from "@/utils";
3 |
4 | interface UserAvatarProps extends React.HTMLAttributes {
5 | user: {
6 | id: number;
7 | firstname: string;
8 | lastname: string;
9 | };
10 | }
11 |
12 | export default function UserAvatar({ user, className }: UserAvatarProps) {
13 | return (
14 |
15 |
16 | {user?.firstname[0]}
17 | {user?.lastname[0]}
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/client/src/features/auth/routes/LoginPage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { LoginCard } from "../components/LoginCard";
3 | import { useNavigate } from "react-router-dom";
4 |
5 | export const LoginPage = () => {
6 | const navigate = useNavigate();
7 | useEffect(() => {
8 | const token = localStorage.getItem("token");
9 | if (token) return navigate("/projects");
10 | }, []);
11 |
12 | return (
13 |
14 |
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/client/src/features/auth/routes/SignupPage.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { SignupCard } from "../components/SignupCard";
3 | import { useEffect } from "react";
4 |
5 | export const SignupPage = () => {
6 | const navigate = useNavigate();
7 | useEffect(() => {
8 | const token = localStorage.getItem("token");
9 | if (token) return navigate("/projects");
10 | }, []);
11 |
12 | return (
13 |
14 |
15 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/client/src/features/users/apis/user-api.ts:
--------------------------------------------------------------------------------
1 | import { baseURL, axiosInstance } from "@/lib/axios";
2 |
3 | export type CurrentUser = {
4 | id: number;
5 | firstname: string;
6 | lastname: string;
7 | email: string;
8 | createdAt: string;
9 | };
10 |
11 | export async function getCurrentUser() {
12 | const res = await axiosInstance.get(`${baseURL}/users/current`);
13 | return res.data.data;
14 | }
15 |
16 | export async function patchUser(payload: {
17 | userId: string;
18 | formData: {
19 | firstname: string;
20 | lastname: string;
21 | };
22 | }) {
23 | const { userId, formData } = payload;
24 | const res = await axiosInstance.patch(`${baseURL}/users/${userId}`, formData);
25 | return res.data.data;
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/components/layout/RootLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router-dom";
2 | import SideMenu from "./SideMenu";
3 | import ButtomMenu from "./ButtomMenu";
4 |
5 | export default function RootLayout() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/components/AlertMassage.tsx:
--------------------------------------------------------------------------------
1 | import { Info, Ban } from "lucide-react";
2 | import { Alert, AlertDescription } from "@/components/ui/alert";
3 |
4 | interface AlertMessageProps extends React.HTMLAttributes {
5 | variant: "default" | "destructive";
6 | title?: string;
7 | message: string;
8 | }
9 |
10 | export function AlertMessage({
11 | variant,
12 | message,
13 | className,
14 | }: AlertMessageProps) {
15 | return (
16 |
17 | {variant === "default" ? (
18 |
19 | ) : (
20 |
21 | )}
22 | {message}
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/server/seeders/20230928135226-categories-seed-file.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /** @type {import('sequelize-cli').Migration} */
4 | module.exports = {
5 | async up(queryInterface, Sequelize) {
6 | await queryInterface.bulkInsert(
7 | 'Categories',
8 | ['bug', 'feature request', 'improvement', 'task', 'other'].map((item) => {
9 | return {
10 | name: item,
11 | project_id: null,
12 | is_default: true,
13 | is_deleted: false,
14 | created_at: new Date(),
15 | updated_at: new Date(),
16 | };
17 | }),
18 | {}
19 | );
20 | },
21 |
22 | async down(queryInterface, Sequelize) {
23 | await queryInterface.bulkDelete('Categories', {});
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/server/config/passport.js:
--------------------------------------------------------------------------------
1 | const passport = require('passport');
2 | const passportJWT = require('passport-jwt');
3 | const { User } = require('../models');
4 |
5 | const JWTStrategy = passportJWT.Strategy;
6 | const ExtractJWT = passportJWT.ExtractJwt;
7 |
8 | // setup passport-jwt
9 | const jwtOptions = {
10 | jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
11 | secretOrKey: process.env.JWT_SECRET,
12 | passReqToCallback: true,
13 | };
14 |
15 | passport.use(
16 | new JWTStrategy(jwtOptions, (req, jwtPayload, cb) => {
17 | User.findByPk(jwtPayload.id)
18 | .then((user) => {
19 | req.user = user;
20 | cb(null, user);
21 | })
22 | .catch((err) => cb(err));
23 | })
24 | );
25 |
26 | module.exports = passport;
27 |
--------------------------------------------------------------------------------
/client/src/providers/index.tsx:
--------------------------------------------------------------------------------
1 | import { ThemeProvider } from "@/providers/theme-provider";
2 | import { QueryClientProvider } from "@tanstack/react-query";
3 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
4 | import { RouterProvider } from "react-router-dom";
5 | import { queryClient } from "@/lib/react-query";
6 | import router from "@/routes";
7 |
8 | export default function AppProviders() {
9 | return (
10 | <>
11 |
12 |
13 |
14 |
15 |
16 |
17 | >
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/server/models/membership.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const { Model } = require('sequelize');
3 | module.exports = (sequelize, DataTypes) => {
4 | class Membership extends Model {
5 | /**
6 | * Helper method for defining associations.
7 | * This method is not a part of Sequelize lifecycle.
8 | * The `models/index` file will call this method automatically.
9 | */
10 | static associate(models) {
11 | // define association here
12 | }
13 | }
14 | Membership.init(
15 | {
16 | userId: DataTypes.INTEGER,
17 | projectId: DataTypes.INTEGER,
18 | },
19 | {
20 | sequelize,
21 | modelName: 'Membership',
22 | tableName: 'Memberships',
23 | underscored: true,
24 | }
25 | );
26 | return Membership;
27 | };
28 |
--------------------------------------------------------------------------------
/client/src/components/ModeToggle.tsx:
--------------------------------------------------------------------------------
1 | import { Moon, Sun } from "lucide-react";
2 | import { Button } from "@/components/ui/button";
3 | import { useTheme } from "@/providers/theme-provider";
4 |
5 | export function ModeToggle() {
6 | const { theme, setTheme } = useTheme();
7 | const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark");
8 |
9 | return (
10 | <>
11 |
15 | Toggle theme
16 | >
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/server/app.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | if (process.env.NODE_ENV !== 'production') {
4 | require('dotenv').config({ path: path.resolve(__dirname, './.env') });
5 | }
6 |
7 | const express = require('express');
8 | const cors = require('cors');
9 | const routes = require('./routes');
10 | const db = require('./models');
11 | const passport = require('./config/passport');
12 | const { corsOptionsDelegate } = require('./config/cors');
13 |
14 | const app = express();
15 | const port = process.env.PORT || 3000;
16 |
17 | app.use(express.json());
18 | app.use(passport.initialize());
19 | app.use(cors(corsOptionsDelegate));
20 |
21 | app.use('/api', routes);
22 |
23 | app.listen(port, () => {
24 | console.info(`App listening on http://localhost:${port}`);
25 | });
26 |
27 | module.exports = app;
28 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "preserve",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | "baseUrl": ".",
24 | "paths": {
25 | "@/*": ["./src/*"]
26 | }
27 | },
28 | "include": ["src"],
29 | "references": [{ "path": "./tsconfig.node.json" }]
30 | }
31 |
--------------------------------------------------------------------------------
/client/src/components/ProtectedRoutes.tsx:
--------------------------------------------------------------------------------
1 | import { checkPermission } from "@/features/auth/apis/auth-api";
2 | import { useMutation } from "@tanstack/react-query";
3 | import { useEffect } from "react";
4 | import { Outlet, useNavigate } from "react-router-dom";
5 |
6 | export default function ProtectedRoutes() {
7 | const navigate = useNavigate();
8 | const permissionMutation = useMutation({
9 | mutationFn: checkPermission,
10 | onSuccess: () => navigate("/projects"),
11 | onError: () => {
12 | return navigate("/login");
13 | },
14 | });
15 |
16 | useEffect(() => {
17 | const token = localStorage.getItem("token");
18 | if (!token) return navigate("/login");
19 | permissionMutation.mutate({ token });
20 | }, []);
21 |
22 | return permissionMutation.isSuccess ? : null;
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/utils";
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ));
22 | Label.displayName = LabelPrimitive.Root.displayName;
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/client/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/server/seeders/20230928132342-projects-seed-file.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 |
4 | const { User } = require('../models');
5 |
6 | module.exports = {
7 | async up(queryInterface, Sequelize) {
8 | const rootUser = await User.findOne({
9 | where: {
10 | email: 'root@example.com',
11 | },
12 | raw: true,
13 | });
14 | await queryInterface.bulkInsert('Projects', [
15 | {
16 | name: 'Issuezy',
17 | description:
18 | 'Issues made easy. Managing your issues with team members is no longer a pain!',
19 | creator_id: rootUser.id,
20 | is_public: true,
21 | is_deleted: false,
22 | created_at: new Date(),
23 | updated_at: new Date(),
24 | },
25 | ]);
26 | },
27 |
28 | async down(queryInterface, Sequelize) {
29 | await queryInterface.bulkDelete('Projects', {});
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/client/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | },
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/server/seeders/20230928134227-memberships-seed-file.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 |
4 | const { User, Project } = require('../models');
5 |
6 | module.exports = {
7 | async up(queryInterface, Sequelize) {
8 | const rootProject = await Project.findOne({
9 | where: {
10 | name: 'Issuezy',
11 | },
12 | raw: true,
13 | });
14 | const users = await queryInterface.sequelize.query(
15 | 'SELECT id FROM Users;',
16 | { type: queryInterface.sequelize.QueryTypes.SELECT }
17 | );
18 | await queryInterface.bulkInsert(
19 | 'Memberships',
20 | Array.from({ length: 3 }, (_, i) => ({
21 | user_id: users[i].id,
22 | project_id: rootProject.id,
23 | created_at: new Date(),
24 | updated_at: new Date(),
25 | }))
26 | );
27 | },
28 |
29 | async down(queryInterface, Sequelize) {
30 | await queryInterface.bulkDelete('Memberships', {});
31 | },
32 | };
33 |
--------------------------------------------------------------------------------
/server/controllers/comment-controller.js:
--------------------------------------------------------------------------------
1 | const commentService = require('../services/comment-service');
2 |
3 | const commentController = {
4 | getComments: (req, res, next) => {
5 | commentService.getComments(req, (err, data) => {
6 | if (err) return next(err);
7 | res.json({ status: 'success', data });
8 | });
9 | },
10 | postComment: (req, res, next) => {
11 | commentService.postComment(req, (err, data) => {
12 | if (err) return next(err);
13 | res.json({ status: 'success', data });
14 | });
15 | },
16 | patchComment: (req, res, next) => {
17 | commentService.patchComment(req, (err, data) => {
18 | if (err) return next(err);
19 | res.json({ status: 'success', data });
20 | });
21 | },
22 | deleteComment: (req, res, next) => {
23 | commentService.deleteComment(req, (err, data) => {
24 | if (err) return next(err);
25 | res.json({ status: 'success', data });
26 | });
27 | },
28 | };
29 |
30 | module.exports = commentController;
31 |
--------------------------------------------------------------------------------
/server/config/index.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | if (process.env.NODE_ENV !== 'production') {
4 | require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
5 | }
6 |
7 | const config = {
8 | development: {
9 | username: process.env.DB_USERNAME,
10 | password: process.env.DB_PASSWORD,
11 | database: process.env.DB_NAME,
12 | host: process.env.DB_HOST,
13 | port: process.env.DB_PORT,
14 | dialect: 'mysql',
15 | },
16 | test: {
17 | username: process.env.DB_USERNAME,
18 | password: process.env.DB_PASSWORD,
19 | database: process.env.DB_NAME,
20 | host: process.env.DB_HOST,
21 | port: process.env.DB_PORT,
22 | dialect: 'mysql',
23 | },
24 | production: {
25 | username: process.env.MYSQL_USERNAME,
26 | password: process.env.MYSQL_PASSWORD,
27 | database: process.env.MYSQL_NAME,
28 | host: process.env.MYSQL_HOSTNAME,
29 | port: process.env.MYSQL_PORT,
30 | dialect: 'mysql',
31 | },
32 | };
33 |
34 | module.exports = config;
35 |
--------------------------------------------------------------------------------
/server/migrations/20230829091856-create-user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 | module.exports = {
4 | async up(queryInterface, Sequelize) {
5 | await queryInterface.createTable('Users', {
6 | id: {
7 | allowNull: false,
8 | autoIncrement: true,
9 | primaryKey: true,
10 | type: Sequelize.INTEGER,
11 | },
12 | name: {
13 | allowNull: false,
14 | type: Sequelize.STRING,
15 | },
16 | email: {
17 | allowNull: false,
18 | type: Sequelize.STRING,
19 | },
20 | password: {
21 | allowNull: false,
22 | type: Sequelize.STRING,
23 | },
24 | created_at: {
25 | allowNull: false,
26 | type: Sequelize.DATE,
27 | },
28 | updated_at: {
29 | allowNull: false,
30 | type: Sequelize.DATE,
31 | },
32 | });
33 | },
34 | async down(queryInterface, _Sequelize) {
35 | await queryInterface.dropTable('Users');
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/server/controllers/category-controller.js:
--------------------------------------------------------------------------------
1 | const categoryService = require('../services/category-service');
2 |
3 | const categoryController = {
4 | getCategories: (req, res, next) => {
5 | categoryService.getCategories(req, (err, data) => {
6 | if (err) return next(err);
7 | res.json({ status: 'success', data });
8 | });
9 | },
10 | postCategory: (req, res, next) => {
11 | categoryService.postCategory(req, (err, data) => {
12 | if (err) return next(err);
13 | res.json({ status: 'success', data });
14 | });
15 | },
16 | patchCategory: (req, res, next) => {
17 | categoryService.patchCategory(req, (err, data) => {
18 | if (err) return next(err);
19 | res.json({ status: 'success', data });
20 | });
21 | },
22 | deleteCategory: (req, res, next) => {
23 | categoryService.deleteCategory(req, (err, data) => {
24 | if (err) return next(err);
25 | res.json({ status: 'success', data });
26 | });
27 | },
28 | };
29 |
30 | module.exports = categoryController;
31 |
--------------------------------------------------------------------------------
/server/models/category.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const { Model } = require('sequelize');
3 | module.exports = (sequelize, DataTypes) => {
4 | class Category extends Model {
5 | /**
6 | * Helper method for defining associations.
7 | * This method is not a part of Sequelize lifecycle.
8 | * The `models/index` file will call this method automatically.
9 | */
10 | static associate(models) {
11 | // define association here
12 | Category.belongsTo(models.Project, {
13 | foreignKey: 'projectId',
14 | as: 'Project',
15 | });
16 | Category.hasMany(models.Issue, { foreignKey: 'categoryId' });
17 | }
18 | }
19 | Category.init(
20 | {
21 | name: DataTypes.STRING,
22 | projectId: DataTypes.INTEGER,
23 | isDefault: DataTypes.BOOLEAN,
24 | isDeleted: DataTypes.BOOLEAN,
25 | },
26 | {
27 | sequelize,
28 | modelName: 'Category',
29 | tableName: 'Categories',
30 | underscored: true,
31 | }
32 | );
33 | return Category;
34 | };
35 |
--------------------------------------------------------------------------------
/server/models/comment.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const { Model } = require('sequelize');
3 | module.exports = (sequelize, DataTypes) => {
4 | class Comment extends Model {
5 | /**
6 | * Helper method for defining associations.
7 | * This method is not a part of Sequelize lifecycle.
8 | * The `models/index` file will call this method automatically.
9 | */
10 | static associate(models) {
11 | // define association here
12 | Comment.belongsTo(models.Issue, {
13 | foreignKey: 'issueId',
14 | as: 'Issue',
15 | });
16 | Comment.belongsTo(models.User, {
17 | foreignKey: 'userId',
18 | as: 'User',
19 | });
20 | }
21 | }
22 | Comment.init(
23 | {
24 | text: DataTypes.STRING,
25 | issueId: DataTypes.INTEGER,
26 | userId: DataTypes.INTEGER,
27 | isDeleted: DataTypes.BOOLEAN,
28 | },
29 | {
30 | sequelize,
31 | modelName: 'Comment',
32 | tableName: 'Comments',
33 | underscored: true,
34 | }
35 | );
36 | return Comment;
37 | };
38 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "issuezy",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "start": "node app.js",
8 | "dev": "nodemon app.js",
9 | "db:migrate": "npx sequelize-cli db:migrate",
10 | "db:seed": "npx sequelize-cli db:seed:all",
11 | "db:unseed": "npx sequelize-cli db:seed:undo:all",
12 | "lint": "eslint \"**/*.js\" --fix",
13 | "test": "echo \"Error: no test specified\" && exit 1"
14 | },
15 | "license": "ISC",
16 | "dependencies": {
17 | "bcryptjs": "^2.4.3",
18 | "cors": "^2.8.5",
19 | "dotenv": "^16.3.1",
20 | "express": "^4.18.2",
21 | "jsonwebtoken": "^9.0.1",
22 | "method-override": "^3.0.0",
23 | "mysql2": "^3.6.0",
24 | "passport": "^0.6.0",
25 | "passport-jwt": "^4.0.1",
26 | "sequelize": "^6.32.1",
27 | "sequelize-cli": "^6.6.1"
28 | },
29 | "devDependencies": {
30 | "eslint": "^8.48.0",
31 | "eslint-config-airbnb-base": "^15.0.0",
32 | "eslint-plugin-import": "^2.28.1",
33 | "nodemon": "^3.0.1"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/features/users/routes/SettingsPage.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import UserProfileForm from "../components/UserProfileForm";
3 | import { CurrentUser, getCurrentUser } from "../apis/user-api";
4 | import Spinner from "@/components/ui/spinner";
5 |
6 | export function SettingsPage() {
7 | const currentUserQuery = useQuery({
8 | queryKey: ["currentUser"],
9 | queryFn: getCurrentUser,
10 | });
11 |
12 | if (currentUserQuery.isFetching || currentUserQuery.isLoading)
13 | return ;
14 |
15 | const currentUser = currentUserQuery.data.currentUser as CurrentUser;
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
Settings
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/client/src/features/auth/apis/auth-api.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { baseURL } from "@/lib/axios";
3 |
4 | const axiosInstance = axios.create({
5 | baseURL,
6 | });
7 |
8 | export async function userLogin(payload: { email: string; password: string }) {
9 | const { email, password } = payload;
10 | const { data } = await axiosInstance.post(`${baseURL}/users/signin`, {
11 | email,
12 | password,
13 | });
14 | return data;
15 | }
16 |
17 | export async function userSignup(payload: {
18 | firstname: string;
19 | lastname: string;
20 | email: string;
21 | password: string;
22 | passwordCheck: string;
23 | }) {
24 | const { firstname, lastname, email, password, passwordCheck } = payload;
25 | const { data } = await axiosInstance.post(`${baseURL}/users/signup`, {
26 | firstname,
27 | lastname,
28 | email,
29 | password,
30 | passwordCheck,
31 | });
32 | return data;
33 | }
34 |
35 | export async function checkPermission({ token }: { token: string }) {
36 | const { data } = await axiosInstance.post(`${baseURL}/users/permission`, {
37 | token,
38 | });
39 | return data;
40 | }
41 |
--------------------------------------------------------------------------------
/server/controllers/user-controller.js:
--------------------------------------------------------------------------------
1 | const userService = require('../services/user-service');
2 |
3 | const userController = {
4 | signUp: (req, res, next) => {
5 | userService.signUpUser(req, (err, data) => {
6 | if (err) return next(err);
7 | res.json({ status: 'success', data });
8 | });
9 | },
10 | signIn: (req, res, next) => {
11 | userService.signInUser(req, (err, data) => {
12 | if (err) return next(err);
13 | res.json({ status: 'success', data });
14 | });
15 | },
16 | checkPermission: (req, res, next) => {
17 | userService.checkPermission(req, (err, data) => {
18 | if (err) return next(err);
19 | res.json({ status: 'success', data });
20 | });
21 | },
22 | getCurrentUser: (req, res, next) => {
23 | userService.getCurrentUser(req, (err, data) => {
24 | if (err) return next(err);
25 | res.json({ status: 'success', data });
26 | });
27 | },
28 | patchUser: (req, res, next) => {
29 | userService.patchUser(req, (err, data) => {
30 | if (err) return next(err);
31 | res.json({ status: 'success', data });
32 | });
33 | },
34 | };
35 |
36 | module.exports = userController;
37 |
--------------------------------------------------------------------------------
/server/models/project.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const { Model } = require('sequelize');
3 | module.exports = (sequelize, DataTypes) => {
4 | class Project extends Model {
5 | /**
6 | * Helper method for defining associations.
7 | * This method is not a part of Sequelize lifecycle.
8 | * The `models/index` file will call this method automatically.
9 | */
10 | static associate(models) {
11 | Project.belongsTo(models.User, {
12 | foreignKey: 'creatorId',
13 | as: 'Creator',
14 | });
15 | Project.belongsToMany(models.User, {
16 | through: models.Membership,
17 | foreignKey: 'projectId',
18 | as: 'Members',
19 | });
20 | Project.hasMany(models.Category, { foreignKey: 'projectId' });
21 | Project.hasMany(models.Issue, { foreignKey: 'projectId' });
22 | }
23 | }
24 | Project.init(
25 | {
26 | name: DataTypes.STRING,
27 | description: DataTypes.TEXT,
28 | creatorId: DataTypes.INTEGER,
29 | isPublic: DataTypes.BOOLEAN,
30 | isDeleted: DataTypes.BOOLEAN,
31 | },
32 | {
33 | sequelize,
34 | modelName: 'Project',
35 | tableName: 'Projects',
36 | underscored: true,
37 | }
38 | );
39 | return Project;
40 | };
41 |
--------------------------------------------------------------------------------
/client/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SwitchPrimitives from "@radix-ui/react-switch";
3 |
4 | import { cn } from "@/utils";
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/server/migrations/20230830114812-create-membership.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 | module.exports = {
4 | async up(queryInterface, Sequelize) {
5 | await queryInterface.createTable('Memberships', {
6 | id: {
7 | allowNull: false,
8 | autoIncrement: true,
9 | primaryKey: true,
10 | type: Sequelize.INTEGER,
11 | },
12 | user_id: {
13 | allowNull: false,
14 | type: Sequelize.INTEGER,
15 | references: {
16 | model: 'Users',
17 | key: 'id',
18 | },
19 | onDelete: 'CASCADE',
20 | },
21 | project_id: {
22 | allowNull: false,
23 | type: Sequelize.INTEGER,
24 | references: {
25 | model: 'Projects',
26 | key: 'id',
27 | },
28 | // membership referencing project id will be deleted as well when the project is deleted
29 | onDelete: 'CASCADE',
30 | },
31 | created_at: {
32 | allowNull: false,
33 | type: Sequelize.DATE,
34 | },
35 | updated_at: {
36 | allowNull: false,
37 | type: Sequelize.DATE,
38 | },
39 | });
40 | },
41 | async down(queryInterface, Sequelize) {
42 | await queryInterface.dropTable('Memberships');
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/server/migrations/20230831085418-create-category.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 | module.exports = {
4 | async up(queryInterface, Sequelize) {
5 | await queryInterface.createTable('Categories', {
6 | id: {
7 | allowNull: false,
8 | autoIncrement: true,
9 | primaryKey: true,
10 | type: Sequelize.INTEGER,
11 | },
12 | name: {
13 | allowNull: false,
14 | type: Sequelize.STRING,
15 | },
16 | project_id: {
17 | references: {
18 | model: 'Projects',
19 | key: 'id',
20 | },
21 | onDelete: 'SET NULL',
22 | type: Sequelize.INTEGER,
23 | },
24 | is_default: {
25 | allowNull: false,
26 | defaultValue: false,
27 | type: Sequelize.BOOLEAN,
28 | },
29 | is_deleted: {
30 | allowNull: false,
31 | defaultValue: false,
32 | type: Sequelize.BOOLEAN,
33 | },
34 | created_at: {
35 | allowNull: false,
36 | type: Sequelize.DATE,
37 | },
38 | updated_at: {
39 | allowNull: false,
40 | type: Sequelize.DATE,
41 | },
42 | });
43 | },
44 | async down(queryInterface, Sequelize) {
45 | await queryInterface.dropTable('Categories');
46 | },
47 | };
48 |
--------------------------------------------------------------------------------
/server/seeders/20230928131712-users-seed-file.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 | const bcrypt = require('bcryptjs');
4 |
5 | const seedPassword = process.env.SEED_PASSWORD;
6 |
7 | module.exports = {
8 | async up(queryInterface, Sequelize) {
9 | await queryInterface.bulkInsert(
10 | 'Users',
11 | [
12 | {
13 | firstname: 'Jessica',
14 | lastname: 'Chen',
15 | email: 'root@example.com',
16 | password: await bcrypt.hash(seedPassword, 10),
17 | created_at: new Date(),
18 | updated_at: new Date(),
19 | },
20 | {
21 | firstname: 'Melody',
22 | lastname: 'Lin',
23 | email: 'user1@example.com',
24 | password: await bcrypt.hash(seedPassword, 10),
25 | created_at: new Date(),
26 | updated_at: new Date(),
27 | },
28 | {
29 | firstname: 'Evelyn',
30 | lastname: 'Wang',
31 | email: 'user2@example.com',
32 | password: await bcrypt.hash(seedPassword, 10),
33 | created_at: new Date(),
34 | updated_at: new Date(),
35 | },
36 | ],
37 | {}
38 | );
39 | },
40 |
41 | async down(queryInterface, Sequelize) {
42 | await queryInterface.bulkDelete('Users', {});
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/server/migrations/20230902195109-create-comment.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 | module.exports = {
4 | async up(queryInterface, Sequelize) {
5 | await queryInterface.createTable('Comments', {
6 | id: {
7 | allowNull: false,
8 | autoIncrement: true,
9 | primaryKey: true,
10 | type: Sequelize.INTEGER,
11 | },
12 | text: {
13 | allowNull: false,
14 | type: Sequelize.STRING,
15 | },
16 | issue_id: {
17 | allowNull: false,
18 | references: {
19 | model: 'Issues',
20 | key: 'id',
21 | },
22 | type: Sequelize.INTEGER,
23 | },
24 | user_id: {
25 | allowNull: false,
26 | references: {
27 | model: 'Users',
28 | key: 'id',
29 | },
30 | type: Sequelize.INTEGER,
31 | },
32 | is_deleted: {
33 | allowNull: false,
34 | defaultValue: false,
35 | type: Sequelize.BOOLEAN,
36 | },
37 | created_at: {
38 | allowNull: false,
39 | type: Sequelize.DATE,
40 | },
41 | updated_at: {
42 | allowNull: false,
43 | type: Sequelize.DATE,
44 | },
45 | });
46 | },
47 | async down(queryInterface, Sequelize) {
48 | await queryInterface.dropTable('Comments');
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/server/controllers/issue-controller.js:
--------------------------------------------------------------------------------
1 | const issueService = require('../services/issue-service');
2 |
3 | const issueController = {
4 | getIssues: (req, res, next) => {
5 | issueService.getIssues(req, (err, data) => {
6 | if (err) return next(err);
7 | res.json({ status: 'success', data });
8 | });
9 | },
10 | getIssue: (req, res, next) => {
11 | issueService.getIssue(req, (err, data) => {
12 | if (err) return next(err);
13 | res.json({ status: 'success', data });
14 | });
15 | },
16 | postIssue: (req, res, next) => {
17 | issueService.postIssue(req, (err, data) => {
18 | if (err) return next(err);
19 | res.json({ status: 'success', data });
20 | });
21 | },
22 | patchIssue: (req, res, next) => {
23 | issueService.patchIssue(req, (err, data) => {
24 | if (err) return next(err);
25 | res.json({ status: 'success', data });
26 | });
27 | },
28 | deleteIssue: (req, res, next) => {
29 | issueService.deleteIssue(req, (err, data) => {
30 | if (err) return next(err);
31 | res.json({ status: 'success', data });
32 | });
33 | },
34 | assignIssue: (req, res, next) => {
35 | issueService.assignIssue(req, (err, data) => {
36 | if (err) return next(err);
37 | res.json({ status: 'success', data });
38 | });
39 | },
40 | };
41 |
42 | module.exports = issueController;
43 |
--------------------------------------------------------------------------------
/server/migrations/20230829142038-create-project.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 | module.exports = {
4 | async up(queryInterface, Sequelize) {
5 | await queryInterface.createTable('Projects', {
6 | id: {
7 | allowNull: false,
8 | autoIncrement: true,
9 | primaryKey: true,
10 | type: Sequelize.INTEGER,
11 | },
12 | name: {
13 | allowNull: false,
14 | type: Sequelize.STRING,
15 | },
16 | description: {
17 | type: Sequelize.TEXT,
18 | },
19 | creator_id: {
20 | allowNull: false,
21 | type: Sequelize.INTEGER,
22 | references: {
23 | model: 'Users',
24 | key: 'id',
25 | },
26 | },
27 | is_public: {
28 | allowNull: false,
29 | type: Sequelize.BOOLEAN,
30 | defaultValue: true,
31 | },
32 | is_deleted: {
33 | allowNull: false,
34 | type: Sequelize.BOOLEAN,
35 | defaultValue: false,
36 | },
37 | created_at: {
38 | allowNull: false,
39 | type: Sequelize.DATE,
40 | },
41 | updated_at: {
42 | allowNull: false,
43 | type: Sequelize.DATE,
44 | },
45 | });
46 | },
47 | async down(queryInterface, Sequelize) {
48 | await queryInterface.dropTable('Projects');
49 | },
50 | };
51 |
--------------------------------------------------------------------------------
/client/src/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Navigate,
3 | Route,
4 | createBrowserRouter,
5 | createRoutesFromElements,
6 | } from "react-router-dom";
7 | import { SignupPage, LoginPage } from "@/features/auth";
8 | import {
9 | AllProjectsPage,
10 | DashboardPage,
11 | ProjectPage,
12 | IssuePage,
13 | } from "@/features/projects";
14 | import { SettingsPage, TasksPage } from "@/features/users";
15 |
16 | import ProtectedRoutes from "@/components/ProtectedRoutes";
17 | import RootLayout from "@/components/layout/RootLayout";
18 |
19 | const router = createBrowserRouter(
20 | createRoutesFromElements(
21 | <>
22 | } />
23 | } />
24 | }>
25 | }>
26 | } />
27 | } />
28 | } />
29 | } />
30 | } />
31 | } />
32 |
33 |
34 | } />
35 | >,
36 | ),
37 | );
38 |
39 | export default router;
40 |
--------------------------------------------------------------------------------
/server/models/index.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const fs = require('fs');
4 | const path = require('path');
5 | require('dotenv').config();
6 | const Sequelize = require('sequelize');
7 | const process = require('process');
8 | const basename = path.basename(__filename);
9 | const env = process.env.NODE_ENV || 'development';
10 | const config = require(`${__dirname}/../config/index.js`)[env];
11 | const db = {};
12 |
13 | let sequelize;
14 | if (config.use_env_variable) {
15 | sequelize = new Sequelize(process.env[config.use_env_variable], config);
16 | } else {
17 | sequelize = new Sequelize(
18 | config.database,
19 | config.username,
20 | config.password,
21 | config
22 | );
23 | }
24 |
25 | fs.readdirSync(__dirname)
26 | .filter(
27 | (file) =>
28 | file.indexOf('.') !== 0 &&
29 | file !== basename &&
30 | file.slice(-3) === '.js' &&
31 | file.indexOf('.test.js') === -1
32 | )
33 | .forEach((file) => {
34 | const model = require(path.join(__dirname, file))(
35 | sequelize,
36 | Sequelize.DataTypes
37 | );
38 | db[model.name] = model;
39 | });
40 |
41 | Object.keys(db).forEach((modelName) => {
42 | if (db[modelName].associate) {
43 | db[modelName].associate(db);
44 | }
45 | });
46 |
47 | db.sequelize = sequelize;
48 | db.Sequelize = Sequelize;
49 |
50 | db.sequelize
51 | .authenticate()
52 | .then(() => console.log('Database connected'))
53 | .catch((err) => console.log(err));
54 |
55 | module.exports = db;
56 |
--------------------------------------------------------------------------------
/server/models/user.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const { Model } = require('sequelize');
3 | module.exports = (sequelize, DataTypes) => {
4 | class User extends Model {
5 | /**
6 | * Helper method for defining associations.
7 | * This method is not a part of Sequelize lifecycle.
8 | * The `models/index` file will call this method automatically.
9 | */
10 | static associate(models) {
11 | User.hasMany(models.Project, { foreignKey: 'creatorId' });
12 | User.belongsToMany(models.Project, {
13 | through: models.Membership,
14 | foreignKey: 'userId',
15 | as: 'JoinedProjects',
16 | });
17 | User.hasMany(models.Issue, {
18 | foreignKey: 'reporterId',
19 | as: 'ReportedIssues',
20 | });
21 | User.hasMany(models.Issue, {
22 | foreignKey: 'assigneeId',
23 | as: 'AssignedIssues',
24 | });
25 | User.hasMany(models.Comment, { foreignKey: 'userId' });
26 | }
27 | }
28 | User.init(
29 | {
30 | firstname: DataTypes.STRING,
31 | lastname: DataTypes.STRING,
32 | email: DataTypes.STRING,
33 | password: DataTypes.STRING,
34 | },
35 | {
36 | sequelize,
37 | modelName: 'User',
38 | tableName: 'Users',
39 | underscored: true,
40 | defaultScope: {
41 | attributes: { exclude: ['password'] },
42 | },
43 | scopes: {
44 | withPassword: {
45 | attributes: {},
46 | },
47 | },
48 | }
49 | );
50 | return User;
51 | };
52 |
--------------------------------------------------------------------------------
/server/models/issue.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | const { Model } = require('sequelize');
3 | module.exports = (sequelize, DataTypes) => {
4 | class Issue extends Model {
5 | /**
6 | * Helper method for defining associations.
7 | * This method is not a part of Sequelize lifecycle.
8 | * The `models/index` file will call this method automatically.
9 | */
10 | static associate(models) {
11 | // define association here
12 | Issue.belongsTo(models.Project, {
13 | foreignKey: 'projectId',
14 | as: 'Project',
15 | });
16 | Issue.belongsTo(models.Category, {
17 | foreignKey: 'categoryId',
18 | as: 'Category',
19 | });
20 | Issue.belongsTo(models.User, {
21 | foreignKey: 'reporterId',
22 | as: 'Reporter',
23 | });
24 | Issue.belongsTo(models.User, {
25 | foreignKey: 'assigneeId',
26 | as: 'Assignee',
27 | });
28 | Issue.hasMany(models.Comment, { foreignKey: 'issueId' });
29 | }
30 | }
31 | Issue.init(
32 | {
33 | title: DataTypes.STRING,
34 | description: DataTypes.STRING,
35 | status: DataTypes.STRING,
36 | priority: DataTypes.INTEGER,
37 | categoryId: DataTypes.INTEGER,
38 | projectId: DataTypes.INTEGER,
39 | reporterId: DataTypes.INTEGER,
40 | assigneeId: DataTypes.INTEGER,
41 | isDeleted: DataTypes.BOOLEAN,
42 | },
43 | {
44 | sequelize,
45 | modelName: 'Issue',
46 | tableName: 'Issues',
47 | underscored: true,
48 | }
49 | );
50 | return Issue;
51 | };
52 |
--------------------------------------------------------------------------------
/client/src/features/categories/apis/category-api.ts:
--------------------------------------------------------------------------------
1 | import { baseURL, axiosInstance } from "@/lib/axios";
2 |
3 | export type Category = {
4 | id: number;
5 | name: string;
6 | isDefault: boolean;
7 | };
8 |
9 | // * 取得專案所有 category
10 | export async function getCategories(payload: { projectId: string }) {
11 | const { projectId } = payload;
12 | const res = await axiosInstance.get(
13 | `${baseURL}/projects/${projectId}/categories`,
14 | );
15 | return res.data.data;
16 | }
17 |
18 | // * 新增一個 category
19 | export async function postCategory(payload: {
20 | projectId: string;
21 | name: string;
22 | }) {
23 | const { projectId, name } = payload;
24 | const res = await axiosInstance.post(
25 | `${baseURL}/projects/${projectId}/categories`,
26 | {
27 | name,
28 | },
29 | );
30 | return res.data.data;
31 | }
32 |
33 | // * 修改一個 category
34 | export async function patchCategory(payload: {
35 | projectId: string;
36 | categoryId: string;
37 | name: string;
38 | }) {
39 | const { projectId, categoryId, name } = payload;
40 | const res = await axiosInstance.patch(
41 | `${baseURL}/projects/${projectId}/categories/${categoryId}`,
42 | {
43 | name,
44 | },
45 | );
46 | return res.data.data;
47 | }
48 |
49 | // * 刪除一個 category
50 | export async function deleteCategory(payload: {
51 | projectId: string;
52 | categoryId: string;
53 | }) {
54 | const { projectId, categoryId } = payload;
55 | const res = await axiosInstance.delete(
56 | `${baseURL}/projects/${projectId}/categories/${categoryId}`,
57 | );
58 | return res.data.data;
59 | }
60 |
--------------------------------------------------------------------------------
/client/src/components/ui/spinner.tsx:
--------------------------------------------------------------------------------
1 | export default function Spinner() {
2 | return (
3 |
4 |
20 |
Loading...
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/client/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
3 |
4 | import { cn } from "@/utils";
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ));
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ));
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
47 |
48 | export { Avatar, AvatarImage, AvatarFallback };
49 |
--------------------------------------------------------------------------------
/client/src/components/ui/radio-group.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
3 | import { Circle } from "lucide-react";
4 |
5 | import { cn } from "@/utils";
6 |
7 | const RadioGroup = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => {
11 | return (
12 |
17 | );
18 | });
19 | RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
20 |
21 | const RadioGroupItem = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, children, ...props }, ref) => {
25 | return (
26 |
34 |
35 |
36 |
37 |
38 | );
39 | });
40 | RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
41 |
42 | export { RadioGroup, RadioGroupItem };
43 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 224 71.4% 4.1%;
9 | --card: 0 0% 100%;
10 | --card-foreground: 224 71.4% 4.1%;
11 | --popover: 0 0% 100%;
12 | --popover-foreground: 224 71.4% 4.1%;
13 | --primary: 262.1 83.3% 57.8%;
14 | --primary-foreground: 210 20% 98%;
15 | --secondary: 220 14.3% 95.9%;
16 | --secondary-foreground: 220.9 39.3% 11%;
17 | --muted: 220 14.3% 95.9%;
18 | --muted-foreground: 220 8.9% 46.1%;
19 | --accent: 220 14.3% 95.9%;
20 | --accent-foreground: 220.9 39.3% 11%;
21 | --destructive: 0 84.2% 60.2%;
22 | --destructive-foreground: 210 20% 98%;
23 | --border: 220 13% 91%;
24 | --input: 220 13% 91%;
25 | --ring: 262.1 83.3% 57.8%;
26 | --radius: 0.5rem;
27 | }
28 |
29 | .dark {
30 | --background: 224 71.4% 4.1%;
31 | --foreground: 210 20% 98%;
32 | --card: 224 71.4% 4.1%;
33 | --card-foreground: 210 20% 98%;
34 | --popover: 224 71.4% 4.1%;
35 | --popover-foreground: 210 20% 98%;
36 | --primary: 263.4 70% 50.4%;
37 | --primary-foreground: 210 20% 98%;
38 | --secondary: 215 27.9% 16.9%;
39 | --secondary-foreground: 210 20% 98%;
40 | --muted: 215 27.9% 16.9%;
41 | --muted-foreground: 217.9 10.6% 64.9%;
42 | --accent: 215 27.9% 16.9%;
43 | --accent-foreground: 210 20% 98%;
44 | --destructive: 0 62.8% 50.6%;
45 | --destructive-foreground: 210 20% 98%;
46 | --border: 215 27.9% 16.9%;
47 | --input: 215 27.9% 16.9%;
48 | --ring: 263.4 70% 50.4%;
49 | }
50 | }
51 |
52 | @layer base {
53 | * {
54 | @apply border-border;
55 | }
56 | body {
57 | @apply bg-background text-foreground;
58 | @apply lg:overflow-hidden;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/server/controllers/project-controller.js:
--------------------------------------------------------------------------------
1 | const projectService = require('../services/project-service');
2 |
3 | const projectController = {
4 | getProjects: (req, res, next) => {
5 | projectService.getProjects(req, (err, data) => {
6 | if (err) return next(err);
7 | res.json({ status: 'success', data });
8 | });
9 | },
10 | getProject: (req, res, next) => {
11 | projectService.getProject(req, (err, data) => {
12 | if (err) return next(err);
13 | res.json({ status: 'success', data });
14 | });
15 | },
16 | postProject: (req, res, next) => {
17 | projectService.postProject(req, (err, data) => {
18 | if (err) return next(err);
19 | res.json({ status: 'success', data });
20 | });
21 | },
22 | patchProject: (req, res, next) => {
23 | projectService.patchProject(req, (err, data) => {
24 | if (err) return next(err);
25 | res.json({ status: 'success', data });
26 | });
27 | },
28 | deleteProject: (req, res, next) => {
29 | projectService.deleteProject(req, (err, data) => {
30 | if (err) return next(err);
31 | res.json({ status: 'success', data });
32 | });
33 | },
34 | getMembers: (req, res, next) => {
35 | projectService.getMembers(req, (err, data) => {
36 | if (err) return next(err);
37 | res.json({ status: 'success', data });
38 | });
39 | },
40 | addMember: (req, res, next) => {
41 | projectService.addMember(req, (err, data) => {
42 | if (err) return next(err);
43 | res.json({ status: 'success', data });
44 | });
45 | },
46 | removeMember: (req, res, next) => {
47 | projectService.removeMember(req, (err, data) => {
48 | if (err) return next(err);
49 | res.json({ status: 'success', data });
50 | });
51 | },
52 | };
53 |
54 | module.exports = projectController;
55 |
--------------------------------------------------------------------------------
/client/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
3 |
4 | import { cn } from "@/utils";
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ));
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ));
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
45 |
46 | export { ScrollArea, ScrollBar };
47 |
--------------------------------------------------------------------------------
/client/src/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/utils";
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-[1.1rem] [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "bg-transparent text-blue-500 border-blue-500 [&>svg]:text-blue-500",
13 | destructive:
14 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
15 | },
16 | },
17 | defaultVariants: {
18 | variant: "default",
19 | },
20 | },
21 | );
22 |
23 | const Alert = React.forwardRef<
24 | HTMLDivElement,
25 | React.HTMLAttributes & VariantProps
26 | >(({ className, variant, ...props }, ref) => (
27 |
33 | ));
34 | Alert.displayName = "Alert";
35 |
36 | const AlertTitle = React.forwardRef<
37 | HTMLParagraphElement,
38 | React.HTMLAttributes
39 | >(({ className, ...props }, ref) => (
40 |
45 | ));
46 | AlertTitle.displayName = "AlertTitle";
47 |
48 | const AlertDescription = React.forwardRef<
49 | HTMLParagraphElement,
50 | React.HTMLAttributes
51 | >(({ className, ...props }, ref) => (
52 |
57 | ));
58 | AlertDescription.displayName = "AlertDescription";
59 |
60 | export { Alert, AlertTitle, AlertDescription };
61 |
--------------------------------------------------------------------------------
/client/src/features/comments/apis/comment-api.ts:
--------------------------------------------------------------------------------
1 | import { baseURL, axiosInstance } from "@/lib/axios";
2 |
3 | export type Comment = {
4 | id: number;
5 | text: string;
6 | issueId: number;
7 | userId: number;
8 | createdAt: string;
9 | User: {
10 | id: number;
11 | firstname: string;
12 | lastname: string;
13 | };
14 | };
15 |
16 | // * 取得 issue 所有留言
17 | export async function getComments(payload: {
18 | projectId: string;
19 | issueId: string;
20 | }) {
21 | const { projectId, issueId } = payload;
22 | const res = await axiosInstance.get(
23 | `${baseURL}/projects/${projectId}/issues/${issueId}/comments`,
24 | );
25 | return res.data.data;
26 | }
27 |
28 | // * 新增一筆留言
29 | export async function postComment(payload: {
30 | projectId: string;
31 | issueId: string;
32 | text: string;
33 | }) {
34 | const { projectId, issueId, text } = payload;
35 | const res = await axiosInstance.post(
36 | `${baseURL}/projects/${projectId}/issues/${issueId}/comments`,
37 | {
38 | text,
39 | },
40 | );
41 | return res.data.data;
42 | }
43 |
44 | // * 修改一筆留言
45 | export async function patchComment(payload: {
46 | projectId: string;
47 | issueId: string;
48 | commentId: string;
49 | text: string;
50 | }) {
51 | const { projectId, issueId, commentId, text } = payload;
52 | const res = await axiosInstance.patch(
53 | `${baseURL}/projects/${projectId}/issues/${issueId}/comments/${commentId}`,
54 | {
55 | text,
56 | },
57 | );
58 | return res.data.data;
59 | }
60 |
61 | // * 刪除一筆留言
62 | export async function deleteComment(payload: {
63 | projectId: string;
64 | issueId: string;
65 | commentId: string;
66 | }) {
67 | const { projectId, issueId, commentId } = payload;
68 | const res = await axiosInstance.delete(
69 | `${baseURL}/projects/${projectId}/issues/${issueId}/comments/${commentId}`,
70 | );
71 | return res.data.data;
72 | }
73 |
--------------------------------------------------------------------------------
/client/src/features/projects/routes/AllProjectsPage.tsx:
--------------------------------------------------------------------------------
1 | import { ProjectSheet } from "../components/ProjectSheet";
2 | import { ProjectCard } from "../components/ProjectCard";
3 | import { Link } from "react-router-dom";
4 | import { useQuery } from "@tanstack/react-query";
5 | import {
6 | type Project,
7 | getProjects,
8 | } from "@/features/projects/apis/project-api";
9 | import { ScrollArea } from "@/components/ui/scroll-area";
10 | import Spinner from "@/components/ui/spinner";
11 |
12 | export function AllProjectsPage() {
13 | const { status, data } = useQuery({
14 | queryKey: ["projects"],
15 | queryFn: getProjects,
16 | onError: (error) => console.log(error),
17 | });
18 |
19 | if (status === "loading") return ;
20 |
21 | if (status === "error") {
22 | return Some errors occurred
;
23 | }
24 |
25 | const projects = data.projects as Project[];
26 |
27 | return (
28 |
29 |
30 |
31 |
32 |
Projects
33 |
34 | Here is s a list of your projects!
35 |
36 |
37 |
40 |
41 |
42 |
43 | {projects.map((project) => (
44 |
45 |
46 |
47 | ))}
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/client/src/components/layout/ButtomMenu.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/utils";
2 | import { Button } from "../ui/button";
3 | import { NavLink, useNavigate } from "react-router-dom";
4 | import { ModeToggle } from "../ModeToggle";
5 | import { LayoutGrid, CheckSquare, Settings, LogOut } from "lucide-react";
6 |
7 | interface ButtomMenuProps extends React.HTMLAttributes {}
8 |
9 | export default function ButtomMenu({ className }: ButtomMenuProps) {
10 | const navigate = useNavigate();
11 | const handleLogout = () => {
12 | localStorage.removeItem("token");
13 | navigate("/login");
14 | };
15 |
16 | return (
17 |
18 |
19 |
20 | {({ isActive }) => (
21 |
24 | )}
25 |
26 |
27 | {({ isActive }) => (
28 |
31 | )}
32 |
33 |
34 | {({ isActive }) => (
35 |
38 | )}
39 |
40 |
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/client/src/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useEffect, useState } from "react";
2 |
3 | type Theme = "dark" | "light" | "system";
4 |
5 | type ThemeProviderProps = {
6 | children: React.ReactNode;
7 | defaultTheme?: Theme;
8 | storageKey?: string;
9 | };
10 |
11 | type ThemeProviderState = {
12 | theme: Theme;
13 | setTheme: (theme: Theme) => void;
14 | };
15 |
16 | const initialState: ThemeProviderState = {
17 | theme: "system",
18 | setTheme: () => null,
19 | };
20 |
21 | const ThemeProviderContext = createContext(initialState);
22 |
23 | export function ThemeProvider({
24 | children,
25 | defaultTheme = "system",
26 | storageKey = "vite-ui-theme",
27 | ...props
28 | }: ThemeProviderProps) {
29 | const [theme, setTheme] = useState(
30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
31 | );
32 |
33 | useEffect(() => {
34 | const root = window.document.documentElement;
35 |
36 | root.classList.remove("light", "dark");
37 |
38 | if (theme === "system") {
39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
40 | .matches
41 | ? "dark"
42 | : "light";
43 |
44 | root.classList.add(systemTheme);
45 | return;
46 | }
47 |
48 | root.classList.add(theme);
49 | }, [theme]);
50 |
51 | const value = {
52 | theme,
53 | setTheme: (theme: Theme) => {
54 | localStorage.setItem(storageKey, theme);
55 | setTheme(theme);
56 | },
57 | };
58 |
59 | return (
60 |
61 | {children}
62 |
63 | );
64 | }
65 |
66 | export const useTheme = () => {
67 | const context = useContext(ThemeProviderContext);
68 |
69 | if (context === undefined)
70 | throw new Error("useTheme must be used within a ThemeProvider");
71 |
72 | return context;
73 | };
74 |
--------------------------------------------------------------------------------
/client/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 |
4 | import { cn } from "@/utils";
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | },
24 | );
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | function PriorityBadge({ priority }: { priority: string }) {
37 | switch (priority) {
38 | case "1":
39 | return (
40 | high
41 | );
42 | case "2":
43 | return (
44 |
45 | medium
46 |
47 | );
48 | case "3":
49 | return (
50 |
51 | low
52 |
53 | );
54 | default:
55 | return (
56 |
57 | low
58 |
59 | );
60 | }
61 | }
62 |
63 | export { Badge, PriorityBadge, badgeVariants };
64 |
--------------------------------------------------------------------------------
/client/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as TabsPrimitive from "@radix-ui/react-tabs";
3 |
4 | import { cn } from "@/utils";
5 |
6 | const Tabs = TabsPrimitive.Root;
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ));
21 | TabsList.displayName = TabsPrimitive.List.displayName;
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ));
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ));
51 | TabsContent.displayName = TabsPrimitive.Content.displayName;
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent };
54 |
--------------------------------------------------------------------------------
/server/migrations/20230901130919-create-issue.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | /** @type {import('sequelize-cli').Migration} */
3 | module.exports = {
4 | async up(queryInterface, Sequelize) {
5 | await queryInterface.createTable('Issues', {
6 | id: {
7 | allowNull: false,
8 | autoIncrement: true,
9 | primaryKey: true,
10 | type: Sequelize.INTEGER,
11 | },
12 | title: {
13 | allowNull: false,
14 | type: Sequelize.STRING,
15 | },
16 | description: {
17 | allowNull: false,
18 | type: Sequelize.STRING,
19 | },
20 | status: {
21 | allowNull: false,
22 | defaultValue: 'open',
23 | type: Sequelize.STRING,
24 | },
25 | priority: {
26 | allowNull: false,
27 | defaultValue: 1, // 1 > high, 2 > medium, 3 > low
28 | type: Sequelize.INTEGER,
29 | },
30 | category_id: {
31 | allowNull: false,
32 | references: {
33 | model: 'Categories',
34 | key: 'id',
35 | },
36 | type: Sequelize.INTEGER,
37 | },
38 | project_id: {
39 | allowNull: false,
40 | references: {
41 | model: 'Projects',
42 | key: 'id',
43 | },
44 | type: Sequelize.INTEGER,
45 | },
46 | reporter_id: {
47 | allowNull: false,
48 | references: {
49 | model: 'Users',
50 | key: 'id',
51 | },
52 | type: Sequelize.INTEGER,
53 | },
54 | assignee_id: {
55 | references: {
56 | model: 'Users',
57 | key: 'id',
58 | },
59 | type: Sequelize.INTEGER,
60 | },
61 | is_deleted: {
62 | allowNull: false,
63 | defaultValue: false,
64 | type: Sequelize.BOOLEAN,
65 | },
66 | created_at: {
67 | allowNull: false,
68 | type: Sequelize.DATE,
69 | },
70 | updated_at: {
71 | allowNull: false,
72 | type: Sequelize.DATE,
73 | },
74 | });
75 | },
76 | async down(queryInterface, Sequelize) {
77 | await queryInterface.dropTable('Issues');
78 | },
79 | };
80 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@hookform/resolvers": "^3.3.1",
14 | "@radix-ui/react-alert-dialog": "^1.0.4",
15 | "@radix-ui/react-avatar": "^1.0.3",
16 | "@radix-ui/react-collapsible": "^1.0.3",
17 | "@radix-ui/react-dialog": "^1.0.4",
18 | "@radix-ui/react-dropdown-menu": "^2.0.5",
19 | "@radix-ui/react-icons": "^1.3.0",
20 | "@radix-ui/react-label": "^2.0.2",
21 | "@radix-ui/react-radio-group": "^1.1.3",
22 | "@radix-ui/react-scroll-area": "^1.0.4",
23 | "@radix-ui/react-select": "^1.2.2",
24 | "@radix-ui/react-slot": "^1.0.2",
25 | "@radix-ui/react-switch": "^1.0.3",
26 | "@radix-ui/react-tabs": "^1.0.4",
27 | "@tanstack/react-query": "^4.33.0",
28 | "@tanstack/react-query-devtools": "^4.33.0",
29 | "@tanstack/react-table": "^8.9.7",
30 | "axios": "^1.5.0",
31 | "class-variance-authority": "^0.7.0",
32 | "clsx": "^2.0.0",
33 | "lucide-react": "^0.274.0",
34 | "react": "^18.2.0",
35 | "react-dom": "^18.2.0",
36 | "react-hook-form": "^7.46.1",
37 | "react-responsive": "^9.0.2",
38 | "react-router-dom": "^6.15.0",
39 | "recharts": "^2.8.0",
40 | "tailwind-merge": "^1.14.0",
41 | "tailwindcss-animate": "^1.0.7",
42 | "zod": "^3.22.2"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^20.5.9",
46 | "@types/react": "^18.2.15",
47 | "@types/react-dom": "^18.2.7",
48 | "@typescript-eslint/eslint-plugin": "^6.0.0",
49 | "@typescript-eslint/parser": "^6.0.0",
50 | "@vitejs/plugin-react": "^4.0.3",
51 | "autoprefixer": "^10.4.15",
52 | "eslint": "^8.45.0",
53 | "eslint-plugin-react-hooks": "^4.6.0",
54 | "eslint-plugin-react-refresh": "^0.4.3",
55 | "postcss": "^8.4.29",
56 | "prettier": "^3.0.3",
57 | "prettier-plugin-tailwindcss": "^0.5.4",
58 | "tailwindcss": "^3.3.3",
59 | "typescript": "^5.0.2",
60 | "vite": "^4.4.5"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/client/src/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/utils";
4 |
5 | const Card = React.forwardRef<
6 | HTMLDivElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Card.displayName = "Card";
19 |
20 | const CardHeader = React.forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = "CardHeader";
31 |
32 | const CardTitle = React.forwardRef<
33 | HTMLParagraphElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
44 | ));
45 | CardTitle.displayName = "CardTitle";
46 |
47 | const CardDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ));
57 | CardDescription.displayName = "CardDescription";
58 |
59 | const CardContent = React.forwardRef<
60 | HTMLDivElement,
61 | React.HTMLAttributes
62 | >(({ className, ...props }, ref) => (
63 |
64 | ));
65 | CardContent.displayName = "CardContent";
66 |
67 | const CardFooter = React.forwardRef<
68 | HTMLDivElement,
69 | React.HTMLAttributes
70 | >(({ className, ...props }, ref) => (
71 |
76 | ));
77 | CardFooter.displayName = "CardFooter";
78 |
79 | export {
80 | Card,
81 | CardHeader,
82 | CardFooter,
83 | CardTitle,
84 | CardDescription,
85 | CardContent,
86 | };
87 |
--------------------------------------------------------------------------------
/client/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | "outline-desctructive":
19 | "border border-destructive text-destructive bg-transparent shadow-sm hover:bg-accent",
20 | secondary:
21 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
22 | ghost: "hover:bg-accent hover:text-accent-foreground",
23 | link: "text-primary underline-offset-4 hover:underline",
24 | },
25 | size: {
26 | default: "h-9 px-4 py-2",
27 | sm: "h-8 rounded-md px-3 text-xs",
28 | lg: "h-10 rounded-md px-8",
29 | icon: "h-9 w-9",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | },
37 | );
38 |
39 | export interface ButtonProps
40 | extends React.ButtonHTMLAttributes,
41 | VariantProps {
42 | asChild?: boolean;
43 | }
44 |
45 | const Button = React.forwardRef(
46 | ({ className, variant, size, asChild = false, ...props }, ref) => {
47 | const Comp = asChild ? Slot : "button";
48 | return (
49 |
54 | );
55 | },
56 | );
57 | Button.displayName = "Button";
58 |
59 | export { Button };
60 | // eslint-disable-next-line react-refresh/only-export-components
61 | export { buttonVariants };
62 |
--------------------------------------------------------------------------------
/client/src/features/issues/components/TablePagination.tsx:
--------------------------------------------------------------------------------
1 | import { Table } from "@tanstack/react-table";
2 | import {
3 | ChevronsLeft,
4 | ChevronLeft,
5 | ChevronRight,
6 | ChevronsRight,
7 | } from "lucide-react";
8 | import { Button } from "@/components/ui/button";
9 |
10 | interface DataTablePaginationProps {
11 | table: Table;
12 | }
13 |
14 | export function TablePagination({
15 | table,
16 | }: DataTablePaginationProps) {
17 | return (
18 |
19 |
20 |
21 | Page {table.getState().pagination.pageIndex + 1} of{" "}
22 | {table.getPageCount()}
23 |
24 |
25 |
34 |
43 |
52 |
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | /*eslint-env node*/
3 | module.exports = {
4 | darkMode: ['class'],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: '2rem',
15 | screens: {
16 | '2xl': '1400px',
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: 'hsl(var(--border))',
22 | input: 'hsl(var(--input))',
23 | ring: 'hsl(var(--ring))',
24 | background: 'hsl(var(--background))',
25 | foreground: 'hsl(var(--foreground))',
26 | primary: {
27 | DEFAULT: 'hsl(var(--primary))',
28 | foreground: 'hsl(var(--primary-foreground))',
29 | },
30 | secondary: {
31 | DEFAULT: 'hsl(var(--secondary))',
32 | foreground: 'hsl(var(--secondary-foreground))',
33 | },
34 | destructive: {
35 | DEFAULT: 'hsl(var(--destructive))',
36 | foreground: 'hsl(var(--destructive-foreground))',
37 | },
38 | muted: {
39 | DEFAULT: 'hsl(var(--muted))',
40 | foreground: 'hsl(var(--muted-foreground))',
41 | },
42 | accent: {
43 | DEFAULT: 'hsl(var(--accent))',
44 | foreground: 'hsl(var(--accent-foreground))',
45 | },
46 | popover: {
47 | DEFAULT: 'hsl(var(--popover))',
48 | foreground: 'hsl(var(--popover-foreground))',
49 | },
50 | card: {
51 | DEFAULT: 'hsl(var(--card))',
52 | foreground: 'hsl(var(--card-foreground))',
53 | },
54 | },
55 | borderRadius: {
56 | lg: 'var(--radius)',
57 | md: 'calc(var(--radius) - 2px)',
58 | sm: 'calc(var(--radius) - 4px)',
59 | },
60 | keyframes: {
61 | 'accordion-down': {
62 | from: { height: 0 },
63 | to: { height: 'var(--radix-accordion-content-height)' },
64 | },
65 | 'accordion-up': {
66 | from: { height: 'var(--radix-accordion-content-height)' },
67 | to: { height: 0 },
68 | },
69 | },
70 | animation: {
71 | 'accordion-down': 'accordion-down 0.2s ease-out',
72 | 'accordion-up': 'accordion-up 0.2s ease-out',
73 | },
74 | },
75 | },
76 | plugins: [require('tailwindcss-animate')],
77 | };
78 |
--------------------------------------------------------------------------------
/client/src/features/projects/components/ProjectChart.tsx:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from "react-responsive";
2 | import { PieChart, Pie, Legend, Tooltip, ResponsiveContainer } from "recharts";
3 |
4 | const capitalize = (word: string) => {
5 | return word.charAt(0).toUpperCase() + word.slice(1);
6 | };
7 |
8 | const COLORS = [
9 | "#d0b9f9",
10 | "#b18af4",
11 | "#925bf0",
12 | "#732dec",
13 | "#5a13d2",
14 | "#f9a8d4",
15 | "#f472b6",
16 | "#ec4899",
17 | "#db2777",
18 | "#be185d",
19 | "#93c5fd",
20 | "#60a5fa",
21 | "#3b82f6",
22 | "#2563eb",
23 | "#1d4ed8",
24 | ];
25 |
26 | const RADIAN = Math.PI / 180;
27 |
28 | const renderColorfulLegendText = (value: string) => {
29 | return {value};
30 | };
31 |
32 | const renderCustomizedLabel = ({
33 | cx,
34 | cy,
35 | midAngle,
36 | innerRadius,
37 | outerRadius,
38 | percent,
39 | index,
40 | }: any) => {
41 | const radius = innerRadius + (outerRadius - innerRadius) * 1.4;
42 | const x = cx + radius * Math.cos(-midAngle * RADIAN);
43 | const y = cy + radius * Math.sin(-midAngle * RADIAN);
44 |
45 | return (
46 | cx ? "start" : "end"}
51 | dominantBaseline="central"
52 | className="opacity-80"
53 | >
54 | {`${(percent * 100).toFixed(0)}%`}
55 |
56 | );
57 | };
58 |
59 | export function ProjectChart({ title, data }: { title: string; data: any }) {
60 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
61 | const result = Array.from(Object.keys(data), (key, index) => ({
62 | name: capitalize(key),
63 | value: data[key],
64 | fill: COLORS[index],
65 | }));
66 |
67 | return (
68 |
69 |
70 |
80 |
92 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/client/src/features/projects/components/ProjectCard.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardFooter,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 | import { formatTime } from "@/utils";
10 | import { Badge } from "../../../components/ui/badge";
11 | import UserAvatar from "../../../components/UserAvatar";
12 | import { type Project } from "@/features/projects/apis/project-api";
13 | interface ProjectCardProps extends React.HTMLAttributes {
14 | project: Project;
15 | }
16 |
17 | export function ProjectCard({ project }: ProjectCardProps) {
18 | const filteredIssues = project.Issues.filter(
19 | (issue) => issue.isDeleted !== true,
20 | );
21 | return (
22 |
23 |
24 |
25 |
26 | {project.isPublic ? "public" : "private"}
27 |
28 |
29 |
30 | {formatTime(project.createdAt)}
31 |
32 |
33 |
34 | {project.name}
35 |
36 | {project.description}
37 |
38 |
39 |
40 |
41 | Creator
42 |
43 |
44 |
45 |
46 |
47 |
Members
48 |
49 | {project.Members.length}
50 |
51 |
52 |
53 |
Issues
54 |
55 | {filteredIssues.length}
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/client/src/features/projects/routes/ProjectPage.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { IssueSheet, IssuesTable, columns } from "@/features/issues";
3 | import { useParams } from "react-router-dom";
4 | import { useQuery } from "@tanstack/react-query";
5 | import { type Project, getProject } from "@/features/projects/apis/project-api";
6 | import { Button } from "@/components/ui/button";
7 | import { Badge } from "@/components/ui/badge";
8 | import { type Issue, getIssues } from "@/features/issues/apis/issue-api";
9 | import { useMediaQuery } from "react-responsive";
10 | import { GaugeCircle } from "lucide-react";
11 | import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
12 | import Spinner from "@/components/ui/spinner";
13 |
14 | export function ProjectPage() {
15 | const { id } = useParams();
16 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
17 | const projectQuery = useQuery({
18 | queryKey: ["projects", id],
19 | queryFn: () => getProject({ projectId: id as string }),
20 | });
21 | const issuesQuery = useQuery({
22 | queryKey: ["projects", id, "issues"],
23 | queryFn: () => getIssues({ projectId: id as string }),
24 | });
25 |
26 | if (projectQuery.status === "loading" || issuesQuery.status === "loading")
27 | return ;
28 | if (projectQuery.status === "error" || issuesQuery.status === "error") {
29 | return Something went wrong
;
30 | }
31 |
32 | const project = projectQuery.data.project as Project;
33 | const issues = issuesQuery.data.issues as Issue[];
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | {project.isPublic ? "public" : "private"}
42 |
43 |
44 | {project.name}
45 |
46 |
{project.description}
47 |
48 |
49 |
50 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/client/src/features/issues/apis/issue-api.ts:
--------------------------------------------------------------------------------
1 | import { baseURL, axiosInstance } from "@/lib/axios";
2 |
3 | export type Issue = {
4 | id: number;
5 | title: string;
6 | description: string;
7 | status: "open" | "in progress" | "wait for review" | "close";
8 | priority: "1" | "2" | "3";
9 | categoryId: number;
10 | reporterId: number;
11 | assigneeId: number | undefined | null;
12 | projectId: number;
13 | createdAt: string;
14 | updatedAt: string;
15 | Reporter: { id: number; firstname: string; lastname: string };
16 | Assignee: { id: number; firstname: string; lastname: string };
17 | Category: { id: number; name: string };
18 | };
19 |
20 | // * 取得專案所有 issue
21 | export async function getIssues(payload: { projectId: string }) {
22 | const { projectId } = payload;
23 | const res = await axiosInstance.get(
24 | `${baseURL}/projects/${projectId}/issues`,
25 | );
26 | return res.data.data;
27 | }
28 |
29 | // * 取得特定 issue
30 | export async function getIssue(payload: {
31 | projectId: string;
32 | issueId: string;
33 | }) {
34 | const { projectId, issueId } = payload;
35 | const res = await axiosInstance.get(
36 | `${baseURL}/projects/${projectId}/issues/${issueId}`,
37 | );
38 | return res.data.data;
39 | }
40 |
41 | // * 新增一筆 issue
42 | export async function postIssue(payload: {
43 | projectId: string;
44 | formData: {
45 | title: string;
46 | description: string;
47 | priority: string;
48 | categoryId: number;
49 | };
50 | }) {
51 | const { projectId, formData } = payload;
52 | const res = await axiosInstance.post(
53 | `${baseURL}/projects/${projectId}/issues`,
54 | formData,
55 | );
56 | return res.data.data;
57 | }
58 |
59 | // * 修改一筆 issue
60 | export async function patchIssue(payload: {
61 | projectId: string;
62 | issueId: string;
63 | formData: {
64 | title: string;
65 | description: string;
66 | priority: string;
67 | categoryId: number;
68 | status: string;
69 | };
70 | }) {
71 | const { projectId, issueId, formData } = payload;
72 | const res = await axiosInstance.patch(
73 | `${baseURL}/projects/${projectId}/issues/${issueId}`,
74 | formData,
75 | );
76 | return res.data.data;
77 | }
78 |
79 | // * 刪除一筆 issue
80 | export async function deleteIssue(payload: {
81 | projectId: string;
82 | issueId: string;
83 | }) {
84 | const { projectId, issueId } = payload;
85 | const res = await axiosInstance.delete(
86 | `${baseURL}/projects/${projectId}/issues/${issueId}`,
87 | );
88 | return res.data.data;
89 | }
90 |
91 | // * assign user to issue
92 | export async function assignIssue(payload: {
93 | projectId: string;
94 | issueId: string;
95 | formData: {
96 | assigneeId: number;
97 | };
98 | }) {
99 | const { projectId, issueId, formData } = payload;
100 | const res = await axiosInstance.patch(
101 | `${baseURL}/projects/${projectId}/issues/${issueId}/assign`,
102 | formData,
103 | );
104 | return res.data.data;
105 | }
106 |
--------------------------------------------------------------------------------
/client/src/features/issues/components/DeleteIssueAlert.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | } from "@/components/ui/alert-dialog";
11 | import { AlertMessage } from "../../../components/AlertMassage";
12 | import { useMutation, useQueryClient } from "@tanstack/react-query";
13 | import { deleteIssue } from "@/features/issues/apis/issue-api";
14 | import { useState } from "react";
15 | import { useNavigate } from "react-router-dom";
16 | import { XOctagon } from "lucide-react";
17 | import { AxiosError } from "axios";
18 | import { ErrorResponseData } from "@/lib/axios";
19 |
20 | interface DeleteIssueAlertProps extends React.HTMLAttributes {
21 | projectId: string;
22 | issueId: string;
23 | showDeleteDialog: boolean;
24 | setShowDeleteDialog: React.Dispatch>;
25 | }
26 |
27 | export function DeleteIssueAlert({
28 | projectId,
29 | issueId,
30 | showDeleteDialog,
31 | setShowDeleteDialog,
32 | }: DeleteIssueAlertProps) {
33 | const [deleteIssueError, setDeleteIssueError] = useState(
34 | "",
35 | );
36 | const queryClient = useQueryClient();
37 | const navigate = useNavigate();
38 | const issueMutation = useMutation({
39 | mutationFn: deleteIssue,
40 | onSuccess: (data) => {
41 | const { deletedIssue } = data;
42 | queryClient.setQueryData(
43 | ["projects", projectId, "issues", issueId],
44 | deletedIssue,
45 | );
46 | queryClient.invalidateQueries(["projects", projectId, "issues"], {
47 | exact: true,
48 | });
49 | setShowDeleteDialog(false);
50 | navigate(`/projects/${projectId}`);
51 | },
52 | onError: (error: AxiosError) =>
53 | setDeleteIssueError(error.response?.data.message),
54 | });
55 |
56 | const handleConfirm = (e: React.MouseEvent) => {
57 | e.preventDefault();
58 | issueMutation.mutate({ projectId, issueId });
59 | };
60 |
61 | return (
62 |
63 |
64 |
65 |
66 |
67 | Are you sure about deleting the issue?
68 |
69 |
70 | This action cannot be undone. This will permanently delete the
71 | issue.
72 |
73 | {deleteIssueError && (
74 |
75 | )}
76 |
77 |
78 | Cancel
79 | Delete
80 |
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/client/src/features/projects/apis/project-api.ts:
--------------------------------------------------------------------------------
1 | import { baseURL, axiosInstance } from "@/lib/axios";
2 |
3 | export type Project = {
4 | id: number;
5 | name: string;
6 | description: string;
7 | isPublic: boolean;
8 | creatorId: number;
9 | createdAt: string;
10 | Creator: {
11 | id: number;
12 | firstname: string;
13 | lastname: string;
14 | };
15 | Members: {
16 | id: number;
17 | firstname: string;
18 | lastname: string;
19 | }[];
20 | Issues: {
21 | id: number;
22 | title: string;
23 | isDeleted: boolean;
24 | }[];
25 | categories: {
26 | id: number;
27 | name: string;
28 | isDefault: boolean;
29 | }[];
30 | };
31 |
32 | // * 取得所有專案
33 | export async function getProjects() {
34 | const res = await axiosInstance.get(`${baseURL}/projects`);
35 | return res.data.data;
36 | }
37 |
38 | // * 取得特定專案資訊
39 | export async function getProject(payload: { projectId: string }) {
40 | const { projectId } = payload;
41 | const res = await axiosInstance.get(`${baseURL}/projects/${projectId}`);
42 | return res.data.data;
43 | }
44 |
45 | // * 取得特定專案成員
46 | export async function getMembers(payload: { projectId: string }) {
47 | const { projectId } = payload;
48 | const res = await axiosInstance.get(
49 | `${baseURL}/projects/${projectId}/members`,
50 | );
51 | return res.data.data;
52 | }
53 |
54 | // * 新增一個專案
55 | export async function postProject(payload: {
56 | formData: {
57 | name: string;
58 | description: string;
59 | isPublic: boolean;
60 | };
61 | }) {
62 | const { formData } = payload;
63 | const res = await axiosInstance.post(`${baseURL}/projects`, formData);
64 | return res.data.data;
65 | }
66 |
67 | // * 更新專案資訊
68 | export async function patchProject(payload: {
69 | projectId: string;
70 | formData: {
71 | name: string;
72 | description: string;
73 | isPublic: boolean;
74 | };
75 | }) {
76 | const { projectId, formData } = payload;
77 | const res = await axiosInstance.patch(
78 | `${baseURL}/projects/${projectId}`,
79 | formData,
80 | );
81 | return res.data.data;
82 | }
83 |
84 | // * 刪除一個專案
85 | export async function deleteProject(payload: { projectId: string }) {
86 | const { projectId } = payload;
87 | const res = await axiosInstance.delete(`${baseURL}/projects/${projectId}`);
88 | return res.data.data;
89 | }
90 |
91 | // * 新增專案成員
92 | export async function addMember(payload: {
93 | projectId: string;
94 | formData: {
95 | email: string;
96 | };
97 | }) {
98 | const { projectId, formData } = payload;
99 | const res = await axiosInstance.post(
100 | `${baseURL}/projects/${projectId}/members`,
101 | formData,
102 | );
103 | return res.data.data;
104 | }
105 |
106 | // * 移除專案成員
107 | export async function removeMember(payload: {
108 | projectId: string;
109 | memberId: string;
110 | }) {
111 | const { projectId, memberId } = payload;
112 | const res = await axiosInstance.delete(
113 | `${baseURL}/projects/${projectId}/members/${memberId}`,
114 | );
115 | return res.data.data;
116 | }
117 |
--------------------------------------------------------------------------------
/client/src/features/projects/components/ChartBoard.tsx:
--------------------------------------------------------------------------------
1 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
2 | import { ProjectChart } from "./ProjectChart";
3 | import { Issue } from "@/features/issues/apis/issue-api";
4 | import { Project } from "../apis/project-api";
5 | import { useMemo } from "react";
6 |
7 | export function ChartBoard({
8 | issues,
9 | project,
10 | }: {
11 | issues: Issue[];
12 | project: Project;
13 | }) {
14 | const { categoryCounts, statusCounts, priorityCounts } = useMemo(
15 | () => calculateIssue(issues, project),
16 | [issues, project],
17 | );
18 |
19 | return (
20 |
21 |
22 |
23 |
Issues overview
24 |
25 | Total issues: {issues.length}
26 |
27 |
28 |
29 | Status
30 | Category
31 | Priority
32 |
33 |
34 | {issues.length ? (
35 | <>
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 | >
46 | ) : (
47 |
48 | There are no issues yet.
49 |
50 | )}
51 |
52 | );
53 | }
54 |
55 | function calculateIssue(issues: Issue[], project: Project) {
56 | const allCategories = project.categories.map((category) => category.name);
57 | const allStatuses = ["open", "in progress", "wait for review", "close"];
58 | const priorityNames = {
59 | "1": "high",
60 | "2": "medium",
61 | "3": "low",
62 | };
63 |
64 | // Initialize counters for categories, statuses, and priorities with counts set to 0
65 | const categoryCounts = Object.fromEntries(
66 | allCategories.map((category) => [category, 0]),
67 | );
68 | const statusCounts = Object.fromEntries(
69 | allStatuses.map((status) => [status, 0]),
70 | );
71 | const priorityCounts: { [key: string]: number } = {
72 | high: 0,
73 | medium: 0,
74 | low: 0,
75 | };
76 |
77 | // Iterate through the list of issues
78 | issues.forEach((issue) => {
79 | const category = issue.Category.name;
80 | categoryCounts[category] += 1;
81 |
82 | const status = issue.status;
83 | statusCounts[status] += 1;
84 |
85 | const priorityValue = issue.priority;
86 | const priorityName = priorityNames[priorityValue];
87 |
88 | priorityCounts[priorityName] += 1;
89 | });
90 | return { categoryCounts, statusCounts, priorityCounts };
91 | }
92 |
--------------------------------------------------------------------------------
/client/src/features/projects/components/DeleteProjectAlert.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | AlertDialogTrigger,
11 | } from "@/components/ui/alert-dialog";
12 | import { AlertMessage } from "../../../components/AlertMassage";
13 | import { useMutation, useQueryClient } from "@tanstack/react-query";
14 | import { useState } from "react";
15 | import { useNavigate } from "react-router-dom";
16 | import { Trash2, XOctagon } from "lucide-react";
17 | import { Button } from "@/components/ui/button";
18 | import { useMediaQuery } from "react-responsive";
19 | import { deleteProject } from "../apis/project-api";
20 | import { AxiosError } from "axios";
21 | import { ErrorResponseData } from "@/lib/axios";
22 |
23 | interface DeleteIssueAlertProps extends React.HTMLAttributes {
24 | projectId: string;
25 | }
26 |
27 | export function DeleteProjectAlert({ projectId }: DeleteIssueAlertProps) {
28 | const [open, setOpen] = useState(false);
29 | const [deleteProjectError, setDeleteProjectError] = useState<
30 | string | undefined
31 | >("");
32 | const queryClient = useQueryClient();
33 | const navigate = useNavigate();
34 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
35 | const projectMutation = useMutation({
36 | mutationFn: deleteProject,
37 | onSuccess: (data) => {
38 | const { deletedProject } = data;
39 | queryClient.setQueryData(["projects", projectId], deletedProject);
40 | queryClient.invalidateQueries(["projects"], {
41 | exact: true,
42 | });
43 | queryClient.invalidateQueries(["projects", projectId], {
44 | exact: true,
45 | });
46 | setOpen(false);
47 | navigate(`/projects`);
48 | },
49 | onError: (error: AxiosError) =>
50 | setDeleteProjectError(error.response?.data.message),
51 | });
52 |
53 | const handleConfirm = (e: React.MouseEvent) => {
54 | e.preventDefault();
55 | projectMutation.mutate({ projectId });
56 | };
57 |
58 | return (
59 |
60 |
61 |
64 |
65 |
66 |
67 |
68 |
69 | Are you sure about deleting the project?
70 |
71 |
72 | This action cannot be undone. This will permanently delete the
73 | project.
74 |
75 | {deleteProjectError && (
76 |
77 | )}
78 |
79 |
80 | Cancel
81 | Delete
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/client/src/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/utils";
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ));
17 | Table.displayName = "Table";
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ));
25 | TableHeader.displayName = "TableHeader";
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ));
37 | TableBody.displayName = "TableBody";
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 |
48 | ));
49 | TableFooter.displayName = "TableFooter";
50 |
51 | const TableRow = React.forwardRef<
52 | HTMLTableRowElement,
53 | React.HTMLAttributes
54 | >(({ className, ...props }, ref) => (
55 |
63 | ));
64 | TableRow.displayName = "TableRow";
65 |
66 | const TableHead = React.forwardRef<
67 | HTMLTableCellElement,
68 | React.ThHTMLAttributes
69 | >(({ className, ...props }, ref) => (
70 | |
78 | ));
79 | TableHead.displayName = "TableHead";
80 |
81 | const TableCell = React.forwardRef<
82 | HTMLTableCellElement,
83 | React.TdHTMLAttributes
84 | >(({ className, ...props }, ref) => (
85 | |
90 | ));
91 | TableCell.displayName = "TableCell";
92 |
93 | const TableCaption = React.forwardRef<
94 | HTMLTableCaptionElement,
95 | React.HTMLAttributes
96 | >(({ className, ...props }, ref) => (
97 |
102 | ));
103 | TableCaption.displayName = "TableCaption";
104 |
105 | export {
106 | Table,
107 | TableHeader,
108 | TableBody,
109 | TableFooter,
110 | TableHead,
111 | TableRow,
112 | TableCell,
113 | TableCaption,
114 | };
115 |
--------------------------------------------------------------------------------
/client/src/features/categories/components/DeleteCategoryAlert.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | AlertDialogTrigger,
11 | } from "@/components/ui/alert-dialog";
12 | import { AlertMessage } from "../../../components/AlertMassage";
13 | import { useMutation, useQueryClient } from "@tanstack/react-query";
14 | import { useState } from "react";
15 | import { Trash2, XOctagon } from "lucide-react";
16 | import { Button } from "@/components/ui/button";
17 | import { Category, deleteCategory } from "../apis/category-api";
18 | import { AxiosError } from "axios";
19 | import { ErrorResponseData } from "@/lib/axios";
20 |
21 | interface DeleteCategoryAlertProps
22 | extends React.HTMLAttributes {
23 | projectId: string;
24 | category: Category;
25 | }
26 |
27 | export function DeleteCategoryAlert({
28 | projectId,
29 | category,
30 | }: DeleteCategoryAlertProps) {
31 | const [open, setOpen] = useState(false);
32 | const [deleteCategoryError, setDeleteCategoryError] = useState<
33 | string | undefined
34 | >("");
35 | const queryClient = useQueryClient();
36 | const projectMutation = useMutation({
37 | mutationFn: deleteCategory,
38 | onSuccess: () => {
39 | queryClient.invalidateQueries(["projects"], { exact: true });
40 | queryClient.invalidateQueries(["projects", projectId], { exact: true });
41 | queryClient.invalidateQueries(["projects", projectId, "issues"], {
42 | exact: true,
43 | });
44 | queryClient.invalidateQueries(["projects", projectId, "categories"], {
45 | exact: true,
46 | });
47 | setDeleteCategoryError("");
48 | setOpen(false);
49 | },
50 | onError: (error: AxiosError) =>
51 | setDeleteCategoryError(error.response?.data.message),
52 | });
53 |
54 | const handleConfirm = (e: React.MouseEvent) => {
55 | e.preventDefault();
56 | projectMutation.mutate({
57 | projectId,
58 | categoryId: category.id.toString(),
59 | });
60 | };
61 |
62 | return (
63 |
64 |
65 |
68 |
69 |
70 |
71 |
72 |
73 | Are you sure you want to delete category{" "}
74 | "{category.name}" ?
75 |
76 |
77 | All issues of this category will be marked as "other" category.
78 |
79 | {deleteCategoryError && (
80 |
81 | )}
82 |
83 |
84 | Cancel
85 | Delete
86 |
87 |
88 |
89 | );
90 | }
91 |
--------------------------------------------------------------------------------
/client/src/features/projects/components/RemoveMemberAlert.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | AlertDialogTrigger,
11 | } from "@/components/ui/alert-dialog";
12 | import { AlertMessage } from "../../../components/AlertMassage";
13 | import { useMutation, useQueryClient } from "@tanstack/react-query";
14 | import { useState } from "react";
15 | import { UserMinus2, XOctagon } from "lucide-react";
16 | import { Button } from "@/components/ui/button";
17 | import { removeMember } from "../apis/project-api";
18 | import { Member } from "./MembersList";
19 | import { AxiosError } from "axios";
20 | import { ErrorResponseData } from "@/lib/axios";
21 |
22 | interface RemoveMemberAlertProps extends React.HTMLAttributes {
23 | member: Member;
24 | projectId: string;
25 | }
26 |
27 | export function RemoveMemberAlert({
28 | member,
29 | projectId,
30 | }: RemoveMemberAlertProps) {
31 | const [open, setOpen] = useState(false);
32 | const [removeMemberError, setRemoveMemberError] = useState<
33 | string | undefined
34 | >("");
35 | const queryClient = useQueryClient();
36 | const projectMutation = useMutation({
37 | mutationFn: removeMember,
38 | onSuccess: () => {
39 | queryClient.invalidateQueries(["projects"], { exact: true });
40 | queryClient.invalidateQueries(["projects", projectId], { exact: true });
41 | queryClient.invalidateQueries(["projects", projectId, "members"], {
42 | exact: true,
43 | });
44 | queryClient.invalidateQueries(["projects", projectId, "issues"]);
45 | setRemoveMemberError("");
46 | setOpen(false);
47 | },
48 | onError: (error: AxiosError) =>
49 | setRemoveMemberError(error.response?.data.message),
50 | });
51 |
52 | const handleConfirm = (e: React.MouseEvent) => {
53 | e.preventDefault();
54 | projectMutation.mutate({
55 | projectId,
56 | memberId: member.id.toString(),
57 | });
58 | };
59 |
60 | return (
61 |
62 |
63 |
66 |
67 |
68 |
69 |
70 |
71 | Are you sure you want to remove{" "}
72 |
73 | {member.firstname} {member.lastname}
74 | {" "}
75 | from the project?
76 |
77 |
78 | The member will not be able to view the project anymore if the
79 | project is private.
80 |
81 | {removeMemberError && (
82 |
83 | )}
84 |
85 |
86 | Cancel
87 | Remove
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/client/src/features/comments/components/DeleteCommentAlert.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertDialog,
3 | AlertDialogAction,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | } from "@/components/ui/alert-dialog";
11 | import { AlertMessage } from "../../../components/AlertMassage";
12 | import { useMutation, useQueryClient } from "@tanstack/react-query";
13 | import { Comment, deleteComment } from "@/features/comments/apis/comment-api";
14 | import { useState } from "react";
15 | import { XOctagon } from "lucide-react";
16 | import { AxiosError } from "axios";
17 | import { ErrorResponseData } from "@/lib/axios";
18 |
19 | interface DeleteCommentAlertProps extends React.HTMLAttributes {
20 | projectId: string;
21 | issueId: string;
22 | comment: Comment;
23 | showDeleteDialog: boolean;
24 | setShowDeleteDialog: React.Dispatch>;
25 | }
26 |
27 | export function DeleteCommentAlert({
28 | projectId,
29 | issueId,
30 | comment,
31 | showDeleteDialog,
32 | setShowDeleteDialog,
33 | }: DeleteCommentAlertProps) {
34 | const [deleteCommentError, setDeleteCommentError] = useState<
35 | string | undefined
36 | >("");
37 | const queryClient = useQueryClient();
38 | const commentMutation = useMutation({
39 | mutationFn: deleteComment,
40 | onSuccess: (data) => {
41 | const { deletedComment } = data;
42 | queryClient.setQueryData(
43 | [
44 | "projects",
45 | projectId,
46 | "issues",
47 | issueId,
48 | "comments",
49 | deletedComment.id,
50 | ],
51 | deletedComment,
52 | );
53 | queryClient.invalidateQueries(
54 | ["projects", projectId, "issues", issueId, "comments"],
55 | { exact: true },
56 | );
57 | setShowDeleteDialog(false);
58 | },
59 | onError: (error: AxiosError) =>
60 | setDeleteCommentError(error.response?.data.message),
61 | });
62 |
63 | const handleConfirm = (e: React.MouseEvent) => {
64 | e.preventDefault();
65 | commentMutation.mutate({
66 | projectId,
67 | issueId,
68 | commentId: comment.id.toString(),
69 | });
70 | };
71 |
72 | return (
73 | {
76 | setShowDeleteDialog(open);
77 | setDeleteCommentError("");
78 | }}
79 | >
80 |
81 |
82 |
83 |
84 | Are you sure about deleting the comment?
85 |
86 |
87 | This action cannot be undone. This will permanently delete the
88 | comment.
89 |
90 | {deleteCommentError && (
91 |
92 | )}
93 |
94 |
95 | Cancel
96 | Delete
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/client/src/components/layout/SideMenu.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/utils";
2 | import { Button } from "../ui/button";
3 | import { NavLink, useNavigate } from "react-router-dom";
4 | import { ModeToggle } from "../ModeToggle";
5 | import { LayoutGrid, CheckSquare, Settings, LogOut } from "lucide-react";
6 | import logoLight from "@/assets/logo-light.png";
7 | import logoDark from "@/assets/logo-dark.png";
8 | import { useQuery, useQueryClient } from "@tanstack/react-query";
9 | import {
10 | type CurrentUser,
11 | getCurrentUser,
12 | } from "@/features/users/apis/user-api";
13 | import Spinner from "../ui/spinner";
14 |
15 | interface SideMenuProps extends React.HTMLAttributes {}
16 |
17 | export default function SideMenu({ className }: SideMenuProps) {
18 | const navigate = useNavigate();
19 | const queryClient = useQueryClient();
20 | const { status, data } = useQuery({
21 | queryKey: ["currentUser"],
22 | queryFn: getCurrentUser,
23 | });
24 |
25 | const handleLogout = () => {
26 | localStorage.removeItem("token");
27 | queryClient.clear();
28 | navigate("/login");
29 | };
30 |
31 | if (status === "loading") return ;
32 | if (status === "error") {
33 | return Something went wrong
;
34 | }
35 |
36 | const currentUser = data.currentUser as CurrentUser;
37 |
38 | return (
39 |
40 |
41 |
42 |

43 |

44 |
45 | Hi, {currentUser.firstname}
46 |
47 |
48 |
49 | {({ isActive }) => (
50 |
57 | )}
58 |
59 |
60 | {({ isActive }) => (
61 |
68 | )}
69 |
70 |
71 | {({ isActive }) => (
72 |
79 | )}
80 |
81 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/client/src/features/comments/components/CommentCard.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Card, CardContent } from "@/components/ui/card";
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuLabel,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { formatTime } from "@/utils";
12 | import UserAvatar from "@/components/UserAvatar";
13 | import { Button } from "@/components/ui/button";
14 | import { EditCommentSheet } from "./EditCommentSheet";
15 | import { DeleteCommentAlert } from "./DeleteCommentAlert";
16 | import { Comment } from "@/features/comments/apis/comment-api";
17 | import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
18 | import { Authorization } from "@/components/Authorization";
19 |
20 | interface CommentCardProps extends React.HTMLAttributes {
21 | comment: Comment;
22 | projectId: string;
23 | issueId: string;
24 | }
25 |
26 | export function CommentCard({ comment, projectId, issueId }: CommentCardProps) {
27 | const [showEditSheet, setShowEditSheet] = useState(false);
28 | const [showDeleteDialog, setShowDeleteDialog] = useState(false);
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 |
37 | {comment.User.firstname}
38 | {comment.User.lastname}
39 |
40 |
{formatTime(comment.createdAt)}
41 |
42 |
43 |
48 |
49 |
53 |
54 |
55 |
56 | Actions
57 |
58 | setShowEditSheet(true)}>
59 | Edit comment
60 |
61 | setShowDeleteDialog(true)}
64 | >
65 | Delete comment
66 |
67 |
68 |
69 |
76 |
83 |
84 | {comment.text}
85 |
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/server/routes/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const router = express.Router();
3 | const userController = require('../controllers/user-controller');
4 | const projectController = require('../controllers/project-controller');
5 | const categoryController = require('../controllers/category-controller');
6 | const issueController = require('../controllers/issue-controller');
7 | const commentController = require('../controllers/comment-controller');
8 | const { apiErrorHandler } = require('../middlewares/error-handler');
9 | const { authenticated } = require('../middlewares/auth');
10 |
11 | // * auth related
12 | router.post('/users/signup', userController.signUp);
13 | router.post('/users/signin', userController.signIn);
14 | router.post('/users/permission', userController.checkPermission);
15 | router.get('/users/current', authenticated, userController.getCurrentUser);
16 |
17 | router.patch('/users/:id', authenticated, userController.patchUser);
18 |
19 | // * projects
20 | router.delete(
21 | '/projects/:id/members/:uid',
22 | authenticated,
23 | projectController.removeMember
24 | );
25 | router.get(
26 | '/projects/:id/members',
27 | authenticated,
28 | projectController.getMembers
29 | );
30 | router.post(
31 | '/projects/:id/members',
32 | authenticated,
33 | projectController.addMember
34 | );
35 |
36 | // * categories
37 | router.patch(
38 | '/projects/:id/categories/:cid',
39 | authenticated,
40 | categoryController.patchCategory
41 | );
42 | router.delete(
43 | '/projects/:id/categories/:cid',
44 | authenticated,
45 | categoryController.deleteCategory
46 | );
47 | router.get(
48 | '/projects/:id/categories',
49 | authenticated,
50 | categoryController.getCategories
51 | );
52 | router.post(
53 | '/projects/:id/categories',
54 | authenticated,
55 | categoryController.postCategory
56 | );
57 |
58 | // * comments
59 | router.patch(
60 | '/projects/:id/issues/:iid/comments/:cid',
61 | authenticated,
62 | commentController.patchComment
63 | );
64 | router.delete(
65 | '/projects/:id/issues/:iid/comments/:cid',
66 | authenticated,
67 | commentController.deleteComment
68 | );
69 | router.post(
70 | '/projects/:id/issues/:iid/comments',
71 | authenticated,
72 | commentController.postComment
73 | );
74 | router.get(
75 | '/projects/:id/issues/:iid/comments',
76 | authenticated,
77 | commentController.getComments
78 | );
79 |
80 | // * issues
81 | router.patch(
82 | '/projects/:id/issues/:iid/assign',
83 | authenticated,
84 | issueController.assignIssue
85 | );
86 | router.get(
87 | '/projects/:id/issues/:iid',
88 | authenticated,
89 | issueController.getIssue
90 | );
91 | router.patch(
92 | '/projects/:id/issues/:iid',
93 | authenticated,
94 | issueController.patchIssue
95 | );
96 | router.delete(
97 | '/projects/:id/issues/:iid',
98 | authenticated,
99 | issueController.deleteIssue
100 | );
101 | router.get('/projects/:id/issues', authenticated, issueController.getIssues);
102 | router.post('/projects/:id/issues', authenticated, issueController.postIssue);
103 |
104 | router.get('/projects/:id', authenticated, projectController.getProject);
105 | router.patch('/projects/:id', authenticated, projectController.patchProject);
106 | router.delete('/projects/:id', authenticated, projectController.deleteProject);
107 | router.get('/projects', authenticated, projectController.getProjects);
108 | router.post('/projects', authenticated, projectController.postProject);
109 |
110 | router.get('/', (req, res) => {
111 | res.send('Hello World!');
112 | });
113 |
114 | router.use('/', apiErrorHandler);
115 |
116 | module.exports = router;
117 |
--------------------------------------------------------------------------------
/server/seeders/20230928135909-issues-seed-file.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /** @type {import('sequelize-cli').Migration} */
4 | const { Project } = require('../models');
5 |
6 | module.exports = {
7 | async up(queryInterface, Sequelize) {
8 | const SEED_ISSUES = [
9 | {
10 | title: 'User Registration Flow Enhancement',
11 | description:
12 | 'Improve the user registration process to include email verification and password strength checks.',
13 | },
14 | {
15 | title: 'Payment Gateway Integration',
16 | description:
17 | 'Integrate a secure payment gateway to facilitate online transactions in the e-commerce platform.',
18 | },
19 | {
20 | title: 'Search Functionality Optimization',
21 | description:
22 | 'Optimize the search functionality to provide faster and more relevant search results to users.',
23 | },
24 | {
25 | title: 'User Feedback Collection',
26 | description:
27 | 'Implement a user feedback mechanism to gather valuable insights for product improvement.',
28 | },
29 | {
30 | title: 'Security Vulnerability Assessment',
31 | description:
32 | 'Perform a comprehensive security assessment to identify and address vulnerabilities in the application.',
33 | },
34 | {
35 | title: 'Login Page Redesign',
36 | description:
37 | 'Redesign the login page to improve user experience and modernize the design.',
38 | },
39 | {
40 | title: 'Database Connection Error',
41 | description:
42 | 'Investigate and resolve intermittent database connection issues that affect application performance.',
43 | },
44 | {
45 | title: 'Responsive Layout Fixes',
46 | description:
47 | 'Ensure the website layout adapts correctly to various screen sizes and devices.',
48 | },
49 | {
50 | title: 'Bug in User Profile Page',
51 | description:
52 | 'Fix a bug on the user profile page where some information is not displayed correctly.',
53 | },
54 | {
55 | title: 'Performance Optimization',
56 | description:
57 | 'Identify and address bottlenecks to improve page load times and overall system performance.',
58 | },
59 | ];
60 | const categories = await queryInterface.sequelize.query(
61 | 'SELECT id FROM Categories;',
62 | { type: queryInterface.sequelize.QueryTypes.SELECT }
63 | );
64 | const users = await queryInterface.sequelize.query(
65 | 'SELECT id FROM Users;',
66 | { type: queryInterface.sequelize.QueryTypes.SELECT }
67 | );
68 | const rootProject = await Project.findOne({
69 | where: {
70 | name: 'Issuezy',
71 | },
72 | raw: true,
73 | });
74 | await queryInterface.bulkInsert(
75 | 'Issues',
76 | SEED_ISSUES.map((issue) => {
77 | return {
78 | title: issue.title,
79 | description: issue.description,
80 | status: 'open',
81 | priority: Math.floor(Math.random() * 3) + 1,
82 | category_id:
83 | categories[Math.floor(Math.random() * categories.length)].id,
84 | project_id: rootProject.id,
85 | reporter_id: users[Math.floor(Math.random() * users.length)].id,
86 | assignee_id: users[Math.floor(Math.random() * users.length)].id,
87 | is_deleted: false,
88 | created_at: new Date(),
89 | updated_at: new Date(),
90 | };
91 | }),
92 | {}
93 | );
94 | },
95 |
96 | async down(queryInterface, Sequelize) {
97 | await queryInterface.bulkDelete('Issues', {});
98 | },
99 | };
100 |
--------------------------------------------------------------------------------
/client/src/components/Authorization.tsx:
--------------------------------------------------------------------------------
1 | import { Comment } from "@/features/comments/apis/comment-api";
2 | import { Issue } from "@/features/issues/apis/issue-api";
3 | import { getMembers } from "@/features/projects/apis/project-api";
4 | import { Member } from "@/features/projects/components/MembersList";
5 | import { CurrentUser, getCurrentUser } from "@/features/users/apis/user-api";
6 | import { useQuery } from "@tanstack/react-query";
7 |
8 | const POLICIES_CONFIG = {
9 | "project:edit": { allowedRoles: ["admin"] },
10 | "project:delete": { allowedRoles: ["admin"] },
11 | "member:add": { allowedRoles: ["admin"] },
12 | "member:remove": { allowedRoles: ["admin"] },
13 | "category:add": { allowedRoles: ["admin"] },
14 | "category:edit": { allowedRoles: ["admin"] },
15 | "category:delete": { allowedRoles: ["admin"] },
16 | "issue:edit": { allowedRoles: ["admin", "reporter", "assignee"] },
17 | "issue:delete": { allowedRoles: ["admin"] },
18 | "issue:assign": { allowedRoles: ["admin", "member"] },
19 | "comment:edit": { allowedRoles: ["admin", "commenter"] },
20 | "comment:delete": { allowedRoles: ["admin", "commenter"] },
21 | };
22 |
23 | export function useAuthorization({
24 | projectId,
25 | issue,
26 | comment,
27 | }: {
28 | projectId?: string;
29 | issue?: Issue;
30 | comment?: Comment;
31 | }) {
32 | const currentUserQuery = useQuery({
33 | queryKey: ["currentUser"],
34 | queryFn: getCurrentUser,
35 | });
36 | const membersQuery = useQuery({
37 | queryKey: ["projects", projectId, "members"],
38 | queryFn: () => getMembers({ projectId } as { projectId: string }),
39 | });
40 |
41 | const isLoading =
42 | currentUserQuery.isFetching ||
43 | currentUserQuery.isLoading ||
44 | membersQuery.isFetching ||
45 | membersQuery.isLoading;
46 |
47 | if (isLoading) return { isLoading };
48 |
49 | const currentUser = currentUserQuery.data.currentUser as CurrentUser;
50 | const creator = membersQuery.data.project.Creator as Member;
51 | const members = membersQuery.data.project.Members as Member[];
52 | const reporter = issue?.Reporter;
53 | const assignee = issue?.Assignee;
54 | const commenter = comment?.User;
55 | return { currentUser, creator, members, reporter, assignee, commenter };
56 | }
57 |
58 | interface AuthorizationProps {
59 | projectId?: string;
60 | issue?: Issue;
61 | comment?: Comment;
62 | action: string;
63 | children: React.ReactNode;
64 | }
65 |
66 | export function Authorization({
67 | projectId,
68 | issue,
69 | comment,
70 | action,
71 | children,
72 | }: AuthorizationProps) {
73 | const {
74 | isLoading,
75 | currentUser,
76 | creator,
77 | members,
78 | reporter,
79 | assignee,
80 | commenter,
81 | } = useAuthorization({ projectId, issue, comment });
82 |
83 | if (isLoading) return null;
84 |
85 | // * get the list of user roles
86 | const getUserRoles = () => {
87 | const roles = [];
88 | if (currentUser.id === creator.id) {
89 | roles.push("admin");
90 | }
91 | if (members.map((member) => member.id).includes(currentUser.id)) {
92 | roles.push("member");
93 | }
94 | if (currentUser.id === reporter?.id) {
95 | roles.push("reporter");
96 | }
97 | if (currentUser.id === assignee?.id) {
98 | roles.push("assignee");
99 | }
100 | if (currentUser.id === commenter?.id) {
101 | roles.push("commenter");
102 | }
103 | return roles;
104 | };
105 |
106 | // * check if user role is in allowed role
107 | const isAuthorized = () => {
108 | const roles = getUserRoles();
109 |
110 | if (!Object.keys(POLICIES_CONFIG).includes(action)) {
111 | return false;
112 | }
113 |
114 | const allowedRoles =
115 | POLICIES_CONFIG[action as keyof typeof POLICIES_CONFIG].allowedRoles;
116 |
117 | return roles.some((role) => allowedRoles.includes(role));
118 | };
119 |
120 | return isAuthorized() ? children : null;
121 | }
122 |
--------------------------------------------------------------------------------
/server/services/comment-service.js:
--------------------------------------------------------------------------------
1 | const { Comment, Issue, Project, User } = require('../models');
2 | const { customError } = require('../helpers/error-helper');
3 |
4 | const commentService = {
5 | getComments: async (req, cb) => {
6 | try {
7 | const issueId = req.params.iid;
8 | const issue = await Issue.findByPk(issueId);
9 | if (!issue) throw customError(400, 'Issue does not exist!');
10 |
11 | const comments = await Comment.findAll({
12 | where: {
13 | issueId,
14 | // only get undeleted comments
15 | isDeleted: false,
16 | },
17 | include: [
18 | {
19 | model: User,
20 | as: 'User',
21 | attributes: ['id', 'firstname', 'lastname'],
22 | },
23 | ],
24 | attributes: ['id', 'text', 'issueId', 'userId', 'createdAt'],
25 | });
26 | cb(null, { comments });
27 | } catch (err) {
28 | cb(err);
29 | }
30 | },
31 | postComment: async (req, cb) => {
32 | try {
33 | const { text } = req.body;
34 | const projectId = req.params.id;
35 | const issueId = req.params.iid;
36 | const userId = req.user.id;
37 | if (text.trim().length === 0)
38 | throw customError(400, 'Comment is required!');
39 | if (text.trim().length > 250)
40 | throw customError(400, 'Comment cannot be more than 250 words!');
41 |
42 | const project = await Project.findByPk(projectId);
43 | const issue = await Issue.findByPk(issueId);
44 | if (!project) throw customError(400, 'project does not exist!');
45 | if (!issue) throw customError(400, 'Issue does not exist!');
46 |
47 | const newComment = await Comment.create({
48 | text,
49 | issueId,
50 | userId,
51 | });
52 | cb(null, { newComment });
53 | } catch (err) {
54 | cb(err);
55 | }
56 | },
57 | patchComment: async (req, cb) => {
58 | try {
59 | const { text } = req.body;
60 | const projectId = req.params.id;
61 | const commentId = req.params.cid;
62 | const userId = req.user.id;
63 | if (text.trim().length === 0) throw customError(400, 'Text is required!');
64 | if (text.trim().length > 250)
65 | throw customError(400, 'Text cannot be more than 250 words!');
66 |
67 | const project = await Project.findByPk(projectId);
68 | const comment = await Comment.findByPk(commentId);
69 | if (!project) throw customError(400, 'project does not exist!');
70 | if (!comment) throw customError(400, 'Comment does not exist!');
71 |
72 | // * 只有 project owner 和 comment 作者本人可以編輯留言
73 | if (![comment.userId, project.creatorId].includes(userId))
74 | throw customError(400, 'You are not allowed to edit the comment!');
75 |
76 | const updatedComment = await comment.update({
77 | text,
78 | });
79 | cb(null, { updatedComment });
80 | } catch (err) {
81 | cb(err);
82 | }
83 | },
84 | deleteComment: async (req, cb) => {
85 | try {
86 | const projectId = req.params.id;
87 | const commentId = req.params.cid;
88 | const userId = req.user.id;
89 |
90 | const project = await Project.findByPk(projectId);
91 | const comment = await Comment.findByPk(commentId);
92 | if (!project) throw customError(400, 'project does not exist!');
93 | if (!comment) throw customError(400, 'Comment does not exist!');
94 |
95 | if (comment.isDeleted)
96 | throw customError(400, 'Comment has already been deleted!');
97 |
98 | // * 只有 project owner 和 comment 作者本人可以刪除留言
99 | if (![comment.userId, project.creatorId].includes(userId))
100 | throw customError(400, 'You are not allowed to delete the comment!');
101 |
102 | const deletedComment = await comment.update({ isDeleted: true });
103 | cb(null, { deletedComment });
104 | } catch (err) {
105 | cb(err);
106 | }
107 | },
108 | };
109 |
110 | module.exports = commentService;
111 |
--------------------------------------------------------------------------------
/client/src/features/issues/components/IssueActionsDropdown.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DropdownMenu,
3 | DropdownMenuContent,
4 | DropdownMenuItem,
5 | DropdownMenuLabel,
6 | DropdownMenuSeparator,
7 | DropdownMenuTrigger,
8 | } from "@/components/ui/dropdown-menu";
9 | import { Contact2, MoreHorizontal, Pencil, Trash2 } from "lucide-react";
10 | import { Button } from "../../../components/ui/button";
11 | import { AssignIssueSheet } from "./AssignIssueSheet";
12 | import { Issue } from "@/features/issues/apis/issue-api";
13 | import { useQuery } from "@tanstack/react-query";
14 | import { Project, getProject } from "@/features/projects/apis/project-api";
15 | import { EditIssueSheet } from "./EditIssueSheet";
16 | import { DeleteIssueAlert } from "./DeleteIssueAlert";
17 | import { useState } from "react";
18 | import { Authorization } from "@/components/Authorization";
19 |
20 | export default function IssueActionsDropdown({ issue }: { issue: Issue }) {
21 | const [showEditSheet, setShowEditSheet] = useState(false);
22 | const [showAssignSheet, setShowAssignSheet] = useState(false);
23 | const [showDeleteDialog, setShowDeleteDialog] = useState(false);
24 | const projectQuery = useQuery({
25 | queryKey: ["projects", issue.projectId.toString()],
26 | queryFn: () => getProject({ projectId: issue.projectId.toString() }),
27 | });
28 |
29 | if (projectQuery.isLoading || projectQuery.isFetching) {
30 | return (
31 |
32 |
36 |
37 | );
38 | }
39 |
40 | const project = projectQuery.data.project as Project;
41 |
42 | return (
43 |
44 |
45 |
49 |
50 |
51 | Actions
52 |
53 |
58 | setShowEditSheet(true)}>
59 |
60 | Edit issue
61 |
62 |
63 |
68 | setShowAssignSheet(true)}>
69 |
70 | {issue.Assignee ? "Reassign user" : "Assign user"}
71 |
72 |
73 |
78 | setShowDeleteDialog(true)}
80 | className="text-destructive"
81 | >
82 |
83 | Delete issue
84 |
85 |
86 |
87 |
93 |
99 |
105 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/server/services/user-service.js:
--------------------------------------------------------------------------------
1 | const jwt = require('jsonwebtoken');
2 | const bcrypt = require('bcryptjs');
3 | const { User } = require('../models');
4 | const { customError } = require('../helpers/error-helper');
5 |
6 | const userService = {
7 | signUpUser: async (req, cb) => {
8 | const { firstname, lastname, email, password, passwordCheck } = req.body;
9 | try {
10 | if (
11 | firstname.trim().length === 0 ||
12 | lastname.trim().length === 0 ||
13 | email.trim().length === 0 ||
14 | password.trim().length === 0 ||
15 | passwordCheck.trim().length === 0
16 | )
17 | throw customError(400, 'All fields are required!');
18 | if (password !== passwordCheck) {
19 | throw customError(400, 'Passwords do not match!');
20 | }
21 | const user = await User.findOne({ where: { email } });
22 | if (user) throw customError(400, 'Email already exists!');
23 | const hashedPassword = await bcrypt.hash(password, 10);
24 | const newUser = await User.create({
25 | firstname,
26 | lastname,
27 | email,
28 | password: hashedPassword,
29 | });
30 | const userData = newUser.toJSON();
31 | delete userData.password;
32 | cb(null, { user: userData });
33 | } catch (err) {
34 | cb(err);
35 | }
36 | },
37 | signInUser: async (req, cb) => {
38 | const { email, password } = req.body;
39 | try {
40 | if (email.trim().length === 0 || password.trim().length === 0)
41 | throw customError(400, 'Email and password cannot be blank!');
42 | const user = await User.scope('withPassword').findOne({
43 | where: { email },
44 | });
45 | if (!user) throw customError(400, 'Email or password is wrong!');
46 | const res = await bcrypt.compare(password, user.password);
47 | if (!res) throw customError(400, 'Email or password is wrong!');
48 |
49 | const userData = user.toJSON();
50 | delete userData.password;
51 |
52 | const token = jwt.sign(userData, process.env.JWT_SECRET, {
53 | expiresIn: '24h',
54 | });
55 | return cb(null, {
56 | token,
57 | user: userData,
58 | });
59 | } catch (err) {
60 | return cb(err);
61 | }
62 | },
63 | checkPermission: async (req, cb) => {
64 | const { token } = req.body;
65 | if (token) {
66 | try {
67 | const decode = jwt.verify(token, process.env.JWT_SECRET);
68 | if (decode) {
69 | delete decode.iat;
70 | delete decode.exp;
71 | delete decode.email;
72 | return cb(null, { decode });
73 | }
74 | } catch {
75 | cb(customError(401, 'Invalid token'));
76 | }
77 | } else {
78 | cb(customError(400, 'Please provide a token'));
79 | }
80 | },
81 | getCurrentUser: async (req, cb) => {
82 | try {
83 | const userId = req.user.id;
84 | const currentUser = await User.findByPk(userId, {
85 | attributes: ['id', 'firstname', 'lastname', 'email', 'createdAt'],
86 | raw: true,
87 | });
88 | if (!currentUser) throw customError(400, 'User does not exist!');
89 | return cb(null, { currentUser });
90 | } catch (err) {
91 | return cb(err);
92 | }
93 | },
94 | patchUser: async (req, cb) => {
95 | try {
96 | const { firstname, lastname } = req.body;
97 | const userId = req.params.id;
98 | const currentUserId = req.user.id;
99 | if (!firstname.trim().length || !lastname.trim().length)
100 | throw customError(400, 'All fields are required!');
101 | if (parseInt(userId) !== currentUserId)
102 | throw customError(
103 | 400,
104 | 'You are not allowed to change other users info!'
105 | );
106 |
107 | const user = await User.findByPk(userId);
108 | if (!user) throw customError(400, 'User does not exist!');
109 | const updatedUser = await user.update({
110 | firstname,
111 | lastname,
112 | });
113 | return cb(null, { updatedUser });
114 | } catch (err) {
115 | return cb(err);
116 | }
117 | },
118 | };
119 |
120 | module.exports = userService;
121 |
--------------------------------------------------------------------------------
/server/seeders/20230928140236-comments-seed-file.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable quotes */
2 | 'use strict';
3 |
4 | /** @type {import('sequelize-cli').Migration} */
5 | module.exports = {
6 | async up(queryInterface, Sequelize) {
7 | const SEED_COMMENTS = [
8 | {
9 | text: "I don't know what causes this issue!",
10 | },
11 | {
12 | text: 'I have tested the mobile app on my phone, and it works perfectly!',
13 | },
14 | {
15 | text: 'The payment gateway integration seems to be secure and reliable.',
16 | },
17 | {
18 | text: 'I noticed that the search functionality is much faster now. Great job!',
19 | },
20 | {
21 | text: 'I provided some feedback on the user feedback form. Hope it helps!',
22 | },
23 | {
24 | text: "Security is a top priority. Let's make sure we address all vulnerabilities.",
25 | },
26 | {
27 | text: 'I encountered an issue during registration. It needs attention.',
28 | },
29 | {
30 | text: 'I think we should improve the UI of the mobile app.',
31 | },
32 | {
33 | text: 'The payment process is smooth, but we could add more payment options.',
34 | },
35 | {
36 | text: 'The search results are quite accurate now. Impressive!',
37 | },
38 | {
39 | text: "I'm glad we have a feedback mechanism in place. It shows that we value our users.",
40 | },
41 | {
42 | text: "Security is a concern. Let's perform a thorough assessment.",
43 | },
44 | {
45 | text: 'I have some suggestions for the registration process.',
46 | },
47 | {
48 | text: 'The mobile app looks great on my tablet as well.',
49 | },
50 | {
51 | text: 'We should consider adding support for international payment methods.',
52 | },
53 | {
54 | text: 'The search feature is a game-changer for our platform.',
55 | },
56 | {
57 | text: 'I appreciate the feedback collection. It helps us improve.',
58 | },
59 | {
60 | text: "Let's schedule a security assessment soon.",
61 | },
62 | {
63 | text: 'I found a minor bug during registration.',
64 | },
65 | {
66 | text: 'I have some design ideas for the mobile app.',
67 | },
68 | {
69 | text: 'The payment gateway is reliable and secure.',
70 | },
71 | {
72 | text: 'The search functionality is lightning fast now.',
73 | },
74 | {
75 | text: 'User feedback is invaluable for making enhancements.',
76 | },
77 | {
78 | text: 'Security should always be a top priority.',
79 | },
80 | {
81 | text: 'The registration process could be more user-friendly.',
82 | },
83 | {
84 | text: "I tested the app on various devices, and it's responsive.",
85 | },
86 | {
87 | text: 'The payment experience is hassle-free.',
88 | },
89 | {
90 | text: 'The search results are spot-on.',
91 | },
92 | {
93 | text: 'I submitted some feedback. Looking forward to improvements.',
94 | },
95 | {
96 | text: "Security checks are crucial. Let's not skip them.",
97 | },
98 | ];
99 | const users = await queryInterface.sequelize.query(
100 | 'SELECT id FROM Users;',
101 | { type: queryInterface.sequelize.QueryTypes.SELECT }
102 | );
103 | const issues = await queryInterface.sequelize.query(
104 | 'SELECT id FROM Issues;',
105 | { type: queryInterface.sequelize.QueryTypes.SELECT }
106 | );
107 | await queryInterface.bulkInsert(
108 | 'Comments',
109 | SEED_COMMENTS.map((comment) => {
110 | return {
111 | text: comment.text,
112 | issue_id: issues[Math.floor(Math.random() * issues.length)].id,
113 | user_id: users[Math.floor(Math.random() * users.length)].id,
114 | is_deleted: false,
115 | created_at: new Date(),
116 | updated_at: new Date(),
117 | };
118 | }),
119 | {}
120 | );
121 | },
122 |
123 | async down(queryInterface, Sequelize) {
124 | await queryInterface.bulkDelete('Comments', {});
125 | },
126 | };
127 |
--------------------------------------------------------------------------------
/client/src/features/projects/components/MembersList.tsx:
--------------------------------------------------------------------------------
1 | import UserAvatar from "@/components/UserAvatar";
2 | import { useQuery } from "@tanstack/react-query";
3 | import { getMembers } from "@/features/projects/apis/project-api";
4 | import { Badge } from "@/components/ui/badge";
5 | import { AddMemberSheet } from "./AddMemberSheet";
6 | import { RemoveMemberAlert } from "./RemoveMemberAlert";
7 | import { useState } from "react";
8 | import {
9 | Collapsible,
10 | CollapsibleContent,
11 | CollapsibleTrigger,
12 | } from "@/components/ui/collapsible";
13 | import { ChevronsUpDown } from "lucide-react";
14 | import { Button } from "@/components/ui/button";
15 | import { Authorization } from "@/components/Authorization";
16 |
17 | export type Member = {
18 | id: number;
19 | firstname: string;
20 | lastname: string;
21 | email: string;
22 | };
23 |
24 | export function MembersList({ projectId }: { projectId: string }) {
25 | const [isOpen, setIsOpen] = useState(true);
26 | const projectQuery = useQuery({
27 | queryKey: ["projects", projectId, "members"],
28 | queryFn: () => getMembers({ projectId }),
29 | });
30 |
31 | if (projectQuery.isLoading || projectQuery.isFetching)
32 | return (
33 |
34 |
35 |
36 |
Members
37 |
41 |
42 |
43 |
44 | );
45 | if (projectQuery.status === "error") {
46 | return Something went wrong
;
47 | }
48 |
49 | const creator = projectQuery.data.project.Creator as Member;
50 | const members = projectQuery.data.project.Members as Member[];
51 |
52 | return (
53 |
58 |
59 |
60 |
Members
61 |
62 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | {creator.firstname} {creator.lastname}
79 |
80 |
{creator.email}
81 |
82 |
83 |
Creator
84 |
85 | {members.length > 0 && (
86 |
87 | {members.map((member) => (
88 |
89 |
90 |
91 |
92 | {member.firstname} {member.lastname}
93 |
94 |
95 | {member.email}
96 |
97 |
98 |
99 |
100 |
101 |
102 | ))}
103 |
104 | )}
105 |
106 |
107 | );
108 | }
109 |
--------------------------------------------------------------------------------
/client/src/features/projects/components/AddMemberSheet.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Input } from "@/components/ui/input";
3 | import {
4 | Sheet,
5 | SheetContent,
6 | SheetDescription,
7 | SheetFooter,
8 | SheetHeader,
9 | SheetTitle,
10 | SheetTrigger,
11 | } from "@/components/ui/sheet";
12 | import {
13 | Form,
14 | FormControl,
15 | FormField,
16 | FormItem,
17 | FormLabel,
18 | FormMessage,
19 | } from "@/components/ui/form";
20 | import { useMediaQuery } from "react-responsive";
21 | import * as z from "zod";
22 | import { useForm } from "react-hook-form";
23 | import { zodResolver } from "@hookform/resolvers/zod";
24 | import { useMutation, useQueryClient } from "@tanstack/react-query";
25 | import { addMember } from "@/features/projects/apis/project-api";
26 | import { useState } from "react";
27 | import { AlertMessage } from "../../../components/AlertMassage";
28 | import { UserPlus2 } from "lucide-react";
29 | import { AxiosError } from "axios";
30 | import { ErrorResponseData } from "@/lib/axios";
31 |
32 | interface AddMemberSheetProps extends React.HTMLAttributes {
33 | projectId: string;
34 | }
35 |
36 | const addMemberFormSchema = z.object({
37 | email: z.string().email().min(1, {
38 | message: "Email cannot be blank",
39 | }),
40 | });
41 |
42 | export function AddMemberSheet({ projectId }: AddMemberSheetProps) {
43 | const [open, setOpen] = useState(false);
44 | const [addMemberError, setAddMemberError] = useState("");
45 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
46 | const queryClient = useQueryClient();
47 | const projectMutation = useMutation({
48 | mutationFn: addMember,
49 | onSuccess: () => {
50 | queryClient.invalidateQueries(["projects"], { exact: true });
51 | queryClient.invalidateQueries(["projects", projectId], { exact: true });
52 | queryClient.invalidateQueries(["projects", projectId, "members"], {
53 | exact: true,
54 | });
55 | form.reset({ email: "" });
56 | setAddMemberError("");
57 | setOpen(false);
58 | },
59 | onError: (error: AxiosError) =>
60 | setAddMemberError(error.response?.data.message),
61 | });
62 |
63 | const form = useForm>({
64 | resolver: zodResolver(addMemberFormSchema),
65 | defaultValues: {
66 | email: "",
67 | },
68 | });
69 |
70 | function onSubmit(values: z.infer) {
71 | projectMutation.mutate({
72 | projectId,
73 | formData: {
74 | ...values,
75 | },
76 | });
77 | }
78 |
79 | return (
80 |
81 |
82 |
86 |
87 |
91 |
119 |
120 |
121 |
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/client/src/features/categories/components/AddCategorySheet.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Sheet,
4 | SheetContent,
5 | SheetFooter,
6 | SheetHeader,
7 | SheetTitle,
8 | SheetTrigger,
9 | } from "@/components/ui/sheet";
10 | import {
11 | Form,
12 | FormControl,
13 | FormField,
14 | FormItem,
15 | FormLabel,
16 | FormMessage,
17 | } from "@/components/ui/form";
18 | import * as z from "zod";
19 | import { useForm } from "react-hook-form";
20 | import { zodResolver } from "@hookform/resolvers/zod";
21 | import { useMediaQuery } from "react-responsive";
22 | import { useState } from "react";
23 | import { useMutation, useQueryClient } from "@tanstack/react-query";
24 | import { AlertMessage } from "@/components/AlertMassage";
25 | import { Plus } from "lucide-react";
26 | import { Input } from "@/components/ui/input";
27 | import { postCategory } from "../apis/category-api";
28 | import { ErrorResponseData } from "@/lib/axios";
29 | import { AxiosError } from "axios";
30 |
31 | interface AddCategorySheetProps extends React.HTMLAttributes {
32 | projectId: string;
33 | }
34 |
35 | const categoryFormSchema = z.object({
36 | name: z
37 | .string()
38 | .min(1, {
39 | message: "Category name cannot be blank",
40 | })
41 | .max(20, {
42 | message: "Category name cannot be more than 20 characters",
43 | }),
44 | });
45 |
46 | export function AddCategorySheet({ projectId }: AddCategorySheetProps) {
47 | const [open, setOpen] = useState(false);
48 | const [addCategoryError, setAddCategoryError] = useState(
49 | "",
50 | );
51 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
52 | const queryClient = useQueryClient();
53 | const categoryMutation = useMutation({
54 | mutationFn: postCategory,
55 | onSuccess: () => {
56 | queryClient.invalidateQueries(["projects"], { exact: true });
57 | queryClient.invalidateQueries(["projects", projectId], { exact: true });
58 | queryClient.invalidateQueries(["projects", projectId, "categories"], {
59 | exact: true,
60 | });
61 | form.reset({ name: "" });
62 | setAddCategoryError("");
63 | setOpen(false);
64 | },
65 | onError: (error: AxiosError) =>
66 | setAddCategoryError(error.response?.data.message),
67 | });
68 |
69 | const form = useForm>({
70 | resolver: zodResolver(categoryFormSchema),
71 | defaultValues: {
72 | name: "",
73 | },
74 | });
75 |
76 | function onSubmit(values: z.infer) {
77 | categoryMutation.mutate({
78 | projectId,
79 | name: values.name,
80 | });
81 | }
82 |
83 | return (
84 | {
87 | setOpen(open);
88 | setAddCategoryError("");
89 | }}
90 | >
91 |
92 |
96 |
97 |
101 |
126 |
127 |
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/client/src/features/auth/components/LoginCard.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Card,
4 | CardContent,
5 | CardFooter,
6 | CardHeader,
7 | CardTitle,
8 | } from "@/components/ui/card";
9 | import {
10 | Form,
11 | FormControl,
12 | FormField,
13 | FormItem,
14 | FormLabel,
15 | FormMessage,
16 | } from "@/components/ui/form";
17 | import { Input } from "@/components/ui/input";
18 | import { useState } from "react";
19 | import { Link, useNavigate } from "react-router-dom";
20 | import * as z from "zod";
21 | import { useForm } from "react-hook-form";
22 | import { zodResolver } from "@hookform/resolvers/zod";
23 | import { userLogin } from "../apis/auth-api.ts";
24 | import { useMutation } from "@tanstack/react-query";
25 | import { ModeToggle } from "@/components/ModeToggle";
26 | import logoLight from "@/assets/logo-light.png";
27 | import logoDark from "@/assets/logo-dark.png";
28 | import { AlertMessage } from "@/components/AlertMassage";
29 | import { AxiosError } from "axios";
30 | import { ErrorResponseData } from "@/lib/axios";
31 |
32 | const loginFormSchema = z.object({
33 | email: z.string().email().nonempty({
34 | message: "Email cannot be blank",
35 | }),
36 | password: z.string().nonempty({
37 | message: "Password cannot be blank",
38 | }),
39 | });
40 |
41 | export function LoginCard() {
42 | const [loginError, setLoginError] = useState("");
43 | const navigate = useNavigate();
44 | const loginMutation = useMutation({
45 | mutationFn: userLogin,
46 | onSuccess: (data) => {
47 | localStorage.setItem("token", data.data.token);
48 | navigate("/projects");
49 | },
50 | onError: (error: AxiosError) =>
51 | setLoginError(error.response?.data.message),
52 | });
53 |
54 | const form = useForm>({
55 | resolver: zodResolver(loginFormSchema),
56 | defaultValues: {
57 | email: "",
58 | password: "",
59 | },
60 | });
61 |
62 | function onSubmit(values: z.infer) {
63 | loginMutation.mutate(values);
64 | }
65 |
66 | return (
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | Welcome back
75 |
76 |
121 |
122 |
123 | );
124 | }
125 |
--------------------------------------------------------------------------------
/client/src/features/categories/components/CategoriesList.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { ChevronsUpDown } from "lucide-react";
3 |
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Collapsible,
7 | CollapsibleContent,
8 | CollapsibleTrigger,
9 | } from "@/components/ui/collapsible";
10 | import { Badge } from "@/components/ui/badge";
11 | import { Project } from "../../projects/apis/project-api";
12 | import { useQuery } from "@tanstack/react-query";
13 | import { Category, getCategories } from "../apis/category-api";
14 | import { AddCategorySheet } from "./AddCategorySheet";
15 | import { EditCategorySheet } from "./EditCategorySheet";
16 | import { DeleteCategoryAlert } from "./DeleteCategoryAlert";
17 | import { Authorization } from "@/components/Authorization";
18 |
19 | export function CategoriesList({ project }: { project: Project }) {
20 | const [isOpen, setIsOpen] = useState(false);
21 | const categoriesQuery = useQuery({
22 | queryKey: ["projects", project.id.toString(), "categories"],
23 | queryFn: () => getCategories({ projectId: project.id.toString() }),
24 | });
25 |
26 | if (categoriesQuery.isLoading || categoriesQuery.isFetching)
27 | return (
28 |
29 |
30 |
31 |
Categories
32 |
36 |
37 |
38 |
39 | );
40 | if (categoriesQuery.status === "error") {
41 | return Something went wrong
;
42 | }
43 |
44 | const categories = categoriesQuery.data.categories as Category[];
45 |
46 | const defaultCategories = categories.filter((category) => category.isDefault);
47 | const projectCategories = categories.filter(
48 | (category) => !category.isDefault,
49 | );
50 |
51 | return (
52 |
57 |
58 |
59 |
Categories
60 |
61 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 | {defaultCategories.map((category) => (
74 |
75 | {category.name}
76 | Default
77 |
78 | ))}
79 |
80 | {projectCategories.map((category) => (
81 |
85 |
86 | {category.name}
87 |
88 |
89 |
93 |
97 |
98 |
102 |
106 |
107 |
108 |
109 | ))}
110 |
111 |
112 | );
113 | }
114 |
--------------------------------------------------------------------------------
/server/services/category-service.js:
--------------------------------------------------------------------------------
1 | const { Op } = require('sequelize');
2 | const { Category, Project, Issue } = require('../models');
3 | const { customError } = require('../helpers/error-helper');
4 |
5 | const categoryService = {
6 | getCategories: async (req, cb) => {
7 | try {
8 | const projectId = req.params.id;
9 | const project = await Project.findByPk(projectId);
10 | if (!project) throw customError(400, 'Project does not exist!');
11 | const categories = await Category.findAll({
12 | where: {
13 | [Op.or]: [
14 | {
15 | isDefault: true,
16 | isDeleted: false,
17 | },
18 | {
19 | isDefault: false,
20 | isDeleted: false,
21 | projectId,
22 | },
23 | ],
24 | },
25 | attributes: ['id', 'name', 'isDefault'],
26 | });
27 | cb(null, { categories });
28 | } catch (err) {
29 | cb(err);
30 | }
31 | },
32 | postCategory: async (req, cb) => {
33 | try {
34 | const { name } = req.body;
35 | const userId = req.user.id;
36 | const projectId = req.params.id;
37 | if (name.trim().length === 0) throw customError(400, 'Name is required!');
38 |
39 | const project = await Project.findByPk(projectId);
40 | if (!project) throw customError(400, 'Project does not exist!');
41 |
42 | // * only project owner can add category
43 | if (userId !== project.creatorId)
44 | throw customError(400, 'You are not allowed to add new categories!');
45 |
46 | const newCategory = await Category.create({ name, projectId });
47 | cb(null, { newCategory });
48 | } catch (err) {
49 | cb(err);
50 | }
51 | },
52 | patchCategory: async (req, cb) => {
53 | try {
54 | const { name } = req.body;
55 | const userId = req.user.id;
56 | if (name.trim().length === 0) throw customError(400, 'Name is required!');
57 | const projectId = req.params.id;
58 | const categoryId = req.params.cid;
59 | const project = await Project.findByPk(projectId);
60 | const category = await Category.findByPk(categoryId);
61 |
62 | if (!project) throw customError(400, 'Project does not exist!');
63 | if (!category) throw customError(400, 'Category does not exist!');
64 |
65 | // * only project owner can edit category
66 | if (userId !== project.creatorId)
67 | throw customError(400, 'You are not allowed to edit categories!');
68 |
69 | if (category.isDefault)
70 | throw customError(400, 'Cannot change default categories!');
71 | if (category.projectId !== parseInt(projectId))
72 | throw customError(400, 'Cannot change categories of other projects!');
73 |
74 | const updatedCategory = await category.update({ name });
75 | cb(null, { updatedCategory });
76 | } catch (err) {
77 | cb(err);
78 | }
79 | },
80 | deleteCategory: async (req, cb) => {
81 | try {
82 | const userId = req.user.id;
83 | const projectId = req.params.id;
84 | const categoryId = req.params.cid;
85 | const project = await Project.findByPk(projectId);
86 | const category = await Category.findByPk(categoryId);
87 |
88 | if (!project) throw customError(400, 'Project does not exist!');
89 | if (!category) throw customError(400, 'Category does not exist!');
90 |
91 | // * only project owner can delete category
92 | if (userId !== project.creatorId)
93 | throw customError(400, 'You are not allowed to delete categories!');
94 |
95 | if (category.isDefault)
96 | throw customError(400, 'Cannot delete default categories!');
97 | if (category.isDeleted)
98 | throw customError(400, 'Category has already been deleted!');
99 | if (category.projectId !== parseInt(projectId))
100 | throw customError(400, 'Cannot delete categories of other projects!');
101 |
102 | const deletedCategory = await category.update({ isDeleted: true });
103 |
104 | // * when category is deleted, issues of this category will be set to category 'other's
105 | const otherCategory = await Category.findOne({
106 | where: { name: 'other' },
107 | });
108 | await Issue.update(
109 | { categoryId: otherCategory.id },
110 | {
111 | where: {
112 | categoryId: deletedCategory.id,
113 | },
114 | }
115 | );
116 |
117 | cb(null, { deletedCategory });
118 | } catch (err) {
119 | cb(err);
120 | }
121 | },
122 | };
123 |
124 | module.exports = categoryService;
125 |
--------------------------------------------------------------------------------
/client/src/features/projects/routes/DashboardPage.tsx:
--------------------------------------------------------------------------------
1 | import { Project, getProject } from "@/features/projects/apis/project-api";
2 | import { Badge } from "@/components/ui/badge";
3 | import { Button } from "@/components/ui/button";
4 | import { useQuery } from "@tanstack/react-query";
5 | import { useMediaQuery } from "react-responsive";
6 | import { Link, useParams } from "react-router-dom";
7 | import { TableProperties } from "lucide-react";
8 | import UserAvatar from "@/components/UserAvatar";
9 | import { formatTime } from "@/utils";
10 | import { MembersList } from "@/features/projects/components/MembersList";
11 | import { ScrollArea } from "@/components/ui/scroll-area";
12 | import Spinner from "@/components/ui/spinner";
13 | import { EditProjectSheet } from "../components/EditProjectSheet";
14 | import { DeleteProjectAlert } from "../components/DeleteProjectAlert";
15 | import { Issue, getIssues } from "@/features/issues/apis/issue-api";
16 | import { ChartBoard } from "../components/ChartBoard";
17 | import { CategoriesList } from "@/features/categories";
18 | import { Authorization } from "@/components/Authorization";
19 |
20 | export function DashboardPage() {
21 | const { id } = useParams();
22 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
23 | const projectQuery = useQuery({
24 | queryKey: ["projects", id],
25 | queryFn: () => id && getProject({ projectId: id }),
26 | });
27 | const issuesQuery = useQuery({
28 | queryKey: ["projects", id, "issues"],
29 | queryFn: () => id && getIssues({ projectId: id }),
30 | });
31 |
32 | const isLoading =
33 | projectQuery.isLoading ||
34 | projectQuery.isFetching ||
35 | issuesQuery.isLoading ||
36 | issuesQuery.isFetching;
37 |
38 | const isError = projectQuery.isError || issuesQuery.isError;
39 |
40 | if (isLoading) return ;
41 | if (isError) {
42 | return Something went wrong
;
43 | }
44 |
45 | const project = projectQuery.data.project as Project;
46 | const issues = issuesQuery.data.issues as Issue[];
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
54 | {project.isPublic ? "public" : "private"}
55 |
56 |
57 | {project.name}
58 |
59 |
{project.description}
60 |
61 |
62 |
63 |
64 | Creator:
65 |
66 |
Created at: {formatTime(project.createdAt)}
67 |
68 |
69 |
70 |
74 |
75 |
79 |
80 |
81 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 | {/* charts */}
93 |
94 |
95 |
96 | {/* members */}
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 | );
106 | }
107 |
--------------------------------------------------------------------------------
/client/src/features/comments/components/CommentSheet.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Textarea } from "@/components/ui/textarea";
3 | import {
4 | Sheet,
5 | SheetContent,
6 | SheetFooter,
7 | SheetHeader,
8 | SheetTitle,
9 | SheetTrigger,
10 | } from "@/components/ui/sheet";
11 | import {
12 | Form,
13 | FormControl,
14 | FormField,
15 | FormItem,
16 | FormLabel,
17 | FormMessage,
18 | } from "@/components/ui/form";
19 | import * as z from "zod";
20 | import { useForm } from "react-hook-form";
21 | import { zodResolver } from "@hookform/resolvers/zod";
22 | import { useMediaQuery } from "react-responsive";
23 | import { useState } from "react";
24 | import { useMutation, useQueryClient } from "@tanstack/react-query";
25 | import { postComment } from "@/features/comments/apis/comment-api";
26 | import { AlertMessage } from "../../../components/AlertMassage";
27 | import { Plus } from "lucide-react";
28 | import { AxiosError } from "axios";
29 | import { ErrorResponseData } from "@/lib/axios";
30 |
31 | interface CommentSheetProps extends React.HTMLAttributes {
32 | projectId: string;
33 | issueId: string;
34 | }
35 |
36 | const commentFormSchema = z.object({
37 | text: z
38 | .string()
39 | .min(1, {
40 | message: "Comment cannot be blank",
41 | })
42 | .max(250, {
43 | message: "Comment cannot be more than 250 characters",
44 | }),
45 | });
46 |
47 | export function CommentSheet({ projectId, issueId }: CommentSheetProps) {
48 | const [open, setOpen] = useState(false);
49 | const [addCommentError, setAddCommentError] = useState(
50 | "",
51 | );
52 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
53 | const queryClient = useQueryClient();
54 | const commentMutation = useMutation({
55 | mutationFn: postComment,
56 | onSuccess: (data) => {
57 | const { newComment } = data;
58 | queryClient.setQueryData(
59 | ["projects", projectId, "issues", issueId, "comments", newComment.id],
60 | newComment,
61 | );
62 | queryClient.invalidateQueries(
63 | ["projects", projectId, "issues", issueId, "comments"],
64 | { exact: true },
65 | );
66 | form.reset({ text: "" });
67 | setAddCommentError("");
68 | setOpen(false);
69 | },
70 | onError: (error: AxiosError) =>
71 | setAddCommentError(error.response?.data.message),
72 | });
73 |
74 | const form = useForm>({
75 | resolver: zodResolver(commentFormSchema),
76 | defaultValues: {
77 | text: "",
78 | },
79 | });
80 |
81 | function onSubmit(values: z.infer) {
82 | commentMutation.mutate({
83 | projectId: projectId.toString(),
84 | issueId: issueId.toString(),
85 | text: values.text,
86 | });
87 | }
88 |
89 | return (
90 | {
93 | setOpen(open);
94 | setAddCommentError("");
95 | }}
96 | >
97 |
98 |
101 |
102 |
106 |
137 |
138 |
139 |
140 | );
141 | }
142 |
--------------------------------------------------------------------------------
/client/src/features/categories/components/EditCategorySheet.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import {
3 | Sheet,
4 | SheetContent,
5 | SheetFooter,
6 | SheetHeader,
7 | SheetTitle,
8 | SheetTrigger,
9 | } from "@/components/ui/sheet";
10 | import {
11 | Form,
12 | FormControl,
13 | FormField,
14 | FormItem,
15 | FormLabel,
16 | FormMessage,
17 | } from "@/components/ui/form";
18 | import * as z from "zod";
19 | import { useForm } from "react-hook-form";
20 | import { zodResolver } from "@hookform/resolvers/zod";
21 | import { useMediaQuery } from "react-responsive";
22 | import { useState } from "react";
23 | import { useMutation, useQueryClient } from "@tanstack/react-query";
24 | import { AlertMessage } from "@/components/AlertMassage";
25 | import { Pencil } from "lucide-react";
26 | import { Input } from "@/components/ui/input";
27 | import { Category, patchCategory } from "../apis/category-api";
28 | import { AxiosError } from "axios";
29 | import { ErrorResponseData } from "@/lib/axios";
30 |
31 | interface EditCategorySheetProps extends React.HTMLAttributes {
32 | projectId: string;
33 | category: Category;
34 | }
35 |
36 | const categoryFormSchema = z.object({
37 | name: z
38 | .string()
39 | .min(1, {
40 | message: "Category name cannot be blank",
41 | })
42 | .max(20, {
43 | message: "Category name cannot be more than 20 characters",
44 | }),
45 | });
46 |
47 | export function EditCategorySheet({
48 | projectId,
49 | category,
50 | }: EditCategorySheetProps) {
51 | const [open, setOpen] = useState(false);
52 | const [editCategoryError, setEditCategoryError] = useState<
53 | string | undefined
54 | >("");
55 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
56 | const queryClient = useQueryClient();
57 | const categoryMutation = useMutation({
58 | mutationFn: patchCategory,
59 | onSuccess: () => {
60 | queryClient.invalidateQueries(["projects"], { exact: true });
61 | queryClient.invalidateQueries(["projects", projectId], { exact: true });
62 | queryClient.invalidateQueries(["projects", projectId, "issues"], {
63 | exact: true,
64 | });
65 | queryClient.invalidateQueries(["projects", projectId, "categories"], {
66 | exact: true,
67 | });
68 | form.reset({ name: "" });
69 | setEditCategoryError("");
70 | setOpen(false);
71 | },
72 | onError: (error: AxiosError) =>
73 | setEditCategoryError(error.response?.data.message),
74 | });
75 |
76 | const form = useForm>({
77 | resolver: zodResolver(categoryFormSchema),
78 | defaultValues: {
79 | name: category.name,
80 | },
81 | });
82 |
83 | function onSubmit(values: z.infer) {
84 | categoryMutation.mutate({
85 | projectId,
86 | categoryId: category.id.toString(),
87 | name: values.name,
88 | });
89 | }
90 |
91 | return (
92 | {
95 | setOpen(open);
96 | setEditCategoryError("");
97 | }}
98 | >
99 |
100 |
104 |
105 |
109 |
134 |
135 |
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/client/src/features/comments/components/EditCommentSheet.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Textarea } from "@/components/ui/textarea";
3 | import {
4 | Sheet,
5 | SheetContent,
6 | SheetFooter,
7 | SheetHeader,
8 | SheetTitle,
9 | } from "@/components/ui/sheet";
10 | import {
11 | Form,
12 | FormControl,
13 | FormField,
14 | FormItem,
15 | FormLabel,
16 | FormMessage,
17 | } from "@/components/ui/form";
18 | import * as z from "zod";
19 | import { useForm } from "react-hook-form";
20 | import { zodResolver } from "@hookform/resolvers/zod";
21 | import { useMediaQuery } from "react-responsive";
22 | import { useState } from "react";
23 | import { useMutation, useQueryClient } from "@tanstack/react-query";
24 | import {
25 | patchComment,
26 | type Comment,
27 | } from "@/features/comments/apis/comment-api";
28 | import { AlertMessage } from "../../../components/AlertMassage";
29 | import { AxiosError } from "axios";
30 | import { ErrorResponseData } from "@/lib/axios";
31 |
32 | interface EditCommentSheetProps extends React.HTMLAttributes {
33 | projectId: string;
34 | issueId: string;
35 | comment: Comment;
36 | showEditSheet: boolean;
37 | setShowEditSheet: React.Dispatch>;
38 | }
39 |
40 | const commentFormSchema = z.object({
41 | text: z
42 | .string()
43 | .min(1, {
44 | message: "Comment cannot be blank",
45 | })
46 | .max(250, {
47 | message: "Comment cannot be more than 250 characters",
48 | }),
49 | });
50 |
51 | export function EditCommentSheet({
52 | projectId,
53 | issueId,
54 | comment,
55 | showEditSheet,
56 | setShowEditSheet,
57 | }: EditCommentSheetProps) {
58 | const [editCommentError, setEditCommentError] = useState(
59 | "",
60 | );
61 | const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
62 | const queryClient = useQueryClient();
63 | const commentMutation = useMutation({
64 | mutationFn: patchComment,
65 | onSuccess: (data) => {
66 | const { updatedComment } = data;
67 | queryClient.setQueryData(
68 | [
69 | "projects",
70 | projectId,
71 | "issues",
72 | issueId,
73 | "comments",
74 | updatedComment.id,
75 | ],
76 | updatedComment,
77 | );
78 | queryClient.invalidateQueries(
79 | ["projects", projectId, "issues", issueId, "comments"],
80 | { exact: true },
81 | );
82 | setShowEditSheet(false);
83 | setEditCommentError("");
84 | },
85 | onError: (error: AxiosError) =>
86 | setEditCommentError(error.response?.data.message),
87 | });
88 |
89 | const form = useForm>({
90 | resolver: zodResolver(commentFormSchema),
91 | defaultValues: {
92 | text: comment.text,
93 | },
94 | });
95 |
96 | function onSubmit(values: z.infer) {
97 | commentMutation.mutate({
98 | projectId,
99 | issueId,
100 | commentId: comment.id.toString(),
101 | text: values.text,
102 | });
103 | }
104 |
105 | return (
106 | {
109 | setShowEditSheet(open);
110 | setEditCommentError("");
111 | }}
112 | >
113 |
117 |
148 |
149 |
150 |
151 | );
152 | }
153 |
--------------------------------------------------------------------------------