diff --git a/data.json b/data.json index a2c844ff..11ff432b 100644 --- a/data.json +++ b/data.json @@ -1,5 +1,5 @@ [ - { + { "_id": "682bab8c12155b00101732ce", "message": "Berlin baby", "hearts": 37, @@ -7,7 +7,7 @@ "__v": 0 }, { - "_id": "682e53cc4fddf50010bbe739", + "_id": "682e53cc4fddf50010bbe739", "message": "My family!", "hearts": 0, "createdAt": "2025-05-22T22:29:32.232Z", @@ -25,7 +25,7 @@ "message": "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED 🤞🏼\n", "hearts": 6, "createdAt": "2025-05-21T21:42:23.862Z", - "__v": 0 + "__v": 0 }, { "_id": "682e45804fddf50010bbe736", @@ -53,7 +53,7 @@ "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 + "__v": 0 }, { "_id": "682cebbe17487d0010a298b5", @@ -74,7 +74,7 @@ "message": "Summer is coming...", "hearts": 2, "createdAt": "2025-05-20T15:03:22.379Z", - "__v": 0 + "__v": 0 }, { "_id": "682c706c951f7a0017130024", @@ -118,4 +118,4 @@ "createdAt": "2025-05-19T22:07:08.999Z", "__v": 0 } -] \ No newline at end of file +] diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js new file mode 100644 index 00000000..b1b4f26b --- /dev/null +++ b/middleware/authMiddleware.js @@ -0,0 +1,26 @@ +import { User } from "../models/User.js"; + +export 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({ + success: false, + message: "Unauthorized: Invalid or missing access token", + loggedOut: true, + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Internal server error", + error: error.message, + }); + } +}; diff --git a/models/Thoughts.js b/models/Thoughts.js new file mode 100644 index 00000000..c7399707 --- /dev/null +++ b/models/Thoughts.js @@ -0,0 +1,23 @@ +import mongoose, { Schema } from "mongoose"; + +// Mongoose schema and model for Thought +const thoughtsSchema = new mongoose.Schema({ + message: { + type: String, + required: [true, "Message is required"], + trim: true, + minlength: [5, "Message must be at least 5 characters"], + maxlength: [140, "Message cannot be longer than 140 characters"], + }, + hearts: { + type: Number, + default: 0, + min: [0, "Hearts cannot be negative"], + }, + createdAt: { + type: Date, + default: Date.now, + }, +}); + +export const Thoughts = mongoose.model("Thoughts", thoughtsSchema); diff --git a/models/User.js b/models/User.js new file mode 100644 index 00000000..2b2fe4e0 --- /dev/null +++ b/models/User.js @@ -0,0 +1,31 @@ +import mongoose, { Schema } from "mongoose"; +import crypto from "crypto"; + +const UserSchema = new Schema( + { + username: { + type: String, + required: true, + unique: true, + trim: true, + }, + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + password: { + type: String, + required: true, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, + }, + { timestamps: true }, +); + +export const User = mongoose.model("User", UserSchema); diff --git a/package.json b/package.json index bf25bb68..fe038cc2 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/SaraEnderborg/js-project-api#readme", + "bugs": { + "url": "https://github.com/SaraEnderborg/js-project-api/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/SaraEnderborg/js-project-api.git" + }, + "license": "ISC", + "author": "", + "type": "module", + "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", + "@babel/core": "^7.28.6", + "@babel/node": "^7.28.6", + "@babel/preset-env": "^7.28.6", + "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/routes/thoughtRoutes.js b/routes/thoughtRoutes.js new file mode 100644 index 00000000..7788612f --- /dev/null +++ b/routes/thoughtRoutes.js @@ -0,0 +1,240 @@ +import express from "express"; +import mongoose from "mongoose"; +import { Thoughts } from "../models/Thoughts.js"; +import { authenticateUser } from "../middleware/authMiddleware.js"; + +const router = express.Router(); + +// Get all thoughts with optional filters +router.get("/", async (req, res) => { + const { minHearts, search, limit, sort } = req.query; + + const query = {}; + + if (minHearts) { + query.hearts = { $gte: Number(minHearts) }; //$gte-greater than or equal to + } + if (search) { + query.message = { $regex: search, $options: "i" }; //i for case-insensitive, regex=regular expression) for pattern matching, options for additional settings + } + + const sortOptions = sort === "createdAt" ? { createdAt: -1 } : {}; + + try { + let thoughtsQuery = Thoughts.find(query).sort(sortOptions); + + if (limit) { + thoughtsQuery = thoughtsQuery.limit(Number(limit)); + } + + const thoughts = await thoughtsQuery; + + if (thoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No thoughts found matching the criteria", + }); + } + return res.status(200).json({ + success: true, + response: thoughts, + message: "Thoughts retrieved successfully", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: [], + message: "An error occurred while retrieving thoughts", + }); + } +}); + +// Get a single thought by 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 Thoughts.findById(id); + + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found", + }); + } + + return res.status(200).json({ + success: true, + response: thought, + message: "Thought retrieved successfully", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: null, + message: "An error occurred while retrieving the thought", + }); + } +}); + +router.post("/", authenticateUser, async (req, res) => { + const body = req.body; + + try { + const newThought = new Thoughts({ + message: body.message, + hearts: body.hearts, + }); + + const createdThought = await newThought.save(); + + return res.status(201).json({ + success: true, + response: createdThought, + message: "Thought created successfully", + }); + } catch (error) { + return res.status(400).json({ + success: false, + response: null, + message: error.message, + }); + } +}); + +router.delete("/:id", authenticateUser, 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 deletedThought = await Thoughts.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) { + res.status(500).json({ + success: false, + response: null, + message: "Failed to delete thought", + }); + } +}); + +router.patch("/:id", authenticateUser, async (req, res) => { + const { id } = req.params; + const { message } = req.body; + + if (!message) { + return res.status(400).json({ + success: false, + response: null, + message: "Message is required for update", + }); + } + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format", + }); + } + + try { + const updatedThought = await Thoughts.findByIdAndUpdate( + id, + { 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) { + return res.status(400).json({ + success: false, + response: null, + message: error.message, + }); + } +}); + +router.patch("/:id/like", 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 updatedThought = await Thoughts.findByIdAndUpdate( + id, + { $inc: { hearts: 1 } }, // $inc is a MongoDB operator, increments the hearts field by 1 and saves the updated value in the database + { 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 liked successfully", + }); + } catch (error) { + console.log("LIKE error:", error); + return res.status(500).json({ + success: false, + response: null, + message: "An error occurred while liking the thought", + }); + } +}); + +export default router; diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 00000000..96ee9e28 --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,98 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import { User } from "../models/User.js"; + +const router = express.Router(); + +router.post("/user-signup", async (req, res) => { + try { + const { username, email, password } = req.body; + + if (!username || !email || !password) { + return res.status(400).json({ + success: false, + message: "Username, email, and password are required", + }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + message: "invalid email format", + }); + } + + const existingUser = await User.findOne({ email: email.toLowerCase() }); + + if (existingUser) { + return res.status(409).json({ + success: false, + message: "An error occurred when creating the user", + }); + } + + const salt = bcrypt.genSaltSync(); + const hashedPassword = bcrypt.hashSync(password, salt); + const user = new User({ + username, + email: email.toLowerCase(), + password: hashedPassword, + }); + + await user.save(); + + res.status(201).json({ + success: true, + message: "User created successfully", + response: { + username: user.username, + email: user.email, + userId: user._id, + accessToken: user.accessToken, + }, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: "Failed to create user", + response: error, + }); + } +}); + +router.post("/user-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: { + username: user.username, + email: user.email, + userId: user._id, + accessToken: user.accessToken, + }, + }); + } else { + res.status(401).json({ + success: false, + message: "Invalid email or password", + response: null, + }); + } + } catch (error) { + res.status(400).json({ + success: false, + message: "Login failed", + response: error, + }); + } +}); + +export default router; diff --git a/server.js b/server.js index f47771bd..d8b89ca2 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,39 @@ -import cors from "cors" -import express from "express" +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import mongoose from "mongoose"; +import userRoutes from "./routes/userRoutes.js"; +import thoughtRoutes from "./routes/thoughtRoutes.js"; +import listEndpoints from "express-list-endpoints"; -// 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() +const mongoUrl = process.env.MONGO_URL; -// Add middlewares to enable cors and json body parsing -app.use(cors()) -app.use(express.json()) +try { + await mongoose.connect(mongoUrl); + console.log("Connected to MongoDB"); +} catch (error) { + console.error("MongoDB connection error:", error); + process.exit(1); +} -// Start defining your routes here -app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) +const port = process.env.PORT || 8080; +const app = express(); + +app.use(cors()); +app.use(express.json()); + +const endpoints = listEndpoints(app); + +app.get("/", (_req, res) => { + res.json({ + message: "Welcome to my Happy Thoughts API.", + endpoints, + }); +}); + +app.use("/users", userRoutes); +app.use("/thoughts", thoughtRoutes); -// Start the server app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) + console.log(`Server running on http://localhost:${port}`); +});