├── README.md
├── client
├── .eslintrc.cjs
├── .gitignore
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
│ └── vite.svg
├── src
│ ├── App.jsx
│ ├── Assets
│ │ └── Images
│ │ │ ├── black-bg.png
│ │ │ ├── blog.png
│ │ │ ├── blog1.png
│ │ │ ├── blog2.png
│ │ │ ├── blog3.png
│ │ │ ├── code.jpg
│ │ │ ├── community.png
│ │ │ ├── emma.jpg
│ │ │ ├── endless-constellation.png
│ │ │ ├── img1.png
│ │ │ ├── img2.svg
│ │ │ ├── img3.png
│ │ │ ├── logo.png
│ │ │ ├── nobg.png
│ │ │ └── notfound.png
│ ├── Components
│ │ ├── Auth
│ │ │ ├── ForgetPassword.jsx
│ │ │ ├── Login.jsx
│ │ │ ├── ResetPassword.jsx
│ │ │ └── SignUp.jsx
│ │ ├── Dashboard
│ │ │ ├── Dashboard.jsx
│ │ │ ├── EditPost.jsx
│ │ │ ├── EditProfile.jsx
│ │ │ ├── FloatingButton.jsx
│ │ │ ├── Posts.jsx
│ │ │ ├── ProfilePic.jsx
│ │ │ ├── Profiles.jsx
│ │ │ ├── ReadPostPage.jsx
│ │ │ ├── SearchBox.jsx
│ │ │ ├── SideMenu.jsx
│ │ │ ├── Subscriber.jsx
│ │ │ ├── UserPost.jsx
│ │ │ └── Write.jsx
│ │ ├── Drawer.jsx
│ │ ├── Footer.jsx
│ │ ├── Home.jsx
│ │ ├── Navbar.jsx
│ │ ├── NewHome.jsx
│ │ ├── NotFound.jsx
│ │ ├── TimeAgo.jsx
│ │ ├── TopMenu.jsx
│ │ ├── UseGet.jsx
│ │ ├── index.html
│ │ └── redux
│ │ │ ├── AccessTokenSlice.jsx
│ │ │ ├── MyPostSlice.jsx
│ │ │ ├── PendingSlice.jsx
│ │ │ ├── PostSlice.jsx
│ │ │ ├── UserDataSlice.jsx
│ │ │ ├── UserPostSlice.jsx
│ │ │ ├── UserSlice.jsx
│ │ │ └── store.jsx
│ ├── color.txt
│ ├── index.css
│ └── main.jsx
├── tailwind.config.js
├── vercel.json
└── vite.config.js
└── server
├── .env.sample
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── nest-cli.json
├── package-lock.json
├── package.json
├── public
└── avatars
│ └── 402a5f16-9272-416c-a3f8-fa4ae236833b.webp
├── src
├── app.module.ts
├── config
│ ├── database.config.ts
│ ├── index.ts
│ └── jwt.config.ts
├── main.ts
└── modules
│ ├── auth
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ ├── auth.service.ts
│ ├── dto
│ │ ├── login-user.dto.ts
│ │ ├── password-reset.dto.ts
│ │ ├── resend-code.dto.ts
│ │ └── verifyUserData.dto.ts
│ ├── guards
│ │ ├── jwt-guard.guard.ts
│ │ ├── local-auth.guard.ts
│ │ └── roles.guard.ts
│ └── strategies
│ │ ├── jwt.strategy.ts
│ │ └── local.strategy.ts
│ ├── category
│ ├── category.controller.ts
│ ├── category.module.ts
│ ├── category.service.ts
│ ├── dto
│ │ ├── create-category.dto.ts
│ │ └── update-category.dto.ts
│ └── entities
│ │ └── category.entity.ts
│ ├── comment
│ ├── comment.controller.ts
│ ├── comment.module.ts
│ ├── comment.service.ts
│ ├── dto
│ │ ├── create-comment.dto.ts
│ │ └── update-comment.dto.ts
│ ├── entities
│ │ └── comment.entity.ts
│ └── interface
│ │ └── comment.interface.ts
│ ├── common
│ ├── decorators
│ │ └── role.decorator.ts
│ ├── enum
│ │ ├── getPost.enum.ts
│ │ ├── role.enum.ts
│ │ └── upload-folder.enum.ts
│ ├── interface
│ │ └── index.interface.ts
│ ├── utils..ts
│ └── validators
│ │ └── image-pipe.pipe.ts
│ ├── otp
│ ├── dto
│ │ └── verify-otp.ts
│ ├── entities
│ │ └── otp.entity.ts
│ ├── otp.controller.ts
│ ├── otp.module.ts
│ └── otp.service.ts
│ ├── post
│ ├── dto
│ │ ├── create-post.dto.ts
│ │ └── update-post.dto.ts
│ ├── entities
│ │ └── post.entity.ts
│ ├── post.controller.ts
│ ├── post.module.ts
│ ├── post.service.ts
│ └── slug.provider.ts
│ ├── search
│ ├── search.controller.ts
│ ├── search.dto.ts
│ ├── search.module.ts
│ └── search.service.ts
│ ├── uploads
│ ├── uploads.controller.ts
│ ├── uploads.module.ts
│ └── uploads.service.ts
│ └── user
│ ├── dto
│ ├── create-user.dto.ts
│ ├── login-user.dto.ts
│ ├── update-user-role.dto.ts
│ ├── update-user-sensitive.dto.ts
│ └── update-user.dto.ts
│ ├── entities
│ └── user.entity.ts
│ ├── user.controller.ts
│ ├── user.module.ts
│ └── user.service.ts
├── test
├── app.e2e-spec.ts
└── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
/README.md:
--------------------------------------------------------------------------------
1 | # Blog Website With TypeScript, NestJS, PostgreSQL, TypeORM, and SwaggerJS
2 |
3 | This is a blog Post Website Application created with with NodeJS and React, also used some technologies along side with it, which are:
4 |
5 | - Typescript: for type checking in javascript
6 | - NestJS: a nodejs framework which was built on top express a nodejs light weight framework.
7 | - PostgreSQL: the RDBMS that was used to store the blog post, comments and all in the database.
8 | - TypeORM: the ORM which is used to connect the database.
9 | - SwaggerJS: for the API documentation.
10 |
11 | ## Database Design
12 |
13 | - [Database Design For The Blog](https://drawsql.app/teams/oluwatosin/diagrams/blog-database-design/embed)
14 |
15 | ## Getting Started
16 |
17 | ```
18 | $ git clone https://github.com/dkrest1/My-Blog.git
19 | $ cd server
20 | ```
21 |
22 | ## Create a `.env` file and put in the right credentials
23 |
24 | ```
25 | $ cp .env.sample .env
26 | ```
27 |
28 | ## Installation
29 |
30 | ```
31 | $ npm install
32 | ```
33 |
34 | ### API Documentation
35 |
36 | - http://localhost:3000/api#/
37 |
--------------------------------------------------------------------------------
/client/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: { browser: true, es2020: true },
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:react/recommended',
6 | 'plugin:react/jsx-runtime',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
10 | settings: { react: { version: '18.2' } },
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': 'warn',
14 | },
15 | }
16 |
--------------------------------------------------------------------------------
/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 | .env
12 | dist
13 | dist-ssr
14 | *.local
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Blog App
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/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": "vite build",
9 | "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@fortawesome/fontawesome-svg-core": "^6.4.0",
14 | "@fortawesome/free-regular-svg-icons": "^6.4.0",
15 | "@fortawesome/free-solid-svg-icons": "^6.4.0",
16 | "@fortawesome/react-fontawesome": "^0.2.0",
17 | "@heroicons/react": "^2.0.18",
18 | "@material-tailwind/react": "^2.0.0",
19 | "@reduxjs/toolkit": "^1.9.5",
20 | "ahooks": "^3.7.7",
21 | "axios": "^1.4.0",
22 | "classnames": "^2.3.2",
23 | "date-fns": "^2.30.0",
24 | "draft-js": "^0.11.7",
25 | "draftjs-utils": "^0.10.2",
26 | "form-data": "^4.0.0",
27 | "html-to-draftjs": "^1.5.0",
28 | "immutable": "^4.3.0",
29 | "js-cookie": "^3.0.5",
30 | "react": "^18.2.0",
31 | "react-dom": "^18.2.0",
32 | "react-loading": "^2.0.3",
33 | "react-redux": "^8.0.5",
34 | "react-router-dom": "^6.11.1",
35 | "react-toastify": "^9.1.3",
36 | "react-use": "^17.4.0",
37 | "react-use-click-away": "^1.0.10",
38 | "redux": "^4.2.1"
39 | },
40 | "devDependencies": {
41 | "@types/react": "^18.0.28",
42 | "@types/react-dom": "^18.0.11",
43 | "@vitejs/plugin-react": "^4.0.0",
44 | "autoprefixer": "^10.4.14",
45 | "eslint": "^8.38.0",
46 | "eslint-plugin-react": "^7.32.2",
47 | "eslint-plugin-react-hooks": "^4.6.0",
48 | "eslint-plugin-react-refresh": "^0.3.4",
49 | "postcss": "^8.4.23",
50 | "tailwindcss": "^3.3.2",
51 | "vite": "^4.3.5"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/client/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/client/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/App.jsx:
--------------------------------------------------------------------------------
1 | import {BrowserRouter, Routes, Route} from 'react-router-dom';
2 | import { useState } from 'react';
3 | import { Login } from './Components/Auth/Login';
4 | import Home from './Components/Home';
5 | import SignUpForm from './Components/Auth/SignUp';
6 | import Dashboard from './Components/Dashboard/Dashboard';
7 | import Footer from './Components/Footer';
8 | import Profiles from './Components/Dashboard/Profiles';
9 | import WritingPage from './Components/Dashboard/Write';
10 | import Navbar from './Components/Navbar';
11 | import { useSelector, useDispatch } from 'react-redux';
12 | import ReadPostPage from './Components/Dashboard/ReadPostPage';
13 | import { useEffect } from 'react';
14 | import axios from 'axios';
15 | import { token } from './Components/redux/AccessTokenSlice';
16 | import { user, getUser } from './Components/redux/UserDataSlice';
17 | import { EditPost } from './Components/Dashboard/EditPost';
18 | import { ForgetPassword } from './Components/Auth/ForgetPassword';
19 | import { ResetPassword } from './Components/Auth/ResetPassword';
20 | import ReactLoading from 'react-loading'
21 | import { pending, getPending } from './Components/redux/PendingSlice';
22 | import Cookies from 'js-cookie';
23 | import NotFound from './Components/NotFound';
24 | import NewHome from './Components/NewHome';
25 |
26 | function App() {
27 | const accessToken = useSelector(token)
28 | const userData=useSelector(user)
29 | const dispatch = useDispatch()
30 | const initialState = localStorage.getItem("profilePic") || null;
31 | const [selectedFile, setSelectedFile] = useState(initialState);
32 | const isPending= useSelector(pending)
33 | useEffect(()=>{
34 | if(!userData){
35 | const headers={
36 | Authorization: `Bearer ${accessToken}`
37 | }
38 | dispatch(getPending(true))
39 | axios.get('http://localhost:3000/user/me', {headers})
40 | .then((response)=>{
41 | dispatch(getUser(response.data))
42 | dispatch(getPending(false))
43 | })
44 | .catch((error)=>{
45 | console.log(error)
46 | dispatch(getPending(false))
47 | })
48 | }
49 | },[userData])
50 | useEffect(()=>{
51 | if(!userData && accessToken){
52 | Cookies.remove('token')
53 | }
54 | })
55 |
56 | return (
57 |
58 |
59 |
60 | {isPending ? (
61 |
62 |
69 |
70 | ) : (
71 |
72 | } />
73 | {/* } /> */}
74 | } />
75 | } />
76 | }
79 | />
80 |
88 | }
89 | />
90 | }
93 | />
94 | } />
95 | } />
96 | } />
97 | } />
98 | }/>
99 |
100 | )}
101 |
102 |
103 |
104 | );
105 | }
106 |
107 | export default App;
108 |
--------------------------------------------------------------------------------
/client/src/Assets/Images/black-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/black-bg.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/blog.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/blog.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/blog1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/blog1.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/blog2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/blog2.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/blog3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/blog3.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/code.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/code.jpg
--------------------------------------------------------------------------------
/client/src/Assets/Images/community.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/community.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/emma.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/emma.jpg
--------------------------------------------------------------------------------
/client/src/Assets/Images/endless-constellation.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/endless-constellation.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/img1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/img1.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/img2.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/client/src/Assets/Images/img3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/img3.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/logo.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/nobg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/nobg.png
--------------------------------------------------------------------------------
/client/src/Assets/Images/notfound.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Assets/Images/notfound.png
--------------------------------------------------------------------------------
/client/src/Components/Auth/ForgetPassword.jsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import React, { useState } from 'react'
3 | import {Button, Dialog, DialogBody, DialogFooter, DialogHeader} from '@material-tailwind/react'
4 | import { ResetPassword } from './ResetPassword'
5 |
6 | export const ForgetPassword = () => {
7 | const [userEmail, setEmail] = useState(
8 | {
9 | email: ''
10 | }
11 | )
12 | const [error, setError] = useState('')
13 | const [open, setOpen] = useState(false)
14 |
15 | const handleForgetPassword=(event)=>{
16 | event.preventDefault()
17 | if(userEmail.email===''){
18 | setError("Please enter your email to recover your password")
19 | }else{
20 | axios.post('http://localhost:3000/auth/forget/password')
21 | .then((response)=>{
22 | console.log(response)
23 | if(response.statusText==='OK'){
24 | setOpen(true)
25 | }
26 | })
27 | .catch((error)=>{
28 | console.log(error)
29 |
30 | })
31 | }
32 | }
33 | // console.log(userEmail)
34 | return (
35 |
36 |
Forgot Password Recovery
37 |
38 | setOpen(!open)} size='xl'>
39 | Password Reset
40 |
41 | A link to reset password has been sent to your mail.
42 |
43 |
44 | setOpen(!open)} className=''>
45 | OK
46 |
47 |
48 |
49 |
50 |
70 |
71 |
72 | )
73 | }
74 |
--------------------------------------------------------------------------------
/client/src/Components/Auth/Login.jsx:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { React, useState } from "react";
3 | import { Link, useNavigate } from "react-router-dom";
4 | import { ToastContainer, toast } from "react-toastify";
5 | import { token } from "../redux/AccessTokenSlice";
6 | import { useSelector, useDispatch } from "react-redux";
7 | import { getToken } from "../redux/AccessTokenSlice";
8 | import { user } from "../redux/UserDataSlice";
9 | import { getUser } from "../redux/UserDataSlice";
10 | import Cookies from "js-cookie";
11 | import { useEffect } from "react";
12 | import { getPending, pending } from "../redux/PendingSlice";
13 |
14 | export const Login = () => {
15 | const userData = useSelector(user);
16 | const accessToken = useSelector(token);
17 | const isPending = useSelector(pending);
18 | const dispatch = useDispatch();
19 |
20 | const [formValues, setFormValues] = useState({
21 | email: "",
22 | password: "",
23 | });
24 | const navigateTo = useNavigate();
25 | const notify = (status) => toast(status);
26 | const handleInputChange = (event) => {
27 | const { name, value } = event.target;
28 | setFormValues((prevValues) => ({ ...prevValues, [name]: value }));
29 | };
30 | const handleLoginSubmit = (event) => {
31 | event.preventDefault();
32 | dispatch(getPending(true));
33 | axios
34 | .post("http://localhost:3000/auth/login", formValues)
35 | .then(function (response) {
36 | if (response.statusText === "Created") {
37 | let data = response.data.access_token;
38 | dispatch(getToken(data));
39 | // setToken(data)
40 | Cookies.set("token", data, { expires: 1 });
41 | const headers = {
42 | Authorization: `Bearer ${data}`,
43 | };
44 | axios
45 | .get("http://localhost:3000/user/me", { headers })
46 | .then(function (response) {
47 | if (response.statusText === "OK") {
48 | let data = response.data;
49 | dispatch(getUser(data));
50 | let status = "Login Successful!";
51 | notify(status);
52 | setTimeout(() => {
53 | navigateTo("/");
54 | }, 2000);
55 | dispatch(getPending(false));
56 | }
57 | })
58 | .catch(function (error) {
59 | console.log(error);
60 | let status = error.response.data.message;
61 | notify(status);
62 | dispatch(getPending(false));
63 | });
64 | }
65 | dispatch(getPending(false));
66 | })
67 | .catch(function (error) {
68 | console.log(error);
69 | let status = error.response.data.message;
70 | notify(status);
71 | dispatch(getPending(false));
72 | // setIsPending(false)
73 | });
74 | };
75 | const handleForgotPassword = (event) => {
76 | event.preventDefault();
77 | navigateTo("/forgot-password");
78 | };
79 | // console.log(userData)
80 | return (
81 |
82 |
83 |
84 |
85 |
86 | Login
87 |
88 |
152 |
153 |
154 |
155 | );
156 | };
157 |
--------------------------------------------------------------------------------
/client/src/Components/Auth/ResetPassword.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useState } from 'react';
3 |
4 | export const ResetPassword = () => {
5 | const [formValues, setFormValues] = useState({
6 | email: "",
7 | password: "",
8 | confirmPassword: ""
9 | });
10 | const [error, setError] = useState('')
11 | const handleInputChange =(event)=>{
12 | const {name, value} = event.target
13 | setFormValues((prevValues)=>({...prevValues, [name]:value}))
14 | setError((prevValues)=>({...prevValues, [name]:''}))
15 | }
16 |
17 | const handleResetPassword =(event)=>{
18 | event.preventDefault()
19 | console.log('clicked')
20 | if(formValues.email===''){
21 | setError('Please enter your email')
22 | } else if(formValues.password===''){
23 | setError('please enter new password')
24 | } else if(formValues.confirmPassword===''){
25 | setError('Please enter new password again')
26 | }
27 | else if(formValues.password !==formValues.confirmPassword){
28 | setError("Password do not match!")
29 | }
30 | else{
31 | console.log('good to go!!')
32 | }
33 | }
34 | return (
35 |
93 | )
94 | }
95 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/Dashboard.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from "react";
2 | import {useClickAway} from 'react-use'
3 | import { NavLink, useNavigate } from "react-router-dom";
4 | import { useSelector, useDispatch } from "react-redux";
5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
6 | import { faSearch, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
7 | import Posts from "./Posts";
8 | import { user } from "../redux/UserDataSlice";
9 | import { token } from "../redux/AccessTokenSlice";
10 | import { getPosts } from "../redux/PostSlice";
11 | import { post } from "../redux/PostSlice";
12 | import FloatingButton from "./FloatingButton";
13 | import SearchBox from "./SearchBox";
14 | import UseGet from "../UseGet";
15 | import { toast, ToastContainer } from "react-toastify";
16 |
17 | export default function Dashboard() {
18 | const accessToken = useSelector(token)
19 | const postFetched = useSelector(post)
20 | const dispatch = useDispatch()
21 | const [isOpen, setIsOpen] = useState(false);
22 |
23 | const {fetchedData, isPending, setIsPending} = UseGet("http://localhost:3000/post?page=1&limit=0", accessToken)
24 | useEffect(()=>{
25 | fetchedData &&(
26 | dispatch(getPosts(fetchedData)),
27 | setIsPending(false)
28 | )
29 | },[fetchedData])
30 |
31 | const ref = useRef(null);
32 | useClickAway(ref,()=>{
33 | setIsOpen(false);
34 | });
35 |
36 | const [searchItem, setsearchItem] = useState('')
37 | const [filtered, setFiltered] = useState([])
38 | const [searchError, setSearchError] = useState('')
39 | const notify = ()=>toast(searchError)
40 |
41 | const handleInputChange =(event)=>{
42 | setsearchItem(event.target.value)
43 | }
44 | const handleSearchSubmit =(event)=>{
45 | event.preventDefault()
46 | if(searchItem===''){
47 | setFiltered([])
48 | } else if(filteredItem.length < 1 ){
49 | setSearchError("No matched post")
50 | notify()
51 | }
52 | else{
53 | setFiltered(filteredItem)
54 | }
55 | }
56 | let filteredItem
57 | if (searchItem){
58 | filteredItem = postFetched.filter((obj)=>{
59 | let searched = obj.title.toLowerCase().includes(searchItem.toLowerCase()) || obj.content.toLowerCase().includes(searchItem.toLowerCase()) || obj.user.firstname.toLowerCase().includes(searchItem.toLowerCase()) || obj.user.lastname.toLowerCase().includes(searchItem.toLowerCase())
60 | return(searched)
61 | })
62 | }
63 |
64 | return (
65 |
66 | {/* {accessToken ?
67 | <> */}
68 |
69 |
70 |
71 |
72 |
Latest Posts
73 |
74 | {/* */}
75 |
85 |
86 |
87 |
88 | Post
89 |
90 |
91 |
92 |
93 |
94 | {
95 | filtered.length > 0 ?
96 |
97 | :
98 |
99 | }
100 |
101 |
102 |
103 |
104 |
105 | {/* > : gotoLogin()} */}
106 |
107 | )
108 | }
109 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/EditPost.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useEffect, useState } from 'react'
3 | import { useParams, useNavigate } from 'react-router-dom'
4 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
5 | import { faPenToSquare, faPlus, } from '@fortawesome/free-solid-svg-icons'
6 | import { user } from '../redux/UserDataSlice'
7 | import { post } from '../redux/PostSlice'
8 | import { nanoid } from '@reduxjs/toolkit'
9 | import axios from 'axios'
10 | import { ToastContainer, toast } from 'react-toastify'
11 | import { token } from '../redux/AccessTokenSlice'
12 | import { useSelector } from 'react-redux'
13 | import { PhotoIcon } from '@heroicons/react/24/solid'
14 | import UseGet from '../UseGet'
15 | import Subscriber from './Subscriber'
16 |
17 | export const EditPost = () => {
18 | const {id} = useParams()
19 | const accessToken = useSelector(token)
20 | let edit = JSON.parse(localStorage.getItem('editPost'))
21 | const userData = useSelector(user)
22 | const navigateTo = useNavigate();
23 | const [postValues, setPostValues] = useState({
24 | title: edit ? edit.title : '',
25 | content: edit ? edit.content : '' ,
26 | published: false
27 | })
28 | useEffect(()=>{
29 | if(!accessToken){
30 | const gotoLogin =()=>{
31 | navigateTo('/login')
32 | }
33 | gotoLogin()
34 | }
35 | })
36 | const handleInputChange =(event)=>{
37 | const {name, value} = event.target
38 | setPostValues((prevValues)=>({...prevValues, [name]: value}))
39 | }
40 | let status
41 | const notify =()=> toast(status)
42 |
43 | const onPublishPost =(event)=>{
44 | event.preventDefault()
45 | if(!postValues.title && !postValues.content){
46 | console.log('Title or Content cannot be empty!')
47 | }else{
48 | const headers = {
49 | Authorization: `Bearer ${accessToken}`,
50 | 'content-type': 'application/json',
51 | }
52 | axios.patch(`http://localhost:3000/post/${id}`, postValues, {headers})
53 | .then((response)=>{
54 | if(response.statusText='OK'){
55 | status='Post updated successfully!'
56 | notify()
57 | setPostValues({title:'', content:''})
58 | setTimeout(() => {
59 | navigateTo('/dashboard')
60 | }, 2000);
61 | }
62 | })
63 | .catch((err)=>{
64 | console.log(err)
65 | status="Something happened, couldn't update post."
66 | notify()
67 | })
68 | }
69 | }
70 | const fileInputRef = React.createRef();
71 | const [storyImage, setStoryImage] = useState(null)
72 |
73 | const handleFileUpload = (event) => {
74 | const file = event.target.files[0];
75 | const imageFile = URL.createObjectURL(file)
76 | setStoryImage(imageFile)
77 | };
78 | const hanldeAddImage = () => {
79 | // Trigger click event on the hidden file input element
80 | if (fileInputRef.current) {
81 | fileInputRef.current.click();
82 | }
83 | };
84 | const handleRemoveImage =()=>{
85 | setStoryImage(null)
86 | }
87 |
88 | return (
89 |
90 |
91 |
92 |
93 |
94 | Edit Post
95 |
96 |
97 |
98 |
99 |
102 | {/* */}
103 |
104 | Update
105 |
106 |
107 |
108 |
109 |
110 |
114 | Title
115 |
116 |
123 |
124 |
125 |
129 | Body
130 |
131 |
132 |
137 |
138 |
Add Photos
139 |
140 |
141 |
148 | {storyImage &&
149 |
150 |
151 |
152 |
153 | }
154 |
155 |
156 |
157 |
158 |
159 |
160 | )
161 | }
162 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/EditProfile.jsx:
--------------------------------------------------------------------------------
1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
2 | import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
3 | import React, { useEffect, useState } from 'react'
4 | import { useSelector, useDispatch } from 'react-redux'
5 | import ProfileAvatar from './ProfilePic'
6 | import { Button, Dialog, DialogBody, DialogFooter, DialogHeader } from '@material-tailwind/react'
7 | import { user } from '../redux/UserDataSlice'
8 | import { post } from '../redux/PostSlice'
9 | import { token } from '../redux/AccessTokenSlice'
10 | import axios from 'axios'
11 | import {ToastContainer, toast} from 'react-toastify'
12 | import { getUser} from '../redux/UserDataSlice'
13 | import { userPost } from '../redux/MyPostSlice'
14 | import { Link, useNavigate } from 'react-router-dom'
15 |
16 | export const RenderMeTab = ()=>{
17 | const userData = useSelector(user)
18 | const accessToken =useSelector(token)
19 | const myposts = useSelector(userPost)
20 | const navigateTo = useNavigate
21 | const [open, setOpen]= useState(false)
22 | const[openDelete, setOpenDelete] =useState(false)
23 | const [deleteStatus, setDeleteStatus] = useState('')
24 | const [updateData, setUpdateData] = useState(
25 | {
26 | email: '',
27 | firstname: '',
28 | lastname: '',
29 | password: ''
30 | }
31 | )
32 | const dispatch = useDispatch()
33 |
34 | const handleInputChange=(event)=>{
35 | const {name, value} = event.target
36 | setUpdateData((prevValues)=>({...prevValues, [name]: value}))
37 | }
38 | let updates={
39 | ...(updateData.email && {email : updateData.email}),
40 | ...(updateData.firstname && {firstname : updateData.firstname}),
41 | ...(updateData.lastname && {lastname : updateData.lastname}),
42 | ...(updateData.password && {password : updateData.password}),
43 | }
44 | const [updatedUser, setUpdatedUser] = useState(null)
45 | useEffect(()=>{
46 | if(!updatedUser){
47 | return
48 | }
49 | else{
50 | dispatch(getUser(updatedUser))
51 | }
52 | })
53 | let status
54 | const notify=()=>toast(status)
55 | const handleUpdateDetails = (event)=>{
56 | event.preventDefault()
57 | const headers={
58 | Authorization: `Bearer ${accessToken}`
59 | }
60 | axios.patch('http://localhost:3000/user/me', updates, {headers})
61 | .then((response)=>{
62 | if(response.statusText==='OK'){
63 | axios.get('http://localhost:3000/user/me', {headers})
64 | .then((response)=>{
65 | let data = response.data
66 | setUpdatedUser(data)
67 | })
68 | status = 'Profile updated successfully!'
69 | notify()
70 | setUpdateData({email:'', firstname:'', lastname:'', password:''})
71 | }
72 | })
73 | .catch((error)=>{
74 | console.log(error)
75 | status = "Something happened, couldn't update!"
76 | notify()
77 | })
78 | }
79 | const handleDeleteAccount=()=>{
80 | const headers ={
81 | Authorization: `Bearer ${accessToken}`
82 | }
83 | axios.delete('http://localhost:3000/user/me', {headers})
84 | .then((response)=>{
85 | console.log(response)
86 | setOpen(false)
87 | setDeleteStatus(response.data)
88 | setOpenDelete(true)
89 | })
90 | .catch((error)=>{
91 | console.log(error)
92 | })
93 | }
94 | const logOut=()=>{
95 | navigateTo('/login')
96 | }
97 |
98 | return(
99 |
100 |
101 |
102 |
103 | {userData && !userData.profilePicture ?
104 |
105 | Tap Click to upload Profile image
106 | :
107 |
Tap Click to change profile image
108 |
109 | }
110 |
111 |
{userData.firstname} {userData.lastname}
112 |
{userData.email}
113 |
114 |
{myposts&& myposts.length} Posts
115 |
116 |
117 |
169 |
170 | {
171 | userData.role === 'subscriber' ?
172 | <>
173 |
Want to publish stories? Request to be an Author
174 |
Become an Author > :
175 | userData.role === 'author' ?
176 | <>
177 |
Want to be able to moderate and regulate the space? Request to be a Moderator
178 |
Become an Moderator > : ""
179 | }
180 |
181 |
182 |
setOpen(!open)} size='xl'>
183 | Delete Account
184 |
185 | Your Account will be deleted and will no longer exist. Are you sure you want to continue?
186 |
187 |
188 | setOpen(false)} className=''>
189 | NO
190 |
191 |
192 | YES
193 |
194 |
195 |
196 |
setOpenDelete(!openDelete)} size='xl'>
197 | Delete Account
198 |
199 | {deleteStatus}
200 |
201 |
202 |
203 |
204 | OK
205 |
206 |
207 |
208 |
209 | {/*
210 | Deactivate Account
211 | Deactivating Account will suspend your account until you sign back in
212 | */}
213 |
setOpen(!open)}>
214 | Delete Account
215 | Permanetly delete your account and all your published contents
216 |
217 |
218 |
219 |
220 | )
221 | }
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/FloatingButton.jsx:
--------------------------------------------------------------------------------
1 | import { IconButton, SpeedDial, SpeedDialHandler, SpeedDialContent, SpeedDialAction, Typography,
2 | } from "@material-tailwind/react";
3 | import {PlusIcon, PencilSquareIcon,Square3Stack3DIcon, PhotoIcon } from "@heroicons/react/24/outline";
4 | import { NavLink } from "react-router-dom";
5 |
6 | const FloatingButton =()=> {
7 | const labelProps = {
8 | variant: "small",
9 | color: "blue-gray",
10 | className:
11 | "absolute top-2/4 -left-2/4 -translate-y-2/4 -translate-x-3/4 font-normal font-bold ",
12 | };
13 |
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Photos
27 |
28 |
29 |
30 |
31 | Publish
32 |
33 |
34 |
35 |
36 | My Stories
37 |
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
45 | export default FloatingButton;
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/ProfilePic.jsx:
--------------------------------------------------------------------------------
1 | import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 | import { Avatar } from '@material-tailwind/react';
4 | import React, { useState } from 'react';
5 | import { useEffect } from 'react';
6 | import { useSelector, useDispatch } from 'react-redux';
7 | import { addFile, userDetails } from '../redux/UserSlice';
8 | import { user } from '../redux/UserDataSlice';
9 | import { token } from '../redux/AccessTokenSlice';
10 | import axios from 'axios';
11 | import { ToastContainer, toast } from 'react-toastify';
12 | import FormData from 'form-data';
13 | // import { userDetails } from '../redux/UserSlice';
14 |
15 | const ProfileAvatar = () => {
16 | const userdetails = useSelector(userDetails)
17 | const userData = useSelector(user)
18 | const accessToken = useSelector(token)
19 | // console.log(userData)
20 | // console.log(userdetails.profilePic)
21 | const dispatch = useDispatch()
22 | const params = new URLSearchParams()
23 | // const user = useSelector((state)=>state.user.name)
24 | const fileInputRef = React.createRef();
25 | let status
26 | const notify=()=> toast(status)
27 |
28 | // const FormData = require('form-data')
29 | const formData = new FormData()
30 | const headers ={
31 | Authorization : `Bearer ${accessToken}`,
32 | 'Content-type': 'multipart/form-data'
33 | }
34 | const handleFileUpload = (event) => {
35 | const file = event.target.files[0];
36 | const imageFile = URL.createObjectURL(file)
37 | dispatch(addFile(imageFile))
38 | formData.append('file', imageFile)
39 |
40 | axios.post('http://localhost:3000/image/upload/avatar', formData, {headers})
41 | .then((response)=>{
42 | console.log(response)
43 | if(response.statusText==='Created'){
44 | status ='Profile pic uploaded successfully'
45 | notify()
46 | }
47 | })
48 | .catch((error)=>{
49 | console.log(error)
50 | status='Error uploading profile pic'
51 | notify()
52 | })
53 | };
54 |
55 | useEffect(()=>{
56 | axios.get('http://localhost:3000/image/view/avatar?folder=avatars&filename=187799c0-d512-4f60-87b7-54d059f18883.webp', {headers})
57 | .then((response)=>{
58 | console.log(response)
59 | })
60 | .catch((error)=>{
61 | console.log(error)
62 | })
63 | })
64 |
65 | useEffect(()=>{
66 | localStorage.setItem('profilePic', userdetails.profilePic)
67 | },[userdetails.profilePic])
68 |
69 | const handleAvatarClick = () => {
70 | // Trigger click event on the hidden file input element
71 | if (fileInputRef.current) {
72 | fileInputRef.current.click();
73 | }
74 | };
75 |
76 |
77 | // localStorage.removeItem('profilePic')
78 | return (
79 |
93 | );
94 | };
95 |
96 | export default ProfileAvatar;
97 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/Profiles.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import {Tabs, TabsHeader, TabsBody, Tab, TabPanel,} from "@material-tailwind/react";
3 | import {PencilSquareIcon, UserCircleIcon, BookmarkIcon,} from "@heroicons/react/24/solid";
4 | import { RenderMeTab } from "./EditProfile";
5 | import { userDetails } from "../redux/UserSlice";
6 | import { useSelector } from "react-redux";
7 | // import { selectAllPosts } from "../redux/PostsSlice";
8 | import Subscriber from "./Subscriber";
9 | import UserPost from "./UserPost";
10 | import Posts from "./Posts";
11 | import { useNavigate } from "react-router-dom";
12 | import { user } from "../redux/UserDataSlice";
13 |
14 | export default function Profiles({accessToken}) {
15 | const userData = useSelector(user)
16 | const userdetails = useSelector(userDetails)
17 | // const postArray = useSelector(selectAllPosts)
18 | const navigateTo = useNavigate()
19 | useEffect(()=>{
20 | if(!accessToken){
21 | navigateTo('/login')
22 | }
23 | })
24 |
25 | const tabData = [
26 | {
27 | label: "My Posts",
28 | value: "my posts",
29 | icon: PencilSquareIcon,
30 | desc: userData && userData.role !=='subscriber' ? :
31 | ,
33 | },
34 | {
35 | label: "Bookmarks",
36 | value: "Bookmarks",
37 | icon: BookmarkIcon,
38 | desc: "You have not bookmarked any post",
39 | },
40 | {
41 | label: "Settings",
42 | value: "settings",
43 | icon: UserCircleIcon,
44 | desc: ,
45 | },
46 | ];
47 |
48 | return (
49 |
50 | {
51 | !accessToken ?
52 |
login
53 | :
54 |
55 |
56 |
57 | {tabData.map(({ label, value, icon }) => (
58 |
59 |
60 | {React.createElement(icon, { className: "w-5 h-5 md:w-7 md:h-7" })}
61 | {label}
62 |
63 |
64 | ))}
65 |
66 |
67 | {tabData.map(({ value, desc }) => (
68 |
69 | {desc}
70 |
71 | ))}
72 |
73 |
74 |
75 | }
76 |
77 | );
78 | }
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/SearchBox.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useState } from 'react'
3 | import { useSelector } from 'react-redux'
4 | // import { selectAllPosts } from '../redux/PostsSlice'
5 | import Posts from './Posts'
6 |
7 | const SearchBox = ({filtered}) => {
8 | // const arrayPost = useSelector(selectAllPosts)
9 |
10 |
11 | return (
12 |
13 | {/*
*/}
22 | {/* { filtered.length > 0 && */}
23 |
24 | {/* } */}
25 |
26 | )
27 | }
28 |
29 | export default SearchBox
30 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/SideMenu.jsx:
--------------------------------------------------------------------------------
1 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
2 | import { faUserCircle, faClose } from '@fortawesome/free-solid-svg-icons';
3 | import React from 'react';
4 | import { useState } from 'react';
5 | // import {useClickAway} from 'react-use'
6 | import { NavLink } from 'react-router-dom';
7 | import { useSelector, useDispatch } from 'react-redux';
8 |
9 | const SideMenu = ({isOpen, setIsOpen}) => {
10 | const user = useSelector((state)=>state.user.user)
11 |
12 | const toggleMenu = () => {
13 | setIsOpen(!isOpen);
14 | };
15 |
16 | return (
17 | <>
18 | {/* Button to toggle the menu */}
19 |
22 |
23 |
24 |
25 | {/* The menu */}
26 |
31 |
32 | {/* Close button */}
33 |
37 |
38 |
39 |
40 | {/* Menu content */}
41 |
42 | subscriber
43 | {/* would be dynamic: user's level would be fetched from backend */}
44 |
45 |
47 | Profile
48 |
49 |
51 | Write
52 |
53 |
55 | Become an Author
56 | {/* would be dynamic: would determined based on user's current level */}
57 |
58 |
59 |
60 |
61 |
70 |
71 | >
72 | );
73 | };
74 |
75 | export default SideMenu;
76 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/Subscriber.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { NavLink } from 'react-router-dom'
3 | import { Button } from '@material-tailwind/react'
4 |
5 | const Subscriber = () => {
6 | return (
7 |
8 |
9 |
10 |
You need to be an Author to publish posts
11 |
12 | Request to become an Author
13 |
14 |
15 |
16 |
17 | )
18 | }
19 |
20 | export default Subscriber
21 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/UserPost.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | // import { selectAllPosts } from '../redux/PostsSlice'
3 | // import { userDetails } from '../redux/UserSlice'
4 | import { useSelector, useDispatch } from 'react-redux'
5 | import Posts from './Posts'
6 | import FloatingButton from './FloatingButton'
7 | import UseGet from '../UseGet'
8 | import { userPost } from '../redux/MyPostSlice'
9 | import { getMyPosts } from '../redux/MyPostSlice'
10 |
11 | const UserPost = ({accessToken}) => {
12 | const myPost = useSelector(userPost)
13 | const dispatch = useDispatch()
14 | const {fetchedData, isPending, setIsPending} = UseGet("http://localhost:3000/post/me", accessToken,)
15 | useEffect(()=>{
16 | dispatch(getMyPosts(fetchedData))
17 | setIsPending(false)
18 | },[fetchedData])
19 |
20 | return (
21 |
22 | {
23 | myPost && myPost.length<1 ?
24 |
You have not written any post yet. Click the button below to start writing your stories
25 | :
26 |
27 | }
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default UserPost
36 |
--------------------------------------------------------------------------------
/client/src/Components/Dashboard/Write.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useState, useEffect, useRef } from "react";
3 | import { NavLink, useNavigate } from "react-router-dom";
4 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5 | import { faPlus, faTimes } from "@fortawesome/free-solid-svg-icons";
6 | import { useSelector, useDispatch } from "react-redux";
7 | // import { allUserPosts } from "../redux/UserPostSlice";
8 | import { ToastContainer, toast } from 'react-toastify';
9 | import 'react-toastify/dist/ReactToastify.css';
10 | import { Typography } from "@material-tailwind/react";
11 | // import { userDetails } from "../redux/UserSlice";
12 | import Subscriber from "./Subscriber";
13 | // import { selectAllPosts } from "../redux/PostsSlice";
14 | import Posts from "./Posts";
15 | import { PhotoIcon, } from "@heroicons/react/24/outline";
16 | // import { addPost } from "../redux/PostsSlice";
17 | import axios from "axios";
18 | import { nanoid } from "@reduxjs/toolkit";
19 | import { post } from "../redux/PostSlice";
20 | import { user } from "../redux/UserDataSlice";
21 |
22 | function WritingPage({accessToken}) {
23 | // const userPost = useSelector(allUserPosts)
24 | const userData = useSelector(user)
25 | const fetchedPost = useSelector(post)
26 | // console.log(postFetched)
27 | const dispatch = useDispatch()
28 | // const userdetails = useSelector(userDetails)
29 | // const postArray= useSelector(selectAllPosts)
30 | const navigateTo = useNavigate();
31 | const [postValues, setPostValues] = useState({
32 | // id: nanoid(),
33 | title: '',
34 | content: '',
35 | // published: false
36 | })
37 |
38 | const handleInputChange =(event)=>{
39 | const {name, value} = event.target
40 | setPostValues((prevValues)=>({...prevValues, [name]: value}))
41 | }
42 |
43 | const notify =()=> toast('Published successfuly')
44 |
45 | const onPublishPost =(event)=>{
46 | event.preventDefault()
47 | if(!postValues.title && !postValues.content){
48 | console.log('Title or Content cannot be empty!')
49 | }else{
50 | const headers = {
51 | Authorization: `Bearer ${accessToken}`,
52 | 'content-type': 'application/json',
53 | }
54 | axios.post("http://localhost:3000/post/create", postValues, {headers})
55 | .then((response)=>{
56 | // console.log(response)
57 | if(response.status===201){
58 | notify()
59 | setPostValues({title:'', content:''})
60 | // navigateTo('/dashboard')
61 | }
62 | })
63 | .catch((err)=>console.log(err))
64 | }
65 | }
66 |
67 | const [storyImage, setStoryImage] = useState(null)
68 |
69 | const handleFileUpload = (event) => {
70 | const file = event.target.files[0];
71 | const imageFile = URL.createObjectURL(file)
72 | setStoryImage(imageFile)
73 | };
74 |
75 | const hanldeAddImage = () => {
76 | // Trigger click event on the hidden file input element
77 | if (fileInputRef.current) {
78 | fileInputRef.current.click();
79 | }
80 | };
81 | const handleRemoveImage =()=>{
82 | setStoryImage(null)
83 | }
84 | const fileInputRef = React.createRef();
85 | useEffect(()=>{
86 | if(!accessToken){
87 | const gotoLogin =()=>{
88 | navigateTo('/login')
89 | }
90 | gotoLogin()
91 | }
92 | })
93 |
94 |
95 | return (
96 |
97 |
98 | {userData && userData.role !=='subscriber' ?
99 |
100 | :
101 |
102 |
103 |
104 |
105 | Write a new post
106 |
107 |
108 |
109 |
110 |
113 | {/* */}
114 |
115 | Publish
116 |
117 |
118 |
119 |
120 |
121 |
125 | Title
126 |
127 |
134 |
135 |
136 |
140 | Content
141 |
142 |
143 |
148 |
149 |
Add Photos
150 |
151 |
152 |
159 | {storyImage &&
160 |
161 |
162 |
163 |
164 | }
165 |
166 |
167 |
168 |
169 | {/*
170 |
Check Recent Posts
171 |
172 |
*/}
173 |
174 | }
175 |
176 | );
177 | }
178 |
179 | export default WritingPage;
180 |
--------------------------------------------------------------------------------
/client/src/Components/Drawer.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import {
3 | Drawer,
4 | Button,
5 | Typography,
6 | IconButton,
7 | Avatar,
8 | } from "@material-tailwind/react";
9 | import { XMarkIcon } from "@heroicons/react/24/outline";
10 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
11 | import { faBars, faUserCircle } from "@fortawesome/free-solid-svg-icons";
12 | import { NavLink, Link, useNavigate } from "react-router-dom";
13 | import { useSelector,useDispatch } from "react-redux";
14 | import { getUser, user } from "./redux/UserDataSlice";
15 | import { getToken, token } from "./redux/AccessTokenSlice";
16 | import Cookies from "js-cookie";
17 | import { toast, ToastContainer } from "react-toastify";
18 |
19 |
20 | const ProfileDrawer =()=> {
21 | const accessToken = useSelector(token)
22 | const userData = useSelector(user)
23 | const dispatch = useDispatch()
24 | const navigateTo = useNavigate()
25 | let status
26 | const notify =()=> toast(status)
27 | const [open, setOpen] = useState(false);
28 | const openDrawer = () => setOpen(true);
29 | const closeDrawer = () => setOpen(false);
30 | const onLogout=()=>{
31 | Cookies.remove('token')
32 | dispatch(getToken(null))
33 | dispatch(getUser(null))
34 | status='Successfully logged out!'
35 | notify()
36 | closeDrawer()
37 | navigateTo('/login')
38 | }
39 | return(
40 |
41 |
42 | { !userData ?
43 |
:
48 |
49 | }
50 |
51 |
57 |
58 |
59 | User Profile
60 |
61 |
66 |
67 |
68 |
69 |
70 |
71 | {/* Menu content */}
72 | { userData ?
73 |
:
74 | }
75 |
{userData && userData.firstname}
76 |
{userData && userData.role}
77 |
78 | {/* would be dynamic: user's level would be fetched from backend */}
79 |
80 | {
81 | accessToken ?
82 | <>
83 |
85 | Profile
86 |
87 |
89 | Write
90 |
91 |
93 | Become an Author
94 | {/* would be dynamic: would determined based on user's current level */}
95 |
96 | > :
97 | <>
98 |
100 | Login
101 |
102 |
104 | Sign Up
105 | {/* would be dynamic: would determined based on user's current level */}
106 |
107 | >
108 | }
109 |
110 |
111 |
112 |
130 |
131 |
132 |
133 | )
134 | }
135 |
136 | export default ProfileDrawer
--------------------------------------------------------------------------------
/client/src/Components/Footer.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Footer = () => {
4 | return (
5 |
6 |
7 |
8 |
9 | © 2023 My Tech Blog. All Rights Reserved.
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default Footer;
18 |
--------------------------------------------------------------------------------
/client/src/Components/Home.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Navbar from './Navbar';
3 | import blog from '../Assets/Images/blog3.png'
4 | import community from '../Assets/Images/community.png'
5 | import { Login } from './Auth/Login';
6 | import { Link, NavLink } from 'react-router-dom';
7 | import Footer from './Footer';
8 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
9 | import { faUsers } from '@fortawesome/free-solid-svg-icons';
10 | import { useSelector, useDispatch } from 'react-redux';
11 | import { Button, Typography } from '@material-tailwind/react';
12 | import { user } from './redux/UserDataSlice';
13 | import { token } from './redux/AccessTokenSlice';
14 | const Home =()=> {
15 | const accessToken = useSelector(token)
16 | const userData = useSelector(user)
17 | // console.log(userData)
18 | // console.log(accessToken)
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
MY BLOG
26 |
27 |
28 |
You want
lastest and Top Trending Stories in and outside the tech space?
29 |
30 | {!accessToken ?
31 |
32 |
Register / Login to start creating your stories
33 |
34 | Register
36 | Login
38 |
39 |
40 | :
41 | <>
42 |
43 |
Diversity
44 |
45 | Discover a diverse array of topics in our blog. Dive into the fascinating world of science and technology, where we explore groundbreaking innovations and discuss their impact on society.
46 |
47 |
Get Started
48 |
49 |
50 |
51 |
All you need in one place
52 |
53 | >
54 | }
55 |
56 | Check out the latest trending blog stories
57 |
58 | TOP STORIES
59 |
60 |
61 |
62 |
63 | Community
64 |
65 |
66 |
67 | Spring your inspiration from the experiences of writers with likeminds
68 |
69 |
70 |
71 |
72 |
73 |
74 | )
75 | }
76 |
77 | export default Home;
78 |
--------------------------------------------------------------------------------
/client/src/Components/Navbar.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from 'react'
2 | import { useClickAway } from 'react-use'
3 | import logo from '../Assets/Images/logo.png'
4 | // import code from '../Assets/Images/code.png'
5 | import {NavLink, Link} from 'react-router-dom'
6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
7 | import { faBars, faTimes, faClose } from '@fortawesome/free-solid-svg-icons'
8 | import { faBell } from '@fortawesome/free-solid-svg-icons'
9 | import { useSelector, useDispatch } from 'react-redux'
10 | import ProfileDrawer from './Drawer'
11 | import { token } from './redux/AccessTokenSlice'
12 | import { user } from './redux/UserDataSlice'
13 |
14 |
15 |
16 | const Navbar =()=> {
17 |
18 | const userData = useSelector(user)
19 | const accessToken = useSelector(token)
20 | const [isOpen, setIsOpen] = useState(false);
21 | const toggleMenu = () => setIsOpen(!isOpen);
22 | const closeMenu =()=>setIsOpen(false)
23 |
24 | const ref = useRef(null)
25 | useClickAway(ref,()=>{
26 | // setIsOpen(false)
27 | closeMenu()
28 | })
29 | // console.log(accessToken)
30 |
31 | return (
32 |
33 |
34 |
35 |
36 | {/* Toggle Menu Button*/}
37 |
39 | {accessToken &&
40 |
45 | {isOpen ? : }
46 |
47 | }
48 |
49 |
50 |
51 |
52 |
</>
53 | {/*
*/}
57 |
My Tech Blog
58 |
59 |
60 | {/* Menu for larger screens */}
61 |
62 |
63 | isActive ? " bg-blue-600 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-white hover:bg-blue-800 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}
65 | >
66 | Home
67 |
68 | {!accessToken ?
69 | <>
70 | isActive ? " bg-blue-600 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-white hover:bg-blue-800 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}>
72 | Login
73 |
74 | isActive ? " bg-blue-600 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-white hover:bg-blue-800 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}>
76 | Sign up
77 |
78 | >
79 | :
80 | <>
81 | isActive ? " bg-blue-600 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-white hover:bg-blue-800 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}
83 | >
84 | Dashboard
85 |
86 | isActive ? " bg-blue-600 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-white hover:bg-blue-800 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}
88 | >
89 | Profile
90 |
91 | >}
92 |
93 |
94 |
95 | {/* Button for notifications */}
96 |
97 | {/*
98 |
102 |
103 | 2
104 |
105 | */}
106 |
107 |
110 |
111 |
112 |
113 | {/* Mobile Menu */}
114 |
118 |
122 |
123 |
124 |
125 |
127 | Home
128 |
129 | {accessToken && (
130 |
132 | Dashboard
133 |
134 | )}
135 | {!accessToken &&(
136 | <>
137 |
139 | Login
140 |
141 |
143 | Sign up
144 |
145 | >
146 | )}
147 |
148 |
149 |
150 |
151 |
152 | )
153 | }
154 |
155 | export default Navbar;
--------------------------------------------------------------------------------
/client/src/Components/NewHome.jsx:
--------------------------------------------------------------------------------
1 | import { faUsers } from '@fortawesome/free-solid-svg-icons';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 | import React from 'react'
4 | import { Link } from 'react-router-dom';
5 | import blogPng from '../Assets/Images/nobg.png'
6 |
7 | const NewHome = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
Top
14 | Trending Stories
15 |
16 |
17 | Discover a diverse array of topics, articles and stories in our
18 | blog.
19 |
20 |
24 | Top Stories
25 |
26 |
27 |
28 |
33 |
34 |
35 |
36 |
37 | Diversity
38 |
39 |
40 | Discover a diverse array of topics in our blog. Dive into the
41 | fascinating world of science and technology, where we explore
42 | groundbreaking innovations and discuss their impact on society.
43 |
44 |
48 | Get Started
49 |
50 |
51 |
52 |
56 |
57 | All you need in one place
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | export default NewHome
--------------------------------------------------------------------------------
/client/src/Components/NotFound.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router-dom'
3 | import notFound from '../Assets/Images/notfound.png'
4 |
5 | const NotFound = () => {
6 | return (
7 |
8 |
9 |
10 |
Return to
11 |
12 | Home Page
13 |
14 |
15 |
16 | );
17 | }
18 |
19 | export default NotFound
--------------------------------------------------------------------------------
/client/src/Components/TimeAgo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {parseISO, formatDistanceToNow} from 'date-fns'
3 |
4 | const TimeAgo = ({timestamp}) => {
5 | let timeAgo=''
6 | if(timestamp){
7 | const date = parseISO(timestamp)
8 | const timePeriod = formatDistanceToNow(date)
9 | timeAgo=`${timePeriod} ago`
10 | }
11 | return (
12 |
13 | {timeAgo}
14 |
15 |
16 | )
17 | }
18 |
19 | export default TimeAgo
20 |
--------------------------------------------------------------------------------
/client/src/Components/TopMenu.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from 'react'
2 | import { useClickAway } from 'react-use'
3 | import logo from '../Assets/Images/logo.png'
4 | // import code from '../Assets/Images/code.png'
5 | import {NavLink, Link} from 'react-router-dom'
6 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
7 | import { faBars, faTimes, faClose } from '@fortawesome/free-solid-svg-icons'
8 | import { faBell } from '@fortawesome/free-solid-svg-icons'
9 | import { useSelector, useDispatch } from 'react-redux'
10 | import SideMenu from './Dashboard/SideMenu'
11 | import ProfileDrawer from './Drawer'
12 |
13 |
14 |
15 |
16 | const TopMenu =()=> {
17 | const user = useSelector((state)=>state.user.user)
18 |
19 | const [isOpen, setIsOpen] = useState(false);
20 | const toggleMenu = () => setIsOpen(!isOpen);
21 | const closeMenu =()=>setIsOpen(false)
22 |
23 | const ref = useRef(null)
24 | useClickAway(ref,()=>{
25 | // setIsOpen(false)
26 | closeMenu()
27 | })
28 |
29 |
30 | return (
31 |
32 |
33 |
34 |
35 | {/* Toggle Menu Button*/}
36 |
38 |
43 | {isOpen ? : }
44 |
45 |
46 |
47 |
48 |
49 |
</>
50 | {/*
*/}
54 |
My Tech Blog
55 |
56 |
57 | {/* Menu for larger screens */}
58 | {/*
59 |
60 | isActive ? " bg-gray-900 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}
62 |
63 | >
64 | Home
65 |
66 | isActive ? " bg-gray-900 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}>
68 | Login
69 |
70 | isActive ? " bg-gray-900 text-white font-bold text-lg p-2 rounded":"" ?isPending: "text-gray-300 hover:bg-gray-700 hover:text-white px-3 py-2 rounded-md text-sm font-medium md:text-lg"}>
72 | Sign up
73 |
74 |
75 |
*/}
76 |
77 |
80 | {/* Button for notifications */}
81 |
82 | {/*
83 |
87 |
88 | 2
89 |
90 | */}
91 |
92 |
93 |
94 |
95 | {/* Mobile Menu */}
96 |
100 |
104 |
105 |
106 |
107 |
109 | Home
110 |
111 | {user && (
112 |
114 | Dashboard
115 |
116 | )}
117 | {!user &&(<>
118 |
120 | Login
121 |
122 |
124 | Sign up
125 |
126 | >)}
127 |
128 |
129 |
130 |
131 | )
132 | }
133 |
134 | export default TopMenu;
--------------------------------------------------------------------------------
/client/src/Components/UseGet.jsx:
--------------------------------------------------------------------------------
1 |
2 | import axios from 'axios'
3 | import React, { useEffect, useState} from 'react'
4 |
5 | const UseGet = (url, token) => {
6 | const [fetchedData, setFetchedData] = useState(null)
7 | const [isPending, setIsPending] = useState(true)
8 | useEffect(()=>{
9 | const headers = {
10 | Authorization: `Bearer ${token}`
11 | }
12 | axios.get(url, {headers})
13 | .then((response)=>{
14 | // console.log(response.data)
15 | if(response.statusText==='OK'){
16 | let data = response.data
17 | setFetchedData(data.sort((a, b)=>b.created_at.localeCompare(a.created_at)))
18 | }
19 | })
20 | .catch((error)=>{
21 | console.log(error)
22 | setIsPending(false)
23 | })
24 | },[url])
25 |
26 | return {fetchedData, isPending, setIsPending}
27 |
28 | }
29 |
30 | export default UseGet;
31 |
--------------------------------------------------------------------------------
/client/src/Components/index.html:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/client/src/Components/index.html
--------------------------------------------------------------------------------
/client/src/Components/redux/AccessTokenSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 | import Cookies from 'js-cookie';
3 |
4 | const initialState = {
5 | token: Cookies.get('token') || null
6 | }
7 |
8 | export const accessTokenSlice = createSlice({
9 | name: 'accessToken',
10 | initialState,
11 | reducers: {
12 | getToken: (state, action)=>{
13 | state.token = action.payload
14 | },
15 |
16 | }
17 | })
18 |
19 | export const token = (state)=> state.accessToken.token
20 | export const{getToken} = accessTokenSlice.actions
21 | export default accessTokenSlice.reducer
--------------------------------------------------------------------------------
/client/src/Components/redux/MyPostSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const initialState = {
4 | mypost: null
5 | }
6 |
7 | export const myPostSlice = createSlice({
8 | name: 'myposts',
9 | initialState,
10 | reducers:{
11 | getMyPosts: (state, action)=>{
12 | state.mypost = action.payload
13 | }
14 | }
15 | })
16 |
17 | export const userPost = (state)=>state.myPost.mypost
18 | export const {getMyPosts} = myPostSlice.actions
19 | export default myPostSlice.reducer
--------------------------------------------------------------------------------
/client/src/Components/redux/PendingSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const initialState = {
4 | isPending: false,
5 | };
6 |
7 | export const pendingSlice = createSlice({
8 | name: "pending",
9 | initialState,
10 | reducers: {
11 | getPending: (state, action) => {
12 | state.isPending = action.payload;
13 | },
14 | },
15 | });
16 |
17 | export const pending = (state) => state.pending.isPending;
18 | export const { getPending } = pendingSlice.actions;
19 | export default pendingSlice.reducer;
20 |
--------------------------------------------------------------------------------
/client/src/Components/redux/PostSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const initialState = {
4 | post: null
5 | }
6 |
7 | export const postSlice = createSlice({
8 | name: 'serverPost',
9 | initialState,
10 | reducers: {
11 | getPosts: (state, action)=>{
12 | state.post = action.payload
13 | }
14 | }
15 | })
16 |
17 | export const post = (state)=>state.postData.post
18 | export const {getPosts} = postSlice.actions
19 | export default postSlice.reducer
--------------------------------------------------------------------------------
/client/src/Components/redux/UserDataSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 | const initialState = {
4 | user: null
5 | }
6 | export const userDataSlice = createSlice({
7 | name: 'userData',
8 | initialState,
9 | reducers: {
10 | getUser: (state, action)=>{
11 | state.user = action.payload
12 | }
13 | }
14 | })
15 |
16 | export const user = (state)=> state.userData.user
17 | export const {getUser} = userDataSlice.actions
18 | export default userDataSlice.reducer
--------------------------------------------------------------------------------
/client/src/Components/redux/UserPostSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice, nanoid } from "@reduxjs/toolkit";
2 | import storyImg from '../../Assets/Images/blog1.png'
3 |
4 |
5 | const initialState =
6 | {
7 | title: "",
8 | content: '',
9 | }
10 |
11 |
12 |
13 | export const userPostSlice = createSlice({
14 | name: 'userPost',
15 | initialState,
16 | reducers:{
17 | postAdded:{
18 | reducer(state, action){
19 | state.push(action.payload)
20 | },
21 | prepare(title, content ){
22 | return{
23 | payload: {
24 | title,
25 | content,
26 | }
27 | }
28 | }
29 | }
30 | }
31 | })
32 |
33 | export const fetchPosts = (state)=>state.userPost
34 | export const {postAdded} = userPostSlice.actions
35 | export default userPostSlice.reducer
--------------------------------------------------------------------------------
/client/src/Components/redux/UserSlice.jsx:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit";
2 |
3 |
4 | const initialState = {
5 | name : localStorage.getItem('userName') ||'Emmanuel Ayodeji',
6 | email: localStorage.getItem('userEmail') ||'emma@gmail.com',
7 | profilePic: localStorage.getItem('profilePic') || null,
8 | role: localStorage.getItem('userRole') ||'author',
9 | }
10 |
11 | export const userSlice = createSlice({
12 | name: 'user',
13 | initialState,
14 | reducers: {
15 | addFile: (state, action)=>{
16 | state.profilePic = action.payload
17 | },
18 | nameChange: (state, action)=>{
19 | state.name = action.payload
20 | },
21 | emailChange: (state, action)=>{
22 | state.email = action.payload
23 | }
24 | },
25 | })
26 |
27 | export const userDetails = (state)=>state.user
28 | export const{addFile, nameChange, emailChange}= userSlice.actions;
29 | export default userSlice.reducer;
--------------------------------------------------------------------------------
/client/src/Components/redux/store.jsx:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit'
2 | import userReducer from './UserSlice'
3 | // import postReducer from './PostsSlice'
4 | import userPostReducer from './UserPostSlice'
5 | import tokenReducer from './AccessTokenSlice'
6 | import userDataReducer from './UserDataSlice'
7 | import postDataReducer from './PostSlice'
8 | import myPostReducer from './MyPostSlice'
9 | import pendingReducer from './PendingSlice'
10 |
11 | export const store = configureStore({
12 | reducer: {
13 | user: userReducer,
14 | // post: postReducer,
15 | userPost: userPostReducer,
16 | accessToken: tokenReducer,
17 | userData: userDataReducer,
18 | postData: postDataReducer,
19 | myPost: myPostReducer,
20 | pending : pendingReducer,
21 | },
22 | })
--------------------------------------------------------------------------------
/client/src/color.txt:
--------------------------------------------------------------------------------
1 | text-color = grey 900
2 | primary background color = bg-grey-900
3 | button color = slate-300
4 | navbar bg = blue-blue-900
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | @import url('https://fonts.googleapis.com/css2?family=Russo+One&display=swap');
2 | /* @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@300;400;500;600;700&display=swap'); */
3 | @tailwind base;
4 | @tailwind components;
5 | @tailwind utilities;
6 |
7 |
8 | .heading{
9 | font-family: 'Russo One', sans-serif;
10 | }
--------------------------------------------------------------------------------
/client/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App.jsx'
4 | import './index.css'
5 | import {store} from './Components/redux/store'
6 | import {Provider} from 'react-redux'
7 | import { ThemeProvider } from "@material-tailwind/react";
8 |
9 | ReactDOM.createRoot(document.getElementById('root')).render(
10 |
11 |
12 |
13 |
14 |
15 |
16 | ,
17 | )
18 |
--------------------------------------------------------------------------------
/client/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | const withMT = require("@material-tailwind/react/utils/withMT");
3 |
4 | export default withMT( {
5 | content: [
6 | "./index.html",
7 | "./src/**/*.{js,ts,jsx,tsx}",
8 | // "path-to-your-node_modules/@material-tailwind/react/components/**/*.{js,ts,jsx,tsx}",
9 | // "path-to-your-node_modules/@material-tailwind/react/theme/components/**/*.{js,ts,jsx,tsx}",
10 | ],
11 | theme: {
12 | fontFamily: {
13 | 'heading-1': ['Russo One', 'sans-serif']
14 | },
15 | extend: {
16 | keyframes:{
17 | swipeLeft:{
18 | '0%, 10%':{transform: ' translate(-400px)'},
19 | '80%, 100%':{transform: ' translate(0px)'},
20 | },
21 | swipeRight:{
22 | '0%, 10%':{transform: ' translate(400px)'},
23 | '80%, 100%':{transform: ' translate(0px)'},
24 | },
25 | },
26 | animation:{
27 | 'swipeInLeft': 'swipeLeft 1s',
28 | 'swipeInRight': 'swipeRight 1s'
29 | },
30 | backgroundImage:{
31 | 'hero-img': "url('/src/Assets/images/img3.png')"
32 | },
33 | },
34 | },
35 | plugins: [],
36 | })
--------------------------------------------------------------------------------
/client/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }]
3 | }
4 |
--------------------------------------------------------------------------------
/client/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/server/.env.sample:
--------------------------------------------------------------------------------
1 | #app config
2 | APP_PORT=3000
3 |
4 | #database config
5 | DB_HOST=
6 | DB_PORT=5432
7 | DB_USERNAME=
8 | DB_PASSWORD=
9 | DB_DATABASE=
10 |
11 | #jwt config
12 | JWT_SECRET=
13 | JWT_SIGNOPTIONS=
14 |
15 | #slugify
16 | REPLACEMENT=
17 | LOWER=
18 |
19 | #nodemailer
20 | MAIL_HOST=
21 | MAIL_USERNAME=
22 | MAIL_PASSWORD=
23 |
24 | #url link for password reset
25 | PASSWORD_RESET_URL_LINK=
26 |
27 |
--------------------------------------------------------------------------------
/server/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: [
10 | 'plugin:@typescript-eslint/recommended',
11 | 'plugin:prettier/recommended',
12 | ],
13 | root: true,
14 | env: {
15 | node: true,
16 | jest: true,
17 | },
18 | ignorePatterns: ['.eslintrc.js'],
19 | rules: {
20 | '@typescript-eslint/interface-name-prefix': 'off',
21 | '@typescript-eslint/explicit-function-return-type': 'off',
22 | '@typescript-eslint/explicit-module-boundary-types': 'off',
23 | '@typescript-eslint/no-explicit-any': 'off',
24 | },
25 | };
26 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 |
5 | #env
6 | .env
7 |
8 | # Logs
9 | logs
10 | *.log
11 | npm-debug.log*
12 | pnpm-debug.log*
13 | yarn-debug.log*
14 | yarn-error.log*
15 | lerna-debug.log*
16 |
17 | # OS
18 | .DS_Store
19 |
20 | # Tests
21 | /coverage
22 | /.nyc_output
23 |
24 | # IDEs and editors
25 | /.idea
26 | .project
27 | .classpath
28 | .c9/
29 | *.launch
30 | .settings/
31 | *.sublime-workspace
32 |
33 | # IDE - VSCode
34 | .vscode/*
35 | !.vscode/settings.json
36 | !.vscode/tasks.json
37 | !.vscode/launch.json
38 | !.vscode/extensions.json
--------------------------------------------------------------------------------
/server/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "endOfLine": "lf"
5 | }
6 |
--------------------------------------------------------------------------------
/server/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server",
3 | "version": "0.0.1",
4 | "description": "blog api",
5 | "author": "oluwatosin akande",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "build": "nest build",
10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11 | "start": "nest start",
12 | "start:dev": "nest start --watch",
13 | "start:debug": "nest start --debug --watch",
14 | "start:prod": "node dist/main",
15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
16 | "test": "jest",
17 | "test:watch": "jest --watch",
18 | "test:cov": "jest --coverage",
19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20 | "test:e2e": "jest --config ./test/jest-e2e.json"
21 | },
22 | "dependencies": {
23 | "@nestjs-modules/mailer": "^1.8.1",
24 | "@nestjs/axios": "^2.0.0",
25 | "@nestjs/cli": "^9.5.0",
26 | "@nestjs/common": "^9.0.0",
27 | "@nestjs/config": "^2.3.1",
28 | "@nestjs/core": "^9.0.0",
29 | "@nestjs/jwt": "^10.0.2",
30 | "@nestjs/passport": "^9.0.3",
31 | "@nestjs/platform-express": "^9.0.0",
32 | "@nestjs/serve-static": "^3.0.1",
33 | "@nestjs/swagger": "^6.2.1",
34 | "@nestjs/typeorm": "^9.0.1",
35 | "@types/passport-local": "^1.0.35",
36 | "bcrypt": "^5.1.0",
37 | "buffer-to-stream": "^1.0.0",
38 | "class-transformer": "^0.5.1",
39 | "class-validator": "^0.14.0",
40 | "handlebars": "^4.7.7",
41 | "multer": "^1.4.5-lts.1",
42 | "nodemailer": "^6.9.3",
43 | "otp-generator": "^4.0.1",
44 | "passport": "^0.6.0",
45 | "passport-jwt": "^4.0.1",
46 | "passport-local": "^1.0.0",
47 | "pg": "^8.10.0",
48 | "reflect-metadata": "^0.1.13",
49 | "rxjs": "^7.2.0",
50 | "sharp": "^0.32.1",
51 | "slugify": "^1.6.6",
52 | "swagger-ui-express": "^4.6.2",
53 | "typeorm": "^0.3.12"
54 | },
55 | "devDependencies": {
56 | "@nestjs/schematics": "^9.0.0",
57 | "@nestjs/testing": "^9.0.0",
58 | "@types/bcrypt": "^5.0.0",
59 | "@types/buffer-to-stream": "^1.0.0",
60 | "@types/express": "^4.17.13",
61 | "@types/jest": "29.2.4",
62 | "@types/multer": "^1.4.7",
63 | "@types/node": "18.11.18",
64 | "@types/nodemailer": "^6.4.8",
65 | "@types/otp-generator": "^4.0.0",
66 | "@types/passport-jwt": "^3.0.8",
67 | "@types/supertest": "^2.0.11",
68 | "@typescript-eslint/eslint-plugin": "^5.0.0",
69 | "@typescript-eslint/parser": "^5.0.0",
70 | "axios": "^1.4.0",
71 | "eslint": "^8.0.1",
72 | "eslint-config-prettier": "^8.3.0",
73 | "eslint-plugin-prettier": "^4.0.0",
74 | "jest": "29.3.1",
75 | "prettier": "^2.3.2",
76 | "source-map-support": "^0.5.20",
77 | "supertest": "^6.1.3",
78 | "ts-jest": "29.0.3",
79 | "ts-loader": "^9.2.3",
80 | "ts-node": "^10.0.0",
81 | "tsconfig-paths": "4.1.1",
82 | "typescript": "^4.7.4"
83 | },
84 | "jest": {
85 | "moduleFileExtensions": [
86 | "js",
87 | "json",
88 | "ts"
89 | ],
90 | "rootDir": "src",
91 | "testRegex": ".*\\.spec\\.ts$",
92 | "transform": {
93 | "^.+\\.(t|j)s$": "ts-jest"
94 | },
95 | "collectCoverageFrom": [
96 | "**/*.(t|j)s"
97 | ],
98 | "coverageDirectory": "../coverage",
99 | "testEnvironment": "node"
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/server/public/avatars/402a5f16-9272-416c-a3f8-fa4ae236833b.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/server/public/avatars/402a5f16-9272-416c-a3f8-fa4ae236833b.webp
--------------------------------------------------------------------------------
/server/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 | import { ConfigModule, ConfigService } from '@nestjs/config';
4 | import { MailerModule } from '@nestjs-modules/mailer';
5 | import { UserModule } from './modules/user/user.module';
6 | import { SearchModule } from './modules/search/search.module';
7 | import { AuthModule } from './modules/auth/auth.module';
8 | import { PostModule } from './modules/post/post.module';
9 | import { UploadsModule } from './modules/uploads/uploads.module';
10 | import { dbConfig, jwtConfig } from './config';
11 | import { User } from './modules/user/entities/user.entity';
12 | import { Post } from './modules/post/entities/post.entity';
13 | import { Category } from './modules/category/entities/category.entity';
14 | import { Comment } from './modules/comment/entities/comment.entity';
15 | import { CommentModule } from './modules/comment/comment.module';
16 | import { CategoryModule } from './modules/category/category.module';
17 | import { ServeStaticModule } from '@nestjs/serve-static';
18 | import { OtpModule } from './modules/otp/otp.module';
19 | import { MulterModule } from '@nestjs/platform-express';
20 | import { join } from 'path';
21 | import { memoryStorage } from 'multer';
22 | import { Otp } from './modules/otp/entities/otp.entity';
23 |
24 | @Module({
25 | imports: [
26 | MailerModule.forRootAsync({
27 | imports: [ConfigModule],
28 | useFactory: async (config: ConfigService) => ({
29 | transport: {
30 | host: config.get('MAIL_HOST'),
31 | secure: false,
32 | auth: {
33 | user: config.get('MAIL_USERNAME'),
34 | pass: config.get('MAIL_PASSWORD'),
35 | },
36 | },
37 | }),
38 | inject: [ConfigService],
39 | }),
40 | MulterModule.register({
41 | storage: memoryStorage(),
42 | }),
43 | ServeStaticModule.forRoot({
44 | rootPath: join(__dirname, '..'),
45 | exclude: ['/auth/reset/password'],
46 | }),
47 | ConfigModule.forRoot({ isGlobal: true, load: [dbConfig, jwtConfig] }),
48 | TypeOrmModule.forRootAsync({
49 | imports: [ConfigModule],
50 | inject: [ConfigService],
51 | useFactory: (configService: ConfigService) => {
52 | return {
53 | type: 'postgres',
54 | host: configService.get('database.host'),
55 | port: configService.get('database.port'),
56 | username: configService.get('database.username'),
57 | password: configService.get('database.password'),
58 | database: configService.get('database.db'),
59 | entities: [User, Post, Comment, Category, Otp],
60 | synchronize: true,
61 | logging: false,
62 | };
63 | },
64 | }),
65 | UserModule,
66 | SearchModule,
67 | AuthModule,
68 | PostModule,
69 | CommentModule,
70 | CategoryModule,
71 | UploadsModule,
72 | OtpModule,
73 | ],
74 | })
75 | export class AppModule {}
76 |
--------------------------------------------------------------------------------
/server/src/config/database.config.ts:
--------------------------------------------------------------------------------
1 | import { registerAs } from '@nestjs/config';
2 |
3 | export default registerAs('database', () => ({
4 | host: process.env.DB_HOST,
5 | port: parseInt(process.env.DB_PORT) || 5432,
6 | username: process.env.DB_USERNAME,
7 | password: process.env.DB_PASSWORD,
8 | db: process.env.DB_DATABASE,
9 | }));
10 |
--------------------------------------------------------------------------------
/server/src/config/index.ts:
--------------------------------------------------------------------------------
1 | import dbConfig from './database.config';
2 | import jwtConfig from './jwt.config';
3 |
4 | export { dbConfig, jwtConfig };
5 |
--------------------------------------------------------------------------------
/server/src/config/jwt.config.ts:
--------------------------------------------------------------------------------
1 | import { registerAs } from '@nestjs/config';
2 |
3 | export default registerAs('jwt', () => ({
4 | secret: process.env.JWT_SECRET,
5 | expire: process.env.JWT_SIGNOPTIONS,
6 | }));
7 |
--------------------------------------------------------------------------------
/server/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { AppModule } from './app.module';
3 | import {
4 | SwaggerModule,
5 | DocumentBuilder,
6 | SwaggerDocumentOptions,
7 | } from '@nestjs/swagger';
8 | import { join } from 'path';
9 | import { NestExpressApplication } from '@nestjs/platform-express';
10 |
11 | async function bootstrap() {
12 | const app = await NestFactory.create(AppModule);
13 | app.useStaticAssets(join(__dirname, '..', 'public'));
14 | app.enableCors();
15 | const config = new DocumentBuilder()
16 | .setTitle('My-Blog APIs')
17 | .setDescription(
18 | 'My-Blog APIS endpoint for a blog App, where you can read a blog posts as a subscriber and write blog posts as an author',
19 | )
20 | .setVersion('1.0')
21 | .addTag('blog')
22 | .build();
23 | const options: SwaggerDocumentOptions = {
24 | operationIdFactory: (controllerKey: string, methodKey: string) => methodKey,
25 | };
26 | const document = SwaggerModule.createDocument(app, config, options);
27 | SwaggerModule.setup('api', app, document);
28 | await app.listen(process.env.APP_PORT);
29 | }
30 | bootstrap();
31 |
--------------------------------------------------------------------------------
/server/src/modules/auth/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Post,
4 | Body,
5 | Query,
6 | UseGuards,
7 | Request,
8 | UsePipes,
9 | ValidationPipe,
10 | UnprocessableEntityException,
11 | } from '@nestjs/common';
12 | import { ApiTags, ApiResponse, ApiBody, ApiQuery } from '@nestjs/swagger';
13 | import { AuthService } from './auth.service';
14 | import { localAuthGuard } from './guards/local-auth.guard';
15 | import { JwtAuthGuard } from './guards/jwt-guard.guard';
16 | import { CreateUserDto } from '../user/dto/create-user.dto';
17 | import { UserService } from '../user/user.service';
18 | import {
19 | INormalResponse,
20 | IRegisterResponse,
21 | } from '../common/interface/index.interface';
22 | import { VerifyUserDataDto } from './dto/verifyUserData.dto';
23 | import { ResendCodeDto } from './dto/resend-code.dto';
24 | import { PasswordResetDto } from './dto/password-reset.dto';
25 | import { LoginUserDto } from './dto/login-user.dto';
26 |
27 | @ApiTags('auth')
28 | @Controller('auth')
29 | export class AuthController {
30 | constructor(
31 | private readonly authService: AuthService,
32 | private readonly userService: UserService,
33 | ) {}
34 |
35 | @Post('register')
36 | // @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
37 | @ApiBody({ type: [CreateUserDto] })
38 | @ApiResponse({
39 | status: 201,
40 | description: 'The User has been successfully created.',
41 | })
42 | @ApiResponse({ status: 400, description: 'Bad Request.' })
43 | async create(
44 | @Body() createUserDto: CreateUserDto,
45 | ): Promise {
46 | // check if email exist
47 | const emailExist = await this.userService.findByEmail(createUserDto.email);
48 | if (emailExist) {
49 | throw new UnprocessableEntityException('User already exist');
50 | }
51 |
52 | return await this.authService.register(createUserDto);
53 | }
54 |
55 | @UseGuards(localAuthGuard)
56 | @ApiBody({ type: [LoginUserDto] })
57 | @ApiResponse({
58 | status: 200,
59 | description: 'The User has successfully login.',
60 | })
61 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
62 | @Post('login')
63 | async login(@Request() req: any): Promise<{ access_token: string }> {
64 | return await this.authService.login(req.user);
65 | }
66 |
67 | @Post('/otp/activate')
68 | @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
69 | @ApiBody({ type: [VerifyUserDataDto] })
70 | @ApiResponse({
71 | status: 201,
72 | description: 'User has been activated successfully.',
73 | })
74 | @ApiResponse({ status: 422, description: 'user activated already.' })
75 | @ApiResponse({ status: 400, description: 'Bad Request.' })
76 | @ApiResponse({ status: 404, description: 'Invalid OTP.' })
77 | @ApiResponse({ status: 410, description: 'Expired OTP.' })
78 | async activateUser(
79 | @Body() verifyUserDataDto: VerifyUserDataDto,
80 | ): Promise {
81 | return await this.authService.activateUser(verifyUserDataDto);
82 | }
83 |
84 | @UseGuards(JwtAuthGuard)
85 | @ApiBody({ type: [ResendCodeDto] })
86 | @ApiResponse({
87 | status: 200,
88 | description: 'OTP resend successfully',
89 | })
90 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
91 | @Post('otp/resend')
92 | @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
93 | async resendOTP(
94 | @Body() resendCodeto: ResendCodeDto,
95 | ): Promise {
96 | return await this.authService.resendOtp(resendCodeto);
97 | }
98 |
99 | @UseGuards(JwtAuthGuard)
100 | @ApiBody({ type: [ResendCodeDto] })
101 | @ApiResponse({
102 | status: 201,
103 | description: 'link created for password reset',
104 | })
105 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
106 | @Post('forget/password')
107 | @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
108 | async forgetPassword(
109 | @Body() resendCodeDto: ResendCodeDto,
110 | ): Promise {
111 | return await this.authService.forgetPassword(resendCodeDto);
112 | }
113 |
114 | @UseGuards(JwtAuthGuard)
115 | @ApiBody({ type: [PasswordResetDto] })
116 | @ApiQuery({
117 | name: 'token',
118 | required: true,
119 | description: 'the token required for password reset',
120 | })
121 | @ApiResponse({
122 | status: 200,
123 | description: 'password reset successfully',
124 | })
125 | @ApiResponse({ status: 401, description: 'unauthorized' })
126 | @ApiResponse({ status: 404, description: 'user not found' })
127 | @ApiResponse({ status: 400, description: 'invalid token' })
128 | @Post('reset/password')
129 | @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
130 | async resetPassword(
131 | @Query('token') token: string,
132 | @Body() passwordResetDto: PasswordResetDto,
133 | ): Promise {
134 | return await this.authService.passwordReset(token, passwordResetDto);
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/server/src/modules/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ConfigModule, ConfigService } from '@nestjs/config';
3 | import { PassportModule } from '@nestjs/passport';
4 | import { JwtModule } from '@nestjs/jwt';
5 | import { AuthService } from './auth.service';
6 | import { AuthController } from './auth.controller';
7 | import { UserModule } from '../user/user.module';
8 | import { LocalStrategy } from './strategies/local.strategy';
9 | import { JwtStrategy } from './strategies/jwt.strategy';
10 | import { OtpModule } from '../otp/otp.module';
11 |
12 | @Module({
13 | imports: [
14 | UserModule,
15 | PassportModule,
16 | OtpModule,
17 | JwtModule.registerAsync({
18 | imports: [ConfigModule],
19 | inject: [ConfigService],
20 | useFactory: (configService: ConfigService) => {
21 | return {
22 | secret: configService.get('jWT_SECRET'),
23 | signOptions: {
24 | expiresIn: configService.get('JWT_SIGNOPTIONS'),
25 | },
26 | };
27 | },
28 | }),
29 | ],
30 | controllers: [AuthController],
31 | providers: [AuthService, LocalStrategy, JwtStrategy],
32 | exports: [AuthService, JwtModule],
33 | })
34 | export class AuthModule {}
35 |
--------------------------------------------------------------------------------
/server/src/modules/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BadRequestException,
3 | HttpException,
4 | HttpStatus,
5 | Injectable,
6 | } from '@nestjs/common';
7 | import { JwtService } from '@nestjs/jwt';
8 | import { UserService } from '../user/user.service';
9 | import { User } from '../user/entities/user.entity';
10 | import { comparePassword } from '../common/utils.';
11 | import { OtpService } from '../otp/otp.service';
12 | import {
13 | INormalResponse,
14 | IRegisterResponse,
15 | } from '../common/interface/index.interface';
16 | import { VerifyUserDataDto } from './dto/verifyUserData.dto';
17 | import { ConfigService } from '@nestjs/config';
18 | import { CreateUserDto } from '../user/dto/create-user.dto';
19 | import { PasswordResetDto } from './dto/password-reset.dto';
20 | import { ResendCodeDto } from './dto/resend-code.dto';
21 |
22 | @Injectable()
23 | export class AuthService {
24 | constructor(
25 | private readonly userService: UserService,
26 | private readonly jwtService: JwtService,
27 | private readonly otpService: OtpService,
28 | private readonly configService: ConfigService,
29 | ) {}
30 |
31 | async validateUser(email: string, password: string): Promise {
32 | const user = await this.userService.findByEmail(email);
33 |
34 | if (!user || !(await comparePassword(password, user.password))) {
35 | throw new BadRequestException('Invalid Credentials');
36 | }
37 | // compare password to hashed passwords
38 | if (user && (await comparePassword(password, user.password))) {
39 | // delete password before returning user
40 | delete user.password;
41 | return user;
42 | }
43 | return null;
44 | }
45 |
46 | async login(user: User): Promise<{ access_token: string }> {
47 | const payload = {
48 | sub: user.id,
49 | role: user.role,
50 | };
51 | return {
52 | access_token: await this.jwtService.signAsync(payload),
53 | };
54 | }
55 |
56 | //registering user on signUp
57 | async register(createUserDto: CreateUserDto): Promise {
58 | const mailSubject = `Account Activation`;
59 | try {
60 | const user = await this.userService.create(createUserDto);
61 | const otpResponse = await this.otpService.sendOtp(
62 | createUserDto.email,
63 | mailSubject,
64 | );
65 | return {
66 | id: user.id,
67 | email: user.email,
68 | firstName: user.firstname,
69 | lastName: user.lastname,
70 | role: user.role,
71 | active: user.active,
72 | message: otpResponse.message,
73 | };
74 | } catch (err) {
75 | throw new Error(err);
76 | }
77 | }
78 |
79 | //activate user
80 | async activateUser(
81 | verifyUserData: VerifyUserDataDto,
82 | ): Promise {
83 | const user = await this.userService.findByEmail(verifyUserData.userEmail);
84 | if (!user || user === null) {
85 | return {
86 | message: 'user not found',
87 | status: HttpStatus.NOT_FOUND,
88 | };
89 | }
90 |
91 | if (user.active) {
92 | return {
93 | message: 'user is activated already',
94 | status: HttpStatus.UNPROCESSABLE_ENTITY,
95 | };
96 | }
97 | try {
98 | const otpVerifiedResponse = await this.otpService.verifyOtp(
99 | verifyUserData,
100 | );
101 | if (otpVerifiedResponse.status === 202) {
102 | await this.userService.updateUserSensitive(user.id, { active: true });
103 | return {
104 | message: `Dear ${user.lastname} ${user.firstname} your account has been activated successfully`,
105 | status: HttpStatus.ACCEPTED,
106 | };
107 | } else if (otpVerifiedResponse.status === 404) {
108 | return {
109 | message: 'invalid OTP',
110 | status: HttpStatus.NOT_FOUND,
111 | };
112 | } else if (otpVerifiedResponse.status === 410) {
113 | return {
114 | message: 'OTP has expired',
115 | status: HttpStatus.GONE,
116 | };
117 | }
118 | } catch (err) {
119 | console.log(err);
120 | throw new HttpException(
121 | 'something went wrong, please try again later',
122 | HttpStatus.INTERNAL_SERVER_ERROR,
123 | );
124 | }
125 | }
126 |
127 | //verify token
128 | async verifyToken(token: string): Promise {
129 | try {
130 | const decoded = await this.jwtService.verifyAsync(token, {
131 | secret: this.configService.get('jWT_SECRET'),
132 | });
133 |
134 | const user = await this.userService.findById(decoded.sub);
135 | if (!user || user === null) {
136 | throw new HttpException(
137 | 'token could not be verified',
138 | HttpStatus.BAD_REQUEST,
139 | );
140 | }
141 |
142 | delete user.password;
143 | return user;
144 | } catch (err) {
145 | throw new HttpException('invalid token', HttpStatus.BAD_REQUEST);
146 | }
147 | }
148 |
149 | //resend otp
150 | async resendOtp(resendCodeDto: ResendCodeDto): Promise {
151 | const { email } = resendCodeDto;
152 | const subject = 'Account Activation';
153 | const otp = await this.otpService.resendOtp(email, subject);
154 | return {
155 | message: otp.message,
156 | status: otp.status,
157 | };
158 | }
159 |
160 | async forgetPassword(resendCodeDto: ResendCodeDto): Promise {
161 | const { email } = resendCodeDto;
162 | const user = await this.userService.findByEmail(email);
163 | if (!user || user === null) {
164 | return {
165 | message: 'user not found',
166 | status: HttpStatus.BAD_REQUEST,
167 | };
168 | }
169 |
170 | if (user.active === false) {
171 | await this.otpService.resendOtp(email, 'Account Activation');
172 | return {
173 | message:
174 | 'Please check your email for OTP to activate your account before performing this action',
175 | status: HttpStatus.BAD_REQUEST,
176 | };
177 | }
178 |
179 | const payload = {
180 | email,
181 | sub: user.id,
182 | };
183 |
184 | const resetToken = await this.jwtService.signAsync(payload);
185 |
186 | await this.userService.updateUserSensitive(user.id, {
187 | authToken: resetToken,
188 | });
189 |
190 | const resetLink = `Click the link to reset your password ${process.env.PASSWORD_RESET_URL_LINK}/auth/reset/password?token=${resetToken}`;
191 |
192 | await this.otpService.sendToken(email, 'Password Reset', resetLink);
193 |
194 | return {
195 | message: `please check your email for your password reset link or click ${resetLink}`,
196 | status: HttpStatus.CREATED,
197 | };
198 | }
199 |
200 | async passwordReset(
201 | token: string,
202 | passwordResetDto: PasswordResetDto,
203 | ): Promise {
204 | const { password } = passwordResetDto;
205 | const user = await this.verifyToken(token);
206 |
207 | if (!user || user === null) {
208 | return {
209 | message: 'user not found',
210 | status: HttpStatus.NOT_FOUND,
211 | };
212 | }
213 | if (token !== user.authToken) {
214 | return {
215 | message: 'invalid token',
216 | status: HttpStatus.BAD_REQUEST,
217 | };
218 | }
219 |
220 | if (!password) {
221 | return {
222 | message: 'please provide a reset password',
223 | status: HttpStatus.BAD_REQUEST,
224 | };
225 | }
226 |
227 | await this.userService.update(user.id, { password });
228 | await this.userService.updateUserSensitive(user.id, { authToken: null });
229 |
230 | return {
231 | message: 'password reset successfully',
232 | status: HttpStatus.CREATED,
233 | };
234 | }
235 | }
236 |
--------------------------------------------------------------------------------
/server/src/modules/auth/dto/login-user.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsEmail } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class LoginUserDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | @IsEmail()
8 | email: string;
9 |
10 | @ApiProperty()
11 | @IsNotEmpty()
12 | password: string;
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/modules/auth/dto/password-reset.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class PasswordResetDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | password: string;
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/modules/auth/dto/resend-code.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class ResendCodeDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | @IsEmail()
8 | email: string;
9 | }
10 |
--------------------------------------------------------------------------------
/server/src/modules/auth/dto/verifyUserData.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class VerifyUserDataDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | @IsEmail()
8 | userEmail: string;
9 |
10 | @ApiProperty()
11 | @IsNotEmpty()
12 | otp: string;
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/modules/auth/guards/jwt-guard.guard.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { AuthGuard } from '@nestjs/passport';
3 |
4 | @Injectable()
5 | export class JwtAuthGuard extends AuthGuard('jwt') {}
6 |
--------------------------------------------------------------------------------
/server/src/modules/auth/guards/local-auth.guard.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { AuthGuard } from '@nestjs/passport';
3 |
4 | @Injectable()
5 | export class localAuthGuard extends AuthGuard('local') {}
6 |
--------------------------------------------------------------------------------
/server/src/modules/auth/guards/roles.guard.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
2 | import { Reflector } from '@nestjs/core';
3 | import { ROLES_KEY } from 'src/modules/common/decorators/role.decorator';
4 | import { Role } from 'src/modules/common/enum/role.enum';
5 |
6 | @Injectable()
7 | export class RolesGuard implements CanActivate {
8 | constructor(private reflector: Reflector) {}
9 |
10 | canActivate(context: ExecutionContext): boolean {
11 | const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [
12 | context.getHandler(),
13 | context.getClass(),
14 | ]);
15 |
16 | if (!requiredRoles) {
17 | return true;
18 | }
19 |
20 | const { user } = context.switchToHttp().getRequest();
21 | return requiredRoles.some((role) => user.role?.includes(role));
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/server/src/modules/auth/strategies/jwt.strategy.ts:
--------------------------------------------------------------------------------
1 | import { ExtractJwt, Strategy } from 'passport-jwt';
2 | import { PassportStrategy } from '@nestjs/passport';
3 | import { Injectable } from '@nestjs/common';
4 | import { ConfigService } from '@nestjs/config';
5 |
6 | @Injectable()
7 | export class JwtStrategy extends PassportStrategy(Strategy) {
8 | constructor(private readonly configService: ConfigService) {
9 | super({
10 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
11 | ignoreExpiration: false,
12 | secretOrKey: configService.get('jwt.secret'),
13 | });
14 | }
15 |
16 | async validate(payload: any) {
17 | return {
18 | id: payload.sub,
19 | role: payload.role,
20 | };
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/modules/auth/strategies/local.strategy.ts:
--------------------------------------------------------------------------------
1 | import { Strategy } from 'passport-local';
2 | import { PassportStrategy } from '@nestjs/passport';
3 | import { Injectable, UnauthorizedException } from '@nestjs/common';
4 | import { AuthService } from '../auth.service';
5 | import { User } from 'src/modules/user/entities/user.entity';
6 |
7 | @Injectable()
8 | export class LocalStrategy extends PassportStrategy(Strategy) {
9 | constructor(private authService: AuthService) {
10 | super({
11 | usernameField: 'email',
12 | });
13 | }
14 |
15 | async validate(email: string, password: string): Promise {
16 | const user = await this.authService.validateUser(email, password);
17 | if (!user) {
18 | throw new UnauthorizedException();
19 | }
20 | return user;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/server/src/modules/category/category.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | // Get,
4 | // Post,
5 | // Body,
6 | // Patch,
7 | // Param,
8 | // Delete,
9 | } from '@nestjs/common';
10 | // import { ApiTags } from '@nestjs/swagger';
11 | import { CategoryService } from './category.service';
12 | // import { CreateCategoryDto } from './dto/create-category.dto';
13 | // import { UpdateCategoryDto } from './dto/update-category.dto';
14 |
15 | // @ApiTags('category')
16 | @Controller('category')
17 | export class CategoryController {
18 | constructor(private readonly categoryService: CategoryService) {}
19 |
20 | // @Post()
21 | // create(@Body() createCategoryDto: CreateCategoryDto) {
22 | // return this.categoryService.create(createCategoryDto);
23 | // }
24 |
25 | // @Get()
26 | // findAll() {
27 | // return this.categoryService.findAll();
28 | // }
29 |
30 | // @Get(':id')
31 | // findOne(@Param('id') id: string) {
32 | // return this.categoryService.findOne(+id);
33 | // }
34 |
35 | // @Patch(':id')
36 | // update(
37 | // @Param('id') id: string,
38 | // @Body() updateCategoryDto: UpdateCategoryDto,
39 | // ) {
40 | // return this.categoryService.update(+id, updateCategoryDto);
41 | // }
42 |
43 | // @Delete(':id')
44 | // remove(@Param('id') id: string) {
45 | // return this.categoryService.remove(+id);
46 | // }
47 | }
48 |
--------------------------------------------------------------------------------
/server/src/modules/category/category.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 | import { Category } from './entities/category.entity';
4 | import { CategoryService } from './category.service';
5 | import { CategoryController } from './category.controller';
6 |
7 | @Module({
8 | imports: [TypeOrmModule.forFeature([Category])],
9 | controllers: [CategoryController],
10 | providers: [CategoryService],
11 | })
12 | export class CategoryModule {}
13 |
--------------------------------------------------------------------------------
/server/src/modules/category/category.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { CreateCategoryDto } from './dto/create-category.dto';
3 | import { UpdateCategoryDto } from './dto/update-category.dto';
4 |
5 | @Injectable()
6 | export class CategoryService {
7 | create(createCategoryDto: CreateCategoryDto) {
8 | return 'This action adds a new category';
9 | }
10 |
11 | findAll() {
12 | return `This action returns all category`;
13 | }
14 |
15 | findOne(id: number) {
16 | return `This action returns a #${id} category`;
17 | }
18 |
19 | update(id: number, updateCategoryDto: UpdateCategoryDto) {
20 | return `This action updates a #${id} category`;
21 | }
22 |
23 | remove(id: number) {
24 | return `This action removes a #${id} category`;
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/modules/category/dto/create-category.dto.ts:
--------------------------------------------------------------------------------
1 | export class CreateCategoryDto {}
2 |
--------------------------------------------------------------------------------
/server/src/modules/category/dto/update-category.dto.ts:
--------------------------------------------------------------------------------
1 | import { PartialType } from '@nestjs/swagger';
2 | import { CreateCategoryDto } from './create-category.dto';
3 |
4 | export class UpdateCategoryDto extends PartialType(CreateCategoryDto) {}
5 |
--------------------------------------------------------------------------------
/server/src/modules/category/entities/category.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Entity,
3 | PrimaryGeneratedColumn,
4 | Column,
5 | ManyToMany,
6 | CreateDateColumn,
7 | UpdateDateColumn,
8 | } from 'typeorm';
9 |
10 | import { Post } from 'src/modules/post/entities/post.entity';
11 |
12 | @Entity()
13 | export class Category {
14 | @PrimaryGeneratedColumn()
15 | id: number;
16 | @Column()
17 | category: string;
18 | @ManyToMany(() => Post, (posts) => posts.categories)
19 | posts: Post[];
20 | @CreateDateColumn()
21 | created_at: Date;
22 | @UpdateDateColumn()
23 | updated_at: Date;
24 | }
25 |
--------------------------------------------------------------------------------
/server/src/modules/comment/comment.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | Post,
5 | Body,
6 | Patch,
7 | Query,
8 | Param,
9 | Delete,
10 | Request,
11 | ParseUUIDPipe,
12 | HttpStatus,
13 | UseGuards,
14 | ValidationPipe,
15 | UsePipes,
16 | } from '@nestjs/common';
17 | import {
18 | ApiTags,
19 | ApiResponse,
20 | ApiBody,
21 | ApiParam,
22 | ApiQuery,
23 | } from '@nestjs/swagger';
24 | import { JwtAuthGuard } from '../auth/guards/jwt-guard.guard';
25 | import { CommentService } from './comment.service';
26 | import { CreateCommentDto } from './dto/create-comment.dto';
27 | import { UpdateCommentDto } from './dto/update-comment.dto';
28 | import { IComment } from './interface/comment.interface';
29 | import { Comment } from './entities/comment.entity';
30 |
31 | @ApiTags('comment')
32 | @Controller('comment')
33 | export class CommentController {
34 | constructor(private readonly commentService: CommentService) {}
35 | @UseGuards(JwtAuthGuard)
36 | @ApiResponse({
37 | status: 201,
38 | description: 'Comment created successfully.',
39 | })
40 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
41 | @ApiParam({ name: 'id', description: 'post id to be commented' })
42 | @ApiBody({ type: [CreateCommentDto] })
43 | @Post('create/:id')
44 | @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
45 | async create(
46 | @Param(
47 | 'id',
48 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
49 | )
50 | postId: string,
51 | @Body() createCommentDto: CreateCommentDto,
52 | @Request() req: any,
53 | ): Promise {
54 | const userId = req.user.id;
55 | return await this.commentService.create(userId, postId, createCommentDto);
56 | }
57 |
58 | @UseGuards(JwtAuthGuard)
59 | @ApiResponse({
60 | status: 200,
61 | description: 'Get All Comments.',
62 | })
63 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
64 | @ApiResponse({ status: 400, description: 'User with comment does not exist' })
65 | @ApiQuery({
66 | name: 'page',
67 | required: false,
68 | description: 'the number of pages of comment',
69 | })
70 | @ApiQuery({
71 | name: 'limit',
72 | required: false,
73 | description: 'number of comments per page',
74 | })
75 | @Get()
76 | async getAllComments(
77 | @Query('page') page = 1,
78 | @Query('limit') limit = 10,
79 | ): Promise {
80 | return await this.commentService.getAllComments(page, limit);
81 | }
82 |
83 | @UseGuards(JwtAuthGuard)
84 | @ApiResponse({
85 | status: 200,
86 | description: 'Get All Comments.',
87 | })
88 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
89 | @ApiResponse({ status: 400, description: 'User with comment does not exist' })
90 | @ApiParam({ name: 'id', description: 'comment id' })
91 | @Get(':id')
92 | getOneComment(
93 | @Param(
94 | 'id',
95 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
96 | )
97 | commentId: string,
98 | ) {
99 | return this.commentService.getOneComment(commentId);
100 | }
101 |
102 | @UseGuards(JwtAuthGuard)
103 | @ApiResponse({
104 | status: 200,
105 | description: 'Update comment.',
106 | })
107 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
108 | @ApiResponse({ status: 400, description: 'User with comment does not exist' })
109 | @ApiBody({ type: [UpdateCommentDto] })
110 | @ApiParam({ name: 'id', description: 'the id of the comment' })
111 | @Patch(':id')
112 | update(
113 | @Param(
114 | 'id',
115 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
116 | )
117 | commentId: string,
118 | @Body() updateCommentDto: UpdateCommentDto,
119 | @Request() req: any,
120 | ) {
121 | const userId = req.user.id;
122 | return this.commentService.updateComment(
123 | commentId,
124 | userId,
125 | updateCommentDto,
126 | );
127 | }
128 |
129 | @UseGuards(JwtAuthGuard)
130 | @ApiResponse({
131 | status: 200,
132 | description: 'Delete Comment.',
133 | })
134 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
135 | @ApiParam({ name: 'id', description: 'comment id' })
136 | @Delete(':id')
137 | remove(
138 | @Param(
139 | 'id',
140 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
141 | )
142 | id: string,
143 | @Request() req: any,
144 | ): Promise {
145 | const userId = req.user.id;
146 | return this.commentService.remove(id, userId);
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/server/src/modules/comment/comment.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 | import { Comment } from './entities/comment.entity';
4 | import { CommentService } from './comment.service';
5 | import { CommentController } from './comment.controller';
6 | import { PostModule } from '../post/post.module';
7 | import { UserModule } from '../user/user.module';
8 |
9 | @Module({
10 | imports: [PostModule, UserModule, TypeOrmModule.forFeature([Comment])],
11 | controllers: [CommentController],
12 | providers: [CommentService],
13 | })
14 | export class CommentModule {}
15 |
--------------------------------------------------------------------------------
/server/src/modules/comment/comment.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
2 | import { InjectRepository } from '@nestjs/typeorm';
3 | import { Repository } from 'typeorm';
4 | import { Comment } from './entities/comment.entity';
5 | import { CreateCommentDto } from './dto/create-comment.dto';
6 | import { PostService } from '../post/post.service';
7 | import { UserService } from '../user/user.service';
8 | import { IComment } from './interface/comment.interface';
9 | import { UpdateCommentDto } from './dto/update-comment.dto';
10 |
11 | @Injectable()
12 | export class CommentService {
13 | constructor(
14 | @InjectRepository(Comment)
15 | private readonly commentRepository: Repository,
16 | private readonly userService: UserService,
17 | private readonly postService: PostService,
18 | ) {}
19 | async create(
20 | userId: string,
21 | postId: string,
22 | createCommentDto: CreateCommentDto,
23 | ): Promise {
24 | const user = await this.userService.findById(userId);
25 | const post = await this.postService.getPostById(postId);
26 | const comment = new Comment();
27 | comment.user = user;
28 | comment.post = post;
29 | comment.comment = createCommentDto.comment;
30 | await this.commentRepository.save(comment);
31 | return {
32 | username: `${user.firstname} ${user.lastname}`,
33 | email: user.email,
34 | post_title: post.title,
35 | comment: comment.comment,
36 | created_at: comment.created_at,
37 | updated_at: comment.updated_at,
38 | };
39 | }
40 |
41 | async getAllComments(page: number, limit: number): Promise {
42 | const comments = await this.commentRepository.find({
43 | skip: (page - 1) * limit,
44 | take: limit,
45 | relations: ['user', 'post'],
46 | });
47 | return comments;
48 | }
49 |
50 | async getOneComment(id: string): Promise {
51 | const comment = await this.commentRepository
52 | .createQueryBuilder('comment')
53 | .leftJoinAndSelect('comment.user', 'user')
54 | .leftJoinAndSelect('comment.post', 'post')
55 | .where(`comment.id = :id`, { id })
56 | .getOne();
57 | if (!comment || comment === null) {
58 | throw new HttpException('Comment does not exit', HttpStatus.BAD_REQUEST);
59 | }
60 |
61 | return comment;
62 | }
63 |
64 | async updateComment(
65 | commentId: string,
66 | userId: string,
67 | updateCommentDto: UpdateCommentDto,
68 | ): Promise {
69 | const comment = await this.commentRepository
70 | .createQueryBuilder('comment')
71 | .leftJoinAndSelect('comment.user', 'user')
72 | .where(`comment.id = :commentId`, { commentId })
73 | .getOne();
74 |
75 | if (!comment || comment === null) {
76 | throw new HttpException('comment does not exist', HttpStatus.NOT_FOUND);
77 | }
78 |
79 | if (comment.user.id !== userId || comment.user === null) {
80 | throw new HttpException(
81 | 'user with comment does not exist',
82 | HttpStatus.NOT_ACCEPTABLE,
83 | );
84 | }
85 |
86 | const commentToUpdate = { ...comment, ...updateCommentDto };
87 | const updatedComment = await this.commentRepository.save(commentToUpdate);
88 | delete updatedComment.user;
89 | return updatedComment;
90 | }
91 |
92 | async remove(id: string, userId: string): Promise {
93 | const comment = await this.commentRepository
94 | .createQueryBuilder('comment')
95 | .leftJoinAndSelect('comment.user', 'user')
96 | .where(`comment.id = :id`, { id })
97 | .getOne();
98 |
99 | if (!comment || comment === null) {
100 | throw new HttpException('comment does not exist', HttpStatus.NOT_FOUND);
101 | }
102 |
103 | if (
104 | !comment.user ||
105 | comment.user === null ||
106 | !comment.user.id ||
107 | comment.user.id === null ||
108 | comment.user.id !== userId
109 | ) {
110 | throw new HttpException(
111 | 'user with comment does not exit',
112 | HttpStatus.BAD_REQUEST,
113 | );
114 | }
115 |
116 | await this.commentRepository.delete(id);
117 | return `This comment has been deleted successfully`;
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/server/src/modules/comment/dto/create-comment.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsString, IsNotEmpty } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class CreateCommentDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | @IsString()
8 | comment: string;
9 | }
10 |
--------------------------------------------------------------------------------
/server/src/modules/comment/dto/update-comment.dto.ts:
--------------------------------------------------------------------------------
1 | import { PartialType } from '@nestjs/swagger';
2 | import { CreateCommentDto } from './create-comment.dto';
3 |
4 | export class UpdateCommentDto extends PartialType(CreateCommentDto) {}
5 |
--------------------------------------------------------------------------------
/server/src/modules/comment/entities/comment.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Entity,
3 | Column,
4 | PrimaryGeneratedColumn,
5 | ManyToOne,
6 | CreateDateColumn,
7 | UpdateDateColumn,
8 | } from 'typeorm';
9 | import { User } from 'src/modules/user/entities/user.entity';
10 | import { Post } from 'src/modules/post/entities/post.entity';
11 |
12 | @Entity()
13 | export class Comment {
14 | @PrimaryGeneratedColumn('uuid')
15 | id: string;
16 | @Column({ type: 'text' })
17 | comment: string;
18 | @ManyToOne(() => User, (user) => user.comments)
19 | user: User;
20 | @ManyToOne(() => Post, (post) => post.comments)
21 | post: Post;
22 | @CreateDateColumn()
23 | created_at: Date;
24 | @UpdateDateColumn()
25 | updated_at: Date;
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/modules/comment/interface/comment.interface.ts:
--------------------------------------------------------------------------------
1 | export interface IComment {
2 | username: string;
3 | email: string;
4 | post_title: string;
5 | comment: string;
6 | created_at: Date;
7 | updated_at: Date;
8 | }
9 |
--------------------------------------------------------------------------------
/server/src/modules/common/decorators/role.decorator.ts:
--------------------------------------------------------------------------------
1 | import { SetMetadata } from '@nestjs/common';
2 | import { Role } from '../enum/role.enum';
3 |
4 | export const ROLES_KEY = 'roles';
5 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
6 |
--------------------------------------------------------------------------------
/server/src/modules/common/enum/getPost.enum.ts:
--------------------------------------------------------------------------------
1 | export enum GetPosts {
2 | PAGE = 'page',
3 | LIMIT = 'limit',
4 | }
5 |
--------------------------------------------------------------------------------
/server/src/modules/common/enum/role.enum.ts:
--------------------------------------------------------------------------------
1 | export enum Role {
2 | ADMIN = 'admin',
3 | MODERATOR = 'moderator',
4 | AUTHOR = 'author',
5 | SUBSCRIBER = 'subscriber',
6 | }
7 |
--------------------------------------------------------------------------------
/server/src/modules/common/enum/upload-folder.enum.ts:
--------------------------------------------------------------------------------
1 | export enum Folder {
2 | AVATARS = 'avatars',
3 | POSTS = 'posts',
4 | }
5 |
--------------------------------------------------------------------------------
/server/src/modules/common/interface/index.interface.ts:
--------------------------------------------------------------------------------
1 | import { Role } from '../enum/role.enum';
2 | export interface INormalResponse {
3 | message: string;
4 | status: number;
5 | }
6 |
7 | export interface IRegisterResponse {
8 | id: string;
9 | email: string;
10 | firstName: string;
11 | lastName: string;
12 | role: Role;
13 | active: boolean;
14 | message: string;
15 | }
16 |
--------------------------------------------------------------------------------
/server/src/modules/common/utils..ts:
--------------------------------------------------------------------------------
1 | import * as bcrypt from 'bcrypt';
2 |
3 | export const hashPassword = async (password: string) => {
4 | const salt = await bcrypt.genSalt();
5 | return await bcrypt.hash(password, salt);
6 | };
7 | // compare the password
8 | export const comparePassword = async (
9 | password: string,
10 | hashedPassword: string,
11 | ): Promise => {
12 | return await bcrypt.compare(password, hashedPassword);
13 | };
14 |
--------------------------------------------------------------------------------
/server/src/modules/common/validators/image-pipe.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, PipeTransform } from '@nestjs/common';
2 | import * as path from 'path';
3 | import * as sharp from 'sharp';
4 | // was needed, just here just incase
5 | @Injectable()
6 | export class ImagePipe
7 | implements PipeTransform>
8 | {
9 | async transform(file: Express.Multer.File): Promise {
10 | const originalName = path.parse(file.originalname).name;
11 | const filename = Date.now() + '-' + originalName + '.webp';
12 |
13 | await sharp(file.buffer)
14 | .resize(2000)
15 | .webp({ effort: 3 })
16 | .toFile(path.join('public', filename));
17 |
18 | return filename;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/server/src/modules/otp/dto/verify-otp.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class VerifyOtpDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | @IsEmail()
8 | userEmail: string;
9 |
10 | @ApiProperty()
11 | @IsNotEmpty()
12 | otp: string;
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/modules/otp/entities/otp.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Entity,
3 | Column,
4 | PrimaryGeneratedColumn,
5 | UpdateDateColumn,
6 | CreateDateColumn,
7 | } from 'typeorm';
8 |
9 | @Entity()
10 | export class Otp {
11 | @PrimaryGeneratedColumn('uuid')
12 | id: string;
13 |
14 | @Column({ nullable: true })
15 | userEmail: string;
16 |
17 | @Column()
18 | otp: string;
19 |
20 | @Column({ type: 'timestamp' })
21 | expiry: Date;
22 |
23 | @CreateDateColumn()
24 | created_at: Date;
25 |
26 | @UpdateDateColumn()
27 | updated: Date;
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/modules/otp/otp.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller } from '@nestjs/common';
2 | import { OtpService } from './otp.service';
3 |
4 | @Controller('otp')
5 | export class OtpController {
6 | constructor(private readonly otpService: OtpService) {}
7 | }
8 |
--------------------------------------------------------------------------------
/server/src/modules/otp/otp.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { TypeOrmModule } from '@nestjs/typeorm';
3 | import { Otp } from './entities/otp.entity';
4 | import { HttpModule } from '@nestjs/axios';
5 | import { OtpService } from './otp.service';
6 | import { OtpController } from './otp.controller';
7 |
8 | @Module({
9 | imports: [TypeOrmModule.forFeature([Otp]), HttpModule],
10 | controllers: [OtpController],
11 | providers: [OtpService],
12 | exports: [OtpService],
13 | })
14 | export class OtpModule {}
15 |
--------------------------------------------------------------------------------
/server/src/modules/otp/otp.service.ts:
--------------------------------------------------------------------------------
1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
2 | import { InjectRepository } from '@nestjs/typeorm';
3 | import { Repository } from 'typeorm';
4 | import { Otp } from './entities/otp.entity';
5 | import { MailerService } from '@nestjs-modules/mailer';
6 | import { ConfigService } from '@nestjs/config';
7 | import * as otp from 'otp-generator';
8 | import { INormalResponse } from '../common/interface/index.interface';
9 | import { hashPassword, comparePassword } from '../common/utils.';
10 | import { VerifyOtpDto } from './dto/verify-otp';
11 |
12 | @Injectable()
13 | export class OtpService {
14 | constructor(
15 | @InjectRepository(Otp)
16 | private readonly otpRepository: Repository,
17 | private readonly configService: ConfigService,
18 | private readonly mailerService: MailerService,
19 | ) {}
20 |
21 | private generateOTP(): string {
22 | const OTP = otp.generate(6, {
23 | upperCaseAlphabets: true,
24 | specialChars: false,
25 | });
26 | return OTP;
27 | }
28 |
29 | private async sendCodeToEmail(email: string, subject: string, html: string) {
30 | const message = {
31 | to: email,
32 | from: this.configService.get('MAIL_USERNAME'),
33 | subject: `Blog-API: ${subject}`,
34 | html,
35 | };
36 |
37 | try {
38 | await this.mailerService.sendMail(message);
39 | } catch (err) {
40 | throw new HttpException(
41 | 'something went wrong, please try again later',
42 | HttpStatus.INTERNAL_SERVER_ERROR,
43 | );
44 | }
45 | }
46 |
47 | async sendOtp(email: string, subject: string): Promise {
48 | const otp = this.generateOTP();
49 | const text = `Your OTP code is ${otp} , Kindly use it to activate your account.
`;
50 | //hashed otp before saving to database
51 | const hashedOtp = await hashPassword(otp);
52 | //10mins expiry time
53 | const expiry = new Date(new Date().getTime() + 10 * 60 * 1000);
54 |
55 | await this.createOtp(email, hashedOtp, expiry);
56 | await this.sendCodeToEmail(email, subject, text);
57 | return {
58 | message: 'OTP sent successfully',
59 | status: HttpStatus.CREATED,
60 | };
61 | }
62 |
63 | async sendToken(
64 | email: string,
65 | subject: string,
66 | html: string,
67 | ): Promise {
68 | await this.sendCodeToEmail(email, subject, html);
69 | return {
70 | message: 'OTP sent successfully',
71 | status: HttpStatus.CREATED,
72 | };
73 | }
74 |
75 | async createOtp(email: string, otp: string, expiry: Date): Promise {
76 | const existedOtp = await this.otpRepository.findOneBy({
77 | userEmail: email,
78 | });
79 |
80 | if (!existedOtp || existedOtp === null) {
81 | const newOtp = new Otp();
82 | newOtp.userEmail = email;
83 | newOtp.otp = otp;
84 | newOtp.expiry = expiry;
85 | await this.otpRepository.save(newOtp);
86 | } else if (existedOtp) {
87 | existedOtp.otp = otp;
88 | existedOtp.expiry = expiry;
89 | await this.otpRepository.save(existedOtp);
90 | }
91 | }
92 |
93 | async verifyOtp(verifyOtpDto: VerifyOtpDto): Promise {
94 | const otp = await this.otpRepository.findOneBy({
95 | userEmail: verifyOtpDto.userEmail,
96 | });
97 |
98 | if (otp === null || !otp) {
99 | return {
100 | message: 'OTP not found',
101 | status: HttpStatus.NOT_FOUND,
102 | };
103 | }
104 |
105 | const optCode = await comparePassword(verifyOtpDto.otp, otp.otp);
106 | if (otp && !optCode) {
107 | return {
108 | message: 'invalid otp',
109 | status: HttpStatus.NOT_FOUND,
110 | };
111 | } else if (otp.expiry <= new Date()) {
112 | return {
113 | message: 'OTP has expired',
114 | status: HttpStatus.NOT_FOUND,
115 | };
116 | }
117 |
118 | await this.revokeOtp(verifyOtpDto);
119 | return {
120 | message: 'OTP verified successfully',
121 | status: HttpStatus.ACCEPTED,
122 | };
123 | }
124 |
125 | async resendOtp(email: string, subject: string): Promise {
126 | await this.sendOtp(email, subject);
127 | return {
128 | message: 'OTP sent successfully',
129 | status: HttpStatus.CREATED,
130 | };
131 | }
132 | async revokeOtp(verifyOtpDto: VerifyOtpDto): Promise {
133 | const otp = await this.otpRepository.findOneBy({
134 | userEmail: verifyOtpDto.userEmail,
135 | });
136 |
137 | const otpCode = await comparePassword(verifyOtpDto.otp, otp.otp);
138 | if (!otp || otp === null || !otpCode) {
139 | throw new HttpException('OTP not found', HttpStatus.NOT_FOUND);
140 | }
141 | await this.otpRepository.remove(otp);
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/server/src/modules/post/dto/create-post.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsNotEmpty, IsString } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 | export class CreatePostDto {
4 | @ApiProperty()
5 | readonly id: string;
6 |
7 | @ApiProperty()
8 | @IsNotEmpty()
9 | @IsString()
10 | title: string;
11 |
12 | @ApiProperty()
13 | @IsNotEmpty()
14 | @IsString()
15 | content: string;
16 | }
17 |
--------------------------------------------------------------------------------
/server/src/modules/post/dto/update-post.dto.ts:
--------------------------------------------------------------------------------
1 | import { PartialType } from '@nestjs/swagger';
2 | import { CreatePostDto } from './create-post.dto';
3 |
4 | export class UpdatePostDto extends PartialType(CreatePostDto) {}
5 |
--------------------------------------------------------------------------------
/server/src/modules/post/entities/post.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Entity,
3 | Column,
4 | PrimaryGeneratedColumn,
5 | OneToMany,
6 | ManyToOne,
7 | ManyToMany,
8 | UpdateDateColumn,
9 | CreateDateColumn,
10 | } from 'typeorm';
11 | import { User } from 'src/modules/user/entities/user.entity';
12 | import { Comment } from 'src/modules/comment/entities/comment.entity';
13 | import { Category } from 'src/modules/category/entities/category.entity';
14 |
15 | @Entity()
16 | export class Post {
17 | @PrimaryGeneratedColumn('uuid')
18 | id: string;
19 |
20 | @Column()
21 | title: string;
22 |
23 | @Column({
24 | type: 'text',
25 | })
26 | content: string;
27 |
28 | @Column({
29 | unique: true,
30 | })
31 | slug: string;
32 |
33 | @Column({ nullable: true })
34 | imageId: string;
35 |
36 | @Column('int', { default: 0 })
37 | likeCount: number;
38 |
39 | @ManyToMany(() => User, (user) => user.likedPosts)
40 | likes: User[];
41 |
42 | @Column({ default: false })
43 | published: boolean;
44 |
45 | @ManyToOne(() => User, (user) => user.posts)
46 | user: User;
47 |
48 | @OneToMany(() => Comment, (comments) => comments.post)
49 | comments: Comment[];
50 |
51 | @ManyToMany(() => Category, (categories) => categories.posts)
52 | categories: Category[];
53 |
54 | @Column({
55 | type: Date,
56 | nullable: true,
57 | })
58 | publish_at: Date | null;
59 |
60 | @CreateDateColumn()
61 | created_at: Date;
62 |
63 | @UpdateDateColumn()
64 | updated: Date;
65 | }
66 |
--------------------------------------------------------------------------------
/server/src/modules/post/post.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | Post,
5 | Body,
6 | Patch,
7 | Param,
8 | Delete,
9 | UseGuards,
10 | Request,
11 | ParseUUIDPipe,
12 | HttpStatus,
13 | Query,
14 | ValidationPipe,
15 | UsePipes,
16 | } from '@nestjs/common';
17 | import {
18 | ApiTags,
19 | ApiResponse,
20 | ApiBody,
21 | ApiQuery,
22 | ApiParam,
23 | } from '@nestjs/swagger';
24 | import { JwtAuthGuard } from '../auth/guards/jwt-guard.guard';
25 | import { GetPosts } from '../common/enum/getPost.enum';
26 | import { PostService } from './post.service';
27 | import { CreatePostDto } from './dto/create-post.dto';
28 | import { UpdatePostDto } from './dto/update-post.dto';
29 | import { Post as Posts } from './entities/post.entity';
30 | import { Roles } from '../common/decorators/role.decorator';
31 | import { Role } from '../common/enum/role.enum';
32 | import { RolesGuard } from '../auth/guards/roles.guard';
33 |
34 | @ApiTags('post')
35 | @Controller('post')
36 | export class PostController {
37 | constructor(private readonly postService: PostService) {}
38 |
39 | @UseGuards(JwtAuthGuard, RolesGuard)
40 | @Roles(Role.ADMIN, Role.AUTHOR, Role.MODERATOR, Role.SUBSCRIBER)
41 | @ApiResponse({
42 | status: 201,
43 | description: 'Post created successfully.',
44 | })
45 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
46 | @ApiBody({ type: [CreatePostDto] })
47 | @Post('create')
48 | @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
49 | async create(
50 | @Body() createPostDto: CreatePostDto,
51 | @Request() req: any,
52 | ): Promise {
53 | const post = new Posts();
54 | post.content = createPostDto.content;
55 | post.title = createPostDto.title;
56 | post.user = req.user.id;
57 | return await this.postService.createPost(post, req.user.id);
58 | }
59 | @UseGuards(JwtAuthGuard)
60 | @ApiResponse({
61 | status: 200,
62 | description: 'Post updated successfully.',
63 | })
64 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
65 | @ApiParam({ name: 'id' })
66 | @ApiBody({ type: [UpdatePostDto] })
67 | @Patch(':id')
68 | async updatePost(
69 | @Param(
70 | 'id',
71 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
72 | )
73 | postId: string,
74 | @Body() updatePostDto: UpdatePostDto,
75 | @Request() req: any,
76 | ): Promise {
77 | const userId: string = req.user.id;
78 | return await this.postService.updatePost(userId, postId, updatePostDto);
79 | }
80 |
81 | @UseGuards(JwtAuthGuard)
82 | @ApiResponse({
83 | status: 200,
84 | description: 'Get Personal Posts.',
85 | })
86 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
87 | @ApiParam({ name: 'me' })
88 | @Get('me')
89 | async getPostsByUserId(@Request() req: any): Promise {
90 | const { id } = req.user;
91 | return await this.postService.getPostsByUserId(id);
92 | }
93 |
94 | @ApiResponse({
95 | status: 200,
96 | description: 'Get all posts.',
97 | })
98 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
99 | @ApiQuery({
100 | name: 'page',
101 | enum: GetPosts,
102 | description: 'the number of pages of posts',
103 | })
104 | @ApiQuery({
105 | name: 'limit',
106 | enum: GetPosts,
107 | description: 'the number of post per page',
108 | })
109 | @Get()
110 | getPosts(
111 | @Query('page') page = 1,
112 | @Query('limit') limit = 10,
113 | ): Promise {
114 | return this.postService.getPosts(page, limit);
115 | }
116 |
117 | @UseGuards(JwtAuthGuard)
118 | @ApiResponse({
119 | status: 200,
120 | description: 'Delete Post.',
121 | })
122 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
123 | @ApiParam({ name: 'id' })
124 | @Delete(':id')
125 | deletePost(
126 | @Param(
127 | 'id',
128 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
129 | )
130 | id: string,
131 | @Request() req: any,
132 | ): Promise {
133 | const userId: string = req.user.id;
134 | return this.postService.deletePost(id, userId);
135 | }
136 |
137 | @UseGuards(JwtAuthGuard)
138 | @ApiResponse({
139 | status: 201,
140 | description: 'like a Post.',
141 | })
142 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
143 | @ApiParam({ name: 'id' })
144 | @Post('/like/:id')
145 | async likePost(
146 | @Param(
147 | 'id',
148 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
149 | )
150 | postId: string,
151 | @Request() req: any,
152 | ) {
153 | const userId: string = req.user.id;
154 | return await this.postService.likePost(userId, postId);
155 | }
156 |
157 | @UseGuards(JwtAuthGuard)
158 | @ApiResponse({
159 | status: 200,
160 | description: 'unlike a Post.',
161 | })
162 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
163 | @ApiParam({ name: 'id' })
164 | @Post('/unlike/:id')
165 | async unlikePost(
166 | @Param(
167 | 'id',
168 | new ParseUUIDPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }),
169 | )
170 | postId: string,
171 | @Request() req: any,
172 | ) {
173 | const userId: string = req.user.id;
174 | return await this.postService.unlikePost(userId, postId);
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/server/src/modules/post/post.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { PostService } from './post.service';
3 | import { PostController } from './post.controller';
4 | import { SlugProvider } from './slug.provider';
5 | import { TypeOrmModule } from '@nestjs/typeorm';
6 | import { Post } from './entities/post.entity';
7 | import { UserModule } from '../user/user.module';
8 |
9 | @Module({
10 | imports: [UserModule, TypeOrmModule.forFeature([Post])],
11 | controllers: [PostController],
12 | providers: [PostService, SlugProvider],
13 | exports: [PostService],
14 | })
15 | export class PostModule {}
16 |
--------------------------------------------------------------------------------
/server/src/modules/post/post.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
2 | import { CreatePostDto } from './dto/create-post.dto';
3 | import { UpdatePostDto } from './dto/update-post.dto';
4 | import { InjectRepository } from '@nestjs/typeorm';
5 | import { Post } from './entities/post.entity';
6 | import { Repository } from 'typeorm';
7 | import { SlugProvider } from './slug.provider';
8 | import { UserService } from '../user/user.service';
9 |
10 | @Injectable()
11 | export class PostService {
12 | constructor(
13 | @InjectRepository(Post)
14 | private readonly postRepository: Repository,
15 | private readonly slugProvider: SlugProvider,
16 | private readonly userService: UserService,
17 | ) {}
18 |
19 | async createPost(
20 | createPostDto: CreatePostDto,
21 | userId: string,
22 | ): Promise {
23 | const uniqueSlug = await this.createUniqueSlug(createPostDto.title, userId);
24 |
25 | const existedPost = await this.findBySlug(uniqueSlug);
26 |
27 | if (existedPost) {
28 | throw new HttpException(
29 | 'post existed, please create a new post',
30 | HttpStatus.NOT_ACCEPTABLE,
31 | );
32 | }
33 | return await this.postRepository.save(
34 | this.postRepository.create({ ...createPostDto, slug: uniqueSlug }),
35 | );
36 | }
37 |
38 | async updatePost(
39 | userId: string,
40 | postId: string,
41 | updatePostDto: UpdatePostDto,
42 | ): Promise {
43 | const post = await this.postRepository
44 | .createQueryBuilder('post')
45 | .leftJoinAndSelect('post.user', 'user')
46 | .where(`post.id = :postId`, { postId })
47 | .getOne();
48 |
49 | try {
50 | if (!post || post === null || !post.slug) {
51 | throw new HttpException('post does not exist', HttpStatus.NOT_FOUND);
52 | }
53 |
54 | if (post.user.id !== userId) {
55 | throw new HttpException(
56 | 'user with post does not exist',
57 | HttpStatus.NOT_ACCEPTABLE,
58 | );
59 | }
60 |
61 | const slug = await this.createUniqueSlug(
62 | updatePostDto.title,
63 | post.user.id,
64 | );
65 | const PostToUpdate = { ...post, ...updatePostDto, slug };
66 | const updatedPost = await this.postRepository.save(PostToUpdate);
67 | delete updatedPost.user;
68 | return updatedPost;
69 | } catch (err) {
70 | throw new HttpException(
71 | 'user with post does not exist',
72 | HttpStatus.BAD_REQUEST,
73 | );
74 | }
75 | }
76 |
77 | async getPosts(page: number, limit: number): Promise {
78 | const posts = await this.postRepository.find({
79 | skip: (page - 1) * limit,
80 | take: limit,
81 | relations: ['user', 'comments'],
82 | });
83 | return posts;
84 | }
85 |
86 | async getPostById(id: string): Promise {
87 | const post = await this.postRepository.findOneBy({ id });
88 | if (!post || post === null) {
89 | throw new HttpException('post does not exist', HttpStatus.NOT_FOUND);
90 | }
91 | return post;
92 | }
93 |
94 | async getPostsByUserId(id: string): Promise {
95 | const posts = await this.postRepository
96 | .createQueryBuilder('post')
97 | .leftJoinAndSelect('post.user', 'user')
98 | .leftJoinAndSelect('post.comments', 'comments')
99 | .where(`user.id = :id`, { id })
100 | .getMany();
101 | return posts;
102 | }
103 |
104 | private async findBySlug(slug: string): Promise {
105 | return await this.postRepository.findOneBy({ slug });
106 | }
107 |
108 | private async createUniqueSlug(title: string, id: string): Promise {
109 | const slugifyTitle = await this.slugProvider.slugify(title);
110 | const uniqueSlug = slugifyTitle + '-' + id;
111 | return uniqueSlug;
112 | }
113 |
114 | //like a post
115 | async likePost(userId: string, postId: string): Promise {
116 | const user = await this.userService.findById(userId);
117 | const post = await this.postRepository
118 | .createQueryBuilder('post')
119 | .leftJoinAndSelect('post.likes', 'likes')
120 | .where(`post.id = :postId`, { postId })
121 | .getOne();
122 |
123 | if (!user || user === null || !post || post === null) {
124 | throw new HttpException(
125 | 'user or post does not exist',
126 | HttpStatus.NOT_FOUND,
127 | );
128 | }
129 |
130 | const userLikedPost = post.likes.find((user) => user.id === userId);
131 |
132 | //check if user available in the likes if not push in the userid
133 | if (!userLikedPost) {
134 | post.likes.push(user);
135 | post.likeCount += 1;
136 | await this.postRepository.save(post);
137 | }
138 | return post.likeCount;
139 | }
140 |
141 | //unlike a post
142 | async unlikePost(userId: string, postId: string): Promise {
143 | const post = await this.postRepository
144 | .createQueryBuilder('post')
145 | .leftJoinAndSelect('post.likes', 'likes')
146 | .where(`post.id = :postId`, { postId })
147 | .getOne();
148 |
149 | if (!post || post === null) {
150 | throw new HttpException('post does not exist', HttpStatus.NOT_FOUND);
151 | }
152 |
153 | const userLikedPost = post.likes.find((user) => user.id === userId);
154 | //check if user available in the likes if not push in the userid
155 | if (userLikedPost) {
156 | const index = post.likes.indexOf(userLikedPost);
157 | if (index > -1) {
158 | post.likes.splice(index, 1);
159 | post.likeCount -= 1;
160 | await this.postRepository.save(post);
161 | }
162 | }
163 | return post.likeCount;
164 | }
165 |
166 | //delete post
167 | async deletePost(id: string, userId: string): Promise {
168 | const post = await this.postRepository
169 | .createQueryBuilder('post')
170 | .leftJoinAndSelect('post.user', 'user')
171 | .where(`post.id = :id`, { id })
172 | .getOne();
173 | try {
174 | if (!post.user.id || post.user.id === null || post.user.id !== userId) {
175 | throw new HttpException(
176 | 'user with post does not exit',
177 | HttpStatus.BAD_REQUEST,
178 | );
179 | }
180 |
181 | if (post === null || !post) {
182 | throw new HttpException('post does not exist', HttpStatus.NOT_FOUND);
183 | }
184 | await this.postRepository.delete(id);
185 | return `This post has been deleted successfully`;
186 | } catch (err) {
187 | throw new HttpException(
188 | 'user with post does not exist',
189 | HttpStatus.BAD_REQUEST,
190 | );
191 | }
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/server/src/modules/post/slug.provider.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import slugify from 'slugify';
4 |
5 | @Injectable()
6 | export class SlugProvider {
7 | constructor(private readonly configService: ConfigService) {}
8 | async slugify(slug: string): Promise {
9 | return slugify(slug, {
10 | replacement: this.configService.get('process.env.REPLACEMENT'),
11 | lower: true,
12 | });
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/modules/search/search.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller } from '@nestjs/common';
2 | import { ApiTags } from '@nestjs/swagger';
3 | import { SearchService } from './search.service';
4 |
5 | @ApiTags('search')
6 | @Controller('search')
7 | export class SearchController {
8 | constructor(private readonly searchService: SearchService) {}
9 | }
10 |
--------------------------------------------------------------------------------
/server/src/modules/search/search.dto.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dkrest1/blog-api/9383906dce8efbf09a1d3f963c6d81eb464c6d3e/server/src/modules/search/search.dto.ts
--------------------------------------------------------------------------------
/server/src/modules/search/search.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { SearchService } from './search.service';
3 | import { SearchController } from './search.controller';
4 |
5 | @Module({
6 | controllers: [SearchController],
7 | providers: [SearchService]
8 | })
9 | export class SearchModule {}
10 |
--------------------------------------------------------------------------------
/server/src/modules/search/search.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 |
3 | @Injectable()
4 | export class SearchService {}
5 |
--------------------------------------------------------------------------------
/server/src/modules/uploads/uploads.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Delete,
4 | Get,
5 | Query,
6 | UseGuards,
7 | Post,
8 | UploadedFile,
9 | UseInterceptors,
10 | Request,
11 | Response,
12 | Patch,
13 | } from '@nestjs/common';
14 | import {
15 | ApiConsumes,
16 | ApiTags,
17 | ApiResponse,
18 | ApiQuery,
19 | ApiBody,
20 | } from '@nestjs/swagger';
21 | import { FileInterceptor } from '@nestjs/platform-express';
22 | import { ImageService } from './uploads.service';
23 | import { JwtAuthGuard } from '../auth/guards/jwt-guard.guard';
24 | import { INormalResponse } from '../common/interface/index.interface';
25 | import { Folder } from '../common/enum/upload-folder.enum';
26 |
27 | @ApiTags('upload Image')
28 | @Controller('image')
29 | export class UploadsController {
30 | constructor(private readonly imageService: ImageService) {}
31 |
32 | @UseGuards(JwtAuthGuard)
33 | @ApiResponse({
34 | status: 201,
35 | description: 'Image uploaded successfully.',
36 | })
37 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
38 | @ApiResponse({
39 | status: 400,
40 | description:
41 | 'Unacceptable file format, acceptable include png, jpegs, jpeg, webp',
42 | })
43 | @ApiConsumes('multipart/form-data')
44 | @ApiBody({
45 | description: 'File Upload',
46 | schema: {
47 | type: 'object',
48 | properties: {
49 | file: {
50 | type: 'string',
51 | format: 'binary',
52 | },
53 | },
54 | },
55 | })
56 | @Post('upload/avatar')
57 | @UseInterceptors(FileInterceptor('file'))
58 | async uploadAvatarImage(
59 | @UploadedFile() file: Express.Multer.File,
60 | @Request() req: any,
61 | ): Promise {
62 | const imgId = req.user.id;
63 | const imgDir = `avatars`;
64 | try {
65 | const imagePath = await this.imageService.uploadImage(
66 | imgId,
67 | file,
68 | imgDir,
69 | );
70 | return imagePath;
71 | } catch (error) {
72 | return error;
73 | }
74 | }
75 |
76 | @UseGuards(JwtAuthGuard)
77 | @ApiResponse({
78 | status: 201,
79 | description: 'Image uploaded successfully.',
80 | })
81 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
82 | @ApiResponse({
83 | status: 400,
84 | description:
85 | 'Unacceptable file format, acceptable include png, jpegs, jpeg, webp',
86 | })
87 | @ApiQuery({
88 | name: 'folder',
89 | enum: Folder,
90 | description: 'the folder which the image will be accessed from',
91 | })
92 | @ApiQuery({
93 | name: 'filename',
94 | description: 'the file name which we are accesing',
95 | })
96 | @ApiConsumes('multipart/form-data')
97 | @Get('view/avatar')
98 | async getAvatarImage(
99 | @Query('folder') folder: string,
100 | @Query('filename') filename: string,
101 | @Response() res: any,
102 | ): Promise {
103 | await this.imageService.getImage(folder, filename, res);
104 | }
105 |
106 | @UseGuards(JwtAuthGuard)
107 | @ApiResponse({
108 | status: 201,
109 | description: 'Image updated successfully.',
110 | })
111 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
112 | @ApiResponse({
113 | status: 400,
114 | description:
115 | 'Unacceptable file format, acceptable include png, jpegs, jpeg, webp',
116 | })
117 | @ApiQuery({
118 | name: 'folder',
119 | enum: Folder,
120 | description:
121 | 'the folder which the image to be updated will be accessed from',
122 | })
123 | @ApiQuery({
124 | name: 'filename',
125 | description: 'the file name which we are accesing for update',
126 | })
127 | @ApiConsumes('multipart/form-data')
128 | @Patch('avatar')
129 | @UseInterceptors(FileInterceptor('file'))
130 | async updateAvatarImage(
131 | @UploadedFile() file: Express.Multer.File,
132 | @Query('folder') folder: string,
133 | @Query('filename') filename: string,
134 | ): Promise {
135 | return await this.imageService.updateImage(file, folder, filename);
136 | }
137 |
138 | @UseGuards(JwtAuthGuard)
139 | @ApiResponse({
140 | status: 201,
141 | description: 'Image deleted successfully.',
142 | })
143 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
144 | @ApiResponse({
145 | status: 400,
146 | description: 'Image not found',
147 | })
148 | @ApiQuery({
149 | name: 'folder',
150 | enum: Folder,
151 | description:
152 | 'the folder which the image to be deleted will be accessed from',
153 | })
154 | @ApiQuery({
155 | name: 'filename',
156 | description: 'the file name which we are accesing for deletion',
157 | })
158 | @ApiConsumes('multipart/form-data')
159 | @Delete('avatar')
160 | async deleteAvatarImage(
161 | @Query('folder') folder: string,
162 | @Query('filename') filename: string,
163 | ): Promise {
164 | return await this.imageService.deleteImage(folder, filename);
165 | }
166 |
167 | ///////////// Post Image upload route
168 | @ApiResponse({
169 | status: 200,
170 | description: 'Image file path to be accessed.',
171 | })
172 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
173 | @ApiResponse({
174 | status: 400,
175 | description: 'Image not found',
176 | })
177 | @ApiConsumes('multipart/form-data')
178 | @ApiBody({
179 | description: 'File Upload',
180 | schema: {
181 | type: 'object',
182 | properties: {
183 | file: {
184 | type: 'string',
185 | format: 'binary',
186 | },
187 | },
188 | },
189 | })
190 | @Post('upload/post')
191 | @UseInterceptors(FileInterceptor('file'))
192 | async uploadPostImage(
193 | @UploadedFile() file: Express.Multer.File,
194 | @Request() req: any,
195 | ): Promise {
196 | const imgId = req.user.id;
197 | const imgDir = `posts`;
198 | try {
199 | const imagePath = await this.imageService.uploadImage(
200 | imgId,
201 | file,
202 | imgDir,
203 | );
204 | return imagePath;
205 | } catch (error) {
206 | return error;
207 | }
208 | }
209 |
210 | @UseGuards(JwtAuthGuard)
211 | @ApiResponse({
212 | status: 200,
213 | description: 'Image file path to be accessed.',
214 | })
215 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
216 | @ApiResponse({
217 | status: 400,
218 | description: 'Image not found',
219 | })
220 | @ApiQuery({
221 | name: 'folder',
222 | enum: Folder,
223 | description: 'the folder which the image will be saved to',
224 | })
225 | @ApiQuery({
226 | name: 'filename',
227 | description: 'the file name which you are saving',
228 | })
229 | @ApiConsumes('multipart/form-data')
230 | @Get('view/post')
231 | async getPostImage(
232 | @Query('folder') folder: string,
233 | @Query('filename') filename: string,
234 | @Response() res: any,
235 | ): Promise {
236 | await this.imageService.getImage(folder, filename, res);
237 | }
238 |
239 | @UseGuards(JwtAuthGuard)
240 | @ApiResponse({
241 | status: 201,
242 | description: 'Image updated successfully.',
243 | })
244 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
245 | @ApiResponse({
246 | status: 400,
247 | description:
248 | 'Unacceptable file format, acceptable include png, jpegs, jpeg, webp',
249 | })
250 | @ApiQuery({
251 | name: 'folder',
252 | enum: Folder,
253 | description:
254 | 'the folder which the image to be updated will be accessed from',
255 | })
256 | @ApiQuery({
257 | name: 'filename',
258 | description: 'the file name which we are accesing for update',
259 | })
260 | @ApiConsumes('multipart/form-data')
261 | @Patch('post')
262 | @UseInterceptors(FileInterceptor('file'))
263 | async updatePostImage(
264 | @UploadedFile() file: Express.Multer.File,
265 | @Query('folder') folder: string,
266 | @Query('filename') filename: string,
267 | ): Promise {
268 | return await this.imageService.updateImage(file, folder, filename);
269 | }
270 |
271 | @UseGuards(JwtAuthGuard)
272 | @ApiResponse({
273 | status: 201,
274 | description: 'Image deleted successfully.',
275 | })
276 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
277 | @ApiResponse({
278 | status: 400,
279 | description: 'Image not found',
280 | })
281 | @ApiQuery({
282 | name: 'folder',
283 | enum: Folder,
284 | description:
285 | 'the folder which the image to be deleted will be accessed from',
286 | })
287 | @ApiQuery({
288 | name: 'filename',
289 | description: 'the file name which we are accesing for deletion',
290 | })
291 | @ApiConsumes('multipart/form-data')
292 | @Delete('post')
293 | async deletePostImage(
294 | @Query('folder') folder: string,
295 | @Query('filename') filename: string,
296 | ): Promise {
297 | return await this.imageService.deleteImage(folder, filename);
298 | }
299 | }
300 |
--------------------------------------------------------------------------------
/server/src/modules/uploads/uploads.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ImageService } from './uploads.service';
3 | import { UploadsController } from './uploads.controller';
4 | import { UserModule } from '../user/user.module';
5 | import { PostModule } from '../post/post.module';
6 |
7 | @Module({
8 | imports: [UserModule, PostModule],
9 | controllers: [UploadsController],
10 | providers: [ImageService],
11 | exports: [],
12 | })
13 | export class UploadsModule {}
14 |
--------------------------------------------------------------------------------
/server/src/modules/uploads/uploads.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HttpException,
3 | HttpStatus,
4 | Injectable,
5 | NotFoundException,
6 | Res,
7 | } from '@nestjs/common';
8 | import * as path from 'path';
9 | import * as fs from 'fs';
10 | import * as fsExtra from 'fs-extra';
11 | import * as sharp from 'sharp';
12 | import { Response } from 'express';
13 | import { INormalResponse } from '../common/interface/index.interface';
14 |
15 | @Injectable()
16 | export class ImageService {
17 | private readonly publicDir: string = 'public';
18 |
19 | constructor() {
20 | //to ensure the directory is created
21 | fsExtra.ensureDirSync(this.publicDir);
22 | }
23 |
24 | async uploadImage(
25 | imgId: string,
26 | file: Express.Multer.File,
27 | dir: string,
28 | ): Promise {
29 | const requiredFormat = ['png', 'jpeg', 'jpg', 'gif', 'webp'];
30 | //sanitize file extension
31 | const fileFormat = path.parse(file.originalname).ext.split('.')[1];
32 | if (!requiredFormat.includes(fileFormat)) {
33 | throw new HttpException(
34 | 'the required file format are png, jpeg, jpg, webp or gif',
35 | HttpStatus.BAD_REQUEST,
36 | );
37 | }
38 | //resolve img directory
39 | const uploadDir = path.join(this.publicDir, dir);
40 | fsExtra.ensureDirSync(uploadDir);
41 | //resolve filename and convert to webp
42 | const filename = imgId + '.webp';
43 | // optimize img path
44 | const optimizedImagePath = path.join(uploadDir, filename);
45 | //resize and save file up
46 | await sharp(file.buffer)
47 | .resize(800)
48 | .webp({ effort: 3 })
49 | .toFile(optimizedImagePath);
50 | return path.normalize(optimizedImagePath).replace(/\\/g, '/');
51 | }
52 |
53 | async getImage(
54 | folder: string,
55 | filename: string,
56 | @Res() response: Response,
57 | ): Promise {
58 | const resolvedImagePath = path.join(this.publicDir, folder, filename);
59 | const exists = await fs.promises
60 | .access(resolvedImagePath, fs.constants.F_OK)
61 | .then(() => true)
62 | .catch(() => false);
63 | if (!exists) {
64 | throw new NotFoundException('Image not found');
65 | }
66 | const imageStream = fs.createReadStream(resolvedImagePath);
67 | response.setHeader('Content-Type', 'image/webp');
68 | imageStream.pipe(response);
69 | }
70 |
71 | async updateImage(
72 | file: Express.Multer.File,
73 | folder: string,
74 | filename: string,
75 | ): Promise {
76 | const resolvedImagePath = path.join(this.publicDir, folder, filename);
77 |
78 | const exists = await fs.promises
79 | .access(resolvedImagePath, fs.constants.F_OK)
80 | .then(() => true)
81 | .catch(() => false);
82 |
83 | if (!exists) {
84 | throw new NotFoundException('Image not found');
85 | }
86 | const requiredFormat = ['png', 'jpeg', 'jpg', 'gif', 'webp'];
87 | const fileFormat = path.parse(file.originalname).ext.split('.')[1];
88 | if (!requiredFormat.includes(fileFormat)) {
89 | throw new HttpException(
90 | 'the required file format are png, jpeg, jpg, webp or gif',
91 | HttpStatus.BAD_REQUEST,
92 | );
93 | }
94 | await sharp(file.buffer)
95 | .resize(800)
96 | .webp({ effort: 3 })
97 | .toFile(resolvedImagePath);
98 | return path.normalize(resolvedImagePath).replace(/\\/g, '/');
99 | }
100 |
101 | async deleteImage(
102 | folder: string,
103 | filename: string,
104 | ): Promise {
105 | const resolvedImagePath = path.join(this.publicDir, folder, filename);
106 | const exists = await fs.promises
107 | .access(resolvedImagePath, fs.constants.F_OK)
108 | .then(() => true)
109 | .catch(() => false);
110 | if (!exists) {
111 | throw new NotFoundException('Image not found');
112 | }
113 | await fs.promises.unlink(resolvedImagePath);
114 | return {
115 | message: 'image deleted successfully',
116 | status: HttpStatus.OK,
117 | };
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/server/src/modules/user/dto/create-user.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class CreateUserDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | @IsEmail()
8 | email: string;
9 |
10 | @ApiProperty()
11 | @IsNotEmpty()
12 | firstname: string;
13 |
14 | @ApiProperty()
15 | @IsNotEmpty()
16 | lastname: string;
17 |
18 | @ApiProperty()
19 | @IsNotEmpty()
20 | password: string;
21 | }
22 |
--------------------------------------------------------------------------------
/server/src/modules/user/dto/login-user.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 |
4 | export class LoginUserDto {
5 | @ApiProperty()
6 | @IsNotEmpty()
7 | @IsEmail()
8 | email: string;
9 |
10 | @ApiProperty()
11 | @IsNotEmpty()
12 | password: string;
13 | }
14 |
--------------------------------------------------------------------------------
/server/src/modules/user/dto/update-user-role.dto.ts:
--------------------------------------------------------------------------------
1 | import { IsEmail, IsNotEmpty, IsEnum } from 'class-validator';
2 | import { ApiProperty } from '@nestjs/swagger';
3 | import { Role } from 'src/modules/common/enum/role.enum';
4 |
5 | export class UpdateUserRoleDto {
6 | @ApiProperty()
7 | @IsNotEmpty()
8 | @IsEmail()
9 | email: string;
10 |
11 | @ApiProperty({ enum: ['admin', 'moderator', 'author', 'subscriber'] })
12 | @IsEnum(Role)
13 | role: Role;
14 | }
15 |
--------------------------------------------------------------------------------
/server/src/modules/user/dto/update-user-sensitive.dto.ts:
--------------------------------------------------------------------------------
1 | export class UpdateUserSensitive {
2 | active?: boolean;
3 | authToken?: string;
4 | }
5 |
--------------------------------------------------------------------------------
/server/src/modules/user/dto/update-user.dto.ts:
--------------------------------------------------------------------------------
1 | import { PartialType } from '@nestjs/swagger';
2 | import { CreateUserDto } from './create-user.dto';
3 |
4 | export class UpdateUserDto extends PartialType(CreateUserDto) {}
5 |
--------------------------------------------------------------------------------
/server/src/modules/user/entities/user.entity.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Entity,
3 | PrimaryGeneratedColumn,
4 | Column,
5 | CreateDateColumn,
6 | UpdateDateColumn,
7 | OneToMany,
8 | ManyToMany,
9 | JoinTable,
10 | } from 'typeorm';
11 | import { Role } from 'src/modules/common/enum/role.enum';
12 | import { Comment } from 'src/modules/comment/entities/comment.entity';
13 | import { Post } from 'src/modules/post/entities/post.entity';
14 |
15 | @Entity()
16 | export class User {
17 | @PrimaryGeneratedColumn('uuid')
18 | id: string;
19 |
20 | @Column({
21 | unique: true,
22 | })
23 | email: string;
24 |
25 | @Column()
26 | firstname: string;
27 |
28 | @Column()
29 | lastname: string;
30 |
31 | @Column()
32 | password: string;
33 |
34 | @Column({ type: 'enum', enum: Role, default: Role.SUBSCRIBER })
35 | role: Role;
36 |
37 | @ManyToMany(() => Post, (post) => post.likes)
38 | @JoinTable()
39 | likedPosts: Post[];
40 |
41 | @OneToMany(() => Post, (posts) => posts.user)
42 | posts: Post[];
43 |
44 | @OneToMany(() => Comment, (comments) => comments.user)
45 | comments: Comment[];
46 |
47 | @Column({ type: 'bool', default: false })
48 | active: boolean;
49 |
50 | @Column({ nullable: true })
51 | authToken: string;
52 |
53 | @CreateDateColumn()
54 | created_at: Date;
55 |
56 | @UpdateDateColumn()
57 | updated_at: Date;
58 | }
59 |
--------------------------------------------------------------------------------
/server/src/modules/user/user.controller.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Controller,
3 | Get,
4 | Post,
5 | Body,
6 | Patch,
7 | Delete,
8 | Request,
9 | ValidationPipe,
10 | UsePipes,
11 | UseGuards,
12 | NotFoundException,
13 | Query,
14 | } from '@nestjs/common';
15 | import { ApiBody, ApiTags, ApiResponse, ApiQuery } from '@nestjs/swagger';
16 | import { JwtAuthGuard } from '../auth/guards/jwt-guard.guard';
17 | import { UserService } from './user.service';
18 | import { UpdateUserDto } from './dto/update-user.dto';
19 | import { User } from './entities/user.entity';
20 | import { UpdateUserRoleDto } from './dto/update-user-role.dto';
21 | import { RolesGuard } from '../auth/guards/roles.guard';
22 | import { Roles } from '../common/decorators/role.decorator';
23 | import { Role } from '../common/enum/role.enum';
24 |
25 | @ApiTags('user')
26 | @Controller('user')
27 | export class UserController {
28 | constructor(private readonly userService: UserService) {}
29 |
30 | @UseGuards(JwtAuthGuard)
31 | @ApiResponse({
32 | status: 200,
33 | description: 'The User has sucessfully viewed profile.',
34 | })
35 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
36 | @Get('me')
37 | async getUser(@Request() req: any): Promise {
38 | const { id } = req.user;
39 | const user = await this.userService.findById(id);
40 |
41 | if (!user) {
42 | throw new NotFoundException();
43 | }
44 |
45 | // remove the user pa
46 | delete user.password;
47 | return user;
48 | }
49 |
50 | @UseGuards(JwtAuthGuard)
51 | @ApiResponse({
52 | status: 200,
53 | description: 'The User has successfully been updated.',
54 | })
55 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
56 | @ApiBody({ type: [UpdateUserDto] })
57 | @Patch('me')
58 | async update(
59 | @Request() req: any,
60 | @Body() updateUserDto: UpdateUserDto,
61 | ): Promise {
62 | const { id } = req.user;
63 | return await this.userService.update(id, {
64 | ...updateUserDto,
65 | });
66 | }
67 |
68 | @UseGuards(JwtAuthGuard, RolesGuard)
69 | @Roles(Role.ADMIN)
70 | @ApiQuery({
71 | name: 'page',
72 | required: false,
73 | description: 'the number of pages of users',
74 | })
75 | @ApiQuery({
76 | name: 'limit',
77 | required: false,
78 | description: 'the number of users per pages',
79 | })
80 | @ApiResponse({
81 | status: 200,
82 | description: 'Get list of Users on the Application',
83 | })
84 | @ApiResponse({ status: 401, description: 'Unforbidden Resource' })
85 | @Get()
86 | async getUsers(
87 | @Query('page') page = 1,
88 | @Query('limit') limit = 10,
89 | ): Promise {
90 | const users = await this.userService.findUsers(page, limit);
91 | users.map((user) => {
92 | delete user.password;
93 | delete user.authToken;
94 | });
95 | return users;
96 | }
97 |
98 | @UseGuards(JwtAuthGuard)
99 | @ApiResponse({
100 | status: 200,
101 | description: 'The User has successfully been deleted.',
102 | })
103 | @ApiResponse({ status: 401, description: 'Unauthorized.' })
104 | @Delete('me')
105 | async remove(@Request() req: any): Promise {
106 | const { id } = req.user;
107 | return await this.userService.remove(id);
108 | }
109 |
110 | @UseGuards(JwtAuthGuard, RolesGuard)
111 | @Roles(Role.ADMIN)
112 | @Post('admin/updaterole')
113 | @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
114 | @ApiBody({ type: [UpdateUserRoleDto] })
115 | @ApiResponse({
116 | status: 201,
117 | description: 'The User Role has been successfully Updated.',
118 | })
119 | @ApiResponse({ status: 400, description: 'Bad Request.' })
120 | async updateRole(@Body() updateUserRole: UpdateUserRoleDto): Promise {
121 | return await this.userService.updateUserRole(
122 | updateUserRole.email,
123 | updateUserRole.role,
124 | );
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/server/src/modules/user/user.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { UserService } from './user.service';
3 | import { UserController } from './user.controller';
4 | import { TypeOrmModule } from '@nestjs/typeorm';
5 | import { User } from './entities/user.entity';
6 |
7 | @Module({
8 | imports: [TypeOrmModule.forFeature([User])],
9 | controllers: [UserController],
10 | providers: [UserService],
11 | exports: [UserService],
12 | })
13 | export class UserModule {}
14 |
--------------------------------------------------------------------------------
/server/src/modules/user/user.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Injectable,
3 | NotFoundException,
4 | HttpException,
5 | HttpStatus,
6 | } from '@nestjs/common';
7 | import { CreateUserDto } from './dto/create-user.dto';
8 | import { UpdateUserDto } from './dto/update-user.dto';
9 | import { User } from './entities/user.entity';
10 | import { InjectRepository } from '@nestjs/typeorm';
11 | import { Repository } from 'typeorm';
12 | import { hashPassword } from '../common/utils.';
13 | import { Role } from '../common/enum/role.enum';
14 | import { UpdateUserSensitive } from './dto/update-user-sensitive.dto';
15 |
16 | @Injectable()
17 | export class UserService {
18 | constructor(
19 | @InjectRepository(User)
20 | private readonly userRepository: Repository,
21 | ) {}
22 |
23 | async create(createUserDto: CreateUserDto): Promise {
24 | // hashed password before save up
25 | const hashedPassword = await hashPassword(createUserDto.password);
26 | const user = { ...createUserDto, password: hashedPassword };
27 | const createdUser = await this.userRepository.save(
28 | this.userRepository.create(user),
29 | );
30 | delete createdUser.password;
31 | return createdUser;
32 | }
33 |
34 | async update(
35 | id: string,
36 | updateUserDto: UpdateUserDto,
37 | ): Promise {
38 | const user = await this.findById(id);
39 | if (!user || user === null) {
40 | throw new NotFoundException();
41 | }
42 | const hashedPassword = await hashPassword(updateUserDto.password);
43 | const userToUpdate = {
44 | ...user,
45 | ...updateUserDto,
46 | password: hashedPassword,
47 | };
48 |
49 | const updatedUser = await this.userRepository.save(userToUpdate);
50 | delete updatedUser.password;
51 | return updatedUser;
52 | }
53 |
54 | async updateUserSensitive(
55 | userId: string,
56 | updateUserSensitive: UpdateUserSensitive,
57 | ): Promise {
58 | const user = await this.findById(userId);
59 | if (!user || user === null) {
60 | throw new NotFoundException();
61 | }
62 |
63 | if (user) {
64 | if (updateUserSensitive.active) {
65 | user.active = updateUserSensitive.active;
66 | }
67 | if (updateUserSensitive.authToken) {
68 | user.authToken = updateUserSensitive.authToken;
69 | }
70 | }
71 |
72 | const updatedUser = await this.userRepository.save({
73 | ...user,
74 | ...updateUserSensitive,
75 | });
76 | return updatedUser;
77 | }
78 |
79 | async updateUserRole(email: string, role: Role): Promise {
80 | const user = await this.findByEmail(email);
81 | if (!user || user === null) {
82 | throw new HttpException('user not found', HttpStatus.NOT_FOUND);
83 | }
84 |
85 | if (user.active === false) {
86 | throw new HttpException('user is not active', HttpStatus.BAD_REQUEST);
87 | }
88 |
89 | const userToUpdate = {
90 | ...user,
91 | role,
92 | };
93 |
94 | const updatedUser = await this.userRepository.save(userToUpdate);
95 | delete updatedUser.password;
96 | return updatedUser;
97 | }
98 |
99 | async findById(id: string): Promise {
100 | return await this.userRepository.findOneBy({
101 | id,
102 | });
103 | }
104 |
105 | async findByEmail(email: string): Promise {
106 | return await this.userRepository.findOneBy({
107 | email,
108 | });
109 | }
110 |
111 | async findUsers(page: number, limit: number): Promise {
112 | const users = await this.userRepository.find({
113 | skip: (page - 1) * limit,
114 | take: limit,
115 | });
116 | return users;
117 | }
118 |
119 | async remove(id: string): Promise {
120 | const user = await this.findById(id);
121 |
122 | if (!user || user === null) {
123 | throw new NotFoundException();
124 | }
125 | await this.userRepository.delete(id);
126 |
127 | return `We are sorry to let you go ${user.firstname.toUpperCase()} ${user.lastname.toUpperCase()}`;
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/server/test/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { INestApplication } from '@nestjs/common';
3 | import * as request from 'supertest';
4 | import { AppModule } from './../src/app.module';
5 |
6 | describe('AppController (e2e)', () => {
7 | let app: INestApplication;
8 |
9 | beforeEach(async () => {
10 | const moduleFixture: TestingModule = await Test.createTestingModule({
11 | imports: [AppModule],
12 | }).compile();
13 |
14 | app = moduleFixture.createNestApplication();
15 | await app.init();
16 | });
17 |
18 | it('/ (GET)', () => {
19 | return request(app.getHttpServer())
20 | .get('/')
21 | .expect(200)
22 | .expect('Hello World!');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/server/test/jest-e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts"],
3 | "rootDir": ".",
4 | "testEnvironment": "node",
5 | "testRegex": ".e2e-spec.ts$",
6 | "transform": {
7 | "^.+\\.(t|j)s$": "ts-jest"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/server/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "target": "es2017",
10 | "sourceMap": true,
11 | "outDir": "./dist",
12 | "baseUrl": "./",
13 | "incremental": true,
14 | "skipLibCheck": true,
15 | "strictNullChecks": false,
16 | "noImplicitAny": false,
17 | "strictBindCallApply": false,
18 | "forceConsistentCasingInFileNames": false,
19 | "noFallthroughCasesInSwitch": false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------