diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..aef8443 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/README.md b/README.md index 0f9f073..8d863fa 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,35 @@ -# 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. +This repository contains the backend API for Happy Thoughts, built with Node.js, Express, and MongoDB. The API handles authentication, authorization, data validation, and all CRUD operations for thoughts and users. -## Getting started +The API is fully RESTful and deployed to Render. -Install dependencies with `npm install`, then start the server by running `npm run dev` +## Live Site: https://happysharing.netlify.app/ -## 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. +## Features + +- User authentication (sign up & login) +- Password hashing with bcrypt +- Token-based authorization +- Create, read, update & delete thoughts +- Allow anonymous posting +- Like thoughts (authenticated & anonymous) +- Track which users liked which thoughts +- Fetch thoughts liked by the logged-in user +- Filtering & sorting thoughts: By date and number of likes +- Input validation & error handling +- Secure routes for authenticated actions only + +--- + +## Tech Stack + +- Node.js +- Express +- MongoDB +- Mongoose +- bcrypt +- RESTful API design +- Render (deployment) diff --git a/middlewares/authMiddleware.js b/middlewares/authMiddleware.js new file mode 100644 index 0000000..c379e98 --- /dev/null +++ b/middlewares/authMiddleware.js @@ -0,0 +1,36 @@ +import User from "../models/User"; + + +// Global middleware for authentication - To attach req.user everywhere +// I.e. if there is an accessToken in request header, find the matching user of it and attach it to every request +export const optionalAuth = async (req, res, next) => { + try { + const accessToken = req.headers.authorization; + + if (!accessToken) { + return next(); + } + + const matchingUser = await User.findOne({ accessToken: accessToken }); + + if (matchingUser) { + req.user = matchingUser + } + + next(); + + } catch(error) { + console.error("Optional auth error:", error) + next(); + } +}; + + +// To be used in routes that should only be accessed by authorized users +export const authenticateUser = (req, res, next) => { + + if (!req.user) { + return res.status(401).json({ loggedOut: true }); + } + next(); +}; diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..f82eb3f --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,30 @@ +import mongoose from "mongoose"; + +const ThoughtSchema = new mongoose.Schema({ + message: { + type: String, + required: true, + minlength: 1, + maxlength: 140 + }, + hearts: [ + { + userId: { type: mongoose.Schema.Types.ObjectId, default: null } + } + ], + createdAt: { + type: Date, + default: Date.now + }, + editToken: { + type: String, + default: () => crypto.randomUUID() + }, + // For logged-in users: + userId: { + type: mongoose.Schema.Types.ObjectId, + default: null + } +}); + +export default mongoose.model("Thought", ThoughtSchema); \ No newline at end of file diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..6383d2d --- /dev/null +++ b/models/User.js @@ -0,0 +1,24 @@ +import mongoose from "mongoose"; +import crypto from "crypto"; + +const UserSchema = new mongoose.Schema({ + name: { + type: String, + required: true + }, + email: { + type: String, + unique: true, + required: true + }, + password: { + type: String, + required: true + }, + accessToken : { + type: String, + default: () => crypto.randomBytes(128).toString("hex") + } +}); + +export default mongoose.model("User", UserSchema); \ No newline at end of file diff --git a/package.json b/package.json index bf25bb6..48e2d20 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-nodejs": "^0.0.3", "cors": "^2.8.5", + "dotenv": "^17.2.3", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "mongodb": "^7.0.0", + "mongoose": "^9.1.5", "nodemon": "^3.0.1" } } diff --git a/routes/thoughtRoutes.js b/routes/thoughtRoutes.js new file mode 100644 index 0000000..f0d1895 --- /dev/null +++ b/routes/thoughtRoutes.js @@ -0,0 +1,198 @@ +import express from "express"; +import mongoose from "mongoose"; +import Thought from "../models/Thought"; +import { authenticateUser } from "../middlewares/authMiddleware"; +import dotenv from "dotenv"; +dotenv.config(); + +// Endpoint is /thoughts +const router = express.Router(); + + +// All thoughts +router.get("/", async (req, res) => { + + try { + const { minLikes, sortBy, order } = req.query; + const sortingOrder = order === "asc" ? 1 : -1; + + // Variable for telling MongoDB how to prepare the data + const filterAndSort = []; + + // Compute the like count from the hearts array, to use in the filtering + filterAndSort.push({ + $addFields: { + likesCount: { $size: { $ifNull: ["$hearts", []] } } // Handle empty/null hearts + } + }); + + /* --- Functionality for filtering --- */ + if (minLikes) { + filterAndSort.push({ + $match: { likesCount: { $gte: Number(minLikes) } } //gte = Greater than or equals to + }); + } + + /* --- Functionality for sorting --- */ + const sortCriteria = {}; + if (sortBy === "date") { + sortCriteria.createdAt = sortingOrder; + } else if (sortBy === "likes") { + sortCriteria.likesCount = sortingOrder; + sortCriteria.createdAt = -1; // Secondary sort by date + } else { + sortCriteria.createdAt = -1; // Default sorting + } + + filterAndSort.push({ $sort: sortCriteria }); + + /// Remove editToken to prevent it being exposed to users + filterAndSort.push({ + $project: { editToken: 0 } + }); + + /* --- Execute filter and sorting --- */ + const thoughts = await Thought.aggregate(filterAndSort); + + const result = thoughts.map((thought) => { + const isCreator = req.user && thought.userId?.equals(req.user._id); + delete thought.userId; // Remove userId (after isCreator is computed) to prevent it from being exposed on front-end + return { + ...thought, + isCreator + }; + }); + res.json(result); + } catch (error) { + console.error("GET /thoughts error:", error); + res.status(500).json({ message: "Failed to fetch thoughts", error: error.message }); + } +}); + + +// Post a thought +router.post("/", async (req, res) => { + try { + const message = req.body.message; + + // Use mongoose model to create a database entry + const newThought = new Thought({ + message, + userId: req.user ? req.user._id : null + }); + + const savedThought = await newThought.save(); + + res.status(201).json(savedThought); + } catch(error) { + res.status(400).json({ + message: "Failed to save thought to database", + error: error.message + }); + } +}); + + +// Delete a thought +router.delete("/id/:id", async (req, res) => { + const id = req.params.id; + + // Error handling for invalid id input + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: `Invalid id: ${id}` }); + } + + try { + const deletedThought = await Thought.findByIdAndDelete(id); + + // Error handling for no ID match + if(!deletedThought) { + return res.status(404).json({ error: `Thought with id ${id} not found` }); + } + + res.json(deletedThought); + + } catch(error) { + res.status(500).json({error: error.message}); + } +}); + + +// Update the like count of a thought +router.patch("/id/:id/like", async (req, res) => { + try { + const { id } = req.params; + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: `Invalid id: ${id}` }); + } + + const updatedThought = await Thought.findByIdAndUpdate( + id, + { $push: { hearts: { userId: req.user ? req.user._id : null } } }, //Ensures the updated heart count gets returned, and that schema validation also is performed + { new: true, runValidators: true } + ); + + // Error handling for no ID match + if(!updatedThought) { + return res.status(404).json({ error: `Thought with id ${id} not found` }); + } + + res.json(updatedThought); + + } catch(error) { + res.status(500).json({ error: error.message }); + } +}); + + +// Update the message of a thought +router.patch("/id/:id/message", async (req, res) => { + const { id } = req.params; + const { message } = req.body; + + // Error handling for invalid id input + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: `Invalid id: ${id}` }); + } + + try { + const updatedThought = await Thought.findByIdAndUpdate( + id, + { message }, + { new: true, runValidators: true} //Ensures the updated message gets returned, and that schema validation also is performed on the new message + ); + + // Error handling for no ID match + if(!updatedThought) { + return res.status(404).json({error: `Thought with id ${id} not found`}); + } + + res.json(updatedThought); + + } catch(err) { + res.status(500).json({error: err.message}); + } +}); + + +/* --- Authenticated only routes ---*/ + + +// Liked thoughts +router.get("/liked", authenticateUser, async (req, res) => { + try { + const likedThoughts = await Thought + .find({ "hearts.userId": req.user._id }) + .sort({ createdAt: -1 }); + + res.json(likedThoughts); + + } catch (error) { + console.error("GET /thoughts error:", error); + res.status(500).json({ message: "Failed to fetch liked thoughts", error: error.message }); + } +}); + + +export default router; \ No newline at end of file diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 0000000..b651495 --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,64 @@ +import express from "express"; +import User from "../models/User"; +import dotenv from "dotenv"; +dotenv.config(); +import bcrypt from "bcrypt-nodejs"; + +// Endpoint is /users +const router = express.Router(); + +// Create a new user (sign-up) +router.post("/signup", async (req, res) => { + try { + const { name, email, password } = req.body; + const salt = bcrypt.genSaltSync(); + + // Use mongoose model to create a database entry + const user = new User({ name, email, password: bcrypt.hashSync(password, salt) }); + await user.save(); + + res.status(200).json({ + success: true, + message: "User created successfully", + id: user._id, + accessToken: user.accessToken, + name: user.name + }); + } catch(error) { + res.status(400).json({ + success: false, + message: "Failed to create user", + error: error.errors}); + } +}); + + +// Login +router.post("/login", async (req, res) => { + try{ + const { email, password } = req.body; + const user = await User.findOne({email: email}); + + if(!user || !bcrypt.compareSync(password, user.password)) { + return res.status(401).json({ error: "Invalid user credentials" }); + } + + res.json({ + success: true, + message: "Login success", + userId: user._id, + accessToken: user.accessToken, + name: user.name + }); + + } catch(error) { + res.status(500).json({ + success: false, + message: "Something went wrong", + response: error, + }); + } +}); + + +export default router; \ No newline at end of file diff --git a/seedDatabase.js b/seedDatabase.js new file mode 100644 index 0000000..29b0f0a --- /dev/null +++ b/seedDatabase.js @@ -0,0 +1,21 @@ +import Thought from "./models/Thought" + +export const seedDatabase = async () => { + await Thought.deleteMany() // To not have duplicates every time this function runs + + /* --- Using the models created to add data --- */ + + await new Thought({ message: "Berlin baby", hearts: [{ userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }], createdAt: "2025-05-19T22:07:08.999Z" }).save(); + await new Thought({ message: "My family!", createdAt: "2025-05-22T22:29:32.232Z" }).save(); + await new Thought({ message: "The smell of coffee in the morning....", hearts: [{ userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null } ], createdAt: "2025-05-22T22:11:16.075Z" }).save(); + await new Thought({ message: "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED 🤞🏼\n", hearts: [{ userId: null }, { userId: null }, { userId: null }, { userId: null }], createdAt: "2025-05-21T21:42:23.862Z" }).save(); + await new Thought({ message: "I am happy that I feel healthy and have energy again", hearts: [{ userId: null }, { userId: null }, { userId: null }, { userId: null }], createdAt: "2025-05-21T21:28:32.196Z" }).save(); + await new Thought({ message: "Cold beer", hearts: [{ userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }], createdAt: "2025-05-21T19:05:34.113Z" }).save(); + await new Thought({ message: "My friend is visiting this weekend! <3", hearts: [{ userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }], createdAt: "2025-05-21T18:59:58.121Z" }).save(); + await new Thought({ message: "A good joke: \nWhy did the scarecrow win an award?\nBecause he was outstanding in his field!", hearts: [{ userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }, { userId: null }], createdAt: "2025-05-20T20:54:51.082Z" }).save(); + await new Thought({ message: "Tacos and tequila🌮🍹", hearts: [{ userId: null }, { userId: null }], createdAt: "2025-05-19T20:53:18.899Z" }).save(); + await new Thought({ message: "Netflix and late night ice-cream🍦", hearts: [{ userId: null }], createdAt: "2025-05-18T20:51:34.494Z" }).save(); + await new Thought({ message: "The weather is nice!", hearts: [{ userId: null }, { userId: null }], createdAt: "2025-05-20T15:03:22.379Z" }).save(); + await new Thought({ message: "Summer is coming...", createdAt: "2025-05-20T11:58:29.662Z" }).save(); + await new Thought({ message: "good vibes and good things", hearts: [{ userId: null }, { userId: null }, { userId: null }], createdAt: "2025-05-20T03:57:40.322Z" }).save(); +}; \ No newline at end of file diff --git a/server.js b/server.js index f47771b..7ead05d 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,53 @@ -import cors from "cors" -import express from "express" +import cors from "cors"; +import express from "express"; +import expressListEndpoints from "express-list-endpoints"; +import mongoose from "mongoose"; +import { seedDatabase } from "./seedDatabase"; +import dotenv from "dotenv"; +dotenv.config(); +import { optionalAuth } from "./middlewares/authMiddleware.js"; +import thoughtRoutes from "./routes/thoughtRoutes.js"; +import userRoutes from "./routes/userRoutes.js"; // 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() +// 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()) +app.use(cors()); +app.use(express.json()); + +app.use(optionalAuth); // Global middleware for authentication - To attach req.user everywhere if there is an accessToken in the request header + + +/* --- Routes --- */ -// Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) + const endpoints = expressListEndpoints(app); + res.json({ + message: "Welcome to the Happy Thoughts API", + endpoints: endpoints + }); +}); + +// The connections to the different routes with endpoints +app.use("/users", userRoutes); +app.use("/thoughts", thoughtRoutes); + + +/* --- Connect to Mongo --- */ +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/thoughts"; +mongoose.connect(mongoUrl) + .then(() => { + console.log('MongoDB connected'); + + // await seedDatabase(); // Temporary seeding (add async & await) -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) + // Start the server + app.listen(port, () => { + console.log(`Server running on http://localhost:${port}`); + }); + }) + .catch(err => console.error('MongoDB connection error:', err)); \ No newline at end of file