diff --git a/README.md b/README.md index 0f9f073..6a80a66 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,53 @@ -# 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. +A REST API for the Happy Thoughts app. Built with Express.js and MongoDB. -## Getting started +**Live:** https://js-project-api-7ve2.onrender.com -Install dependencies with `npm install`, then start the server by running `npm run dev` +**Frontend:** [js-project-happy-thoughts](https://github.com/Qabalany/js-project-happy-thoughts) -## View it live +## Tech -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. +Express, MongoDB Atlas, Mongoose, bcrypt, JWT, dotenv, Babel, Render + +## Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/` | API docs | +| GET | `/thoughts` | Get all thoughts | +| GET | `/thoughts/:id` | Get one thought | +| POST | `/thoughts` | Create thought | +| POST | `/thoughts/:id/like` | Like a thought | +| PUT | `/thoughts/:id` | Update thought (auth required) | +| DELETE | `/thoughts/:id` | Delete thought (auth required) | +| POST | `/signup` | Register user | +| POST | `/login` | Login and get token | + +### Query Params (GET /thoughts) + +| Param | Default | Example | +|-------|---------|---------| +| `sort` | `date` | `?sort=hearts` | +| `order` | `desc` | `?order=asc` | +| `minHearts` | — | `?minHearts=5` | +| `page` | `1` | `?page=2` | +| `limit` | `20` | `?limit=10` | + +## Run Locally + +```bash +npm install +npm run dev +``` + +Create a `.env` file: +``` +MONGODB_URI=your_mongodb_connection_string +JWT_SECRET=your_secret_key +PORT=8080 +``` + +## View It Live + +https://js-project-api-7ve2.onrender.com diff --git a/data/thoughts.json b/data/thoughts.json new file mode 100644 index 0000000..deeb9cb --- /dev/null +++ b/data/thoughts.json @@ -0,0 +1,26 @@ +[ + { + "id": "1", + "message": "I love learning backend development!", + "hearts": 5, + "createdAt": "2026-02-01T10:30:00Z" + }, + { + "id": "2", + "message": "Coffee and code make me happy", + "hearts": 12, + "createdAt": "2026-02-02T11:00:00Z" + }, + { + "id": "3", + "message": "Finally understanding Express.js!", + "hearts": 8, + "createdAt": "2026-02-03T14:20:00Z" + }, + { + "id": "4", + "message": "Building APIs is actually fun", + "hearts": 3, + "createdAt": "2026-02-04T09:15:00Z" + } +] diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..5cab679 --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,31 @@ +import mongoose from "mongoose" + +// Define the Thought schema +const ThoughtSchema = new mongoose.Schema({ + message: { + type: String, + required: [true, "Message is required"], + minlength: [5, "Message must be at least 5 characters"], + maxlength: [140, "Message cannot exceed 140 characters"], + trim: true + }, + hearts: { + type: Number, + default: 0, + min: 0 + }, + author: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null // null = anonymous thought + }, + createdAt: { + type: Date, + default: Date.now + } +}) + +// Create the Thought model +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..8800263 --- /dev/null +++ b/models/User.js @@ -0,0 +1,56 @@ +import mongoose from "mongoose" +import bcrypt from "bcrypt" + +// Define the User schema +const UserSchema = new mongoose.Schema({ + username: { + type: String, + required: [true, "Username is required"], + unique: true, + minlength: [3, "Username must be at least 3 characters"], + maxlength: [30, "Username cannot exceed 30 characters"], + trim: true + }, + email: { + type: String, + required: [true, "Email is required"], + unique: true, + trim: true, + lowercase: true, + match: [/^\S+@\S+\.\S+$/, "Please enter a valid email address"] + }, + password: { + type: String, + required: [true, "Password is required"], + minlength: [6, "Password must be at least 6 characters"] + }, + createdAt: { + type: Date, + default: Date.now + } +}) + +// Hash password before saving +UserSchema.pre("save", async function () { + // Only hash if password is new or modified + if (!this.isModified("password")) return + + const salt = await bcrypt.genSalt(10) + this.password = await bcrypt.hash(this.password, salt) +}) + +// Method to compare passwords +UserSchema.methods.comparePassword = async function (candidatePassword) { + return bcrypt.compare(candidatePassword, this.password) +} + +// Remove password from JSON output +UserSchema.methods.toJSON = function () { + const user = this.toObject() + delete user.password + return user +} + +const User = mongoose.model("User", UserSchema) + +export default User diff --git a/package.json b/package.json index bf25bb6..a44853e 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.2.3", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.3", + "mongoose": "^9.1.5", "nodemon": "^3.0.1" } } diff --git a/server.js b/server.js index f47771b..7950009 100644 --- a/server.js +++ b/server.js @@ -1,19 +1,360 @@ import cors from "cors" import express from "express" +import listEndpoints from "express-list-endpoints" +import mongoose from "mongoose" +import dotenv from "dotenv" +import jwt from "jsonwebtoken" -// 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 +// Load environment variables +dotenv.config() + +// Import models +import Thought from "./models/Thought.js" +import User from "./models/User.js" + +// Define the port - defaults to 8080 or uses environment variable const port = process.env.PORT || 8080 const app = express() -// Add middlewares to enable cors and json body parsing +// Connect to MongoDB +mongoose.connect(process.env.MONGODB_URI) + .then(() => console.log("✅ Connected to MongoDB")) + .catch((error) => console.error("❌ MongoDB connection error:", error)) + +// Middlewares to enable CORS and JSON body parsing app.use(cors()) app.use(express.json()) -// Start defining your routes here +// ============================================= +// AUTH MIDDLEWARE +// ============================================= + +// Middleware to verify JWT token (required auth) +const authenticate = async (req, res, next) => { + try { + const authHeader = req.headers.authorization + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ error: "Access denied. No token provided." }) + } + + const token = authHeader.replace("Bearer ", "") + const decoded = jwt.verify(token, process.env.JWT_SECRET) + + const user = await User.findById(decoded.userId) + if (!user) { + return res.status(401).json({ error: "Invalid token. User not found." }) + } + + req.user = user + next() + } catch (error) { + res.status(401).json({ error: "Invalid or expired token." }) + } +} + +// Optional auth: attaches user if token present, but doesn't require it +const optionalAuth = async (req, res, next) => { + try { + const authHeader = req.headers.authorization + if (authHeader && authHeader.startsWith("Bearer ")) { + const token = authHeader.replace("Bearer ", "") + const decoded = jwt.verify(token, process.env.JWT_SECRET) + const user = await User.findById(decoded.userId) + if (user) req.user = user + } + } catch (error) { + // Token invalid - just continue without user + } + next() +} + +// ============================================= +// ROOT ENDPOINT +// ============================================= + app.get("/", (req, res) => { - res.send("Hello Technigo!") + res.json({ + message: "🌟 Welcome to Happy Thoughts API!", + routes: listEndpoints(app) + }) +}) + +// ============================================= +// AUTH ENDPOINTS +// ============================================= + +// POST /signup - Register a new user +app.post("/signup", async (req, res) => { + try { + const { username, email, password } = req.body + + // Check if user already exists + const existingUser = await User.findOne({ + $or: [{ email }, { username }] + }) + if (existingUser) { + const field = existingUser.email === email ? "email" : "username" + return res.status(400).json({ + error: `An account with this ${field} already exists.`, + field + }) + } + + // Create and save new user + const user = new User({ username, email, password }) + const savedUser = await user.save() + + // Generate token + const token = jwt.sign( + { userId: savedUser._id }, + process.env.JWT_SECRET, + { expiresIn: "7d" } + ) + + res.status(201).json({ + user: savedUser, + token + }) + } catch (error) { + if (error.name === "ValidationError") { + const errors = {} + Object.keys(error.errors).forEach(key => { + errors[key] = error.errors[key].message + }) + res.status(400).json({ error: "Validation failed", errors }) + } else { + res.status(500).json({ error: "Failed to create account", message: error.message }) + } + } +}) + +// POST /login - Login and get token +app.post("/login", async (req, res) => { + try { + const { email, password } = req.body + + if (!email || !password) { + return res.status(400).json({ error: "Email and password are required." }) + } + + // Find user by email + const user = await User.findOne({ email: email.toLowerCase() }) + if (!user) { + return res.status(401).json({ error: "Invalid email or password." }) + } + + // Compare password + const isMatch = await user.comparePassword(password) + if (!isMatch) { + return res.status(401).json({ error: "Invalid email or password." }) + } + + // Generate token + const token = jwt.sign( + { userId: user._id }, + process.env.JWT_SECRET, + { expiresIn: "7d" } + ) + + res.json({ + user, + token + }) + } catch (error) { + res.status(500).json({ error: "Login failed", message: error.message }) + } +}) + +// ============================================= +// THOUGHT ENDPOINTS +// ============================================= + +// GET /thoughts - Return thoughts with filtering, sorting & pagination +app.get("/thoughts", async (req, res) => { + try { + const { sort, order, minHearts, page, limit: limitParam } = req.query + + // Build filter + const filter = {} + if (minHearts) { + filter.hearts = { $gte: Number(minHearts) } + } + + // Build sort option (default: newest first) + let sortOption = { createdAt: -1 } + if (sort === "hearts") { + sortOption = { hearts: order === "asc" ? 1 : -1 } + } else if (sort === "date") { + sortOption = { createdAt: order === "asc" ? 1 : -1 } + } + + // Pagination + const pageNum = Math.max(1, Number(page) || 1) + const perPage = Math.min(100, Math.max(1, Number(limitParam) || 20)) + const skip = (pageNum - 1) * perPage + + // Count total for pagination info + const total = await Thought.countDocuments(filter) + + const thoughts = await Thought.find(filter) + .sort(sortOption) + .skip(skip) + .limit(perPage) + .populate("author", "username") + + res.json({ + thoughts, + pagination: { + page: pageNum, + limit: perPage, + total, + totalPages: Math.ceil(total / perPage) + } + }) + } catch (error) { + res.status(500).json({ + error: "Failed to fetch thoughts", + message: error.message + }) + } +}) + +// GET /thoughts/:id - Return a single thought (public) +app.get("/thoughts/:id", async (req, res) => { + try { + const { id } = req.params + const thought = await Thought.findById(id).populate("author", "username") + + if (thought) { + res.json(thought) + } else { + res.status(404).json({ error: "Thought not found" }) + } + } catch (error) { + res.status(400).json({ + error: "Invalid thought ID", + message: error.message + }) + } +}) + +// POST /thoughts - Create a new thought (authenticated) +app.post("/thoughts", optionalAuth, async (req, res) => { + try { + const { message } = req.body + + const thoughtData = { message } + // If user is logged in, link the thought to them + if (req.user) { + thoughtData.author = req.user._id + } + + const thought = new Thought(thoughtData) + const savedThought = await thought.save() + + // Populate author before returning + const populated = await savedThought.populate("author", "username") + + res.status(201).json(populated) + } catch (error) { + if (error.name === "ValidationError") { + const errors = Object.values(error.errors).map(err => err.message) + res.status(400).json({ + error: "Validation failed", + messages: errors + }) + } else { + res.status(500).json({ + error: "Failed to create thought", + message: error.message + }) + } + } +}) + +// POST /thoughts/:id/like - Increment hearts count (public) +app.post("/thoughts/:id/like", async (req, res) => { + try { + const { id } = req.params + + const thought = await Thought.findByIdAndUpdate( + id, + { $inc: { hearts: 1 } }, + { new: true } + ).populate("author", "username") + + if (thought) { + res.json(thought) + } else { + res.status(404).json({ error: "Thought not found" }) + } + } catch (error) { + res.status(400).json({ + error: "Failed to like thought", + message: error.message + }) + } +}) + +// PUT /thoughts/:id - Update a thought (authenticated, owner only) +app.put("/thoughts/:id", authenticate, async (req, res) => { + try { + const { id } = req.params + const { message } = req.body + + // Check ownership + const existing = await Thought.findById(id) + if (!existing) { + return res.status(404).json({ error: "Thought not found" }) + } + if (existing.author && existing.author.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "You can only edit your own thoughts." }) + } + + const thought = await Thought.findByIdAndUpdate( + id, + { message }, + { new: true, runValidators: true } + ).populate("author", "username") + + res.json(thought) + } catch (error) { + if (error.name === "ValidationError") { + const errors = Object.values(error.errors).map(err => err.message) + res.status(400).json({ error: "Validation failed", messages: errors }) + } else { + res.status(400).json({ error: "Failed to update thought", message: error.message }) + } + } +}) + +// DELETE /thoughts/:id - Delete a thought (authenticated, owner only) +app.delete("/thoughts/:id", authenticate, async (req, res) => { + try { + const { id } = req.params + + // Check ownership + const existing = await Thought.findById(id) + if (!existing) { + return res.status(404).json({ error: "Thought not found" }) + } + if (existing.author && existing.author.toString() !== req.user._id.toString()) { + return res.status(403).json({ error: "You can only delete your own thoughts." }) + } + + await Thought.findByIdAndDelete(id) + + res.json({ + success: true, + message: "Thought deleted successfully", + deletedThought: existing + }) + } catch (error) { + res.status(400).json({ + error: "Failed to delete thought", + message: error.message + }) + } }) // Start the server