diff --git a/apps/api/.env b/apps/api/.env index ecaf5a7c..7fdedf23 100644 --- a/apps/api/.env +++ b/apps/api/.env @@ -1,3 +1,3 @@ -ACCESS_TOKEN_SECRET=[YOUR_ACCESS_TOKEN_SECRET_HERE] -REFRESH_TOKEN_SECRET=[YOUR_REFRESH_TOKEN_SECRET_HERE] -MONGO_URL=[YOUR_MONGO_CONNECTION_STRING_HERE] \ No newline at end of file +ACCESS_TOKEN_SECRET=62f7814ecb7bd8eed882d0c25403dd0b390dc07673ba3798ed22fc6db3e480750b8f9f6ee845e9e138c7047ecfd7d13c95d081821c1dd85333b53cedac0c7b24 +REFRESH_TOKEN_SECRET=3bcad700ce9c447d3fd5f1af746d5b597ed01ddb6884693ed38a2c7913b2bf3dd69b6c1e974a6d1760c076b55b6ff0c3d92b218faac728e3db31d0474b6888e0 +MONGO_URL=mongodb+srv://nivermartinez96:tSzduB9XoRg2oq1P@cluster0.bhfou.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 diff --git a/apps/api/README.md b/apps/api/README.md index 6066cc0e..658178b6 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -21,7 +21,52 @@ ## Challenges -### Session * +### 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* + +- Post model + - 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 + +### Session 02 + +- Refactor the code from last session to add a post controller + +### Session 03 + +- N/A + +### Session 04 + +- N/A + +### Session 05 + +- Create MongoDB database +- 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* +- **Extra** + - Remove post comments from database when you delete the post ## How to diff --git a/apps/api/src/config/.gitkeep b/apps/api/src/config/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/config/corsConfig.ts b/apps/api/src/config/corsConfig.ts new file mode 100644 index 00000000..045cf517 --- /dev/null +++ b/apps/api/src/config/corsConfig.ts @@ -0,0 +1,15 @@ +const allowedOrigins = ['http://localhost:4200', 'http://localhost:3000']; + +export const corsOptions = { + origin: (origin, callback) => { + // Note: origin will be undefined from same route in local development + if (allowedOrigins.indexOf(origin) !== -1 || !origin) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + optionsSuccessStatus: 200 +}; + +export default { corsOptions }; diff --git a/apps/api/src/controllers/.gitkeep b/apps/api/src/controllers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts new file mode 100644 index 00000000..96bb74b5 --- /dev/null +++ b/apps/api/src/controllers/auth.ts @@ -0,0 +1,97 @@ +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +import { User } from '../models/user'; + +const users: User[] = []; + +const register = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Check that we don't have duplicates + const duplicate = users.find((u) => u.username === username); + if (duplicate) { + return res.status(409).json({ message: 'User already exist' }); + } + + try { + // Encrypt the password + const hashedPassword = await bcrypt.hash(password, 10); + + // Store new user + users.push({ username, password: hashedPassword }); + + res.status(201).json({ message: 'User registered successfully' }); + } catch (e) { + res.status(500).json({ message: e.message }); + } +}; + +const login = async (req, res) => { + const { username, password } = req.body; + + // Check that we have the correct payload + if (!username || !password) { + return res.status(400).json({ + message: 'Username and password are required' + }); + } + + // Retrieve user + const user = users.find((u) => u.username === username); + + // Check if we found the user and the password matches + if (!user || !(await bcrypt.compare(password, user.password))) { + return res.status(401).json({ message: 'Invalid credentials' }); + } + + // 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' }); + + // Save refresh token + res.cookie('refreshToken', refreshToken, { + httpOnly: true, + maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds + }); + + res.json({ accessToken }); +}; + +const refresh = (req, res) => { + // Get refresh token from cookies + const refreshToken = req.cookies.refreshToken; + + if (!refreshToken) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, { username }) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + const accessToken = jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' }); + res.json({ accessToken }); + }); +}; + +const logout = (req, res) => { + res.clearCookie('refreshToken'); + res.json({ message: 'Logged out successfully' }); +}; + +export default { + register, + login, + refresh, + logout +}; diff --git a/apps/api/src/controllers/category.ts b/apps/api/src/controllers/category.ts new file mode 100644 index 00000000..a056adad --- /dev/null +++ b/apps/api/src/controllers/category.ts @@ -0,0 +1,100 @@ +import Category from '../models/category'; + +// Get all categories +const getCategories = async (req, res) => { + 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 }); + } +}; + +// Get category by id +const getCategoryById = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check if we have a category with that id + const category = await Category.findById(id); + + if (!category) { + // If we don't find the category return a 404 status code with a message + return res.status(404).json({ message: 'Category not found' }); + // Note: Remember that json method doesn't interrupt the workflow + // therefore is important to add a "return" to break the process + } + // Return the category with a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Create category +const createCategory = async (req, res) => { + 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 }); + } +}; + +// Update category +const updateCategory = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and update if we have a category with that id + const category = await Category.findByIdAndUpdate(id, req.body, { new: true }); + + // If we don't find the category return a 404 status code with a message + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + // Return the updated category with a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Delete category +const deleteCategory = async (req, res) => { + // Retrieve the id from the route params + const { id } = req.params; + + try { + // Check and delete if we have a category with that id + const category = await Category.findByIdAndDelete(id); + + // If we don't find the category return a 404 status code with a message + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + // Return a 200 status code + res.status(200).json(category); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +export default { + getCategories, + getCategoryById, + createCategory, + updateCategory, + deleteCategory +}; diff --git a/apps/api/src/controllers/post.ts b/apps/api/src/controllers/post.ts new file mode 100644 index 00000000..51893daa --- /dev/null +++ b/apps/api/src/controllers/post.ts @@ -0,0 +1,136 @@ +import Post from '../models/post'; +import Comment from '../models/comment'; + +// Get all posts +const getAllPosts = async (req, res) => { + try { + const posts = await Post.find(); + res.status(200).json(posts); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Get post by category +const getPostByCategory = async (req, res) => { + const { category } = req.params; + try { + const posts = await Post.find({ category }); + + if (!posts) { + return res.status(404).json({ message: 'No posts found for this category' }); + } + + res.status(200).json(posts); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// // Get post by id +const getPostById = async (req, res) => { + const { id } = req.params; + try { + const post = await Post.findById(id).populate('comments'); + + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// // Create post +const createPost = async (req, res) => { + try { + const post = await Post.create(req.body); + res.status(201).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// Crear un comentario y agregarlo al post +const createPostComment = async (req, res) => { + const postId = req.params.id; + const { author, content } = req.body; + + try { + const newComment = new Comment({ author, content }); + await newComment.save(); + + const post = await Post.findById(postId); + + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + post.comments.push(newComment._id); + await post.save(); + + const populatedComment = await Comment.findById(newComment._id); + + if (!populatedComment) { + return res.status(404).json({ message: 'Comment not found' }); + } + + res.status(201).json(populatedComment); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// // Update post +const updatePost = async (req, res) => { + const { id } = req.params; + try { + const post = await Post.findByIdAndUpdate(id, req.body, { new: true }); + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + res.status(200).json(post); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +// // Delete post +const deletePost = async (req, res) => { + const { id } = req.params; + try { + const post = await Post.findById(id); + + if (!post) { + return res.status(404).json({ message: 'Post not found' }); + } + + await Comment.deleteMany({ _id: { $in: post.comments } }); + + await Post.findByIdAndDelete(id); + + res.status(204).json({ message: 'Post and related comments deleted successfully' }); + } catch (error) { + const { message } = error; + res.status(500).json({ message }); + } +}; + +export default { + getAllPosts, + getPostByCategory, + getPostById, + createPost, + createPostComment, + updatePost, + deletePost +}; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index e5fad103..7fd7feb7 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,14 +1,40 @@ +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'; const host = process.env.HOST ?? 'localhost'; const port = process.env.PORT ? Number(process.env.PORT) : 3000; const app = express(); -app.get('/', (req, res) => { - res.send({ message: 'Hello MFEE!' }); -}); +app.use(express.json()); +app.use(helmet()); +app.use(cors(corsOptions)); -app.listen(port, host, () => { - console.log(`[ ready ] http://${host}:${port}`); -}); +app.use('/api/auth', auth); + +app.use(verifyToken); +app.use('/api/categories', categories); +app.use('/api/posts', posts); + +app.use(errorHandler); + +mongoose + .connect(process.env.MONGO_URL) + .then(() => { + console.log('Connected to MongoDB'); + + app.listen(port, host, () => { + console.log(`[ ready ] http://${host}:${port}`); + }); + }) + .catch((e) => { + console.error(e); + }); diff --git a/apps/api/src/middleware/.gitkeep b/apps/api/src/middleware/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 00000000..193a5a9e --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,24 @@ +import jwt from 'jsonwebtoken'; + +export const verifyToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + + if (!authHeader) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const token = authHeader.split(' ')[1]; + jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => { + if (err) { + // Invalid token + return res.status(403).json({ message: 'Forbidden' }); + } + + req.user = user; + next(); + }); +}; + +export default { + verifyToken +}; diff --git a/apps/api/src/middleware/errorHandler.ts b/apps/api/src/middleware/errorHandler.ts new file mode 100644 index 00000000..90969d56 --- /dev/null +++ b/apps/api/src/middleware/errorHandler.ts @@ -0,0 +1,9 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const errorHandler = (err, req, res, next) => { + // Log this for debug purposes + console.error(err.stack); + // Return custom error to user + res.status(500).json({ error: 'Internal Server Error' }); +}; + +export default { errorHandler }; diff --git a/apps/api/src/models/.gitkeep b/apps/api/src/models/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/models/category.ts b/apps/api/src/models/category.ts new file mode 100644 index 00000000..c69bbc87 --- /dev/null +++ b/apps/api/src/models/category.ts @@ -0,0 +1,21 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface ICategory extends Document { + name: string; +} + +export const categorySchema = new Schema( + { + name: { + type: String, + required: [true, 'Property is required'] + } + }, + { + timestamps: true + } +); + +const Category = mongoose.model('Category', categorySchema); + +export default Category; diff --git a/apps/api/src/models/comment.ts b/apps/api/src/models/comment.ts new file mode 100644 index 00000000..ac2cbdf8 --- /dev/null +++ b/apps/api/src/models/comment.ts @@ -0,0 +1,26 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface IComment extends Document { + author: string; + content: string; +} + +export const commentSchema = new Schema( + { + author: { + type: String, + required: [true, 'Property is required'] + }, + content: { + type: String, + required: [true, 'Property is required'] + } + }, + { + timestamps: true + } +); + +const Comment = mongoose.model('Comment', commentSchema); + +export default Comment; diff --git a/apps/api/src/models/post.ts b/apps/api/src/models/post.ts new file mode 100644 index 00000000..658948a7 --- /dev/null +++ b/apps/api/src/models/post.ts @@ -0,0 +1,45 @@ +import mongoose, { Document, Schema } from 'mongoose'; + +interface IPost extends Document { + title: string; + image: string; + description: string; + category: Schema.Types.ObjectId; + comments: Schema.Types.ObjectId[]; +} + +export const postSchema = new Schema( + { + title: { + type: String, + required: [true, 'Property is required'] + }, + image: { + type: String, + required: [true, 'Property is required'] + }, + description: { + type: String, + required: [true, 'Property is required'] + }, + category: { + type: Schema.Types.ObjectId, + ref: 'Category', + required: [true, 'Property is required'] + }, + comments: [ + { + type: Schema.Types.ObjectId, + ref: 'Comment', + default: [] + } + ] + }, + { + timestamps: true + } +); + +const Post = mongoose.model('Post', postSchema); + +export default Post; diff --git a/apps/api/src/models/user.ts b/apps/api/src/models/user.ts new file mode 100644 index 00000000..6ac7896e --- /dev/null +++ b/apps/api/src/models/user.ts @@ -0,0 +1,4 @@ +export type User = { + username: string; + password: string; +}; diff --git a/apps/api/src/routes/.gitkeep b/apps/api/src/routes/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 00000000..062e9bc9 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,15 @@ +import express from 'express'; + +import authController from '../controllers/auth'; + +const router = express.Router(); + +router.post('/register', authController.register); + +router.post('/login', authController.login); + +router.post('/refresh', authController.refresh); + +router.post('/logout', authController.logout); + +export default router; diff --git a/apps/api/src/routes/categories.ts b/apps/api/src/routes/categories.ts new file mode 100644 index 00000000..78e120a1 --- /dev/null +++ b/apps/api/src/routes/categories.ts @@ -0,0 +1,22 @@ +import express from 'express'; + +import categoryController from '../controllers/category'; + +const router = express.Router(); + +// Get all categories +router.get('/', categoryController.getCategories); + +// Get category by id +router.get('/:id', categoryController.getCategoryById); + +// Create category +router.post('/', categoryController.createCategory); + +// Update category +router.patch('/:id', categoryController.updateCategory); + +// Delete category +router.delete('/:id', categoryController.deleteCategory); + +export default router; diff --git a/apps/api/src/routes/posts.ts b/apps/api/src/routes/posts.ts new file mode 100644 index 00000000..b420a607 --- /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(); + +// Get all posts +router.get('/', postController.getAllPosts); + +// Get post by category +router.get('/category/:category', postController.getPostByCategory); + +// // Get post by id +router.get('/:id', postController.getPostById); + +// // Create post +router.post('/', postController.createPost); + +// // Create post comment +router.post('/:id/comments', postController.createPostComment); + +// // Update post +router.patch('/:id', postController.updatePost); + +// // Delete post +router.delete('/:id', postController.deletePost); + +export default router;