diff --git a/README.md b/README.md index 0f9f073..9e0bd1e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,73 @@ -# Project API +# Happy Thoughts API -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. -## Getting started +## Project Overview -Install dependencies with `npm install`, then start the server by running `npm run dev` +Happy Thoughts API is a RESTful backend built with Node.js, Express, and MongoDB. It provides endpoints for creating, reading, updating, liking, and deleting "thoughts" (messages), as well as user authentication and registration. -## View it live +### Key Features -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. +- **Mongoose Schema Models:** All data is structured and validated using Mongoose models for both thoughts and users. This ensures consistent data and enforces rules such as required fields and minimum password length. +- **Password Security:** User passwords are securely hashed using bcrypt before being stored in the database. +- **Authentication:** The API uses access tokens to protect routes for creating, updating, and deleting thoughts. Only authenticated users can perform these actions. +- **Error Handling:** All routes include robust error handling and return clear status codes and messages for invalid input, authentication failures, and other errors. +- **RESTful Design:** The API follows RESTful principles, with clear separation of resources and HTTP methods for CRUD operations. +- **Filtering & Sorting:** Thoughts can be filtered by number of likes (hearts) and sorted by creation date. +- **Frontend Integration:** Designed to work seamlessly with a frontend application, supporting features like updating and deleting thoughts, user signup/login, and error feedback. + +--- + +## Project Requirements + +**Fulfilled requirements:** +- ✅ API documentation with Express List Endpoints +- ✅ Read all thoughts +- ✅ Read a single thought +- ✅ Like a thought +- ✅ Create a thought (authenticated) +- ✅ Update a thought (authenticated) +- ✅ Delete a thought (authenticated) +- ✅ Sign up (register user) +- ✅ Log in (user authentication) +- ✅ RESTful structure +- ✅ Clean code according to guidelines +- ✅ Uses Mongoose models +- ✅ Validates user input +- ✅ Unique email addresses +- ✅ Error handling and status codes +- ✅ Frontend supports Update/Delete/Signup/Login and error handling +- ✅ Passwords encrypted with bcrypt +- ✅ API deployed on Render +- ✅ Backend and frontend are synced +- ✅ Filtering thoughts by number of hearts (`/thoughts?hearts=5`) +- ✅ Sorting by date (newest first) +- ✅ API error messages displayed in frontend during registration +- ✅ Token stored in localStorage and sent in headers + +--- + +## API Endpoints + +- `GET /thoughts` – Get all thoughts (with filtering/sorting) +- `GET /thoughts/:id` – Get a single thought +- `POST /thoughts` – Create a thought (requires authentication) +- `PATCH /thoughts/:id/like` – Like a thought +- `PATCH /thoughts/:id` – Update a thought (requires authentication) +- `DELETE /thoughts/:id` – Delete a thought (requires authentication) +- `POST /user/signup` – Register user +- `POST /user/login` – Log in user + +--- + +## File structure +middleware/ +└──authMiddleware.js +models/ +├── Thought.js +└── User.js +routes/ +├── thoughtRoutes.js +└── userRoutes.js + +└── server,js +``` \ No newline at end of file diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js new file mode 100644 index 0000000..de04619 --- /dev/null +++ b/middleware/authMiddleware.js @@ -0,0 +1,23 @@ +import User from "../models/User.js"; + +const authenticateUser = async (req, res, next) => { + try { + const user = await User.findOne({ + accessToken: req.header("Authorization").replace("Bearer ", ""), + }); + if (user) { + req.user = user; + next(); + } else { + res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true, + }); + } + } catch (err) { + res + .status(500) + .json({ message: "Internal server error", error: err.message }); + } +}; +export default authenticateUser; diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..98f584c --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,23 @@ +import mongoose, { Schema } from "mongoose"; + +const ThoughtSchema = new Schema({ + message: { + type: String, + required: [true, "A Message is required"], + minlength: [5, "A Message must be at least 5 characters"], + maxlength: [140, "A Message cannot exceed 140 characters"], + trim: true, + }, + hearts: { + type: Number, + default: 0, + }, + createdAt: { + type: Date, + default: Date.now, + }, +}); + +const Thought = mongoose.model("Thought", ThoughtSchema); + +export default Thought; diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..62b1b07 --- /dev/null +++ b/models/User.js @@ -0,0 +1,24 @@ +import mongoose, { Schema } from "mongoose"; +import crypto from "crypto"; + +const userSchema = new Schema({ + email: { + type: String, + required: true, + unique: true, + }, + password: { + type: String, + required: true, + minlength: 6, + }, + accessToken: { + type: String, + required: true, + default: () => crypto.randomBytes(128).toString("hex"), + }, +}) + +const User = mongoose.model("User", userSchema); + +export default User; diff --git a/package.json b/package.json index bf25bb6..6b528fe 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,33 @@ "name": "project-api", "version": "1.0.0", "description": "Project API", + "homepage": "https://github.com/JeffieJansson/ht-project-api#readme", + "bugs": { + "url": "https://github.com/JeffieJansson/ht-project-api/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/JeffieJansson/ht-project-api.git" + }, + "license": "ISC", + "author": "", + "type": "commonjs", + "main": "server.js", "scripts": { "start": "babel-node server.js", "dev": "nodemon server.js --exec babel-node" }, - "author": "", - "license": "ISC", "dependencies": { "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", - "express": "^4.17.3", - "nodemon": "^3.0.1" + "dotenv": "^17.2.3", + "express": "^4.22.1", + "express-list-endpoints": "^7.1.1", + "mongodb": "^7.0.0", + "mongoose": "^9.1.5", + "nodemon": "^3.1.11" } } diff --git a/pull_request_template.md b/pull_request_template.md index fb9fdc3..47e2dce 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1 +1,2 @@ -Please include your Render link here. \ No newline at end of file +Please include your Render link here. +https://ht-api-ij7j.onrender.com/ \ No newline at end of file diff --git a/routes/thoughtRoutes.js b/routes/thoughtRoutes.js new file mode 100644 index 0000000..5ae57b9 --- /dev/null +++ b/routes/thoughtRoutes.js @@ -0,0 +1,230 @@ +import express from "express"; +import mongoose from "mongoose"; +import Thought from "../models/Thought.js"; +import authenticateUser from "../middleware/authMiddleware.js"; + +const router = express.Router(); + +//Endpoint is /thoughts +// Get all thoughts +router.get("/", async (req, res) => { + const { hearts } = req.query; + const query = {}; + + if (hearts) { + query.hearts = { $gte: Number(hearts) }; // Filter thoughts with hearts greater than or equal to the specified value + } + + try { + const filteredThoughts = await Thought.find(query).sort({ createdAt: -1 }); // Sort by createdAt in descending order + + if (filteredThoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No thoughts found for that query. Try another one!", + }); + } + return res.status(200).json({ + success: true, + response: filteredThoughts, + message: "Success", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: [], + message: "Failed to fetch thoughts", + }); + } +}); + +// get one thought based on id +router.get("/:id", async (req, res) => { + const { id } = req.params; + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format", + }); + } + + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found", + }); + } + + res.status(200).json({ + success: true, + response: thought, + }); + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Thought couldn't be found", + }); + } +}); + +//create new thought and save to db +router.post("/", authenticateUser, async (req, res) => { + const body = req.body; + + try { + const newThought = { + message: body.message, + }; + + const createdThought = await new Thought(newThought).save(); + + return res.status(201).json({ + success: true, + response: createdThought, + message: "Thought created successfully", + }); + } catch (error) { + if (error.name === "ValidationError") { + return res.status(400).json({ + success: false, + response: null, + message: error.message, + }); + } + + return res.status(500).json({ + success: false, + response: null, + message: "Error creating thought", + }); + } +}); + +// delete a thought by id (DELETE) +router.delete("/:id", authenticateUser, async (req, res) => { + const id = req.params.id; + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format", + }); + } + const deletedThought = await Thought.findByIdAndDelete(id); + + if (!deletedThought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found", + }); + } + + return res.status(200).json({ + success: true, + response: deletedThought, + message: "Thought deleted successfully", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: null, + message: "Error deleting thought", + }); + } +}); + +// Like a thought by id (PATCH) +router.patch("/:id/like", async (req, res) => { + const id = req.params.id; + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format", + }); + } + + const updatedThought = await Thought.findByIdAndUpdate( + id, + { $inc: { hearts: 1 } }, + { new: true } + ); + + if (!updatedThought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found", + }); + } + + return res.status(200).json({ + success: true, + response: updatedThought, + message: "Thought liked successfully", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: null, + message: "Error liking thought", + }); + } +}); + +// Update a thought by id (PATCH) +router.patch("/:id", authenticateUser, async (req, res) => { + const id = req.params.id; + const body = req.body; + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format", + }); + } + + const updatedThought = await Thought.findByIdAndUpdate( + id, + { message: body.message }, + { new: true, runValidators: true } + ); + + if (!updatedThought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found", + }); + } + + return res.status(200).json({ + success: true, + response: updatedThought, + message: "Thought updated successfully", + }); + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Error editing thought", + }); + } +}); + +export default router; diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 0000000..526629c --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,91 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import User from "../models/User.js"; + +const router = express.Router(); + +//endpoint is /user/signup +// Here we can create a new user +router.post("/signup", async (req, res) => { + try { + const { email, password } = req.body; + + if (!password || password.length < 6) { + return res.status(400).json({ + success: false, + message: "Password must be at least 6 characters.", + }); + } + + + const existingUser = await User.findOne({ email: email.toLowerCase() }); + + if (existingUser) { + return res.status(400).json({ + success: false, + message: "User with this email already exists", + }); + } + + const salt = bcrypt.genSaltSync(); + const hashedPassword = bcrypt.hashSync(password, salt); + const user = new User({email, password: hashedPassword}); + + await user.save(); + + res.status(200).json({ + success: true, + message: "User created successfully", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken, + }, + }); + + } catch (error) { + res.status(400).json({ + success: false, + message: "Failed to create user", + response: error, + }); + } +}); + +// endpoint is /user/login +// Here we can verify email and password and return accessToken +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + + const user = await User.findOne({ email: email.toLowerCase() }); + + if (user && bcrypt.compareSync(password, user.password)) { + res.json({ + success: true, + message: "Login successful", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken, + }, + }); + } else { + res.status(401).json({ + success: false, + message: "Invalid email or password", + response: null, + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Something went wrong", + response: error, + }); + } +}); + +// export the router to be used in server.js +export default router; + diff --git a/server.js b/server.js index f47771b..6f5d13a 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,39 @@ import cors from "cors" import express from "express" +import mongoose from "mongoose" +import listEndpoints from "express-list-endpoints"; +import dotenv from "dotenv"; -// Defines the port the app will run on. Defaults to 8080, but can be overridden -// when starting the server. Example command to overwrite PORT env variable value: -// PORT=9000 npm start -const port = process.env.PORT || 8080 +import thoughtRoutes from "./routes/thoughtRoutes.js"; +import userRoutes from "./routes/userRoutes.js"; + +dotenv.config(); + +//Connecting to MongoDB +const mongoUrl = process.env.MONGO_URL +mongoose.connect(mongoUrl) +mongoose.Promise = Promise + +const port = process.env.PORT || 9000 const app = express() // Add middlewares to enable cors and json body parsing app.use(cors()) app.use(express.json()) -// Start defining your routes here +// documentation of the API with express-list-endpoints app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) + const endpoints = listEndpoints(app); + res.json([{ + message: "Welcome to the Thoughts API", + endpoints: endpoints + }]); +}); + +app.use("/user", userRoutes); +app.use("/thoughts", thoughtRoutes); // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`) -}) +}) \ No newline at end of file