diff --git a/.gitignore b/.gitignore index f9418f1b..50bc8c7d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ Thumbs.db .nx/cache .angular + +.env.local diff --git a/apps/api/README.md b/apps/api/README.md index 658178b6..308984a2 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -6,11 +6,11 @@ 2. Make sure to fork all the branches (You need to unselect the checkbox of fork only main branch) 3. You can clone the repository to your local or you can create a codespace in github (We suggest to use your local because we will use postman to test it) 4. Checkout to branch `node/template` this is the starter boilerplate - - `git checkout node/template` + - `git checkout node/template` 5. From here you can create your own branch (We suggest to name it `node/`) - - `git checkout -b node/` + - `git checkout -b node/` 6. We will be working with some examples during the sessions in this same repository, once we finish with the session you can get the example from the branch `node/session-*` and merge it to your branch (We will provide the branch after each session) - - `git merge node/session-*` + - `git merge node/session-*` 7. Each session branch will have the challenges to accomplish and the expected results. You can validate if your endpoint is correct by [running the postman collection](#run-postman-collection) 8. After finish the challenges you need to create a pull request to the base repository, you will have a branch with your EID (`node/`). If you don't know how to do it you can check this [quick guide](#create-pull-request) @@ -24,27 +24,30 @@ ### Session 01 - Create `route` for `posts` endpoint with the following methods: - - `GET /posts` Return an array of all the posts with status code 200 - - `GET /posts/category/:category` Return an array of all the posts by category with status code 200 - - `GET /posts/:id` Return a post by id with category object and each comment object in the array with status code 200 - - `POST /posts` Create a new post and return the created post with status code 201 - - `POST /posts/:id/comments` Create a comment inside the post and return the comment with status code 201 - - `PATCH /posts/:id` Update post information and return the updated post with status code 200 - - `DELETE /posts/:id` Delete the post and return the deleted post with status code 200 or 204 if you decide to not return anything - * *Add 404 validation where needed* + + - `GET /posts` Return an array of all the posts with status code 200 + - `GET /posts/category/:category` Return an array of all the posts by category with status code 200 + - `GET /posts/:id` Return a post by id with category object and each comment object in the array with status code 200 + - `POST /posts` Create a new post and return the created post with status code 201 + - `POST /posts/:id/comments` Create a comment inside the post and return the comment with status code 201 + - `PATCH /posts/:id` Update post information and return the updated post with status code 200 + - `DELETE /posts/:id` Delete the post and return the deleted post with status code 200 or 204 if you decide to not return anything + + * _Add 404 validation where needed_ - Post model - - id: string - - title: string - - image: string - - description: string - - category: string *Id of the category* - - comments: array *Array of comment ids* + + - id: string + - title: string + - image: string + - description: string + - category: string _Id of the category_ + - comments: array _Array of comment ids_ - Comment model - - id: string - - author: string - - content: string + - id: string + - author: string + - content: string ### Session 02 @@ -64,9 +67,9 @@ - Connect to MongoDB database using mongoose - Create models for Post and Comment - Refactor the controller to retrieve information from database - - *Tip: Use `populate` method to get data from reference id* + - _Tip: Use `populate` method to get data from reference id_ - **Extra** - - Remove post comments from database when you delete the post + - Remove post comments from database when you delete the post ## How to @@ -75,26 +78,28 @@ 1. Download postman collection from `apps/api/src/assets/mfee-node.postman_collection.json` 2. Import collection to postman 3. Configure url variable if needed. We set the default value to `http://localhost:3000` if you change the port you will need to update this, after updating the value you need to save it with `Ctrl + S` -![Postman - Variables](assets/postman-variables.png) + ![Postman - Variables](assets/postman-variables.png) 4. Once everything is in place you go to the folder you want to run and click "Run" -![Postman - Open tests](assets/postman-open-tests.png) + ![Postman - Open tests](assets/postman-open-tests.png) 5. The endpoints will be displayed and you just need to click on "Run MFEE - Node.js" -![Postman - Run tests](assets/postman-run-tests.png) + ![Postman - Run tests](assets/postman-run-tests.png) 6. After this a report will be show and all the test should pass -![Postman - Test results](assets/postman-test-results.png) + ![Postman - Test results](assets/postman-test-results.png) ### Create pull request 1. Once you have your commit in place and you push your branch to your forked repository go to `Pull requests` option and then `Create pull request` -![GitHub - Create pull request](assets/github-create-pull-request.png) + ![GitHub - Create pull request](assets/github-create-pull-request.png) 2. Click under `compare across forks` to be able to select the main repository and make sure to select the following - - Base repository: `gus-code/mfee-project` - - Base: `node/` - - Head repository: `/mfee-project` - - Compare: `` - - ![GitHub - Pull request branches](assets/github-pull-request-branches.png) + + - Base repository: `gus-code/mfee-project` + - Base: `node/` + - Head repository: `/mfee-project` + - Compare: `` + + ![GitHub - Pull request branches](assets/github-pull-request-branches.png) + 3. Check that the files you worked on are in place and then click `Create pull request` -4. Add the title with the following format `feat(session-*): ` where * is the number of the session (01, 02, etc.). After that click again on `Create pull request` -![GitHub - Add title](assets/github-add-title.png) -5. After this we will review the PR, give feedback and merge it to your branch \ No newline at end of file +4. Add the title with the following format `feat(session-*): ` where \* is the number of the session (01, 02, etc.). After that click again on `Create pull request` + ![GitHub - Add title](assets/github-add-title.png) +5. After this we will review the PR, give feedback and merge it to your branch diff --git a/apps/api/src/api.http b/apps/api/src/api.http new file mode 100644 index 00000000..dfad8a4c --- /dev/null +++ b/apps/api/src/api.http @@ -0,0 +1,106 @@ +// //////////////////// // +// CATEGORIES // +// //////////////////// // + +### GET CATEGORIES +GET http://localhost:3000/api/categories +Authorization: Bearer [ACCESS_TOKEN] + +### GET NOT VALID ROUTE +GET http://localhost:3000/api/not-valid-route + +### GET CATEGORIES BY ID +PATCH http://localhost:3000/api/categories/cat_1 +Authorization: Bearer [ACCESS_TOKEN] +Content-Type: application/json + +{ + "name": "Other" +} + +// //////////////////// // +// POSTS // +// //////////////////// // + +### 1. GET ALL POSTS +GET http://localhost:3000/api/posts +Authorization: Bearer [ACCESS_TOKEN] + +### 2. GET POSTS BY CATEGORY +GET http://localhost:3000/api/posts/category/cat_1 +Authorization: Bearer [ACCESS_TOKEN] + +### 3. GET POST BY ID +GET http://localhost:3000/api/posts/a47f5337-16f2-49a0-bc14-e97bb24b56a9 +Authorization: Bearer [ACCESS_TOKEN] + +### 4. CREATE POST +POST http://localhost:3000/api/posts +Authorization: Bearer [ACCESS_TOKEN] +Content-Type: application/json + +{ + "title": "Post Test Postman", + "image": "https://images.unsplash.com/photo-1556276797-5086e6b45ff9?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=600&ixid=eyJhcHBfaWQiOjF9&ixlib=rb-1.2.1&q=80&w=800", + "description": "Description from Postman", + "category": "1" +} + +### 5. CREATE POST COMMENT +POST http://localhost:3000/api/posts/a47f5337-16f2-49a0-bc14-e97bb24b56a9/comments +Authorization: Bearer [ACCESS_TOKEN] +Content-Type: application/json + +{ + "author": "MFEE", + "content": "Good content" +} + +### 6. UPDATE POST +PATCH http://localhost:3000/api/posts/a47f5337-16f2-49a0-bc14-e97bb24b56a9 +Authorization: Bearer [ACCESS_TOKEN] +Content-Type: application/json + +{ + "title": "Test Postman NEW", + "description": "Description NEW" +} + +### 7. DELETE POST +DELETE http://localhost:3000/api/posts/a47f5337-16f2-49a0-bc14-e97bb24b56a9 +Authorization: Bearer [ACCESS_TOKEN] + +// //////////////////// // +// AUTH // +// //////////////////// // + +### REGISTER +POST http://localhost:3000/api/auth/register +Content-Type: application/json + +{ + "username": "mfee-test", + "password": "Aa$123" +} + +### LOGIN +POST http://localhost:3000/api/auth/login +Content-Type: application/json + +{ + "username": "mfee-test", + "password": "Aa$123" +} + +### REFRESH +POST http://localhost:3000/api/auth/refresh +Content-Type: application/json + +{ + "username": "mfee-test", + "password": "Aa$123" +} + +### LOGOUT +POST http://localhost:3000/api/auth/logout +Content-Type: application/json \ No newline at end of file diff --git a/apps/api/src/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts index 045cf517..b3b4edd9 100644 --- a/apps/api/src/config/corsConfig.ts +++ b/apps/api/src/config/corsConfig.ts @@ -1,9 +1,9 @@ const allowedOrigins = ['http://localhost:4200', 'http://localhost:3000']; export const corsOptions = { - origin: (origin, callback) => { + origin: (origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { // Note: origin will be undefined from same route in local development - if (allowedOrigins.indexOf(origin) !== -1 || !origin) { + if (!origin || allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index 96bb74b5..5087dfbf 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -1,11 +1,12 @@ import bcrypt from 'bcrypt'; -import jwt from 'jsonwebtoken'; +import jwt, { Secret } from 'jsonwebtoken'; +import { Request, Response } from 'express'; import { User } from '../models/user'; const users: User[] = []; -const register = async (req, res) => { +const register = async (req: Request, res: Response) => { const { username, password } = req.body; // Check that we have the correct payload @@ -29,12 +30,12 @@ const register = async (req, res) => { users.push({ username, password: hashedPassword }); res.status(201).json({ message: 'User registered successfully' }); - } catch (e) { - res.status(500).json({ message: e.message }); + } catch (error) { + res.status(500).json({ message: (error as Error).message }); } }; -const login = async (req, res) => { +const login = async (req: Request, res: Response) => { const { username, password } = req.body; // Check that we have the correct payload @@ -53,8 +54,8 @@ const login = async (req, res) => { } // Generate access token and refresh token - const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); - const refreshToken = jwt.sign({ username }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: '7d' }); + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET as Secret, { expiresIn: '15m' }); + const refreshToken = jwt.sign({ username }, process.env.REFRESH_TOKEN_SECRET as Secret, { expiresIn: '7d' }); // Save refresh token res.cookie('refreshToken', refreshToken, { @@ -65,7 +66,7 @@ const login = async (req, res) => { res.json({ accessToken }); }; -const refresh = (req, res) => { +const refresh = (req: Request, res: Response) => { // Get refresh token from cookies const refreshToken = req.cookies.refreshToken; @@ -73,18 +74,19 @@ const refresh = (req, res) => { return res.status(401).json({ message: 'Unauthorized' }); } - jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, { username }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET as Secret, (err: any, { username }: any) => { if (err) { // Invalid token return res.status(403).json({ message: 'Forbidden' }); } - const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET as Secret, { expiresIn: '15m' }); res.json({ accessToken }); }); }; -const logout = (req, res) => { +const logout = (req: Request, res: Response) => { res.clearCookie('refreshToken'); res.json({ message: 'Logged out successfully' }); }; diff --git a/apps/api/src/controllers/category.ts b/apps/api/src/controllers/category.ts index cf881549..87e13b91 100644 --- a/apps/api/src/controllers/category.ts +++ b/apps/api/src/controllers/category.ts @@ -1,19 +1,20 @@ import Category from '../models/category'; +import { Request, Response } from 'express'; // Get all categories -const getCategories = async (req, res) => { +const getCategories = async (req: Request, res: Response) => { try { const categories = await Category.find(); // Return all the categories with a 200 status code res.status(200).json(categories); } catch (error) { - const { message } = error; - res.status(500).json({ message }); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + res.status(500).json({ message: errorMessage }); } }; // Get category by id -const getCategoryById = async (req, res) => { +const getCategoryById = async (req: Request, res: Response) => { // Retrieve the id from the route params const { id } = req.params; @@ -31,25 +32,25 @@ const getCategoryById = async (req, res) => { // Return the category with a 200 status code res.status(200).json(category); } catch (error) { - const { message } = error; - res.status(500).json({ message }); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + res.status(500).json({ message: errorMessage }); } }; // Create category -const createCategory = async (req, res) => { +const createCategory = async (req: Request, res: Response) => { try { const category = await Category.create(req.body); // Return the created category with a 201 status code res.status(201).json(category); } catch (error) { - const { message } = error; - res.status(500).json({ message }); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + res.status(500).json({ message: errorMessage }); } }; // Update category -const updateCategory = async (req, res) => { +const updateCategory = async (req: Request, res: Response) => { // Retrieve the id from the route params const { id } = req.params; @@ -65,13 +66,13 @@ const updateCategory = async (req, res) => { // Return the updated category with a 200 status code res.status(200).json(category); } catch (error) { - const { message } = error; - res.status(500).json({ message }); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + res.status(500).json({ message: errorMessage }); } }; // Delete category -const deleteCategory = async (req, res) => { +const deleteCategory = async (req: Request, res: Response) => { // Retrieve the id from the route params const { id } = req.params; @@ -87,8 +88,8 @@ const deleteCategory = async (req, res) => { // Return a 200 status code res.status(200).json(category); } catch (error) { - const { message } = error; - res.status(500).json({ message }); + const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred'; + res.status(500).json({ message: errorMessage }); } }; diff --git a/apps/api/src/controllers/post.ts b/apps/api/src/controllers/post.ts new file mode 100644 index 00000000..b5736c39 --- /dev/null +++ b/apps/api/src/controllers/post.ts @@ -0,0 +1,80 @@ +import { Request, Response } from 'express'; +import { validateComment, validatePartialPost, validatePost } from '../helpers/validators'; +import postModel from '../models/posts'; +import { asyncErrorHandler } from '../helpers/controllerErrorHandler'; + +// 1. Get all posts +const getPosts = asyncErrorHandler(async (req: Request, res: Response) => { + const posts = await postModel.getAllPosts(); + res.status(200).json(posts); +}); + +// 2. Get posts by category +const getPostsByCategory = asyncErrorHandler(async (req: Request, res: Response) => { + const { category } = req.params; + + console.log('category', category); + const posts = await postModel.getPostsByCategory(category); + res.status(200).json(posts); +}); + +// 3. Get post by id +const getPostById = asyncErrorHandler(async (req: Request, res: Response) => { + const { id } = req.params; + + const post = await postModel.getPostById(id); + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + res.status(200).json(post); +}); + +// 4. Create post +const createPost = asyncErrorHandler(async (req: Request, res: Response) => { + const validationResult = validatePost(req.body); + if (validationResult.error) { + return res.status(400).json({ message: JSON.parse(validationResult.error.message) }); + } + const newPost = await postModel.createPost(validationResult.data); + res.status(201).json(newPost); +}); + +// 5. Create post comment +const createPostComment = asyncErrorHandler(async (req: Request, res: Response) => { + const validationResult = validateComment(req.body); + if (validationResult.error) { + return res.status(400).json({ message: JSON.parse(validationResult.error.message) }); + } + const { id } = req.params; + const newComment = await postModel.createPostComment(id, validationResult.data); + res.status(201).json(newComment); +}); + +// 6. Update post +const updatePost = asyncErrorHandler(async (req: Request, res: Response) => { + const validationResult = validatePartialPost(req.body); + if (validationResult.error) { + return res.status(400).json({ message: JSON.parse(validationResult.error.message) }); + } + const { id } = req.params; + const updatedPost = await postModel.updatePost(id, validationResult.data); + res.status(200).json(updatedPost); +}); + +// 7. Delete post +const deletePost = asyncErrorHandler(async (req: Request, res: Response) => { + const { id } = req.params; + + await postModel.deletePost(id); + res.status(204).send({ message: `Post deleted successfully` }); +}); + +export default { + getPosts, + getPostsByCategory, + getPostById, + createPost, + createPostComment, + updatePost, + deletePost +}; diff --git a/apps/api/src/data/initialData.ts b/apps/api/src/data/initialData.ts new file mode 100644 index 00000000..c2588033 --- /dev/null +++ b/apps/api/src/data/initialData.ts @@ -0,0 +1,30 @@ +import { Comment, Post } from '../types/types'; + +const POSTS: Post[] = [ + { + title: 'Post Test Postman', + image: + 'https://images.unsplash.com/photo-1556276797-5086e6b45ff9?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=600&ixid=eyJhcHBfaWQiOjF9&ixlib=rb-1.2.1&q=80&w=800', + description: 'Description from Postman', + category: 'cat_1', + comments: ['a47f5337-16f2-49a0-bc14-e97bb24b56a1'] + } +]; + +const COMMENTS: Comment[] = [ + { + author: 'John Doe', + content: 'Great post!' + } +]; + +const CATEGORIES = [ + { + name: 'Other' + }, + { + name: 'React' + } +]; + +export { POSTS, COMMENTS, CATEGORIES }; diff --git a/apps/api/src/helpers/controllerErrorHandler.ts b/apps/api/src/helpers/controllerErrorHandler.ts new file mode 100644 index 00000000..8962ce01 --- /dev/null +++ b/apps/api/src/helpers/controllerErrorHandler.ts @@ -0,0 +1,13 @@ +import { Request, Response, NextFunction } from 'express'; + +const UNKNOWN_ERROR_MESSAGE = 'An unknown error occurred'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const asyncErrorHandler = (fn: (req: Request, res: Response, next: NextFunction) => Promise | Promise) => { + return (req: Request, res: Response, next: NextFunction) => { + Promise.resolve(fn(req, res, next)).catch((error) => { + const errorMessage = error instanceof Error ? error.message : UNKNOWN_ERROR_MESSAGE; + res.status(500).json({ message: errorMessage }); + }); + }; +}; diff --git a/apps/api/src/helpers/dotenvChecker.ts b/apps/api/src/helpers/dotenvChecker.ts new file mode 100644 index 00000000..0ebbd9cb --- /dev/null +++ b/apps/api/src/helpers/dotenvChecker.ts @@ -0,0 +1,18 @@ +import dotenv from 'dotenv'; +import fs from 'fs'; +import path from 'path'; + +export const envChecker = () => { + const local = path.resolve('apps/api/.env.local'); + const fallbackEnvPath = path.resolve('apps/api/.env'); + + if (fs.existsSync(local)) { + dotenv.config({ path: local }); + console.log(`Loaded environment variables from ${local}`); + } else if (fs.existsSync(fallbackEnvPath)) { + dotenv.config({ path: fallbackEnvPath }); + console.log(`Loaded environment variables from ${fallbackEnvPath}`); + } else { + console.log('No environment files found.'); + } +}; diff --git a/apps/api/src/helpers/validators.ts b/apps/api/src/helpers/validators.ts new file mode 100644 index 00000000..7b93b936 --- /dev/null +++ b/apps/api/src/helpers/validators.ts @@ -0,0 +1,25 @@ +import z from 'zod'; + +export const schema = z.object({ + title: z.string().min(1), + image: z.string().min(1).url(), + description: z.string().min(1), + category: z.string().min(1) +}); + +export const commentSchema = z.object({ + author: z.string().min(1), + content: z.string().min(1) +}); + +export const validatePost = (data: unknown) => { + return schema.safeParse(data); +}; + +export const validatePartialPost = (data: unknown) => { + return schema.partial().safeParse(data); +}; + +export const validateComment = (data: unknown) => { + return commentSchema.safeParse(data); +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index daf9dc2e..6cd8be8f 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -2,34 +2,44 @@ import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; import mongoose from 'mongoose'; - import { corsOptions } from './config/corsConfig'; import { verifyToken } from './middleware/auth'; import { errorHandler } from './middleware/errorHandler'; import auth from './routes/auth'; import categories from './routes/categories'; +import posts from './routes/posts'; +import { envChecker } from './helpers/dotenvChecker'; +import { initMongoRecords } from './models/posts'; + +// Load environment variables from .env.local file so that we don't have them in the repo +envChecker(); const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); -app.use(express.json()); -app.use(helmet()); -app.use(cors(corsOptions)); +app.use(express.json()); // middleware to parse json +app.use(helmet()); // middleware to secure the app / remove x-powered-by +app.use(cors(corsOptions)); // middleware to enable cors app.use('/api/auth', auth); app.use(verifyToken); -app.use('/api/categories', categories); +app.use('/api/categories', categories); // EXAMPLE +app.use('/api/posts', posts); +// catch all non-existing routes +app.use((req, res) => { + res.status(404).json({ message: 'route not found' }); +}); app.use(errorHandler); mongoose - .connect(process.env.MONGO_URL) + .connect(`${process.env.MONGO_URL}`) .then(() => { console.log('Connected to MongoDB'); - + initMongoRecords(); // Initialize a few records for testing app.listen(port, host, () => { console.log(`[ ready ] http://${host}:${port}`); }); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 193a5a9e..3596a97f 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -1,6 +1,7 @@ -import jwt from 'jsonwebtoken'; +import jwt, { Secret } from 'jsonwebtoken'; +import { NextFunction, Request, Response } from 'express'; -export const verifyToken = (req, res, next) => { +export const verifyToken = (req: Request, res: Response, next: NextFunction) => { const authHeader = req.headers['authorization']; if (!authHeader) { @@ -8,13 +9,14 @@ export const verifyToken = (req, res, next) => { } const token = authHeader.split(' ')[1]; - jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => { + jwt.verify(token, process.env.ACCESS_TOKEN_SECRET as Secret, (err, user) => { if (err) { // Invalid token return res.status(403).json({ message: 'Forbidden' }); } - req.user = user; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = user; next(); }); }; diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts index 90969d56..458d32d6 100644 --- a/apps/api/src/middleware/errorHandler.ts +++ b/apps/api/src/middleware/errorHandler.ts @@ -1,5 +1,7 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -export const errorHandler = (err, req, res, next) => { +import { Request, Response } from 'express'; + +export const errorHandler = (err: Error, req: Request, res: Response) => { // Log this for debug purposes console.error(err.stack); // Return custom error to user diff --git a/apps/api/src/models/posts.ts b/apps/api/src/models/posts.ts new file mode 100644 index 00000000..89c9275f --- /dev/null +++ b/apps/api/src/models/posts.ts @@ -0,0 +1,84 @@ +import { CuratedPost, Post } from '../types/types'; +import PostDocument from '../schemas/post'; +import CommentDocument from '../schemas/comment'; +import mongoose from 'mongoose'; + +//Initialize the database records so we can test +export const initMongoRecords = async () => { + await mongoose.connection.db.dropCollection('posts'); + await mongoose.connection.db.dropCollection('categories'); + await mongoose.connection.db.dropCollection('comments'); +}; + +// 1. Get all posts +const getAllPosts = async () => { + const posts = await PostDocument.find({}).populate(['comments', 'category']).sort({ createdAt: -1 }); + return posts; +}; + +// 2. Get posts by category +const getPostsByCategory = async (categoryId: string) => { + const postsByCategory = await PostDocument.find({ category: categoryId }).populate(['comments', 'category']).sort({ createdAt: -1 }); + return postsByCategory; +}; + +// 3. Get post by id +const getPostById = async (id: string) => { + const [post] = await PostDocument.find({ _id: id }).populate(['comments', 'category']); + return post; +}; + +// 4. Create post +const createPost = async (data: CuratedPost) => { + // https://stackoverflow.com/questions/71185664/why-does-zod-make-all-my-schema-fields-optional + // Zod make all my schema fields optional + const newPost = { + comments: [], + ...data + }; + const createdPost = await PostDocument.create(newPost); + return createdPost; +}; + +// 5. Create post comment +const createPostComment = async (postId: string, comment: { author: string; content: string }) => { + const newComment = { + ...comment + }; + + const foundPost = await PostDocument.findById(postId); + if (!foundPost) { + throw new Error('Post not found'); + } + const createdComment = await CommentDocument.create(newComment); + + if (!createdComment) { + throw new Error('Comment not created'); + } + + await PostDocument.updateOne({ _id: postId }, { $push: { comments: createdComment._id } }, { new: true }); + return newComment; +}; + +// 6. Update post +const updatePost = async (postId: string, data: Partial) => { + const updatedPost = await PostDocument.findOneAndUpdate({ _id: postId }, data, { new: true }); + return updatedPost; +}; + +// 7. Delete post +const deletePost = async (postId: string) => { + const deletedPost = await PostDocument.findOneAndDelete({ _id: postId }); + const deletedComment = await CommentDocument.findOneAndDelete({ _id: postId }); + return { deletedComments: deletedComment, deletedPost: deletedPost }; +}; + +export default { + getAllPosts, + getPostsByCategory, + getPostById, + createPost, + createPostComment, + updatePost, + deletePost +}; diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts index 78e120a1..d245a489 100644 --- a/apps/api/src/routes/categories.ts +++ b/apps/api/src/routes/categories.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import express from 'express'; import categoryController from '../controllers/category'; diff --git a/apps/api/src/routes/posts.ts b/apps/api/src/routes/posts.ts new file mode 100644 index 00000000..dd480da3 --- /dev/null +++ b/apps/api/src/routes/posts.ts @@ -0,0 +1,27 @@ +import express from 'express'; +import postController from '../controllers/post'; + +const router = express.Router(); + +// 1. Get all posts +router.get('/', postController.getPosts); + +// 2. Get posts by category +router.get('/category/:category', postController.getPostsByCategory); + +// 3. Get post by id +router.get('/:id', postController.getPostById); + +// 4. Create post +router.post('/', postController.createPost); + +// 5. Create post comment +router.post('/:id/comments', postController.createPostComment); + +// 6. Update post +router.patch('/:id', postController.updatePost); + +// 7. Delete post +router.delete('/:id', postController.deletePost); + +export default router; diff --git a/apps/api/src/schemas/comment.ts b/apps/api/src/schemas/comment.ts new file mode 100644 index 00000000..56e08435 --- /dev/null +++ b/apps/api/src/schemas/comment.ts @@ -0,0 +1,26 @@ +import mongoose, { Document } from 'mongoose'; + +interface IComment extends Document { + author: string; + content: string; +} + +const commentSchema = new mongoose.Schema( + { + author: { + type: String, + required: [true, 'Author is required'] + }, + content: { + type: String, + required: [true, 'Content is required'] + } + }, + { + timestamps: true + } +); + +const CommentDocument = mongoose.model('Comment', commentSchema); + +export default CommentDocument; diff --git a/apps/api/src/schemas/post.ts b/apps/api/src/schemas/post.ts new file mode 100644 index 00000000..b03ceef3 --- /dev/null +++ b/apps/api/src/schemas/post.ts @@ -0,0 +1,43 @@ +import mongoose from 'mongoose'; + +interface IPost extends mongoose.Document { + title: string; + image: string; + description: string; + category: mongoose.Schema.Types.ObjectId; + comments: mongoose.Schema.Types.ObjectId[]; +} + +const postSchema = new mongoose.Schema( + { + title: { + type: String, + required: [true, 'title is required'] + }, + image: { + type: String, + required: [true, 'image is required'] + }, + description: { + type: String, + required: [true, 'description is required'] + }, + category: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Category', + required: [true, 'category is required'] + }, + comments: { + type: [mongoose.Schema.Types.ObjectId], + ref: 'Comment', + required: [true, 'comments is required'] + } + }, + { + timestamps: true + } +); + +const PostDocument = mongoose.model('Post', postSchema); + +export default PostDocument; diff --git a/apps/api/src/types/types.ts b/apps/api/src/types/types.ts new file mode 100644 index 00000000..d6944389 --- /dev/null +++ b/apps/api/src/types/types.ts @@ -0,0 +1,19 @@ +export interface Post { + title: string; + image: string; + description: string; + category: string; + comments: string[]; +} + +export interface CuratedPost { + title: string; + image: string; + description: string; + category: string; +} + +export interface Comment { + author: string; + content: string; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index c1e2dd4e..0e3d25aa 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -11,6 +11,7 @@ } ], "compilerOptions": { - "esModuleInterop": true + "esModuleInterop": true, + "strict": true } } diff --git a/package-lock.json b/package-lock.json index b27ba381..771e51d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-router-dom": "6.11.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", + "zod": "3.23.8", "zone.js": "~0.14.0" }, "devDependencies": { @@ -58,8 +59,10 @@ "@swc/cli": "~0.1.62", "@swc/core": "~1.3.85", "@testing-library/react": "14.0.0", + "@types/bcrypt": "^5.0.2", "@types/express": "~4.17.13", "@types/jest": "^29.4.0", + "@types/jsonwebtoken": "^9.0.7", "@types/mongoose": "^5.11.97", "@types/node": "18.16.9", "@types/react": "18.2.33", @@ -8002,6 +8005,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -8231,6 +8243,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", + "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -24106,6 +24127,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zone.js": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.2.tgz", @@ -29309,6 +29338,15 @@ "@babel/types": "^7.20.7" } }, + "@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -29537,6 +29575,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/jsonwebtoken": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", + "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/keyv": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", @@ -41235,6 +41282,11 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true }, + "zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==" + }, "zone.js": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.2.tgz", diff --git a/package.json b/package.json index 221fc6d9..4caa6403 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-router-dom": "6.11.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", + "zod": "3.23.8", "zone.js": "~0.14.0" }, "devDependencies": { @@ -58,8 +59,10 @@ "@swc/cli": "~0.1.62", "@swc/core": "~1.3.85", "@testing-library/react": "14.0.0", + "@types/bcrypt": "^5.0.2", "@types/express": "~4.17.13", "@types/jest": "^29.4.0", + "@types/jsonwebtoken": "^9.0.7", "@types/mongoose": "^5.11.97", "@types/node": "18.16.9", "@types/react": "18.2.33",