diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d3cb2ac --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "postman.settings.dotenv-detection-notification-visibility": false +} diff --git a/README.md b/README.md index 0f9f073..e4c84d1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,60 @@ -# 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. +Backend API for sharing happy thoughts. Users can post thoughts, like others' thoughts, and manage their own content. -## Getting started +## Live Demo -Install dependencies with `npm install`, then start the server by running `npm run dev` +**API:** [https://js-project-api-cathi.onrender.com](https://js-project-api-cathi.onrender.com) -## View it live +## Tech Stack -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. +- Node.js & Express +- MongoDB with Mongoose +- JWT authentication +- bcrypt for passwords + +## API Endpoints + +### Authentication +- `POST /auth/register` - Register user +- `POST /auth/login` - Login user + +### Thoughts +- `GET /thoughts` - Get all thoughts +- `POST /thoughts` - Create thought +- `PATCH /thoughts/:id` - Update thought (own only) +- `DELETE /thoughts/:id` - Delete thought (own only) +- `PATCH /thoughts/:id/like` - Like thought +- `PATCH /thoughts/:id/unlike` - Unlike thought + +## Installation + +1. Clone repo and install dependencies: +```bash +npm install +``` + +2. Create `.env` file: +``` +MONGO_URL=mongodb://localhost/happy-thoughts +JWT_SECRET=your-secret-key +PORT=8080 +``` + +3. Start development server: +```bash +npm run dev +``` + +## Features + +- ✅ Anonymous and authenticated posting +- ✅ User authentication with JWT +- ✅ CRUD operations for thoughts +- ✅ Like/unlike functionality +- ✅ Input validation and error handling +- ✅ Pagination and filtering + +## Deployment + +Deployed on Render with MongoDB Atlas database. diff --git a/controllers/authController.js b/controllers/authController.js new file mode 100644 index 0000000..47690ce --- /dev/null +++ b/controllers/authController.js @@ -0,0 +1,73 @@ +import { validationResult } from "express-validator"; +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; +import { User } from "../models/User.js"; + +const JWT_SECRET = process.env.JWT_SECRET || "supersecret"; + +// User registration +export const register = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { email, password } = req.body; + try { + // Check if user already exists + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res + .status(409) + .json({ message: "That email address already exists" }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create and save user + const user = await User.create({ email, password: hashedPassword }); + + // Create JWT token + const token = jwt.sign( + { userId: user._id, email: user.email }, + JWT_SECRET, + { expiresIn: "2h" } + ); + + res.status(201).json({ token, user: { id: user._id, email: user.email } }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; + +// User login +export const login = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const { email, password } = req.body; + try { + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ message: "Invalid credentials" }); + } + + const match = await bcrypt.compare(password, user.password); + if (!match) { + return res.status(401).json({ message: "Invalid credentials" }); + } + + const token = jwt.sign( + { userId: user._id, email: user.email }, + JWT_SECRET, + { expiresIn: "2h" } + ); + + res.status(200).json({ token, user: { id: user._id, email: user.email } }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}; diff --git a/controllers/thoughtsController.js b/controllers/thoughtsController.js new file mode 100644 index 0000000..6e884eb --- /dev/null +++ b/controllers/thoughtsController.js @@ -0,0 +1,138 @@ +import { Thought } from "../models/Thought.js"; +import { validationResult } from "express-validator"; + +// GET /thoughts +export const getThoughts = async (req, res) => { + const { message, minHearts, sort, page = 1, limit = 10 } = req.query; + try { + const query = {}; + if (message) query.message = { $regex: new RegExp(message, "i") }; + if (minHearts) query.hearts = { $gte: Number(minHearts) }; + + // inkludera user‐fältet så frontend ser ägaren + let thoughtsQuery = Thought.find(query).select( + "message hearts createdAt user" + ); + if (sort === "most-liked") + thoughtsQuery = thoughtsQuery.sort({ hearts: -1 }); + else if (sort === "least-liked") + thoughtsQuery = thoughtsQuery.sort({ hearts: 1 }); + + const total = await Thought.countDocuments(query); + const results = await thoughtsQuery + .skip((page - 1) * limit) + .limit(Number(limit)); + + res.json({ page: Number(page), limit: Number(limit), total, results }); + } catch (error) { + res.status(500).json({ error: "Server error", details: error }); + } +}; + +// GET /thoughts/:id +export const getThoughtById = async (req, res) => { + try { + const thought = await Thought.findById(req.params.id).select( + "message hearts createdAt user" + ); + if (!thought) return res.status(404).json({ error: "Thought not found" }); + res.json(thought); + } catch (error) { + res.status(400).json({ error: "Invalid ID", details: error }); + } +}; + +// POST /thoughts +export const createThought = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) + return res.status(400).json({ errors: errors.array() }); + + try { + const { message } = req.body; + const newThought = new Thought({ + message, + // If user is logged in (req.user exists), save their ID, otherwise null for anonymous + user: req.user?.id || null, + }); + await newThought.save(); + res.status(201).json(newThought); + } catch (error) { + res.status(400).json({ error: "Could not create thought", details: error }); + } +}; + +// PATCH /thoughts/:id +export const updateThought = async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) + return res.status(400).json({ errors: errors.array() }); + + try { + const { id } = req.params; + const { message } = req.body; + const thought = await Thought.findById(id); + if (!thought) return res.status(404).json({ error: "Thought not found" }); + + // ägarskapskontroll + if (!thought.user.equals(req.user.id)) { + return res + .status(403) + .json({ error: "You can only update your own thoughts" }); + } + + thought.message = message; + await thought.save(); + res.json(thought); + } catch (error) { + res.status(400).json({ error: "Invalid update", details: error }); + } +}; + +// DELETE /thoughts/:id +export const deleteThought = async (req, res) => { + try { + const thought = await Thought.findById(req.params.id); + if (!thought) return res.status(404).json({ error: "Thought not found" }); + + // ägarskapskontroll + if (!thought.user.equals(req.user.id)) { + return res + .status(403) + .json({ error: "You can only delete your own thoughts" }); + } + + await thought.deleteOne(); + res.json({ message: "Thought deleted successfully" }); + } catch (error) { + res.status(400).json({ error: "Invalid ID", details: error }); + } +}; + +// 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({ error: "Thought not found" }); + res.json(updated); + } catch (error) { + res.status(400).json({ error: "Failed to like thought", details: error }); + } +}; + +// PATCH /thoughts/:id/unlike +export const unlikeThought = async (req, res) => { + try { + const thought = await Thought.findById(req.params.id); + if (!thought) return res.status(404).json({ error: "Thought not found" }); + thought.hearts = Math.max(thought.hearts - 1, 0); + await thought.save(); + res.json(thought); + } catch (error) { + res.status(400).json({ error: "Failed to unlike thought", details: error }); + } +}; diff --git a/data.json b/data.json deleted file mode 100644 index a2c844f..0000000 --- a/data.json +++ /dev/null @@ -1,121 +0,0 @@ -[ - { - "_id": "682bab8c12155b00101732ce", - "message": "Berlin baby", - "hearts": 37, - "createdAt": "2025-05-19T22:07:08.999Z", - "__v": 0 - }, - { - "_id": "682e53cc4fddf50010bbe739", - "message": "My family!", - "hearts": 0, - "createdAt": "2025-05-22T22:29:32.232Z", - "__v": 0 - }, - { - "_id": "682e4f844fddf50010bbe738", - "message": "The smell of coffee in the morning....", - "hearts": 23, - "createdAt": "2025-05-22T22:11:16.075Z", - "__v": 0 - }, - { - "_id": "682e48bf4fddf50010bbe737", - "message": "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED 🤞🏼\n", - "hearts": 6, - "createdAt": "2025-05-21T21:42:23.862Z", - "__v": 0 - }, - { - "_id": "682e45804fddf50010bbe736", - "message": "I am happy that I feel healthy and have energy again", - "hearts": 13, - "createdAt": "2025-05-21T21:28:32.196Z", - "__v": 0 - }, - { - "_id": "682e23fecf615800105107aa", - "message": "cold beer", - "hearts": 2, - "createdAt": "2025-05-21T19:05:34.113Z", - "__v": 0 - }, - { - "_id": "682e22aecf615800105107a9", - "message": "My friend is visiting this weekend! <3", - "hearts": 6, - "createdAt": "2025-05-21T18:59:58.121Z", - "__v": 0 - }, - { - "_id": "682cec1b17487d0010a298b6", - "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 - }, - { - "_id": "682cebbe17487d0010a298b5", - "message": "Tacos and tequila🌮🍹", - "hearts": 2, - "createdAt": "2025-05-19T20:53:18.899Z", - "__v": 0 - }, - { - "_id": "682ceb5617487d0010a298b4", - "message": "Netflix and late night ice-cream🍦", - "hearts": 1, - "createdAt": "2025-05-18T20:51:34.494Z", - "__v": 0 - }, - { - "_id": "682c99ba3bff2d0010f5d44e", - "message": "Summer is coming...", - "hearts": 2, - "createdAt": "2025-05-20T15:03:22.379Z", - "__v": 0 - }, - { - "_id": "682c706c951f7a0017130024", - "message": "Exercise? I thought you said extra fries! 🍟😂", - "hearts": 14, - "createdAt": "2025-05-20T12:07:08.185Z", - "__v": 0 - }, - { - "_id": "682c6fe1951f7a0017130023", - "message": "I’m on a seafood diet. I see food, and I eat it.", - "hearts": 4, - "createdAt": "2025-05-20T12:04:49.978Z", - "__v": 0 - }, - { - "_id": "682c6f0e951f7a0017130022", - "message": "Cute monkeys🐒", - "hearts": 2, - "createdAt": "2025-05-20T12:01:18.308Z", - "__v": 0 - }, - { - "_id": "682c6e65951f7a0017130021", - "message": "The weather is nice!", - "hearts": 0, - "createdAt": "2025-05-20T11:58:29.662Z", - "__v": 0 - }, - { - "_id": "682bfdb4270ca300105af221", - "message": "good vibes and good things", - "hearts": 3, - "createdAt": "2025-05-20T03:57:40.322Z", - "__v": 0 - }, - { - "_id": "682bab8c12155b00101732ce", - "message": "Berlin baby", - "hearts": 37, - "createdAt": "2025-05-19T22:07:08.999Z", - "__v": 0 - } -] \ No newline at end of file diff --git a/db.js b/db.js new file mode 100644 index 0000000..dac3980 --- /dev/null +++ b/db.js @@ -0,0 +1,14 @@ +// db.js +import mongoose from "mongoose"; + +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/happy-thoughts"; + +export const connectToDatabase = async () => { + try { + await mongoose.connect(mongoUrl); + console.log("Connected to MongoDB"); + } catch (error) { + console.error("Could not connect to MongoDB:", error); + process.exit(1); // Avsluta appen om anslutning misslyckas + } +}; diff --git a/middleware/auth.js b/middleware/auth.js new file mode 100644 index 0000000..348322f --- /dev/null +++ b/middleware/auth.js @@ -0,0 +1,36 @@ +// middleware/auth.js +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "supersecret"; + +export const verifyToken = (req, res, next) => { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith("Bearer ")) { + return res + .status(401) + .json({ message: "Missing or invalid Authorization header" }); + } + const token = auth.split(" ")[1]; + try { + const payload = jwt.verify(token, JWT_SECRET); + req.user = { id: payload.userId, email: payload.email }; + next(); + } catch (err) { + return res.status(401).json({ message: "Invalid or expired token" }); + } +}; + +// Optional auth - checks token if provided but doesn't require it +export const optionalAuth = (req, res, next) => { + const auth = req.headers.authorization; + if (auth && auth.startsWith("Bearer ")) { + const token = auth.split(" ")[1]; + try { + const payload = jwt.verify(token, JWT_SECRET); + req.user = { id: payload.userId, email: payload.email }; + } catch (err) { + // Invalid token, but we don't fail - just continue as anonymous + } + } + next(); +}; diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..a70eba8 --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,25 @@ +import mongoose from "mongoose"; + +const ThoughtSchema = new mongoose.Schema({ + message: { + type: String, + required: true, + minlength: 5, + maxlength: 140, + }, + hearts: { + type: Number, + default: 0, + }, + createdAt: { + type: Date, + default: Date.now, + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: false // Allow anonymous posts + }, +}); + +export const Thought = mongoose.model("Thought", ThoughtSchema); diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..ec306f4 --- /dev/null +++ b/models/User.js @@ -0,0 +1,24 @@ +import mongoose from "mongoose"; + +const UserSchema = new mongoose.Schema( + { + email: { + type: String, + required: [true, "Email is required"], + unique: true, + lowercase: true, + trim: true, + match: [/.+@.+\..+/, "Please provide a valid email address"], + }, + password: { + type: String, + required: [true, "Password is required"], + minlength: [8, "Password must be at least 8 characters long"], + }, + }, + { + timestamps: true, + } +); + +export const User = mongoose.model("User", UserSchema); diff --git a/package.json b/package.json index bf25bb6..99e8dc5 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,14 @@ "@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", + "express-list-endpoints": "^7.1.1", + "express-validator": "^7.2.1", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.15.1", "nodemon": "^3.0.1" } } diff --git a/pull_request_template.md b/pull_request_template.md index fb9fdc3..e4e16a4 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1 +1,4 @@ -Please include your Render link here. \ No newline at end of file +https://js-project-api-cathi.onrender.com + +https://happythoughtsbyc.netlify.app/ + diff --git a/routes/authRoutes.js b/routes/authRoutes.js new file mode 100644 index 0000000..b286d72 --- /dev/null +++ b/routes/authRoutes.js @@ -0,0 +1,24 @@ +import express from "express"; +import { body } from "express-validator"; +import { register, login } from "../controllers/authController.js"; + +const router = express.Router(); + +router.post( + "/register", + [ + body("email").isEmail().withMessage("Must be a valid email"), + body("password") + .isLength({ min: 8 }) + .withMessage("Password must be at least 8 characters"), + ], + register +); + +router.post( + "/login", + [body("email").isEmail(), body("password").exists()], + login +); + +export default router; diff --git a/routes/thoughtsRoutes.js b/routes/thoughtsRoutes.js new file mode 100644 index 0000000..02c1a83 --- /dev/null +++ b/routes/thoughtsRoutes.js @@ -0,0 +1,60 @@ +import express from "express"; +import { body } from "express-validator"; +import { verifyToken, optionalAuth } from "../middleware/auth.js"; +import { + getThoughts, + getThoughtById, + createThought, + updateThought, + deleteThought, + likeThought, + unlikeThought, +} from "../controllers/thoughtsController.js"; + +const router = express.Router(); + +// --- PUBLIC ROUTES --- +router.get("/", getThoughts); +router.get("/:id", getThoughtById); + +// Create thought (public - anyone can post, but we check for optional auth) +router.post( + "/", + optionalAuth, + [ + body("message") + .isString() + .isLength({ min: 5, max: 140 }) + .withMessage("Message must be 5–140 characters."), + ], + createThought +); + +// Like/Unlike a thought (public - anyone can like) +router.patch("/:id/like", optionalAuth, likeThought); +router.patch("/:id/unlike", optionalAuth, unlikeThought); + +// --- SECURED ROUTES --- + +// Update a thought +router.patch( + "/:id", + verifyToken, + [ + body("message") + .optional() + .isString() + .isLength({ min: 5, max: 140 }) + .withMessage("Message (if set) must be 5–140 characters."), + body("hearts") + .optional() + .isInt({ min: 0 }) + .withMessage("Hearts must be a whole number ≥ 0."), + ], + updateThought +); + +// Delete a thought +router.delete("/:id", verifyToken, deleteThought); + +export default router; diff --git a/server.js b/server.js index f47771b..83f093f 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,36 @@ -import cors from "cors" -import express from "express" +import dotenv from "dotenv"; +dotenv.config(); // Load environment variables from .env file -// 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 express from "express"; +import cors from "cors"; +import listEndpoints from "express-list-endpoints"; -// Add middlewares to enable cors and json body parsing -app.use(cors()) -app.use(express.json()) +import { connectToDatabase } from "./db.js"; // import connection +import authRoutes from "./routes/authRoutes.js"; +import thoughtsRoutes from "./routes/thoughtsRoutes.js"; + +const port = process.env.PORT || 8080; +const app = express(); + +app.use(cors()); +app.use(express.json()); + +// Public-API för autentisering +app.use("/auth", authRoutes); -// Start defining your routes here app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) + const endpoints = listEndpoints(app); + res.json({ + message: "Welcome to the Happy Thoughts API", + endpoints, + }); +}); + +app.use("/thoughts", thoughtsRoutes); -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) +// connect to mongoDB then start server +connectToDatabase().then(() => { + app.listen(port, () => { + console.log(`Server running on http://localhost:${port}`); + }); +});