diff --git a/README.md b/README.md index 0f9f073d..5cb62b77 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Project API +render: https://happy-thoughts-api-o47r.onrender.com + 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. ## Getting started diff --git a/data.json b/data.json index a2c844ff..1124d7f1 100644 --- a/data.json +++ b/data.json @@ -110,12 +110,5 @@ "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/middleware/authMiddleware.js b/middleware/authMiddleware.js new file mode 100644 index 00000000..8b19f608 --- /dev/null +++ b/middleware/authMiddleware.js @@ -0,0 +1,33 @@ +import { User } from "../models/User.js" + +export const authenticateUser = async (request, response, next) => { + try { + + const authHeader = request.header("Authorization") || request.get("Authorization") + + if(!authHeader) { + return response.status(401).json({ + message: "Authentication missing or invalid", + loggedOut: true + }) + } + + const user = await User.findOne({ accessToken: authHeader.replace("Bearer ", "")}) + + if (user) { + request.user = user + next() + } else { + response.status(401).json({ + message: "Authentication missing or invalid", + loggedOut: true + }) + } + + } catch (error) { + response.status(500).json({ + message: "internal server error", + error: error.message + }) + } +} \ No newline at end of file diff --git a/models/HappyThought.js b/models/HappyThought.js new file mode 100644 index 00000000..a59c6805 --- /dev/null +++ b/models/HappyThought.js @@ -0,0 +1,22 @@ +import mongoose from "mongoose" + +const happyThoughtSchema = new mongoose.Schema({ + message: { + type: String, + required: true + }, + hearts: { + type: Number + }, + createdAt: { + type: Date, + default: Date.now + }, + author: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + } +}) + +export const HappyThought = mongoose.model("HappyThought", happyThoughtSchema) \ No newline at end of file diff --git a/models/User.js b/models/User.js new file mode 100644 index 00000000..87781a6e --- /dev/null +++ b/models/User.js @@ -0,0 +1,28 @@ +import crypto from "crypto" +import mongoose from "mongoose" + +const userSchema = new mongoose.Schema({ + firstName: { + type: String, + required: true + }, + lastName: { + type: String, + required: true + }, + email: { + type: String, + required: true, + unique: true + }, + password: { + type: String, + required: true + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex") + } +}) + +export const User = mongoose.model("User", userSchema) \ No newline at end of file diff --git a/package.json b/package.json index bf25bb68..9e86b428 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "project-api", "version": "1.0.0", "description": "Project API", + "type": "module", "scripts": { "start": "babel-node server.js", "dev": "nodemon server.js --exec babel-node" @@ -12,8 +13,13 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", - "cors": "^2.8.5", - "express": "^4.17.3", + "bcrypt": "^6.0.0", + "cors": "^2.8.6", + "dotenv": "^17.2.3", + "express": "^4.22.1", + "express-list-endpoints": "^7.1.1", + "mongodb": "^7.2.0", + "mongoose": "^9.5.0", "nodemon": "^3.0.1" } } diff --git a/routes/happyThoughtsRoutes.js b/routes/happyThoughtsRoutes.js new file mode 100644 index 00000000..7a997362 --- /dev/null +++ b/routes/happyThoughtsRoutes.js @@ -0,0 +1,247 @@ +import express from "express" +import mongoose from "mongoose" +import { authenticateUser } from "../middleware/authMiddleware.js" +import { HappyThought } from "../models/HappyThought.js" + +const router = express.Router() + +router.get("/", async (request, response) => { + + const { minLikes } = request.query + + const query = {} + + if (minLikes) { + query.hearts = {$gte: Number(minLikes)} + } + + try { + + const filteredMessages = await HappyThought.find(query) + .sort({ createdAt: "desc" }) + .populate("author", "firstName lastName") + + if (filteredMessages.length === 0) { + return response.status(404).json({ + success: false, + response: [], + message: "No thoughts were found for that query" + }) + } + + return response.status(200).json({ + success: true, + response: filteredMessages, + message: "Success" + }) + } catch (error) { + return response.status(500).json({ + success: false, + response: [], + message: error + }) + } +}) + +router.post("/", authenticateUser, async (request, response) => { + const { message } = request.body + + try { + const newHappyThought = await new HappyThought({ message, author: request.user._id }).save() + await newHappyThought.populate("author", "firstName lastName") + response.status(201).json({ + success: true, + response: newHappyThought, + message: "Happy thought created successfully" + }) + } catch (error) { + response.status(500).json({ + success: false, + response: error, + message: "Couldn't create happy thought" + }) + } +}) + + +router.get("/:id", async (request, response) => { + const { id } = request.params + + try { + const happyThought = await HappyThought.findById(id).populate("author", "firstName lastName") + if (!happyThought) { + return response.status(404).json({ + success: false, + response: null, + message: "Happy thought not found" + }) + } + + response.status(200).json({ + success: true, + response: happyThought + }) + } catch (error) { + response.status(500).json({ + success: false, + response: error, + message: "Happy thought couldn't be found" + }) + } + +}) + + +router.delete("/:id", authenticateUser, async (request, response) => { + const { id } = request.params + + if (!id || !mongoose.isValidObjectId(id)) { + return response.status(400).json({ + success: false, + response: null, + message: "Invalid ID" + }) + } + + if (!request.user || !request.user._id) { + return response.status(401).json({ + success: false, + response: null, + message: "User not authenticated" + }) + } + + try { + + const thought = await HappyThought.findById(id) + + if (!thought) { + return response.status(404).json({ + success: false, + response: null, + message: "Happy thought not found" + }) + } + + // säker ägarcheck: fungerar om author är ObjectId eller populated object + const authorId = thought.author?._id ? thought.author._id.toString() : thought.author?.toString() + if (!authorId || authorId !== request.user._id.toString()) { + return response.status(403).json({ + success: false, + response: null, + message: "You are not authorized to delete this thought" + }) + } + + const deletedThought = await HappyThought.findByIdAndDelete(id) + + if (!deletedThought) { + return response.status(404).json({ + success: false, + response: null, + message: "Happy thought not found" + }) + } + + response.status(200).json({ + success: true, + response: deletedThought, + message: "Happy thought deleted successfully" + }) + } catch (error) { + response.status(500).json({ + success: false, + response: error, + message: "Error deleting happy thought" + }) + } +}) + +router.patch("/:id", authenticateUser, async (request, response) => { + const { id } = request.params + const { message } = request.body + + try { + + if (!message || message.length <5 || message.length > 140) { + return response.status(400).json({ + success: false, + response: null, + message: "Message must be between 5 and 140 characters" + }) + } + + const thought = await HappyThought.findById(id) + + if (!thought) { + return response.status(404).json({ + success: false, + response: null, + message: "Happy Thought not found" + }) + } + + if (!thought.author.equals(request.user._id)) { + return response.status(403).json({ + success: false, + response: null, + message: "You are not authorized to update this thought" + }) + } + + const updatedHappyThought = await HappyThought.findByIdAndUpdate(id, { message }, { new: true}) + + if (!updatedHappyThought) { + return response.status(404).json({ + success: false, + response: null, + message: "Happy Thought not found" + }) + } + + return response.status(200).json({ + success: true, + response: updatedHappyThought, + message: "Happy Thought updated successfully" + }) + + } catch (error) { + response.status(500).json({ + success: false, + response: null, + message: "Could not update thought" + }) + } +}) + +router.patch("/:id/like", async (request, response) => { + const { id } = request.params + + try { + const happyThought = await HappyThought.findByIdAndUpdate(id, { $inc: {hearts: 1}}, { new: true }) + + if (!happyThought) { + return response.status(404).json({ + success: false, + response: null, + message: "Happy thought not found" + }) + } + + response.status(200).json({ + success: true, + response: happyThought, + message: "Happy thought liked successfully" + }) + } catch (error) { + response.status(500).json({ + success: false, + response: error, + message: "Error liking happy thought" + }) + } + +}) + + +export default router \ No newline at end of file diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 00000000..9d388f67 --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,130 @@ +import express from "express" +import bcrypt from "bcrypt" +import { User } from "../models/User.js" + +const router = express.Router() + +const validateEmail = (email) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +router.get("/:id", async (request, response) => { + try { + const { id } = request.params + const user = await User.findById(id) + + if (user) { + response.status(200).json({ + message: "Hej!!!! Välkommen till din sida, " + user.firstName + "!!!", + user + }) + } else { + response.status(404).json({ + error: "user not found" + }) + } + + } catch (error) { + response.status(400).json({ + error: "invalid request" + }) + } +}) + +router.post("/signup", async (request, response) => { + try { + const { firstName, lastName, email, password } = request.body + + if(!email || !validateEmail(email)) { + return response.status(400).json({ + success: false, + message: "Invalid email format" + }) + } + + const normalizedEmail = email.toLowerCase() + + const existingUser = await User.findOne({ email: normalizedEmail}) + + if (existingUser) { + return response.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({ + firstName, + lastName, + email: normalizedEmail, + password: hashedPassword + }) + + const savedUser = await user.save() + + response.status(201).json({ + success: true, + message: "User created successfully", + response: { + email: savedUser.email, + id: savedUser._id, + accessToken: savedUser.accessToken + } + }) + + } catch (error) { + response.status(400).json({ + success: false, + message: "failed to create user", + response: error.message + }) + } +}) + +router.post("/login", async (request, response) => { + try { + const { email, password } = request.body + + if(!email || !validateEmail(email)) { + return response.status(400).json({ + success: false, + message: "Invalid email format" + }) + } + + const normalizedEmail = email.toLowerCase() + + const user = await User.findOne({email: normalizedEmail}) + + if (user && bcrypt.compareSync(password, user.password)) { + response.status(200).json({ + success: true, + message: "Login successful", + response: { + email: user.email, + id: user._id, + accessToken: user.accessToken + } + }) + } else { + response.status(401).json({ + success: false, + message: "Invalid email or password", + response: null + }) + } + } catch (error) { + response.status(500).json({ + success: false, + message: "Something went wrong during login", + response: error + }) + } +}) + +export default router \ No newline at end of file diff --git a/server.js b/server.js index f47771bd..be86a9f9 100644 --- a/server.js +++ b/server.js @@ -1,6 +1,14 @@ import cors from "cors" import express from "express" +import listEndpoints from "express-list-endpoints" +import "dotenv/config" +import mongoose from "mongoose" +import happyThoughtsRoutes from "./routes/happyThoughtsRoutes.js" +import userRoutes from "./routes/userRoutes.js" + +const mongoUrl = process.env.MONGO_URL +mongoose.connect(mongoUrl) // 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 @@ -11,11 +19,26 @@ const app = express() app.use(cors()) app.use(express.json()) + // Start defining your routes here -app.get("/", (req, res) => { - res.send("Hello Technigo!") +app.get("/", (request, response) => { + const endpoints = listEndpoints(app) + + response.json({ + message: "Welcome to happy thoughts api", + endpoints: endpoints + }) }) +// Wake up API with cron-job, scheduled to end: 1 july 2026 kl: 13:00 +app.get('/ping', (request, response) => { + response.status(200).send('pong'); +}); + +app.use("/happy-thoughts", happyThoughtsRoutes) +app.use("/users", userRoutes) + + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`)