├── .eslintrc.json ├── Makefile ├── client ├── graphql │ ├── LogoutQuery.graphql │ ├── DeletePostMutation.graphql │ ├── RefreshAccessTokenQuery.graphql │ ├── LoginMutation.graphql │ ├── SignUpMutation.graphql │ ├── GetMeQuery.graphql │ ├── CreatePostMutation.graphql │ ├── GetPostQuery.graphql │ ├── UpdatePostMutation.graphql │ └── GetAllPostsQuery.graphql ├── components │ ├── FullScreenLoader.tsx │ ├── Message.tsx │ ├── modals │ │ └── post.modal.tsx │ ├── LoadingButton.tsx │ ├── FormInput.tsx │ ├── TextInput.tsx │ ├── Spinner.tsx │ ├── Layout.tsx │ ├── FileUpload.tsx │ ├── Header.tsx │ └── posts │ │ ├── create.post.tsx │ │ ├── update.post.tsx │ │ └── post.component.tsx ├── lib │ └── types.ts ├── requests │ ├── graphqlRequestClient.ts │ └── axiosClient.ts ├── store │ └── index.ts ├── middleware │ └── AuthMiddleware.tsx └── generated │ └── graphql.ts ├── public ├── favicon.ico └── vercel.svg ├── postcss.config.js ├── server ├── resolvers │ ├── index.ts │ ├── user.resolver.ts │ └── post.resolver.ts ├── types │ └── context.ts ├── utils │ ├── connectRedis.ts │ ├── connectDB.ts │ └── jwt.ts ├── controllers │ └── error.controller.ts ├── models │ ├── post.model.ts │ └── user.model.ts ├── schemas │ ├── user.schema.ts │ └── post.schema.ts ├── middleware │ └── deserializeUser.ts └── services │ ├── post.service.ts │ └── user.service.ts ├── styles └── globals.css ├── next-env.d.ts ├── .env.example ├── codegen.yml ├── pages ├── api │ ├── hello.ts │ └── graphql.ts ├── _document.tsx ├── _app.tsx ├── index.tsx ├── profile.tsx ├── login.tsx └── register.tsx ├── .gitignore ├── next.config.js ├── docker-compose.yml ├── tsconfig.json ├── tailwind.config.js ├── package.json └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | docker-compose up -d 3 | 4 | dev-down: 5 | docker-compose down -------------------------------------------------------------------------------- /client/graphql/LogoutQuery.graphql: -------------------------------------------------------------------------------- 1 | query LogoutUser { 2 | logoutUser 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpcodevo/nextjs-typegraphql-api/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /client/graphql/DeletePostMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation DeletePost($deletePostId: String!) { 2 | deletePost(id: $deletePostId) 3 | } 4 | -------------------------------------------------------------------------------- /client/graphql/RefreshAccessTokenQuery.graphql: -------------------------------------------------------------------------------- 1 | query RefreshAccessToken { 2 | refreshAccessToken { 3 | status 4 | access_token 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/graphql/LoginMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation LoginUser($input: LoginInput!) { 2 | loginUser(input: $input) { 3 | status 4 | access_token 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /server/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import UserResolver from './user.resolver'; 2 | import PostResolver from './post.resolver'; 3 | 4 | export const resolvers = [UserResolver, PostResolver] as const; 5 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /client/graphql/SignUpMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation SignUpUser($input: SignUpInput!) { 2 | signupUser(input: $input) { 3 | status 4 | user { 5 | name 6 | email 7 | photo 8 | role 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | MONGO_INITDB_ROOT_USERNAME= 2 | MONGO_INITDB_ROOT_PASSWORD= 3 | MONGO_INITDB_DATABASE= 4 | MONGODB_LOCAL_URI= 5 | 6 | ACCESS_TOKEN_PRIVATE_KEY= 7 | ACCESS_TOKEN_PUBLIC_KEY= 8 | 9 | REFRESH_TOKEN_PRIVATE_KEY= 10 | REFRESH_TOKEN_PUBLIC_KEY= 11 | -------------------------------------------------------------------------------- /client/graphql/GetMeQuery.graphql: -------------------------------------------------------------------------------- 1 | query GetMe { 2 | getMe { 3 | status 4 | user { 5 | _id 6 | id 7 | email 8 | name 9 | role 10 | photo 11 | updatedAt 12 | createdAt 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /codegen.yml: -------------------------------------------------------------------------------- 1 | schema: http://localhost:3000/api/graphql 2 | documents: './client/**/*.graphql' 3 | generates: 4 | ./client/generated/graphql.ts: 5 | plugins: 6 | - typescript 7 | - typescript-operations 8 | - typescript-react-query 9 | config: 10 | fetcher: graphql-request 11 | -------------------------------------------------------------------------------- /client/graphql/CreatePostMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation CreatePost($input: PostInput!) { 2 | createPost(input: $input) { 3 | status 4 | post { 5 | id 6 | title 7 | content 8 | category 9 | user 10 | image 11 | createdAt 12 | updatedAt 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/graphql/GetPostQuery.graphql: -------------------------------------------------------------------------------- 1 | query GetPost($getPostId: String!) { 2 | getPost(id: $getPostId) { 3 | status 4 | post { 5 | id 6 | title 7 | content 8 | image 9 | user { 10 | email 11 | } 12 | category 13 | category 14 | updatedAt 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/graphql/UpdatePostMutation.graphql: -------------------------------------------------------------------------------- 1 | mutation UpdatePost($input: UpdatePostInput!, $updatePostId: String!) { 2 | updatePost(input: $input, id: $updatePostId) { 3 | status 4 | post { 5 | id 6 | title 7 | content 8 | category 9 | image 10 | createdAt 11 | updatedAt 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/types/context.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | import { User } from '../models/user.model'; 3 | 4 | export type Context = { 5 | req: NextApiRequest; 6 | res: NextApiResponse; 7 | deserializeUser: ( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) => Promise; 11 | }; 12 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /client/components/FullScreenLoader.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from './Spinner'; 2 | 3 | const FullScreenLoader = () => { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | }; 12 | 13 | export default FullScreenLoader; 14 | -------------------------------------------------------------------------------- /client/graphql/GetAllPostsQuery.graphql: -------------------------------------------------------------------------------- 1 | query GetAllPosts($input: PostFilter!) { 2 | getPosts(input: $input) { 3 | status 4 | results 5 | posts { 6 | id 7 | _id 8 | id 9 | title 10 | content 11 | category 12 | user { 13 | email 14 | name 15 | photo 16 | } 17 | image 18 | createdAt 19 | updatedAt 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /client/lib/types.ts: -------------------------------------------------------------------------------- 1 | export type IUser = { 2 | _id: string; 3 | id: string; 4 | email: string; 5 | name: string; 6 | role: string; 7 | photo: string; 8 | updatedAt: string; 9 | createdAt: string; 10 | }; 11 | 12 | export type IPost = { 13 | _id: string; 14 | title: string; 15 | content: string; 16 | category: string; 17 | image: string; 18 | createdAt: string; 19 | updatedAt: string; 20 | user: { 21 | email: string; 22 | name: string; 23 | photo: string; 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /client/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | type IMessageProps = { 4 | children: React.ReactNode; 5 | }; 6 | const Message: FC = ({ children }) => { 7 | return ( 8 |
12 | {children} 13 |
14 | ); 15 | }; 16 | 17 | export default Message; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | -------------------------------------------------------------------------------- /client/requests/graphqlRequestClient.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from 'graphql-request'; 2 | import { QueryClient } from 'react-query'; 3 | 4 | const GRAPHQL_ENDPOINT = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT as string; 5 | 6 | const graphqlRequestClient = new GraphQLClient(GRAPHQL_ENDPOINT, { 7 | credentials: 'include', 8 | mode: 'cors', 9 | }); 10 | 11 | export const queryClient = new QueryClient({ 12 | defaultOptions: { 13 | queries: { 14 | staleTime: 5 * 1000, 15 | }, 16 | }, 17 | }); 18 | 19 | export default graphqlRequestClient; 20 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | webpack: (config) => { 5 | config.experiments = config.experiments || {}; 6 | config.experiments.topLevelAwait = true; 7 | return config; 8 | }, 9 | experimental: { 10 | images: { 11 | allowFutureImage: true, 12 | remotePatterns: [ 13 | { 14 | protocol: 'https', 15 | hostname: '**.cloudinary.com', 16 | }, 17 | ], 18 | }, 19 | }, 20 | }; 21 | 22 | module.exports = nextConfig; 23 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | import dotenv from 'dotenv-safe'; 3 | dotenv.config(); 4 | 5 | export default function Document() { 6 | return ( 7 | 8 | 9 | 13 | 14 | 15 |
16 | 17 |
18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /server/utils/connectRedis.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | 3 | const redisUrl = 'redis://localhost:6379'; 4 | 5 | const redisClient = createClient({ 6 | url: redisUrl, 7 | }); 8 | 9 | const connectRedis = async () => { 10 | try { 11 | await redisClient.connect(); 12 | } catch (error: any) { 13 | setInterval(connectRedis, 5000); 14 | } 15 | }; 16 | 17 | connectRedis(); 18 | 19 | redisClient.on('connect', () => 20 | console.log('🚀 Redis client connected successfully') 21 | ); 22 | 23 | redisClient.on('error', (err) => console.error(err)); 24 | 25 | export default redisClient; 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | mongo: 4 | image: mongo 5 | container_name: mongodb 6 | ports: 7 | - '6000:27017' 8 | volumes: 9 | - mongodb:/data/db 10 | env_file: 11 | - ./.env 12 | environment: 13 | MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME} 14 | MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD} 15 | MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE} 16 | redis: 17 | image: redis:latest 18 | container_name: redis 19 | ports: 20 | - '6379:6379' 21 | volumes: 22 | - redis:/data 23 | volumes: 24 | redis: 25 | mongodb: 26 | -------------------------------------------------------------------------------- /client/requests/axiosClient.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { GetMeQuery } from '../generated/graphql'; 3 | const BASE_URL = 'http://localhost:3000/api/graphql'; 4 | 5 | export const authApi = axios.create({ 6 | baseURL: BASE_URL, 7 | withCredentials: true, 8 | }); 9 | 10 | export const axiosGetMe = async (data: string, access_token: string) => { 11 | const response = await authApi.post( 12 | '', 13 | { query: data }, 14 | { 15 | headers: { 16 | cookie: `access_token=${access_token}`, 17 | 'Content-Type': 'application/json', 18 | }, 19 | } 20 | ); 21 | return response.data; 22 | }; 23 | -------------------------------------------------------------------------------- /server/controllers/error.controller.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'apollo-server-micro'; 2 | 3 | const handleCastError = (error: any) => { 4 | const message = `Invalid ${error.path}: ${error.value}`; 5 | throw new ValidationError(message); 6 | }; 7 | 8 | const handleValidationError = (error: any) => { 9 | const message = Object.values(error.errors).map((el: any) => el.message); 10 | throw new ValidationError(`Invalid input: ${message.join(', ')}`); 11 | }; 12 | 13 | const errorHandler = (err: any) => { 14 | if (err.name === 'CastError') handleCastError(err); 15 | if (err.name === 'ValidationError') handleValidationError(err); 16 | throw err; 17 | }; 18 | 19 | export default errorHandler; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "emitDecoratorMetadata": true, 18 | "experimentalDecorators": true, 19 | "strictPropertyInitialization": false 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx}', 5 | './client/components/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | theme: { 8 | extend: { 9 | colors: { 10 | 'ct-dark-600': '#222', 11 | 'ct-dark-200': '#e5e7eb', 12 | 'ct-dark-100': '#f5f6f7', 13 | 'ct-blue-600': '#2363eb', 14 | 'ct-yellow-600': '#f9d13e', 15 | }, 16 | fontFamily: { 17 | Poppins: ['Poppins, sans-serif'], 18 | }, 19 | container: { 20 | center: true, 21 | padding: '1rem', 22 | screens: { 23 | lg: '1125px', 24 | xl: '1125px', 25 | '2xl': '1125px', 26 | }, 27 | }, 28 | }, 29 | }, 30 | plugins: [], 31 | }; 32 | -------------------------------------------------------------------------------- /client/store/index.ts: -------------------------------------------------------------------------------- 1 | import create from 'zustand'; 2 | import { IUser } from '../lib/types'; 3 | 4 | type Store = { 5 | authUser: IUser | null; 6 | uploadingImage: boolean; 7 | pageLoading: boolean; 8 | setAuthUser: (user: IUser) => void; 9 | setUploadingImage: (isUploading: boolean) => void; 10 | setPageLoading: (isLoading: boolean) => void; 11 | }; 12 | 13 | const useStore = create((set) => ({ 14 | authUser: null, 15 | uploadingImage: false, 16 | pageLoading: false, 17 | setAuthUser: (user) => set((state) => ({ ...state, authUser: user })), 18 | setUploadingImage: (isUploading) => 19 | set((state) => ({ ...state, uploadingImage: isUploading })), 20 | setPageLoading: (isLoading) => 21 | set((state) => ({ ...state, pageLoading: isLoading })), 22 | })); 23 | 24 | export default useStore; 25 | -------------------------------------------------------------------------------- /server/models/post.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | ModelOptions, 4 | prop, 5 | Severity, 6 | } from '@typegoose/typegoose'; 7 | import type { Ref } from '@typegoose/typegoose'; 8 | import { User } from './user.model'; 9 | 10 | @ModelOptions({ 11 | schemaOptions: { 12 | timestamps: true, 13 | }, 14 | options: { 15 | allowMixed: Severity.ALLOW, 16 | }, 17 | }) 18 | export class Post { 19 | readonly _id: string; 20 | 21 | @prop({ required: true, unique: true }) 22 | title: string; 23 | 24 | @prop({ required: true }) 25 | content: string; 26 | 27 | @prop({ required: true }) 28 | category: string; 29 | 30 | @prop({ default: 'default.jpeg' }) 31 | image: string; 32 | 33 | @prop({ required: true, ref: () => User }) 34 | user: Ref; 35 | } 36 | 37 | const PostModel = getModelForClass(Post); 38 | export default PostModel; 39 | -------------------------------------------------------------------------------- /client/components/modals/post.modal.tsx: -------------------------------------------------------------------------------- 1 | import ReactDom from 'react-dom'; 2 | import React, { FC } from 'react'; 3 | 4 | type IPostModal = { 5 | openPostModal: boolean; 6 | setOpenPostModal: (openPostModal: boolean) => void; 7 | children: React.ReactNode; 8 | }; 9 | 10 | const PostModal: FC = ({ 11 | openPostModal, 12 | setOpenPostModal, 13 | children, 14 | }) => { 15 | if (!openPostModal) return null; 16 | return ReactDom.createPortal( 17 | <> 18 |
setOpenPostModal(false)} 21 | >
22 |
23 | {children} 24 |
25 | , 26 | document.getElementById('post-modal') as HTMLElement 27 | ); 28 | }; 29 | 30 | export default PostModal; 31 | -------------------------------------------------------------------------------- /client/components/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Spinner from './Spinner'; 3 | 4 | type LoadingButtonProps = { 5 | loading: boolean; 6 | btnColor?: string; 7 | textColor?: string; 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const LoadingButton: React.FC = ({ 12 | textColor = 'text-white', 13 | btnColor = 'bg-ct-yellow-600', 14 | children, 15 | loading = false, 16 | }) => { 17 | return ( 18 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /client/components/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useFormContext } from 'react-hook-form'; 3 | 4 | type FormInputProps = { 5 | label: string; 6 | name: string; 7 | type?: string; 8 | }; 9 | 10 | const FormInput: React.FC = ({ 11 | label, 12 | name, 13 | type = 'text', 14 | }) => { 15 | const { 16 | register, 17 | formState: { errors }, 18 | } = useFormContext(); 19 | return ( 20 |
21 | 24 | 30 | {errors[name] && ( 31 | 32 | {errors[name]?.message as string} 33 | 34 | )} 35 |
36 | ); 37 | }; 38 | 39 | export default FormInput; 40 | -------------------------------------------------------------------------------- /server/utils/connectDB.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | const localUri = process.env.MONGODB_LOCAL_URI as string; 4 | const connection: any = {}; 5 | export async function connectDB() { 6 | if (connection.isConnected) { 7 | console.log('DB is already connected'); 8 | return; 9 | } 10 | 11 | if (mongoose.connections.length > 0) { 12 | connection.isConnected = mongoose.connections[0].readyState; 13 | if (connection.isConnected === 1) { 14 | console.log('use previous connection'); 15 | return; 16 | } 17 | await mongoose.disconnect(); 18 | } 19 | 20 | const db = await mongoose.connect(localUri); 21 | console.log('🚀 MongoDB Database Connected Successfully'); 22 | connection.isConnected = db.connections[0].readyState; 23 | } 24 | 25 | export async function disconnectDB() { 26 | if (connection.isConnected) { 27 | if (process.env.NODE_ENV === 'production') { 28 | await mongoose.disconnect(); 29 | connection.isConnected = false; 30 | } else { 31 | console.log('not discounted'); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import 'react-toastify/dist/ReactToastify.css'; 3 | import type { AppProps } from 'next/app'; 4 | import { QueryClientProvider, Hydrate } from 'react-query'; 5 | import { ReactQueryDevtools } from 'react-query/devtools'; 6 | import { CookiesProvider } from 'react-cookie'; 7 | import { queryClient } from '../client/requests/graphqlRequestClient'; 8 | import PageLayout from '../client/components/Layout'; 9 | import { ToastContainer } from 'react-toastify'; 10 | 11 | function MyApp({ Component, pageProps }: AppProps) { 12 | return ( 13 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export default MyApp; 31 | -------------------------------------------------------------------------------- /client/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useFormContext } from 'react-hook-form'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | type TextInputProps = { 6 | label: string; 7 | name: string; 8 | type?: string; 9 | }; 10 | 11 | const TextInput: React.FC = ({ 12 | label, 13 | name, 14 | type = 'text', 15 | }) => { 16 | const { 17 | register, 18 | formState: { errors }, 19 | } = useFormContext(); 20 | return ( 21 |
22 | 25 | 33 |

39 | {errors[name]?.message as string} 40 |

41 |
42 | ); 43 | }; 44 | 45 | export default TextInput; 46 | -------------------------------------------------------------------------------- /server/resolvers/user.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql'; 2 | import { 3 | LoginInput, 4 | LoginResponse, 5 | SignUpInput, 6 | UserResponse, 7 | } from '../schemas/user.schema'; 8 | import UserService from '../services/user.service'; 9 | import type { Context } from '../types/context'; 10 | 11 | @Resolver() 12 | export default class UserResolver { 13 | constructor(private userService: UserService) { 14 | this.userService = new UserService(); 15 | } 16 | 17 | @Mutation(() => UserResponse) 18 | async signupUser(@Arg('input') input: SignUpInput) { 19 | return this.userService.signUpUser(input); 20 | } 21 | 22 | @Mutation(() => LoginResponse) 23 | async loginUser(@Arg('input') loginInput: LoginInput, @Ctx() ctx: Context) { 24 | return this.userService.loginUser(loginInput, ctx); 25 | } 26 | 27 | @Query(() => UserResponse) 28 | async getMe(@Ctx() ctx: Context) { 29 | return this.userService.getMe(ctx); 30 | } 31 | 32 | @Query(() => LoginResponse) 33 | async refreshAccessToken(@Ctx() ctx: Context) { 34 | return this.userService.refreshAccessToken(ctx); 35 | } 36 | 37 | @Query(() => Boolean) 38 | async logoutUser(@Ctx() ctx: Context) { 39 | return this.userService.logoutUser(ctx); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | prop, 4 | pre, 5 | ModelOptions, 6 | Severity, 7 | index, 8 | } from '@typegoose/typegoose'; 9 | import bcrypt from 'bcryptjs'; 10 | 11 | @pre('save', async function (next) { 12 | if (!this.isModified('password')) return next(); 13 | 14 | this.password = await bcrypt.hash(this.password, 12); 15 | this.passwordConfirm = undefined; 16 | return next(); 17 | }) 18 | @ModelOptions({ 19 | schemaOptions: { 20 | timestamps: true, 21 | }, 22 | options: { 23 | allowMixed: Severity.ALLOW, 24 | }, 25 | }) 26 | @index({ email: 1 }) 27 | export class User { 28 | readonly _id: string; 29 | 30 | @prop({ required: true }) 31 | name: string; 32 | 33 | @prop({ required: true, unique: true, lowercase: true }) 34 | email: string; 35 | 36 | @prop({ default: 'user' }) 37 | role: string; 38 | 39 | @prop({ required: true, select: false }) 40 | password: string; 41 | 42 | @prop({ required: true }) 43 | passwordConfirm: string | undefined; 44 | 45 | @prop({ default: 'default.jpeg' }) 46 | photo: string; 47 | 48 | @prop({ default: true, select: false }) 49 | verified: boolean; 50 | 51 | static async comparePasswords( 52 | hashedPassword: string, 53 | candidatePassword: string 54 | ) { 55 | return await bcrypt.compare(candidatePassword, hashedPassword); 56 | } 57 | } 58 | 59 | const UserModel = getModelForClass(User); 60 | export default UserModel; 61 | -------------------------------------------------------------------------------- /server/resolvers/post.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Arg, Args, Ctx, Mutation, Query, Resolver } from 'type-graphql'; 2 | import { 3 | PostFilter, 4 | PostInput, 5 | PostListResponse, 6 | PostPopulatedResponse, 7 | PostResponse, 8 | UpdatePostInput, 9 | } from '../schemas/post.schema'; 10 | import PostService from '../services/post.service'; 11 | import type { Context } from '../types/context'; 12 | 13 | @Resolver() 14 | export default class PostResolver { 15 | constructor(private postService: PostService) { 16 | this.postService = new PostService(); 17 | } 18 | 19 | @Mutation(() => PostResponse) 20 | async createPost(@Arg('input') input: PostInput, @Ctx() ctx: Context) { 21 | return this.postService.createPost(input, ctx); 22 | } 23 | 24 | @Query(() => PostPopulatedResponse) 25 | async getPost(@Arg('id') id: string, @Ctx() ctx: Context) { 26 | return this.postService.getPost(id, ctx); 27 | } 28 | 29 | @Mutation(() => PostResponse) 30 | async updatePost( 31 | @Arg('id') id: string, 32 | @Arg('input') input: UpdatePostInput, 33 | @Ctx() ctx: Context 34 | ) { 35 | return this.postService.updatePost(id, input, ctx); 36 | } 37 | 38 | @Query(() => PostListResponse) 39 | async getPosts( 40 | @Arg('input', { nullable: true }) input: PostFilter, 41 | @Ctx() ctx: Context 42 | ) { 43 | return this.postService.getPosts(input, ctx); 44 | } 45 | 46 | @Mutation(() => Boolean) 47 | async deletePost(@Arg('id') id: string, @Ctx() ctx: Context) { 48 | return this.postService.deletePost(id, ctx); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /server/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt, { SignOptions } from 'jsonwebtoken'; 2 | 3 | export const signJwt = ( 4 | payload: Object, 5 | keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey', 6 | options?: SignOptions 7 | ) => { 8 | const accessTokenPrivateKey = process.env.ACCESS_TOKEN_PRIVATE_KEY as string; 9 | const refreshTokenPrivateKey = process.env 10 | .REFRESH_TOKEN_PRIVATE_KEY as string; 11 | let privateKey = ''; 12 | if (keyName === 'accessTokenPrivateKey') { 13 | privateKey = Buffer.from(accessTokenPrivateKey, 'base64').toString('ascii'); 14 | } else if (keyName === 'refreshTokenPrivateKey') { 15 | privateKey = Buffer.from(refreshTokenPrivateKey, 'base64').toString( 16 | 'ascii' 17 | ); 18 | } 19 | 20 | return jwt.sign(payload, privateKey, { 21 | ...(options && options), 22 | algorithm: 'RS256', 23 | }); 24 | }; 25 | 26 | export const verifyJwt = ( 27 | token: string, 28 | keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey' 29 | ): T | null => { 30 | let publicKey = ''; 31 | const accessTokenPublicKey = process.env.ACCESS_TOKEN_PUBLIC_KEY as string; 32 | const refreshTokenPublicKey = process.env.REFRESH_TOKEN_PUBLIC_KEY as string; 33 | if (keyName === 'accessTokenPublicKey') { 34 | publicKey = Buffer.from(accessTokenPublicKey, 'base64').toString('ascii'); 35 | } else if (keyName === 'refreshTokenPublicKey') { 36 | publicKey = Buffer.from(refreshTokenPublicKey, 'base64').toString('ascii'); 37 | } 38 | 39 | try { 40 | return jwt.verify(token, publicKey, { 41 | algorithms: ['RS256'], 42 | }) as T; 43 | } catch (error) { 44 | console.log(error); 45 | return null; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /client/middleware/AuthMiddleware.tsx: -------------------------------------------------------------------------------- 1 | import { useCookies } from 'react-cookie'; 2 | import FullScreenLoader from '../components/FullScreenLoader'; 3 | import React from 'react'; 4 | import { GetMeQuery, useGetMeQuery } from '../generated/graphql'; 5 | import { gql } from 'graphql-request'; 6 | import graphqlRequestClient from '../requests/graphqlRequestClient'; 7 | import useStore from '../store'; 8 | import { IUser } from '../lib/types'; 9 | 10 | export const REFRESH_ACCESS_TOKEN = gql` 11 | query { 12 | refreshAccessToken { 13 | status 14 | access_token 15 | } 16 | } 17 | `; 18 | 19 | type AuthMiddlewareProps = { 20 | children: React.ReactElement; 21 | }; 22 | 23 | const AuthMiddleware: React.FC = ({ children }) => { 24 | const [cookies] = useCookies(['logged_in']); 25 | const store = useStore(); 26 | 27 | const query = useGetMeQuery( 28 | graphqlRequestClient, 29 | {}, 30 | { 31 | retry: 1, 32 | enabled: Boolean(cookies.logged_in), 33 | onSuccess: (data) => { 34 | store.setAuthUser(data.getMe.user as IUser); 35 | }, 36 | onError(error: any) { 37 | error.response.errors.forEach(async (err: any) => { 38 | if (err.message.includes('not logged in')) { 39 | try { 40 | await graphqlRequestClient.request(REFRESH_ACCESS_TOKEN); 41 | query.refetch(); 42 | } catch (error) { 43 | document.location.href = '/login'; 44 | } 45 | } 46 | }); 47 | }, 48 | } 49 | ); 50 | 51 | if (query.isLoading) { 52 | return ; 53 | } 54 | 55 | return children; 56 | }; 57 | 58 | export default AuthMiddleware; 59 | -------------------------------------------------------------------------------- /pages/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { ApolloServer } from 'apollo-server-micro'; 4 | import { buildSchema } from 'type-graphql'; 5 | import Cors from 'cors'; 6 | import { resolvers } from '../../server/resolvers'; 7 | import { connectDB } from '../../server/utils/connectDB'; 8 | import deserializeUser from '../../server/middleware/deserializeUser'; 9 | 10 | const cors = Cors({ 11 | methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], 12 | credentials: true, 13 | origin: [ 14 | 'https://studio.apollographql.com', 15 | 'http://localhost:8000', 16 | 'http://localhost:3000', 17 | ], 18 | }); 19 | 20 | function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: any) { 21 | return new Promise((resolve, reject) => { 22 | fn(req, res, (result: any) => { 23 | if (result instanceof Error) { 24 | return reject(result); 25 | } 26 | 27 | return resolve(result); 28 | }); 29 | }); 30 | } 31 | 32 | const schema = await buildSchema({ 33 | resolvers, 34 | dateScalarMode: 'isoDate', 35 | }); 36 | 37 | const server = new ApolloServer({ 38 | schema, 39 | csrfPrevention: true, 40 | context: ({ req, res }: { req: NextApiRequest; res: NextApiResponse }) => ({ 41 | req, 42 | res, 43 | deserializeUser, 44 | }), 45 | }); 46 | 47 | export const config = { 48 | api: { 49 | bodyParser: false, 50 | }, 51 | }; 52 | 53 | const startServer = server.start(); 54 | 55 | export default async function handler( 56 | req: NextApiRequest, 57 | res: NextApiResponse 58 | ) { 59 | await runMiddleware(req, res, cors); 60 | await connectDB(); 61 | await startServer; 62 | await server.createHandler({ path: '/api/graphql' })(req, res); 63 | } 64 | -------------------------------------------------------------------------------- /client/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { twMerge } from 'tailwind-merge'; 3 | type SpinnerProps = { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | bgColor?: string; 8 | }; 9 | const Spinner: React.FC = ({ 10 | width = 5, 11 | height = 5, 12 | color, 13 | bgColor, 14 | }) => { 15 | return ( 16 | 26 | 30 | 34 | 35 | ); 36 | }; 37 | 38 | export default Spinner; 39 | -------------------------------------------------------------------------------- /server/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType, ObjectType } from 'type-graphql'; 2 | import { IsEmail, MaxLength, MinLength } from 'class-validator'; 3 | 4 | @InputType() 5 | export class SignUpInput { 6 | @Field(() => String) 7 | name: string; 8 | 9 | @IsEmail() 10 | @Field(() => String) 11 | email: string; 12 | 13 | @MinLength(8, { message: 'Password must be at least 8 characters long' }) 14 | @MaxLength(32, { message: 'Password must be at most 32 characters long' }) 15 | @Field(() => String) 16 | password: string; 17 | 18 | @Field(() => String) 19 | passwordConfirm: string | undefined; 20 | 21 | @Field(() => String) 22 | photo: string; 23 | } 24 | 25 | @InputType() 26 | export class LoginInput { 27 | @IsEmail() 28 | @Field(() => String) 29 | email: string; 30 | 31 | @MinLength(8, { message: 'Invalid email or password' }) 32 | @MaxLength(32, { message: 'Invalid email or password' }) 33 | @Field(() => String) 34 | password: string; 35 | } 36 | 37 | @ObjectType() 38 | export class UserData { 39 | @Field(() => String) 40 | readonly _id: string; 41 | 42 | @Field(() => String, { nullable: true }) 43 | readonly id: string; 44 | 45 | @Field(() => String) 46 | name: string; 47 | 48 | @Field(() => String) 49 | email: string; 50 | 51 | @Field(() => String) 52 | role: string; 53 | 54 | @Field(() => String) 55 | photo: string; 56 | 57 | @Field(() => Date) 58 | createdAt: Date; 59 | 60 | @Field(() => Date) 61 | updatedAt: Date; 62 | } 63 | 64 | @ObjectType() 65 | export class UserResponse { 66 | @Field(() => String) 67 | status: string; 68 | 69 | @Field(() => UserData) 70 | user: UserData; 71 | } 72 | 73 | @ObjectType() 74 | export class LoginResponse { 75 | @Field(() => String) 76 | status: string; 77 | 78 | @Field(() => String) 79 | access_token: string; 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-typegraphql", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "generate": "graphql-codegen --config codegen.yml" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^2.9.2", 14 | "@typegoose/typegoose": "^9.9.0", 15 | "apollo-server-micro": "^3.9.0", 16 | "axios": "^0.27.2", 17 | "bcryptjs": "^2.4.3", 18 | "class-validator": "^0.13.2", 19 | "cookies-next": "^2.0.4", 20 | "cors": "^2.8.5", 21 | "date-fns": "^2.28.0", 22 | "dotenv-safe": "^8.2.0", 23 | "graphql": "15.x", 24 | "graphql-request": "^4.3.0", 25 | "jsonwebtoken": "^8.5.1", 26 | "micro": "^9.3.4", 27 | "mongoose": "^6.4.0", 28 | "next": "^12.2.0", 29 | "react": "18.2.0", 30 | "react-cookie": "^4.1.1", 31 | "react-dom": "18.2.0", 32 | "react-hook-form": "^7.33.0", 33 | "react-query": "^3.39.1", 34 | "react-toastify": "^9.0.5", 35 | "redis": "^4.1.0", 36 | "reflect-metadata": "^0.1.13", 37 | "tailwind-merge": "^1.3.0", 38 | "type-graphql": "^1.1.1", 39 | "zod": "^3.17.3", 40 | "zustand": "^4.0.0-rc.1" 41 | }, 42 | "devDependencies": { 43 | "@graphql-codegen/cli": "^2.6.2", 44 | "@graphql-codegen/typescript": "^2.5.1", 45 | "@graphql-codegen/typescript-operations": "^2.4.2", 46 | "@graphql-codegen/typescript-react-query": "^3.5.14", 47 | "@types/bcryptjs": "^2.4.2", 48 | "@types/dotenv-safe": "^8.1.2", 49 | "@types/jsonwebtoken": "^8.5.8", 50 | "@types/node": "18.0.0", 51 | "@types/react": "18.0.14", 52 | "@types/react-dom": "18.0.5", 53 | "autoprefixer": "^10.4.7", 54 | "eslint": "8.18.0", 55 | "eslint-config-next": "12.1.6", 56 | "postcss": "^8.4.14", 57 | "tailwindcss": "^3.1.4", 58 | "typescript": "4.7.4" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/middleware/deserializeUser.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError, ForbiddenError } from 'apollo-server-micro'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { checkCookies, getCookie } from 'cookies-next'; 4 | import errorHandler from '../controllers/error.controller'; 5 | import UserModel from '../models/user.model'; 6 | import redisClient from '../utils/connectRedis'; 7 | import { verifyJwt } from '../utils/jwt'; 8 | import { disconnectDB } from '../utils/connectDB'; 9 | 10 | const deserializeUser = async (req: NextApiRequest, res: NextApiResponse) => { 11 | try { 12 | // Get the access token 13 | let access_token; 14 | if ( 15 | req.headers.authorization && 16 | req.headers.authorization.startsWith('Bearer') 17 | ) { 18 | access_token = req.headers.authorization.split(' ')[1]; 19 | } else if (checkCookies('access_token', { req, res })) { 20 | access_token = getCookie('access_token', { req, res }); 21 | } 22 | 23 | if (!access_token) throw new AuthenticationError('No access token found'); 24 | 25 | // Validate the Access token 26 | const decoded = verifyJwt<{ userId: string }>( 27 | String(access_token), 28 | 'accessTokenPublicKey' 29 | ); 30 | 31 | if (!decoded) throw new AuthenticationError('Invalid access token'); 32 | 33 | // Check if the session is valid 34 | const session = await redisClient.get(decoded.userId); 35 | 36 | if (!session) throw new ForbiddenError('Session has expired'); 37 | 38 | // Check if user exist 39 | const user = await UserModel.findById(JSON.parse(session)._id) 40 | .select('+verified') 41 | .lean(true); 42 | await disconnectDB(); 43 | 44 | if (!user || !user.verified) { 45 | throw new ForbiddenError( 46 | 'The user belonging to this token no logger exist' 47 | ); 48 | } 49 | 50 | return user; 51 | } catch (error: any) { 52 | errorHandler(error); 53 | } 54 | }; 55 | 56 | export default deserializeUser; 57 | -------------------------------------------------------------------------------- /client/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { IUser } from '../context/types'; 3 | import { 4 | useGetMeQuery, 5 | useRefreshAccessTokenQuery, 6 | } from '../generated/graphql'; 7 | import graphqlRequestClient, { 8 | queryClient, 9 | } from '../requests/graphqlRequestClient'; 10 | import useStore from '../store'; 11 | import Header from './Header'; 12 | 13 | type LayoutProps = { 14 | children: React.ReactNode; 15 | requireAuth?: boolean; 16 | enableAuth?: boolean; 17 | }; 18 | 19 | const PageLayout: React.FC = ({ 20 | children, 21 | requireAuth, 22 | enableAuth, 23 | }) => { 24 | const store = useStore(); 25 | const query = useRefreshAccessTokenQuery( 26 | graphqlRequestClient, 27 | {}, 28 | { 29 | enabled: false, 30 | retry: 1, 31 | onError(error: any) { 32 | store.setPageLoading(false); 33 | document.location.href = '/login'; 34 | }, 35 | onSuccess(data: any) { 36 | store.setPageLoading(false); 37 | queryClient.refetchQueries('getMe'); 38 | }, 39 | } 40 | ); 41 | const { isLoading, isFetching } = useGetMeQuery( 42 | graphqlRequestClient, 43 | {}, 44 | { 45 | onSuccess: (data) => { 46 | store.setPageLoading(false); 47 | store.setAuthUser(data.getMe.user as IUser); 48 | }, 49 | retry: 1, 50 | enabled: !!enableAuth, 51 | onError(error: any) { 52 | store.setPageLoading(false); 53 | error.response.errors.forEach((err: any) => { 54 | if (err.message.includes('No access token found')) { 55 | query.refetch({ throwOnError: true }); 56 | } 57 | }); 58 | }, 59 | } 60 | ); 61 | 62 | const loading = 63 | isLoading || isFetching || query.isLoading || query.isFetching; 64 | 65 | useEffect(() => { 66 | if (loading) { 67 | store.setPageLoading(true); 68 | } 69 | // eslint-disable-next-line react-hooks/exhaustive-deps 70 | }, [loading]); 71 | 72 | return <>{children}; 73 | }; 74 | 75 | export default PageLayout; 76 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, NextPage } from 'next'; 2 | import { useEffect } from 'react'; 3 | import { dehydrate } from 'react-query'; 4 | import { toast } from 'react-toastify'; 5 | import Header from '../client/components/Header'; 6 | import Message from '../client/components/Message'; 7 | import PostItem from '../client/components/posts/post.component'; 8 | import { 9 | GetMeDocument, 10 | useGetAllPostsQuery, 11 | } from '../client/generated/graphql'; 12 | import { axiosGetMe } from '../client/requests/axiosClient'; 13 | import graphqlRequestClient, { 14 | queryClient, 15 | } from '../client/requests/graphqlRequestClient'; 16 | import useStore from '../client/store'; 17 | 18 | export const getServerSideProps: GetServerSideProps = async ({ req }) => { 19 | if (req.cookies.access_token) { 20 | await queryClient.prefetchQuery(['getMe', {}], () => 21 | axiosGetMe(GetMeDocument, req.cookies.access_token as string) 22 | ); 23 | } else { 24 | return { 25 | redirect: { 26 | destination: '/login', 27 | permanent: false, 28 | }, 29 | }; 30 | } 31 | return { 32 | props: { 33 | dehydratedState: dehydrate(queryClient), 34 | requireAuth: true, 35 | enableAuth: true, 36 | }, 37 | }; 38 | }; 39 | 40 | const HomePage: NextPage = () => { 41 | const store = useStore(); 42 | const { data: posts, isLoading } = useGetAllPostsQuery( 43 | graphqlRequestClient, 44 | { 45 | input: { limit: 10, page: 1 }, 46 | }, 47 | { 48 | select: (data) => data.getPosts.posts, 49 | onError(error: any) { 50 | store.setPageLoading(false); 51 | error.response.errors.forEach((err: any) => { 52 | toast(err.message, { 53 | type: 'error', 54 | position: 'top-right', 55 | }); 56 | }); 57 | }, 58 | } 59 | ); 60 | 61 | useEffect(() => { 62 | if (isLoading) { 63 | store.setPageLoading(true); 64 | } 65 | // eslint-disable-next-line react-hooks/exhaustive-deps 66 | }, [isLoading]); 67 | return ( 68 | <> 69 |
70 |
71 |
72 | {posts?.length === 0 ? ( 73 | There are no posts at the moment 74 | ) : ( 75 |
76 | {posts?.map((post) => ( 77 | 78 | ))} 79 |
80 | )} 81 |
82 |
83 | 84 | ); 85 | }; 86 | 87 | export default HomePage; 88 | -------------------------------------------------------------------------------- /pages/profile.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, NextPage } from 'next'; 2 | import { dehydrate } from 'react-query'; 3 | import Header from '../client/components/Header'; 4 | import { 5 | GetMeDocument, 6 | GetMeQuery, 7 | useGetMeQuery, 8 | } from '../client/generated/graphql'; 9 | import { IUser } from '../client/lib/types'; 10 | import { REFRESH_ACCESS_TOKEN } from '../client/middleware/AuthMiddleware'; 11 | import { axiosGetMe } from '../client/requests/axiosClient'; 12 | import graphqlRequestClient, { 13 | queryClient, 14 | } from '../client/requests/graphqlRequestClient'; 15 | import useStore from '../client/store'; 16 | 17 | type ProfileProps = {}; 18 | 19 | const ProfilePage: NextPage = ({}) => { 20 | const store = useStore(); 21 | 22 | const user = store.authUser; 23 | const query = useGetMeQuery( 24 | graphqlRequestClient, 25 | {}, 26 | { 27 | retry: 1, 28 | onSuccess: (data) => { 29 | store.setAuthUser(data.getMe.user as IUser); 30 | }, 31 | onError(error: any) { 32 | error.response.errors.forEach(async (err: any) => { 33 | if (err.message.includes('not logged in')) { 34 | try { 35 | await graphqlRequestClient.request(REFRESH_ACCESS_TOKEN); 36 | query.refetch(); 37 | } catch (error) { 38 | document.location.href = '/login'; 39 | } 40 | } 41 | }); 42 | }, 43 | } 44 | ); 45 | 46 | return ( 47 | <> 48 |
49 |
50 |
51 |
52 |

Profile Page

53 |
54 |

ID: {user?.id}

55 |

Name: {user?.name}

56 |

Email: {user?.email}

57 |

Role: {user?.role}

58 |
59 |
60 |
61 |
62 | 63 | ); 64 | }; 65 | 66 | export const getServerSideProps: GetServerSideProps = async ({ req }) => { 67 | if (req.cookies.access_token) { 68 | await queryClient.prefetchQuery(['getMe', {}], () => 69 | axiosGetMe(GetMeDocument, req.cookies.access_token as string) 70 | ); 71 | } else { 72 | return { 73 | redirect: { 74 | destination: '/login', 75 | permanent: false, 76 | }, 77 | }; 78 | } 79 | 80 | return { 81 | props: { 82 | dehydratedState: dehydrate(queryClient), 83 | requireAuth: true, 84 | enableAuth: true, 85 | }, 86 | }; 87 | }; 88 | 89 | export default ProfilePage; 90 | -------------------------------------------------------------------------------- /server/schemas/post.schema.ts: -------------------------------------------------------------------------------- 1 | import { MinLength } from 'class-validator'; 2 | import { Field, InputType, ObjectType } from 'type-graphql'; 3 | import { UserData } from './user.schema'; 4 | 5 | @InputType() 6 | export class UpdatePostInput { 7 | @MinLength(10, { message: 'Title must be at least 10 characters long' }) 8 | @Field(() => String, { nullable: true }) 9 | title: string; 10 | 11 | @MinLength(10, { message: 'Content must be at least 10 characters long' }) 12 | @Field(() => String, { nullable: true }) 13 | content: string; 14 | 15 | @Field(() => String, { nullable: true }) 16 | category: string; 17 | 18 | @Field(() => String, { nullable: true }) 19 | image: string; 20 | } 21 | 22 | @InputType() 23 | export class PostInput { 24 | @MinLength(10, { message: 'Title must be at least 10 characters long' }) 25 | @Field(() => String) 26 | title: string; 27 | 28 | @MinLength(10, { message: 'Content must be at least 10 characters long' }) 29 | @Field(() => String) 30 | content: string; 31 | 32 | @Field(() => String) 33 | category: string; 34 | 35 | @Field(() => String) 36 | image: string; 37 | } 38 | 39 | @InputType() 40 | export class PostFilter { 41 | @Field(() => Number, { nullable: true, defaultValue: 1 }) 42 | page: number; 43 | 44 | @Field(() => Number, { nullable: true, defaultValue: 10 }) 45 | limit: number; 46 | } 47 | 48 | @ObjectType() 49 | export class PostDataObj { 50 | @Field(() => String) 51 | readonly _id: string; 52 | 53 | @Field(() => String, { nullable: true }) 54 | readonly id: string; 55 | 56 | @Field(() => String) 57 | title: string; 58 | 59 | @Field(() => String) 60 | category: string; 61 | 62 | @Field(() => String) 63 | content: string; 64 | 65 | @Field(() => String) 66 | image: string; 67 | 68 | @Field(() => Date) 69 | createdAt: Date; 70 | 71 | @Field(() => Date) 72 | updatedAt: Date; 73 | } 74 | 75 | @ObjectType() 76 | export class PostPopulatedData extends PostDataObj { 77 | @Field(() => UserData) 78 | user: UserData; 79 | } 80 | 81 | @ObjectType() 82 | export class PostData extends PostDataObj { 83 | @Field(() => String) 84 | user: string; 85 | } 86 | 87 | @ObjectType() 88 | export class PostResponse { 89 | @Field(() => String) 90 | status: string; 91 | 92 | @Field(() => PostData) 93 | post: PostData; 94 | } 95 | 96 | @ObjectType() 97 | export class PostPopulatedResponse { 98 | @Field(() => String) 99 | status: string; 100 | 101 | @Field(() => PostPopulatedData) 102 | post: PostPopulatedData; 103 | } 104 | 105 | @ObjectType() 106 | export class PostListResponse { 107 | @Field(() => String) 108 | status: string; 109 | 110 | @Field(() => Number) 111 | results: number; 112 | 113 | @Field(() => [PostPopulatedData]) 114 | posts: PostPopulatedData[]; 115 | } 116 | -------------------------------------------------------------------------------- /client/components/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { Controller, useController, useFormContext } from 'react-hook-form'; 3 | import useStore from '../store'; 4 | import Spinner from './Spinner'; 5 | 6 | type FileUpLoaderProps = { 7 | name: string; 8 | }; 9 | const FileUpLoader: React.FC = ({ name }) => { 10 | const { 11 | control, 12 | formState: { errors }, 13 | } = useFormContext(); 14 | const { field } = useController({ name, control }); 15 | const store = useStore(); 16 | 17 | const onFileDrop = useCallback( 18 | async (e: React.SyntheticEvent) => { 19 | const target = e.target as HTMLInputElement; 20 | if (!target.files || target.files.length === 0) return; 21 | const newFile = Object.values(target.files).map((file: File) => file); 22 | const formData = new FormData(); 23 | formData.append('file', newFile[0]); 24 | formData.append('upload_preset', 'nextjs-typegraphql'); 25 | 26 | store.setUploadingImage(true); 27 | const data = await fetch( 28 | 'https://api.cloudinary.com/v1_1/Codevo/image/upload', 29 | { 30 | method: 'POST', 31 | body: formData, 32 | } 33 | ) 34 | .then((res) => { 35 | store.setUploadingImage(false); 36 | 37 | return res.json(); 38 | }) 39 | .catch((err) => { 40 | store.setUploadingImage(false); 41 | console.log(err); 42 | }); 43 | 44 | if (data.secure_url) { 45 | field.onChange(data.secure_url); 46 | } 47 | }, 48 | 49 | [field, store] 50 | ); 51 | 52 | return ( 53 | ( 58 | <> 59 |
60 |
61 | Choose profile photo 62 | 72 |
73 |
74 | {store.uploadingImage && } 75 |
76 |
77 |

82 | {errors[name] && (errors[name]?.message as string)} 83 |

84 | 85 | )} 86 | /> 87 | ); 88 | }; 89 | 90 | export default FileUpLoader; 91 | -------------------------------------------------------------------------------- /server/services/post.service.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'apollo-server-core'; 2 | import errorHandler from '../controllers/error.controller'; 3 | import deserializeUser from '../middleware/deserializeUser'; 4 | import { PostFilter, PostInput } from '../schemas/post.schema'; 5 | import PostModel from '../models/post.model'; 6 | import { Context } from '../types/context'; 7 | 8 | export default class PostService { 9 | async createPost(input: Partial, { req, res }: Context) { 10 | try { 11 | const user = await deserializeUser(req, res); 12 | const post = await PostModel.create({ ...input, user: user?._id }); 13 | return { 14 | status: 'success', 15 | post: { 16 | ...post.toJSON(), 17 | id: post?._id, 18 | }, 19 | }; 20 | } catch (error: any) { 21 | if (error.code === 11000) 22 | throw new ValidationError('Post with that title already exist'); 23 | errorHandler(error); 24 | } 25 | } 26 | 27 | async getPost(id: string, { req, res }: Context) { 28 | try { 29 | await deserializeUser(req, res); 30 | const post = await PostModel.findById(id).populate('user').lean(); 31 | 32 | if (!post) return new ValidationError('No post with that id exists'); 33 | 34 | return { 35 | status: 'success', 36 | post, 37 | }; 38 | } catch (error: any) { 39 | errorHandler(error); 40 | } 41 | } 42 | 43 | async updatePost( 44 | id: string, 45 | input: Partial, 46 | { req, res }: Context 47 | ) { 48 | try { 49 | const user = await deserializeUser(req, res); 50 | const post = await PostModel.findByIdAndUpdate( 51 | id, 52 | { ...input, user: user?._id }, 53 | { 54 | new: true, 55 | runValidators: true, 56 | lean: true, 57 | } 58 | ); 59 | 60 | if (!post) return new ValidationError('No post with that id exists'); 61 | return { 62 | status: 'success', 63 | post: { 64 | ...post, 65 | id: post?._id, 66 | }, 67 | }; 68 | } catch (error: any) { 69 | errorHandler(error); 70 | } 71 | } 72 | 73 | async getPosts(input: PostFilter, { req, res }: Context) { 74 | try { 75 | const user = await deserializeUser(req, res); 76 | const postsQuery = PostModel.find({ user: user?._id }).populate('user'); 77 | 78 | // Pagination 79 | const page = input.page || 1; 80 | const limit = input.limit || 10; 81 | const skip = (page - 1) * limit; 82 | 83 | const posts = await postsQuery 84 | .sort({ createdAt: -1 }) 85 | .skip(skip) 86 | .limit(limit) 87 | .lean(); 88 | return { 89 | status: 'success', 90 | results: posts.length, 91 | posts, 92 | }; 93 | } catch (error: any) { 94 | errorHandler(error); 95 | } 96 | } 97 | 98 | async deletePost(id: string, { req, res }: Context) { 99 | try { 100 | await deserializeUser(req, res); 101 | const post = await PostModel.findByIdAndDelete(id); 102 | 103 | if (!post) return new ValidationError('No post with that id exists'); 104 | 105 | return true; 106 | } catch (error: any) { 107 | errorHandler(error); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /client/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { useState } from 'react'; 3 | import { useQueryClient } from 'react-query'; 4 | import { toast } from 'react-toastify'; 5 | import { LogoutUserQuery, useLogoutUserQuery } from '../generated/graphql'; 6 | import graphqlRequestClient from '../requests/graphqlRequestClient'; 7 | import useStore from '../store'; 8 | import PostModal from './modals/post.modal'; 9 | import CreatePost from './posts/create.post'; 10 | import Spinner from './Spinner'; 11 | 12 | const Header = () => { 13 | const [openPostModal, setOpenPostModal] = useState(false); 14 | const store = useStore(); 15 | const user = store.authUser; 16 | 17 | const queryClient = useQueryClient(); 18 | const { refetch } = useLogoutUserQuery( 19 | graphqlRequestClient, 20 | {}, 21 | { 22 | enabled: false, 23 | onSuccess(data: LogoutUserQuery) { 24 | queryClient.clear(); 25 | document.location.href = '/login'; 26 | }, 27 | onError(error: any) { 28 | error.response.errors.forEach((err: any) => { 29 | toast(err.message, { 30 | type: 'error', 31 | position: 'top-right', 32 | }); 33 | queryClient.clear(); 34 | document.location.href = '/login'; 35 | }); 36 | }, 37 | } 38 | ); 39 | 40 | const handleLogout = () => { 41 | refetch(); 42 | }; 43 | 44 | return ( 45 | <> 46 |
47 | 93 |
94 | 98 | 99 | 100 |
101 | {store.pageLoading && } 102 |
103 | 104 | ); 105 | }; 106 | 107 | export default Header; 108 | -------------------------------------------------------------------------------- /client/components/posts/create.post.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react'; 2 | import { FormProvider, SubmitHandler, useForm } from 'react-hook-form'; 3 | import { twMerge } from 'tailwind-merge'; 4 | import { object, string, TypeOf } from 'zod'; 5 | import { zodResolver } from '@hookform/resolvers/zod'; 6 | import FileUpLoader from '../FileUpload'; 7 | import { LoadingButton } from '../LoadingButton'; 8 | import TextInput from '../TextInput'; 9 | import { useCreatePostMutation } from '../../generated/graphql'; 10 | import graphqlRequestClient, { 11 | queryClient, 12 | } from '../../requests/graphqlRequestClient'; 13 | import { toast } from 'react-toastify'; 14 | import useStore from '../../store'; 15 | 16 | const createPostSchema = object({ 17 | title: string().min(1, 'Title is required'), 18 | category: string().min(1, 'Category is required'), 19 | content: string().min(1, 'Content is required'), 20 | image: string().min(1, 'Image is required'), 21 | }); 22 | 23 | type CreatePostInput = TypeOf; 24 | 25 | type ICreatePostProp = { 26 | setOpenPostModal: (openPostModal: boolean) => void; 27 | }; 28 | 29 | const CreatePost: FC = ({ setOpenPostModal }) => { 30 | const store = useStore(); 31 | const { isLoading, mutate: createPost } = useCreatePostMutation( 32 | graphqlRequestClient, 33 | { 34 | onSuccess(data) { 35 | store.setPageLoading(false); 36 | setOpenPostModal(false); 37 | queryClient.refetchQueries('GetAllPosts'); 38 | toast('Post created successfully', { 39 | type: 'success', 40 | position: 'top-right', 41 | }); 42 | }, 43 | onError(error: any) { 44 | store.setPageLoading(false); 45 | setOpenPostModal(false); 46 | error.response.errors.forEach((err: any) => { 47 | toast(err.message, { 48 | type: 'error', 49 | position: 'top-right', 50 | }); 51 | }); 52 | }, 53 | } 54 | ); 55 | const methods = useForm({ 56 | resolver: zodResolver(createPostSchema), 57 | }); 58 | 59 | const { 60 | register, 61 | handleSubmit, 62 | formState: { errors }, 63 | } = methods; 64 | 65 | useEffect(() => { 66 | if (isLoading) { 67 | store.setPageLoading(true); 68 | } 69 | // eslint-disable-next-line react-hooks/exhaustive-deps 70 | }, [isLoading]); 71 | 72 | const onSubmitHandler: SubmitHandler = async (data) => { 73 | createPost({ input: data }); 74 | }; 75 | return ( 76 |
77 |

Create Post

78 | 79 | 80 |
81 | 82 | 83 |
84 | 87 |