diff --git a/data/data.json b/data/data.json new file mode 100644 index 0000000..3836db6 --- /dev/null +++ b/data/data.json @@ -0,0 +1,42 @@ +[ + { + "id": 1, + "message": "I finally fixed the login bug! 🎉", + "category": "project", + "hearts": 3, + "createdAt": "2024-06-01T10:30:00Z", + "likedBy": ["user123"] + }, + { + "id": 2, + "message": "Meal prepped for the week. Feeling organized!", + "category": "home", + "hearts": 5, + "createdAt": "2024-06-03T08:15:00Z", + "likedBy": [] + }, + { + "id": 3, + "message": "Pasta night with friends 🍝❤️", + "category": "food", + "hearts": 8, + "createdAt": "2024-06-05T19:00:00Z", + "likedBy": ["user123", "user456"] + }, + { + "id": 4, + "message": "Started a new React project today!", + "category": "project", + "hearts": 4, + "createdAt": "2024-06-06T14:00:00Z", + "likedBy": [] + }, + { + "id": 5, + "message": "Cleaned the whole apartment and lit some candles 🕯️", + "category": "home", + "hearts": 2, + "createdAt": "2024-06-07T09:00:00Z", + "likedBy": ["user789"] + } +] diff --git a/middlewares/auth.js b/middlewares/auth.js new file mode 100644 index 0000000..67006ec --- /dev/null +++ b/middlewares/auth.js @@ -0,0 +1,22 @@ +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET; + +export const authenticate = (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader) + return res.status(401).json({ message: "Authorization header missing" }); + + const token = authHeader.split(" ")[1]; + + if (!token) return res.status(401).json({ message: "Token missing" }); + + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = decoded; + next(); + } catch (error) { + res.status(401).json({ message: "Invalid or expired token" }); + } +}; diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..b63cc02 --- /dev/null +++ b/models/User.js @@ -0,0 +1,27 @@ +import bcrypt from "bcrypt"; +import mongoose from "mongoose"; + +const userSchema = new mongoose.Schema({ + username: { type: String, required: true, unique: true, minlength: 3 }, + email: { type: String, required: true, unique: true, lowercase: true }, + password: { type: String, required: true, minlength: 6 }, +}); + +userSchema.pre("save", async function (next) { + if (!this.isModified("password")) return next(); + + try { + const salt = await bcrypt.genSalt(10); + this.password = await bcrypt.hash(this.password, salt); + next(); + } catch (error) { + next(error); + } +}); + +userSchema.methods.comparePassword = async function (candidatePassword) { + return bcrypt.compare(candidatePassword, this.password); +}; + +const User = mongoose.model("User", userSchema); +export default User; diff --git a/package.json b/package.json index bf25bb6..eb38449 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": "^16.5.0", "express": "^4.17.3", - "nodemon": "^3.0.1" + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.15.1", + "nodemon": "^3.1.10" } } diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..f10f6a8 --- /dev/null +++ b/routes/auth.js @@ -0,0 +1,73 @@ +import express from "express"; +import jwt from "jsonwebtoken"; + +import User from "../models/User.js"; + +const router = express.Router(); +const JWT_SECRET = process.env.JWT_SECRET; + +// signing up as a user +router.post("/signup", async (req, res) => { + const { username, email, password } = req.body; + + try { + const existingUser = await User.findOne({ $or: [{ email }, { username }] }); + if (existingUser) { + return res + .status(400) + .json({ success: false, message: "Username or email already exists." }); + } + + const newUser = new User({ username, email, password }); + await newUser.save(); + + const token = jwt.sign( + { id: newUser._id, username: newUser.username }, + JWT_SECRET, + { + expiresIn: "7d", + } + ); + + res.status(201).json({ success: true, message: "User created", token }); + } catch (error) { + res + .status(500) + .json({ success: false, message: "Error creating user", error }); + } +}); + +// login +router.post("/login", async (req, res) => { + const { email, password } = req.body; + + try { + const user = await User.findOne({ email }); + if (!user) { + return res + .status(401) + .json({ success: false, message: "Invalid email or password" }); + } + + const isMatch = await user.comparePassword(password); + if (!isMatch) { + return res + .status(401) + .json({ success: false, message: "Invalid email or password" }); + } + + const token = jwt.sign( + { id: user._id, username: user.username }, + JWT_SECRET, + { + expiresIn: "7d", + } + ); + + res.status(200).json({ success: true, message: "Logged in", token }); + } catch (error) { + res.status(500).json({ success: false, message: "Login error", error }); + } +}); + +export default router; diff --git a/server.js b/server.js index f47771b..73a936b 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,324 @@ -import cors from "cors" -import express from "express" +import cors from "cors"; +import dotenv from "dotenv"; +import express from "express"; +import listEndpoints from "express-list-endpoints"; +import mongoose from "mongoose"; -// 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() +import data from "./data/data.json"; +import { authenticate } from "./middlewares/auth.js"; +import authRoutes from "./routes/auth.js"; -// Add middlewares to enable cors and json body parsing -app.use(cors()) -app.use(express.json()) +const allowedOrigins = [ + "http://localhost:5173", + "https://happy-thoughts-messaging-app.netlify.app", +]; -// Start defining your routes here +dotenv.config(); + +const port = process.env.PORT || 8080; +const app = express(); + +// MIDDLEWARES // +// app.use(cors()); +app.use( + cors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); + + const allowed = allowedOrigins.map((o) => o.replace(/\/$/, "")); + const normalized = origin.replace(/\/$/, ""); + + if (allowed.includes(normalized)) { + callback(null, true); + } else { + callback(new Error("Not allowed by CORS")); + } + }, + methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + optionsSuccessStatus: 204, + }) +); + +app.options("*", cors()); + +app.use(express.json()); +app.use("/auth", authRoutes); + +// MONGO DB CONNECTION // +const mongoUrl = process.env.MONGO_URL; +mongoose.connect(mongoUrl); +mongoose.connection.on("error", (err) => console.error("MongoDB error:", err)); + +// SCHEMA // +const thoughtSchema = new mongoose.Schema({ + message: { type: String, required: true, minlength: 3 }, + hearts: { type: Number, default: 0 }, + likedBy: { type: [String], default: [] }, + category: { type: String, default: "General" }, + createdAt: { type: Date, default: Date.now }, +}); + +const Thought = mongoose.model("Thought", thoughtSchema); + +// API DOCUMENTATION // app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) + const endpoints = listEndpoints(app); + res.json({ + message: "Welcome to the Happy Thoughts API", + endpoints: endpoints, + }); +}); + +// GET ALL THOUGHTS +app.get("/thoughts", async (req, res) => { + const { category, sortBy, page = 1, limit = 10 } = req.query; + + try { + let thoughts = await Thought.find(); + + // Filter by category + if (category) { + thoughts = thoughts.filter( + (item) => item.category?.toLowerCase() === category.toLowerCase() + ); + } + + // Sort by date + if (sortBy === "date") { + thoughts = thoughts.sort( + (a, b) => new Date(b.createdAt) - new Date(a.createdAt) + ); + } + + // Paginate + const start = (page - 1) * limit; + const end = start + +limit; + const paginatedThoughts = thoughts.slice(start, end); + + if (paginatedThoughts.length === 0) { + return res.status(404).json({ + success: false, + message: "No thoughts found for that query.", + response: [], + }); + } + + return res.status(200).json({ + success: true, + message: "Thoughts retrieved successfully.", + page: +page, + limit: +limit, + total: thoughts.length, + response: paginatedThoughts, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Error fetching thoughts.", + response: error, + }); + } +}); + +// GET ONE THOUGHT BY ID +app.get("/thoughts/:id", async (req, res) => { + try { + const { id } = req.params; + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ + success: false, + message: "Thought not found.", + }); + } + + return res.status(200).json({ + success: true, + message: "Thought found.", + response: thought, + }); + } catch (error) { + return res.status(500).json({ + success: false, + message: "Error finding thought.", + response: error, + }); + } +}); + +// GET LIKED THOUGHTS FOR A USER +app.get("/thoughts/liked/:clientId", async (req, res) => { + try { + const { clientId } = req.params; + const likedThoughts = await Thought.find({ likedBy: clientId }); + + res.status(200).json({ + success: true, + message: "Liked thoughts retrieved.", + response: likedThoughts, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Error retrieving liked thoughts.", + response: error, + }); + } +}); + +// POST THOUGHT +app.post("/thoughts", authenticate, async (req, res) => { + const { message, category } = req.body; + + try { + const newThought = await new Thought({ message, category }).save(); + + return res.status(200).json({ + success: true, + response: newThought, + message: "Thought created successfully.", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: error, + message: "Couldn't create thought.", + }); + } +}); + +// DELETE THOUGHT +app.delete("/thoughts/:id", authenticate, async (req, res) => { + const { id } = req.params; + + try { + const thought = await Thought.findByIdAndDelete(id); + + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought could not be found. Can't delete.", + }); + } + + return res.status(200).json({ + success: true, + response: thought, + message: "Thought successfully deleted.", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: error, + message: "Couldn't delete thought.", + }); + } +}); + +// PATCH A THOUGHT +app.patch("/thoughts/:id", authenticate, async (req, res) => { + const { id } = req.params; + const { newMessage } = req.body; + + try { + const thought = await Thought.findByIdAndUpdate( + id, + { message: newMessage }, + { new: true, runValidators: true } + ); + + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought couldn't be found.", + }); + } + + return res.status(200).json({ + success: true, + response: thought, + message: "Thought updated successfully.", + }); + } catch (error) { + return res.status(500).json({ + success: false, + response: error, + message: "Thought unable to be updated.", + }); + } +}); + +// LIKE A THOUGHT +app.post("/thoughts/:id/like", authenticate, async (req, res) => { + try { + const thought = await Thought.findById(req.params.id); + + if (!thought) { + return res + .status(404) + .json({ success: false, message: "Thought not found" }); + } + + if (thought.likedBy.includes(req.user.id)) { + return res.status(400).json({ + success: false, + message: "You have already liked this thought", + }); + } + + thought.hearts += 1; + thought.likedBy.push(req.user.id); + await thought.save(); + + return res + .status(200) + .json({ success: true, message: "Thought liked", thought }); + } catch (error) { + return res + .status(500) + .json({ success: false, message: "Error liking thought", error }); + } +}); + +// UNLIKE A THOUGHT +app.delete("/thoughts/:id/like", authenticate, async (req, res) => { + try { + const thought = await Thought.findById(req.params.id); + + if (!thought) { + return res + .status(404) + .json({ success: false, message: "Thought not found" }); + } + + if (!thought.likedBy.includes(req.user.id)) { + return res + .status(400) + .json({ success: false, message: "You haven't liked this thought" }); + } + + thought.hearts = Math.max(0, thought.hearts - 1); + thought.likedBy = thought.likedBy.filter( + (userId) => userId !== req.user.id + ); + await thought.save(); + + return res + .status(200) + .json({ success: true, message: "Thought unliked", thought }); + } catch (error) { + return res + .status(500) + .json({ success: false, message: "Error unliking thought", error }); + } +}); -// Start the server +// START SERVER // app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) + console.log(`Server running on http://localhost:${port}`); +});