diff --git a/.gitignore b/.gitignore index f1ff414..533f873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ node_modules .DS_Store -.env +.env* +.env.dev +.env.prod .env.local .env.development.local .env.test.local .env.production.local -package-lock.json \ No newline at end of file +package-lock.json +todo.md \ No newline at end of file diff --git a/data/thoughtsList.json b/data/thoughtsList.json new file mode 100644 index 0000000..1124d7f --- /dev/null +++ b/data/thoughtsList.json @@ -0,0 +1,114 @@ +[ + { + "_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 + } +] \ No newline at end of file diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..75a6a58 --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,23 @@ +import mongoose from "mongoose"; + +const thoughtSchema = new mongoose.Schema({ + message: { + required: true, + type: String, + minlength: 5, + maxlength: 140}, + hearts: { + type: Number, + default: 0}, + createdAt: { + type: Date, + default: Date.now + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + } +}) + +export const Thought = mongoose.model("Thought", thoughtSchema) \ No newline at end of file diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..19eead2 --- /dev/null +++ b/models/User.js @@ -0,0 +1,36 @@ +import crypto from "crypto" +import mongoose from "mongoose" + +const userSchema = new mongoose.Schema({ + username: { + type: String, + unique: true, + required: true, + minlength: 2, + trim: true, + }, + email: { + type: String, + unique: true, + sparse: true, + lowercase: true, + trim: true, + validate: { + validator: (email) => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + }, + message: props => `${props.value} is not a valid email address!` + } + }, + password: { + type: String, + required: true, + minlength: 4, + }, + 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 bf25bb6..2339c52 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,17 @@ "author": "", "license": "ISC", "dependencies": { - "@babel/core": "^7.17.9", - "@babel/node": "^7.16.8", - "@babel/preset-env": "^7.16.11", + "@babel/core": "^7.27.3", + "@babel/node": "^7.27.1", + "@babel/preset-env": "^7.27.2", + "bcrypt-nodejs": "^0.0.3", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", - "express": "^4.17.3", - "nodemon": "^3.0.1" + "dotenv": "^16.5.0", + "express": "^4.21.2", + "express-list-endpoints": "^7.1.1", + "mongodb": "^6.17.0", + "mongoose": "^8.15.1", + "nodemon": "^3.1.10" } } diff --git a/pull_request_template.md b/pull_request_template.md index fb9fdc3..e3a7b29 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1 +1,3 @@ -Please include your Render link here. \ No newline at end of file +Please include your Render link here: + +https://js-project-api-b8sd.onrender.com diff --git a/routes/authRouter.js b/routes/authRouter.js new file mode 100644 index 0000000..e3d15fa --- /dev/null +++ b/routes/authRouter.js @@ -0,0 +1,119 @@ +import bcrypt from "bcryptjs" +import { Router } from "express" + +import { User } from "../models/User" + +const authRouter = Router() + +// REGISTRATION ENDPOINT - ASSIGN ENCRYPTED TOKEN (CREATE) +authRouter.post("/register", async (req, res) => { + try { + const { username, email, password } = req.body + if (!username || !password) { + return res.status(400).json({ error: "username and password are required"}) + } + + const salt = bcrypt.genSaltSync() + const user = new User({ + username, + email: email || null , + password: bcrypt.hashSync(password, salt) }) + + await user.save() + + res.status(200).json({ + message: "Signup success", + success: true, + userid: user._id, + username: user.username, + accessToken: user.accessToken}) + + } catch(err) { + if (err.code === 11000) { + const field = Object.keys(err.keyPattern)[0] + return res.status(409).json({ + success: false, + message: `A user with that ${field} already exists.`, + }) + } + if (err.errors) { + const validationErrors = Object.values(err.errors).map(e => e.message) + return res.status(400).json({ + success: false, + message: "Invalid input", + errors: validationErrors + }) + } + console.error(err) + res.status(500).json({ + success: false, + message: "Unexpected server error." + }) + } +}) + +// LOGIN ENDPOINT (FINDS USER) +authRouter.post("/login", async (req, res) => { + try { + const { username, password } = req.body + + const user = await User.findOne({ username: req.body.username }) + if (!user) { + return res.status(404).json({ + success: false, + message: "User does not exist"}) + } + + if (user && bcrypt.compareSync(req.body.password, user.password)) { + res.status(201).json({ + success: true, + message: "User successfully logged in", + userId: user._id, + username: user.username, + accessToken: user.accessToken + }) + } else { + res.status(401).json({ + success: false, + message: "Invalid password" + }) + + }} catch(err) { + res.status(400).json({ + success: false, + notFound: true}) + } +}) + +// MIDDLEWARE TO AUTH +export const authenticateUser = async (req, res, next) => { + try { + + const token = req.header("Authorization") + if (!token) { + return res.status(401).json({ + success: false, + message: "Access token missing. Please login to continue." + }) + } + + const user = await User.findOne({ accessToken: token }) + if(!user) { + return res.status(401).json({ + success: false, + message: "Invalid token. Please login in again.", + loggedOut: true + }) + } + req.user = user + next() + } catch (err) { + console.error("Authentication error", err) + res.status(500).json({ + success: false, + message: "Server error during authentication." + }) + } +} + +export default authRouter \ No newline at end of file diff --git a/routes/thoughtsRouter.js b/routes/thoughtsRouter.js new file mode 100644 index 0000000..9831d25 --- /dev/null +++ b/routes/thoughtsRouter.js @@ -0,0 +1,196 @@ +import { Router } from "express" + +import { Thought } from "../models/Thought" +import { authenticateUser } from "./authRouter" + +const thoughtsRouter = Router() + +// GET ALL THOUGHTS +thoughtsRouter.get("/", async (req, res) => { + + try { + const thoughts = await Thought.find().sort({ createdAt: -1 }) + if (thoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No thoughts available." + }) + } + res.status(200).json({ + success: true, + response: thoughts, + message: "thoughts available." + }) + + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch thoughts." + }) + } +}) + +// GET ONE THOUGHT +thoughtsRouter.get("/:id", async (req, res) => { + const { id } = req.params + + try { + const thought = await Thought.findById(id) + if (!thought) { + return res.status(404).json({ + success: false, + message: "No thought found." + }) + } + res.status(200).json({ + success: true, + response: thought, + message: "Thought found." + }) + + } catch (error) { + res.status(500).json({ + success: false, + response: error.message, + message: "Failed to find this thought." + }) + } +}) + +// POST THOUGHT +thoughtsRouter.post("/", authenticateUser, async (req, res) => { + const { message, hearts, createdAt } = req.body + + try { + const newThought = await new Thought({ message, hearts, createdAt, createdBy: req.user._id }).save() + + res.status(201).json({ + success: true, + response: newThought, + message: "Thought posted successfully." + }) + } catch (error){ + res.status(500).json({ + success: false, + response: error.message, + message: "Could not post thought" + }) + } +}) + + +// DELETE THOUGHT +thoughtsRouter.delete("/:id", authenticateUser, async (req, res) => { + + try { + const delThought = await Thought.findByIdAndDelete({ _id: req.params.id, createdBy: req.user._id }) + + if (!delThought) { + return res.status(404).json({ + success: false, + message: "Not found or authorized" + }) + } + if (delThought.createdBy.toString() !== req.user._id.toString()) { + return res.status(403).json({ + success: false, + message: "Unauthorized to delete this thought" + }) + } + res.status(200).json({ + success: true, + response: delThought, + message: "Thought deleted successfully." + }) + } catch (error){ + res.status(500).json({ + success: false, + error: error.message, + message: "Could not delete thought" + }) + } +}) + +// UPDATE/EDIT THOUGHT +thoughtsRouter.patch("/:id", authenticateUser, async (req, res) => { + const { editThought } = req.body + if (!editThought || editThought.trim().length === 0) { + return res.status(400).json({ + success: false, + message: "editThought is required to update the post." + }) + } + + try { + const thought = await Thought.findByIdAndUpdate({ _id: req.params.id, createdBy: req.user._id }, + { message: editThought }, + { new: true, runValidators: true }) + + if (!thought) { + return res.status(404).json({ + success: false, + message: "Not found, no update possible." + }) + } + if (thought.createdBy.toString() !== req.user._id.toString()) { + return res.status(403).json({ + success: false, + message: "Unauthorized to edit this thought" + }) + } + res.status(200).json({ + success: true, + response: thought, + message: "Thought updated successfully." + }) + } catch (error) { + if (error.name === "ValidationError") { + return res.status(400).json({ + success: false, + errors: error.errors, + message: "Validation failed", + }) + } + res.status(500).json({ + success: false, + error: error.message, + message: "Server error - try again later." + }) + } +}) + +// POST A LIKE +thoughtsRouter.post("/:id/like", async (req, res) => { + + const { id } = req.params + + try { + const thought = await Thought.findByIdAndUpdate( id, { $inc: { hearts: 1 } }, { new: true, runValidators: true }) + + if (!thought) { + return res.status(404).json({ error: "This thought not found, no update possible." }) + } + res.status(201).json({ + success: true, + response: thought, + message: "New like added." + }) + } catch (error) { + if (error.name === "ValidationError") { + return res.status(400).json({ + success: false, + errors: error.errors, + message: "Validation failed", + }) + } + res.status(500).json({ + success: false, + error: error.message, + message: "Server error - try again later." + }) + } +}) + +export default thoughtsRouter diff --git a/routes/usersRouter.js b/routes/usersRouter.js new file mode 100644 index 0000000..a809078 --- /dev/null +++ b/routes/usersRouter.js @@ -0,0 +1,31 @@ +import { Router } from "express" + +import { Thought } from "../models/Thought" +import { authenticateUser } from "./authRouter" + +const usersRouter = Router() + +// GET USER DETAILS +usersRouter.get("/me", authenticateUser, async (req, res) => { + const user = req.user + res.json({ + success: true, + user: { + username: user.username, + email: user.email, + id: user.id + } + }) +}) + +// GET USER THOUGHTS +usersRouter.get("/my-thoughts", authenticateUser, async (req, res) => { + const userId = req.user._id + const thoughts = await Thought.find({ createdBy: userId }) + res.json({ + success: true, + response: thoughts + }) +}) + +export default usersRouter \ No newline at end of file diff --git a/server.js b/server.js index f47771b..dde5a83 100644 --- a/server.js +++ b/server.js @@ -1,21 +1,63 @@ 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 +import thoughtsList from "./data/thoughtsList.json" +import { Thought } from "./models/Thought" +import authRouter, { authenticateUser } from "./routes/authRouter" +import thoughtsRouter from "./routes/thoughtsRouter" +import usersRouter from "./routes/usersRouter" + +// CONNECTION SETTINGS +const port = process.env.PORT || 8000 const app = express() -// Add middlewares to enable cors and json body parsing +dotenv.config() +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost:27017/thoughts" +console.log("🔌 Connecting to MongoDB at:", mongoUrl) + +mongoose.connect(mongoUrl, { + autoIndex: true +}) +.then(() => { + console.log("🌱 Connected to DB:", mongoose.connection.db.databaseName); +}) +.catch((err) => { + console.error("❌ MongoDB connection error:", err) +}) + app.use(cors()) app.use(express.json()) -// Start defining your routes here +// MOUNT ROUTES +app.use("/auth", authRouter) +app.use("/thoughts", thoughtsRouter) +app.use("/users", usersRouter) + +// SEED DATABASE +if (process.env.RESET_DB) { + const seedDatabase = async () => { + await Thought.deleteMany({}) + thoughtsList.forEach(thought => { + new Thought(thought).save() + }) + } + seedDatabase() +} + +// ENDPOINTS DOC app.get("/", (req, res) => { - res.send("Hello Technigo!") + const endpoints = listEndpoints(app) + res.json({ + message: "Hello Happy Thoughts API", + usage: "Visit /thoughts to get all thoughts, or see the list below for all available endpoints.", + endpoints: endpoints + }) }) + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`)