diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..80c190e --- /dev/null +++ b/TODO.md @@ -0,0 +1,53 @@ +# βœ… TODO: Connect Frontend & Backend for Happy Thoughts + +## πŸ₯‡ Step 1: Finalize backend using MongoDB & Mongoose + +- [x] Create a `Thought` model in `models/Thought.js` + - Fields: + [x]`message` (string, required, min/max length), + [x]`hearts`, + [x]`createdAt` +- [ ] Add update thought in frontend and backend +- [x] Seed the database with sample thoughts +- [x] Create route: `GET /thoughts` β†’ return latest (e.g. 20) +- [x] Create route: `POST /thoughts` β†’ save new thought +- [x] Create route: `PATCH /thoughts/:id/like` β†’ increment hearts +- [x] Create route: `DELETE /thoughts/:id` β†’ delete a thought +- [x] BONUS: `GET /thoughts?page=2` β†’ use `.skip().limit()` for pagination +- [ ] BONUS: `GET /thoughts?category=joy` β†’ filter with query params + +--- + +## πŸ₯ˆ Step 2: Add validation & error handling + +- [x] Add Mongoose validation in the model (e.g. min/max message length) +- [x] Use `try/catch` in all routes +- [x] Return proper status codes (400, 404, 500) with `.status().json() +- [x] Return useful error messages for the frontend + +--- + +## πŸ₯‰ Step 3: Connect frontend to the API + +- [x] Change API URL in `happy-thoughts` frontend +- Update fetch requests: +- [x] GET /thoughts β†’ display the thought list +- [x] POST /thoughts β†’ send a new thought +- [x] PATCH /thoughts/:id/like β†’ like a thought +- [x] DELETE /thoughts/:id β†’ delete a thought +- [x] Show errors and loading states in the UI + +## 🏁 Step 4: Deploy & manage environments + +- [x] Deploy backend to Render +- [x] Deploy database to MongoDB Atlas +- [x] Add .env on Render with MONGO_URL +- [x] Update the frontend to point to deployed backend + +## 🌈 Step 5: Stretch goals – once core functionality is working + +- [ ] Add filtering using query parameters (/thoughts?tag=joy) +- [ ] Implement pagination using .skip() and .limit() +- [ ] Add infinite scroll to the frontend +- [ ] Sort thoughts with .sort() – by newest or most liked +- [ ] Group thoughts by category (if using categories) diff --git a/controllers/thoughtsController.js b/controllers/thoughtsController.js new file mode 100644 index 0000000..be00b1a --- /dev/null +++ b/controllers/thoughtsController.js @@ -0,0 +1,154 @@ +// controllers/thoughtsController.js +import { Thought } from "../models/Thought.js"; + +// GET /thoughts +export const getAllThoughts = async (req, res) => { + const { + minHearts, + after, + sortBy = "createdAt", + order = "desc", + page = 1, + limit = 10, + } = req.query; + + const query = {}; + if (minHearts) { + query.hearts = { $gte: parseInt(minHearts) }; + } + if (after) { + query.createdAt = { $gt: new Date(after) }; + } + + const sortOrder = order === "desc" ? -1 : 1; + const skip = (parseInt(page) - 1) * parseInt(limit); + + try { + const totalThoughts = await Thought.countDocuments(query); + const thoughts = await Thought.find(query) + .sort({ [sortBy]: sortOrder }) + .skip(skip) + .limit(parseInt(limit)); + + res.json({ + totalThoughts, + totalPages: Math.ceil(totalThoughts / limit), + currentPage: parseInt(page), + thoughts, + }); + } catch (err) { + res.status(500).json({ + success: false, + message: "Failed to get thoughts", + error: err.message, + }); + } +}; + +// GET /thoughts/:id +export const getThoughtById = async (req, res) => { + try { + const thought = await Thought.findById(req.params.id); + if (!thought) return res.status(404).json({ message: "Thought not found" }); + res.json(thought); + } catch (err) { + res.status(400).json({ message: "Invalid ID", error: err.message }); + } +}; + +// POST /thoughts +export const createThought = async (req, res) => { + const { message } = req.body; + + try { + const newThought = new Thought({ + message, + createdBy: req.user.id, + }); + const saved = await newThought.save(); + res.status(201).json(saved); + } catch (err) { + res.status(400).json({ + success: false, + message: "Failed to create thought", + error: err.message, + }); + } +}; + +// PATCH /thoughts/:id/like +export const likeThought = async (req, res) => { + try { + const updated = await Thought.findByIdAndUpdate( + req.params.id, + { $inc: { hearts: 1 } }, + { new: true } + ); + if (!updated) return res.status(404).json({ message: "Thought not found" }); + res.json(updated); + } catch (err) { + res.status(400).json({ + success: false, + message: "Failed to like thought", + error: err.message, + }); + } +}; + +// PATCH /thoughts/:id – Update the message of a thought +export const updateThought = async (req, res) => { + const { id } = req.params; + const { message } = req.body; + + try { + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ message: "Thought not found" }); + } + + if (thought.createdBy.toString() !== req.user.id) { + return res + .status(403) + .json({ message: "Not authorized to update this thought" }); + } + + thought.message = message || thought.message; + const updated = await thought.save(); + res.json(updated); + } catch (err) { + res.status(400).json({ + success: false, + message: "Failed to update thought", + error: err.message, + }); + } +}; + +// DELETE /thoughts/:id +export const deleteThought = async (req, res) => { + const { id } = req.params; + + try { + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ message: "Thought not found" }); + } + + if (thought.createdBy.toString() !== req.user.id) { + return res + .status(403) + .json({ message: "Not authorized to delete this thought" }); + } + + await thought.deleteOne(); + res.json({ success: true, message: "Thought deleted", id }); + } catch (err) { + res.status(400).json({ + success: false, + message: "Failed to delete thought", + error: err.message, + }); + } +}; diff --git a/data.json b/data/data.json similarity index 97% rename from data.json rename to data/data.json index a2c844f..11ff432 100644 --- a/data.json +++ b/data/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/auth.js b/middleware/auth.js new file mode 100644 index 0000000..d3b5043 --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,21 @@ +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "secret-key"; + +export const authenticateUser = (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader) { + return res.status(401).json({ message: "Access token missing" }); + } + + const token = authHeader.replace("Bearer ", ""); + + try { + const decoded = jwt.verify(token, JWT_SECRET); + req.user = decoded; // du kan nu anvΓ€nda req.user.id i skyddade routes + next(); + } catch (err) { + res.status(401).json({ message: "Invalid token" }); + } +}; diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..88a9dac --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,27 @@ +import mongoose from "mongoose"; + +const ThoughtSchema = new mongoose.Schema({ + message: { + type: String, + required: [true, "A message is required"], + minlength: [5, "A message must be at least 5 characters long"], + maxlength: [140, "A message can't be longer than 140 characters"], + trim: true, + }, + hearts: { + type: Number, + default: 0, + min: [0, "Hearts cannot be negative"], + }, + createdAt: { + type: Date, + default: Date.now, + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, +}); + +export const Thought = mongoose.model("Thought", ThoughtSchema); diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..64ba060 --- /dev/null +++ b/models/User.js @@ -0,0 +1,19 @@ +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; + +const UserSchema = new mongoose.Schema({ + email: { + type: String, + required: true, + unique: true, + }, + + password: { type: String, required: true }, + username: { type: String }, +}); + +UserSchema.pre("save", async function () { + this.password = await bcrypt.hash(this.password, 10); +}); + +export const User = mongoose.model("User", UserSchema); diff --git a/package.json b/package.json index bf25bb6..ba20898 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", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", + "dotenv": "^16.5.0", "express": "^4.17.3", + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.15.1", "nodemon": "^3.0.1" } } diff --git a/routes/thoughts.js b/routes/thoughts.js new file mode 100644 index 0000000..7f421b1 --- /dev/null +++ b/routes/thoughts.js @@ -0,0 +1,33 @@ +// routes/thoughts.js +import express from "express"; +import { authenticateUser } from "../middleware/auth.js"; +import { + getAllThoughts, + getThoughtById, + createThought, + likeThought, + deleteThought, + updateThought, +} from "../controllers/thoughtsController.js"; + +const router = express.Router(); + +// GET /thoughts – List all thoughts +router.get("/", getAllThoughts); + +// GET /thoughts/:id – Get one thought +router.get("/:id", getThoughtById); + +// POST /thoughts – Create a new thought +router.post("/", authenticateUser, createThought); + +// PATCH /thoughts/:id/like – Like a thought +router.patch("/:id/like", likeThought); + +// PATCH /thoughts/:id – Update a thought +router.patch("/:id", authenticateUser, updateThought); + +// DELETE /thoughts/:id – Delete a thought +router.delete("/:id", authenticateUser, deleteThought); + +export default router; diff --git a/routes/users.js b/routes/users.js new file mode 100644 index 0000000..4ea91c3 --- /dev/null +++ b/routes/users.js @@ -0,0 +1,60 @@ +import express from "express"; +import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; +import { User } from "../models/User.js"; + +const router = express.Router(); + +const JWT_SECRET = process.env.JWT_SECRET || "secret-key"; + +// POST /register +router.post("/register", async (req, res) => { + const { email, password, username } = req.body; + + if (!email || !password) { + return res.status(400).json({ message: "Email and password are required" }); + } + + try { + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res + .status(400) + .json({ message: "That email address already exists" }); + } + + const user = await new User({ email, password, username }).save(); + + const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: "24h" }); + + res.status(201).json({ token, id: user._id, email: user.email }); + } catch (err) { + res + .status(500) + .json({ message: "Something went wrong", error: err.message }); + } +}); + +// POST /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({ message: "Invalid email or password" }); + } + + const isPasswordCorrect = await bcrypt.compare(password, user.password); + if (!isPasswordCorrect) { + return res.status(401).json({ message: "Invalid email or password" }); + } + + const token = jwt.sign({ id: user._id }, JWT_SECRET, { expiresIn: "24h" }); + res.status(200).json({ token, id: user._id, email: user.email }); + } catch (err) { + res.status(500).json({ message: "Login failed", error: err.message }); + } +}); + +export default router; diff --git a/server.js b/server.js index f47771b..3d43da2 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,43 @@ -import cors from "cors" -import express from "express" +import cors from "cors"; +import express from "express"; +import fs from "fs"; +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import expressListEndpoints from "express-list-endpoints"; +import thoughtsRoutes from "./routes/thoughts.js"; +import usersRoutes from "./routes/users.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() +dotenv.config(); -// Add middlewares to enable cors and json body parsing -app.use(cors()) -app.use(express.json()) +const port = process.env.PORT || 8082; +const app = express(); + +const mongoUrl = + process.env.MONGO_URL || "mongodb://localhost:27017/happyThoughts"; +mongoose + .connect(mongoUrl) + .then(() => console.log("βœ… Connected to MongoDB")) + .catch((err) => console.error("❌ MongoDB connection error:", err)); + +app.use( + cors({ + methods: ["GET", "POST", "PATCH", "DELETE"], + }) +); +app.use(express.json()); -// Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) + const endpoints = expressListEndpoints(app); + res.json({ + message: "This is an API", + endpoints, + }); +}); + +app.use("/thoughts", thoughtsRoutes); + +app.use("/users", usersRoutes); -// Start the server app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) + console.log(`Server running on http://localhost:${port}`); +}); diff --git a/utils/loadThoughts.js b/utils/loadThoughts.js new file mode 100644 index 0000000..c9b418d --- /dev/null +++ b/utils/loadThoughts.js @@ -0,0 +1,8 @@ +import fs from "fs"; + +const loadThoughts = () => { + const data = fs.readFileSync("data/data.json"); + return JSON.parse(data); +}; + +export default loadThoughts; diff --git a/utils/saveThoughts.js b/utils/saveThoughts.js new file mode 100644 index 0000000..be775d5 --- /dev/null +++ b/utils/saveThoughts.js @@ -0,0 +1,7 @@ +import fs from "fs"; + +const saveThoughts = (thoughts) => { + fs.writeFileSync("data/data.json", JSON.stringify(thoughts, null, 2)); +}; + +export default saveThoughts; diff --git a/utils/seedThoughts.js b/utils/seedThoughts.js new file mode 100644 index 0000000..71e774b --- /dev/null +++ b/utils/seedThoughts.js @@ -0,0 +1,27 @@ +import mongoose from "mongoose"; +import dotenv from "dotenv"; +import fs from "fs"; +import { Thought } from "../models/Thought.js"; + +dotenv.config(); + +const mongoUrl = mongoose + .connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }) + .then(() => console.log("🟒 Connected to MongoDB")) + .catch((err) => console.error("πŸ”΄ MongoDB connection error:", err)); + +const thoughtData = JSON.parse(fs.readFileSync("./data/data.json")); + +const seedDatabase = async () => { + try { + await Thought.deleteMany(); + await Thought.insertMany(thoughtData); + console.log(`🌱 Successfully seeded ${thoughtData.length} thoughts!`); + } catch (err) { + console.error("❌ Seeding error:", err); + } finally { + mongoose.disconnect(); + } +}; + +seedDatabase();