diff --git a/README.md b/README.md index 0f9f073d..696e2598 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,46 @@ -# 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. +Hi there! I built this Happy Thoughts API as part of my Technigo JavaScript Bootcamp 2025 journey. This is a RESTful API built with Express.js and MongoDB that lets users create, read, update, and delete happy thoughts, as well as like them. -## Getting started +## Key Features -Install dependencies with `npm install`, then start the server by running `npm run dev` +- Full CRUD operations: Create, Read, Update, and Delete thoughts +- Like (heart) a thought +- Data stored in MongoDB with Mongoose models +- Input validation (message must be 5-140 characters) +- Error handling with proper HTTP status codes +- Database seeding with sample data + +## Tech Stack + +- Node.js +- Express.js +- MongoDB + Mongoose +- dotenv + +## API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | API documentation | +| GET | `/thoughts` | Get all thoughts (newest first, limit 20) | +| GET | `/thoughts/:id` | Get a single thought by ID | +| POST | `/thoughts` | Create a new thought | +| PATCH | `/thoughts/:id` | Update a thought | +| DELETE | `/thoughts/:id` | Delete a thought | +| POST | `/thoughts/:id/like` | Like a thought (+1 heart) | + +## Getting Started + +1. Install dependencies: `npm install` +2. Create a `.env` file with your MongoDB connection string: + ``` + MONGO_URL=mongodb+srv://your-connection-string + RESET_DB=true + ``` +3. Start the server: `npm run dev` +4. After seeding, set `RESET_DB=false` in `.env` ## View it live -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. +Backend: _add your Render link here_ diff --git a/.babelrc b/backend/.babelrc similarity index 100% rename from .babelrc rename to backend/.babelrc diff --git a/backend/data.json b/backend/data.json new file mode 100644 index 00000000..c8db29e2 --- /dev/null +++ b/backend/data.json @@ -0,0 +1,123 @@ +{ + "thoughts": [ + { + "_id": "682bab8c12155b00101732ce", + "message": "Berlin baby", + "hearts": 37, + "createdAt": "2025-05-19T22:07:08.999Z", + "__v": 0 + }, + { + "_id": "682e53cc4fddf50010bbe739", + "message": "My family!", + "hearts": 0, + "createdAt": "2025-05-22T22:29:32.232Z", + "__v": 0 + }, + { + "_id": "682e4f844fddf50010bbe738", + "message": "The smell of coffee in the morning....", + "hearts": 23, + "createdAt": "2025-05-22T22:11:16.075Z", + "__v": 0 + }, + { + "_id": "682e48bf4fddf50010bbe737", + "message": "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED 🤞🏼\n", + "hearts": 6, + "createdAt": "2025-05-21T21:42:23.862Z", + "__v": 0 + }, + { + "_id": "682e45804fddf50010bbe736", + "message": "I am happy that I feel healthy and have energy again", + "hearts": 13, + "createdAt": "2025-05-21T21:28:32.196Z", + "__v": 0 + }, + { + "_id": "682e23fecf615800105107aa", + "message": "cold beer", + "hearts": 2, + "createdAt": "2025-05-21T19:05:34.113Z", + "__v": 0 + }, + { + "_id": "682e22aecf615800105107a9", + "message": "My friend is visiting this weekend! <3", + "hearts": 6, + "createdAt": "2025-05-21T18:59:58.121Z", + "__v": 0 + }, + { + "_id": "682cec1b17487d0010a298b6", + "message": "A god joke: \nWhy did the scarecrow win an award?\nBecause he was outstanding in his field!", + "hearts": 12, + "createdAt": "2025-05-20T20:54:51.082Z", + "__v": 0 + }, + { + "_id": "682cebbe17487d0010a298b5", + "message": "Tacos and tequila🌮🍹", + "hearts": 2, + "createdAt": "2025-05-19T20:53:18.899Z", + "__v": 0 + }, + { + "_id": "682ceb5617487d0010a298b4", + "message": "Netflix and late night ice-cream🍦", + "hearts": 1, + "createdAt": "2025-05-18T20:51:34.494Z", + "__v": 0 + }, + { + "_id": "682c99ba3bff2d0010f5d44e", + "message": "Summer is coming...", + "hearts": 2, + "createdAt": "2025-05-20T15:03:22.379Z", + "__v": 0 + }, + { + "_id": "682c706c951f7a0017130024", + "message": "Exercise? I thought you said extra fries! 🍟😂", + "hearts": 14, + "createdAt": "2025-05-20T12:07:08.185Z", + "__v": 0 + }, + { + "_id": "682c6fe1951f7a0017130023", + "message": "I’m on a seafood diet. I see food, and I eat it.", + "hearts": 4, + "createdAt": "2025-05-20T12:04:49.978Z", + "__v": 0 + }, + { + "_id": "682c6f0e951f7a0017130022", + "message": "Cute monkeys🐒", + "hearts": 2, + "createdAt": "2025-05-20T12:01:18.308Z", + "__v": 0 + }, + { + "_id": "682c6e65951f7a0017130021", + "message": "The weather is nice!", + "hearts": 0, + "createdAt": "2025-05-20T11:58:29.662Z", + "__v": 0 + }, + { + "_id": "682bfdb4270ca300105af221", + "message": "good vibes and good things", + "hearts": 3, + "createdAt": "2025-05-20T03:57:40.322Z", + "__v": 0 + }, + { + "_id": "682bab8c12155b00101732ce", + "message": "Berlin baby", + "hearts": 37, + "createdAt": "2025-05-19T22:07:08.999Z", + "__v": 0 + } + ] +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 00000000..72d40459 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "kausar", + "version": "1.0.0", + "description": "Project API", + "homepage": "https://github.com/KausarShangareeva/js-project-api-express.js#readme", + "bugs": { + "url": "https://github.com/KausarShangareeva/js-project-api-express.js/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/KausarShangareeva/js-project-api-express.js.git" + }, + "license": "ISC", + "author": "", + "type": "commonjs", + "main": "server.js", + "scripts": { + "start": "babel-node server.js", + "dev": "nodemon server.js --exec babel-node" + }, + "dependencies": { + "@babel/core": "^7.28.6", + "@babel/node": "^7.28.6", + "@babel/preset-env": "^7.28.6", + "bcrypt": "^6.0.0", + "cors": "^2.8.6", + "dotenv": "^17.2.4", + "express": "^4.17.3", + "mongoose": "^9.1.5", + "nodemon": "^3.1.11" + } +} diff --git a/backend/server.js b/backend/server.js new file mode 100644 index 00000000..fee51f5f --- /dev/null +++ b/backend/server.js @@ -0,0 +1,318 @@ +import cors from "cors"; +import express from "express"; +import mongoose from "mongoose"; +import bcrypt from "bcrypt"; +import dotenv from "dotenv"; + +dotenv.config(); + +const app = express(); +app.use(cors()); +app.use(express.json()); + +// Connect to MongoDB +const mongoURL = process.env.MONGO_URL || "mongodb://localhost/happythoughts"; + +mongoose.connect(mongoURL); +mongoose.Promise = Promise; + +// --- User Model --- +const User = mongoose.model("User", { + name: { + type: String, + required: [true, "Name is required"], + minlength: [2, "Name must be at least 2 characters"], + }, + email: { + type: String, + required: [true, "Email is required"], + unique: true, + match: [/.+@.+\..+/, "Please enter a valid email"], + }, + password: { + type: String, + required: [true, "Password is required"], + minlength: [6, "Password must be at least 6 characters"], + }, + accessToken: { + type: String, + default: () => bcrypt.genSaltSync(), + }, +}); + +// --- Thought Model --- +const Thought = mongoose.model("Thought", { + message: { + type: String, + required: [true, "Message is required"], + minlength: [5, "Message must be at least 5 characters"], + maxlength: [140, "Message must be at most 140 characters"], + }, + hearts: { + type: Number, + default: 0, + }, + createdAt: { + type: Date, + default: Date.now, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + }, +}); + +// --- Auth Middleware --- +const auth = async (req, res, next) => { + const token = req.header("Authorization"); + if (!token) { + return res.status(401).json({ error: "Not logged in" }); + } + const user = await User.findOne({ accessToken: token }); + if (!user) { + return res.status(401).json({ error: "Invalid token" }); + } + req.user = user; + next(); +}; + +// --- Seed database --- +if (process.env.RESET_DB === "true") { + const seedDB = async () => { + await Thought.deleteMany(); + const thoughts = [ + { + message: "Code is like humor. When you have to explain it, it's bad.", + hearts: 12, + }, + { + message: "First, solve the problem. Then, write the code.", + hearts: 25, + }, + { + message: "The best error message is the one that never shows up.", + hearts: 8, + }, + { message: "Talk is cheap. Show me the code.", hearts: 31 }, + { + message: "It works on my machine! Then we ship your machine.", + hearts: 19, + }, + { message: "Simplicity is the soul of efficiency.", hearts: 14 }, + { message: "Make it work, make it right, make it fast.", hearts: 22 }, + { + message: + "Every great developer you know got there by solving problems they were unqualified to solve.", + hearts: 17, + }, + { + message: + "The only way to learn a new programming language is by writing programs in it.", + hearts: 9, + }, + { + message: + "Programming is the art of telling another human what one wants the computer to do.", + hearts: 11, + }, + ]; + await Thought.insertMany(thoughts); + console.log("Database seeded!"); + }; + seedDB(); +} + +// --- Routes --- + +// GET / - API documentation +app.get("/", (req, res) => { + res.json({ + name: "Happy Thoughts API", + endpoints: [ + { method: "POST", path: "/register", description: "Register a new user" }, + { method: "POST", path: "/login", description: "Login" }, + { method: "GET", path: "/thoughts", description: "Get all thoughts" }, + { method: "GET", path: "/thoughts/:id", description: "Get one thought" }, + { + method: "POST", + path: "/thoughts", + description: "Create a thought (auth)", + }, + { + method: "PATCH", + path: "/thoughts/:id", + description: "Update a thought (auth)", + }, + { + method: "DELETE", + path: "/thoughts/:id", + description: "Delete a thought (auth)", + }, + { + method: "POST", + path: "/thoughts/:id/like", + description: "Like a thought", + }, + ], + }); +}); + +// POST /register - create a new user +app.post("/register", async (req, res) => { + try { + const { name, email, password } = req.body; + const existing = await User.findOne({ email }); + if (existing) { + return res + .status(400) + .json({ error: "That email address already exists" }); + } + const hashedPassword = await bcrypt.hash(password, 10); + const user = await new User({ + name, + email, + password: hashedPassword, + }).save(); + res.status(201).json({ + id: user._id, + name: user.name, + email: user.email, + accessToken: user.accessToken, + }); + } catch (err) { + res.status(400).json({ error: "Could not register", details: err.message }); + } +}); + +// POST /login - login +app.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + const user = await User.findOne({ email }); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + const isMatch = await bcrypt.compare(password, user.password); + if (!isMatch) { + return res.status(401).json({ error: "Wrong password" }); + } + res.json({ + id: user._id, + name: user.name, + email: user.email, + accessToken: user.accessToken, + }); + } catch (err) { + res.status(400).json({ error: "Could not login", details: err.message }); + } +}); + +// GET /thoughts - get all thoughts (newest first) +app.get("/thoughts", async (req, res) => { + try { + const thoughts = await Thought.find() + .sort({ createdAt: -1 }) + .limit(20) + .populate("user", "name"); + res.json(thoughts); + } catch (err) { + res.status(400).json({ error: "Could not get thoughts" }); + } +}); + +// GET /thoughts/:id - get one thought +app.get("/thoughts/:id", async (req, res) => { + try { + const thought = await Thought.findById(req.params.id).populate( + "user", + "name", + ); + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + res.json(thought); + } catch (err) { + res.status(400).json({ error: "Invalid id" }); + } +}); + +// POST /thoughts - create a new thought (auth required) +app.post("/thoughts", auth, async (req, res) => { + try { + const thought = await new Thought({ + message: req.body.message, + user: req.user._id, + }).save(); + const populated = await thought.populate("user", "name"); + res.status(201).json(populated); + } catch (err) { + res + .status(400) + .json({ error: "Could not save thought", details: err.message }); + } +}); + +// PATCH /thoughts/:id - update a thought (auth, only owner) +app.patch("/thoughts/:id", auth, async (req, res) => { + try { + const thought = await Thought.findById(req.params.id); + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + if (String(thought.user) !== String(req.user._id)) { + return res + .status(403) + .json({ error: "You can only edit your own thoughts" }); + } + thought.message = req.body.message; + await thought.save(); + const populated = await thought.populate("user", "name"); + res.json(populated); + } catch (err) { + res + .status(400) + .json({ error: "Could not update thought", details: err.message }); + } +}); + +// DELETE /thoughts/:id - delete a thought (auth, only owner) +app.delete("/thoughts/:id", auth, async (req, res) => { + try { + const thought = await Thought.findById(req.params.id); + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + if (String(thought.user) !== String(req.user._id)) { + return res + .status(403) + .json({ error: "You can only delete your own thoughts" }); + } + await thought.deleteOne(); + res.json(thought); + } catch (err) { + res.status(400).json({ error: "Could not delete thought" }); + } +}); + +// POST /thoughts/:id/like - add a heart to a thought +app.post("/thoughts/:id/like", async (req, res) => { + try { + const thought = await Thought.findByIdAndUpdate( + req.params.id, + { $inc: { hearts: 1 } }, + { new: true }, + ); + if (!thought) { + return res.status(404).json({ error: "Thought not found" }); + } + res.json(thought); + } catch (err) { + res.status(400).json({ error: "Could not like thought" }); + } +}); + +// Start server +const port = process.env.PORT || 3000; +app.listen(port, () => { + console.log(`Server running on http://localhost:${port}`); +}); diff --git a/data.json b/data.json deleted file mode 100644 index a2c844ff..00000000 --- a/data.json +++ /dev/null @@ -1,121 +0,0 @@ -[ - { - "_id": "682bab8c12155b00101732ce", - "message": "Berlin baby", - "hearts": 37, - "createdAt": "2025-05-19T22:07:08.999Z", - "__v": 0 - }, - { - "_id": "682e53cc4fddf50010bbe739", - "message": "My family!", - "hearts": 0, - "createdAt": "2025-05-22T22:29:32.232Z", - "__v": 0 - }, - { - "_id": "682e4f844fddf50010bbe738", - "message": "The smell of coffee in the morning....", - "hearts": 23, - "createdAt": "2025-05-22T22:11:16.075Z", - "__v": 0 - }, - { - "_id": "682e48bf4fddf50010bbe737", - "message": "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED 🤞🏼\n", - "hearts": 6, - "createdAt": "2025-05-21T21:42:23.862Z", - "__v": 0 - }, - { - "_id": "682e45804fddf50010bbe736", - "message": "I am happy that I feel healthy and have energy again", - "hearts": 13, - "createdAt": "2025-05-21T21:28:32.196Z", - "__v": 0 - }, - { - "_id": "682e23fecf615800105107aa", - "message": "cold beer", - "hearts": 2, - "createdAt": "2025-05-21T19:05:34.113Z", - "__v": 0 - }, - { - "_id": "682e22aecf615800105107a9", - "message": "My friend is visiting this weekend! <3", - "hearts": 6, - "createdAt": "2025-05-21T18:59:58.121Z", - "__v": 0 - }, - { - "_id": "682cec1b17487d0010a298b6", - "message": "A god joke: \nWhy did the scarecrow win an award?\nBecause he was outstanding in his field!", - "hearts": 12, - "createdAt": "2025-05-20T20:54:51.082Z", - "__v": 0 - }, - { - "_id": "682cebbe17487d0010a298b5", - "message": "Tacos and tequila🌮🍹", - "hearts": 2, - "createdAt": "2025-05-19T20:53:18.899Z", - "__v": 0 - }, - { - "_id": "682ceb5617487d0010a298b4", - "message": "Netflix and late night ice-cream🍦", - "hearts": 1, - "createdAt": "2025-05-18T20:51:34.494Z", - "__v": 0 - }, - { - "_id": "682c99ba3bff2d0010f5d44e", - "message": "Summer is coming...", - "hearts": 2, - "createdAt": "2025-05-20T15:03:22.379Z", - "__v": 0 - }, - { - "_id": "682c706c951f7a0017130024", - "message": "Exercise? I thought you said extra fries! 🍟😂", - "hearts": 14, - "createdAt": "2025-05-20T12:07:08.185Z", - "__v": 0 - }, - { - "_id": "682c6fe1951f7a0017130023", - "message": "I’m on a seafood diet. I see food, and I eat it.", - "hearts": 4, - "createdAt": "2025-05-20T12:04:49.978Z", - "__v": 0 - }, - { - "_id": "682c6f0e951f7a0017130022", - "message": "Cute monkeys🐒", - "hearts": 2, - "createdAt": "2025-05-20T12:01:18.308Z", - "__v": 0 - }, - { - "_id": "682c6e65951f7a0017130021", - "message": "The weather is nice!", - "hearts": 0, - "createdAt": "2025-05-20T11:58:29.662Z", - "__v": 0 - }, - { - "_id": "682bfdb4270ca300105af221", - "message": "good vibes and good things", - "hearts": 3, - "createdAt": "2025-05-20T03:57:40.322Z", - "__v": 0 - }, - { - "_id": "682bab8c12155b00101732ce", - "message": "Berlin baby", - "hearts": 37, - "createdAt": "2025-05-19T22:07:08.999Z", - "__v": 0 - } -] \ No newline at end of file diff --git a/package.json b/package.json deleted file mode 100644 index bf25bb68..00000000 --- a/package.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "project-api", - "version": "1.0.0", - "description": "Project API", - "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", - "cors": "^2.8.5", - "express": "^4.17.3", - "nodemon": "^3.0.1" - } -} diff --git a/pull_request_template.md b/pull_request_template.md deleted file mode 100644 index fb9fdc30..00000000 --- a/pull_request_template.md +++ /dev/null @@ -1 +0,0 @@ -Please include your Render link here. \ No newline at end of file diff --git a/server.js b/server.js deleted file mode 100644 index f47771bd..00000000 --- a/server.js +++ /dev/null @@ -1,22 +0,0 @@ -import cors from "cors" -import express from "express" - -// 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 -const app = express() - -// Add middlewares to enable cors and json body parsing -app.use(cors()) -app.use(express.json()) - -// Start defining your routes here -app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) - -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -})