diff --git a/data.json b/data.json index a2c844f..c0592d6 100644 --- a/data.json +++ b/data.json @@ -1,121 +1,489 @@ [ - { + { "_id": "682bab8c12155b00101732ce", "message": "Berlin baby", "hearts": 37, "createdAt": "2025-05-19T22:07:08.999Z", - "__v": 0 + "__v": 0, + "tags": [ + "travel" + ] }, { - "_id": "682e53cc4fddf50010bbe739", + "_id": "682e53cc4fddf50010bbe739", "message": "My family!", "hearts": 0, "createdAt": "2025-05-22T22:29:32.232Z", - "__v": 0 + "__v": 0, + "tags": [ + "family" + ] }, { "_id": "682e4f844fddf50010bbe738", "message": "The smell of coffee in the morning....", "hearts": 23, "createdAt": "2025-05-22T22:11:16.075Z", - "__v": 0 + "__v": 0, + "tags": [ + "food" + ] }, { "_id": "682e48bf4fddf50010bbe737", - "message": "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED 🤞🏼\n", + "message": "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED \ud83e\udd1e\ud83c\udffc", "hearts": 6, "createdAt": "2025-05-21T21:42:23.862Z", - "__v": 0 + "__v": 0, + "tags": [ + "home", + "family" + ] }, { "_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 + "__v": 0, + "tags": [ + "wellness" + ] }, { "_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 + "__v": 0, + "tags": [ + "food" + ] }, { "_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 + "__v": 0, + "tags": [ + "humor" + ] }, { - "_id": "682cebbe17487d0010a298b5", - "message": "Tacos and tequila🌮🍹", - "hearts": 2, - "createdAt": "2025-05-19T20:53:18.899Z", - "__v": 0 + "_id": "07a90e8458254cd191f7fe95", + "message": "Spontaneous road trips \ud83d\ude97", + "hearts": 4, + "createdAt": "2025-05-23T13:29:38.579944Z", + "__v": 0, + "tags": [ + "friends" + ] }, { - "_id": "682ceb5617487d0010a298b4", - "message": "Netflix and late night ice-cream🍦", - "hearts": 1, - "createdAt": "2025-05-18T20:51:34.494Z", - "__v": 0 + "_id": "fcfa78207afa49f7b2594711", + "message": "Movie night with friends \ud83c\udf7f", + "hearts": 39, + "createdAt": "2025-05-21T19:29:38.580109Z", + "__v": 0, + "tags": [ + "nature" + ] }, { - "_id": "682c99ba3bff2d0010f5d44e", - "message": "Summer is coming...", + "_id": "3536a0ba77ec457b9795a289", + "message": "A peaceful morning walk \ud83c\udf04", + "hearts": 5, + "createdAt": "2025-05-21T10:29:38.580147Z", + "__v": 0, + "tags": [ + "entertainment" + ] + }, + { + "_id": "910d586b37f24972a8dc5be5", + "message": "Freshly baked bread smell \ud83e\udd56", + "hearts": 22, + "createdAt": "2025-05-26T22:29:38.580184Z", + "__v": 0, + "tags": [ + "friends", + "nature" + ] + }, + { + "_id": "9b4133f63bfe44c5942c1f2b", + "message": "Laughing until your stomach hurts \ud83d\ude02", "hearts": 2, - "createdAt": "2025-05-20T15:03:22.379Z", - "__v": 0 + "createdAt": "2025-05-18T22:29:38.580221Z", + "__v": 0, + "tags": [ + "food", + "nature" + ] }, { - "_id": "682c706c951f7a0017130024", - "message": "Exercise? I thought you said extra fries! 🍟😂", + "_id": "cfbf58267b394bbfa59194df", + "message": "The sound of rain while falling asleep \ud83c\udf27\ufe0f", + "hearts": 11, + "createdAt": "2025-05-19T20:29:38.580358Z", + "__v": 0, + "tags": [ + "humor", + "wellness" + ] + }, + { + "_id": "1e9f0cc1eec64d1380fbc872", + "message": "A clean desk and a fresh to-do list \ud83d\uddd2\ufe0f", + "hearts": 24, + "createdAt": "2025-05-18T17:29:38.580375Z", + "__v": 0, + "tags": [ + "humor", + "nature" + ] + }, + { + "_id": "ffc30cff7b2c4e53b5c1b1b1", + "message": "Camping under the stars \u2728", + "hearts": 34, + "createdAt": "2025-05-22T17:29:38.580462Z", + "__v": 0, + "tags": [ + "home" + ] + }, + { + "_id": "51b03bb41b794757847c8cf5", + "message": "A finished workout \ud83c\udfcb\ufe0f\u200d\u2642\ufe0f", + "hearts": 17, + "createdAt": "2025-05-24T09:29:38.580678Z", + "__v": 0, + "tags": [ + "friends", + "travel" + ] + }, + { + "_id": "22048fed82f14ee4bb8d0ab8", + "message": "Good coffee and a great book \u2615\ud83d\udcda", + "hearts": 5, + "createdAt": "2025-05-20T17:29:38.580724Z", + "__v": 0, + "tags": [ + "nature" + ] + }, + { + "_id": "3ba0daf6c89a412b9817f241", + "message": "Playing games with family \ud83c\udfb2", + "hearts": 18, + "createdAt": "2025-05-22T15:29:38.580753Z", + "__v": 0, + "tags": [ + "nature" + ] + }, + { + "_id": "3f2bc5aa74704e9f94563551", + "message": "Dinner with loved ones \u2764\ufe0f", + "hearts": 39, + "createdAt": "2025-05-19T18:29:38.580766Z", + "__v": 0, + "tags": [ + "friends" + ] + }, + { + "_id": "0a1363f9e2c34920a52230ca", + "message": "Jokes that never get old \ud83d\ude06", + "hearts": 33, + "createdAt": "2025-05-26T17:29:38.580778Z", + "__v": 0, + "tags": [ + "humor" + ] + }, + { + "_id": "4d11d277debd44be92901ffc", + "message": "Beautiful sunset views \ud83c\udf05", "hearts": 14, - "createdAt": "2025-05-20T12:07:08.185Z", - "__v": 0 + "createdAt": "2025-05-24T02:29:38.580798Z", + "__v": 0, + "tags": [ + "wellness" + ] }, { - "_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": "55dd876c62924eb4aeaad5f6", + "message": "Listening to your favorite song \ud83c\udfb6", + "hearts": 9, + "createdAt": "2025-05-25T11:29:38.580818Z", + "__v": 0, + "tags": [ + "work", + "nature" + ] }, { - "_id": "682c6f0e951f7a0017130022", - "message": "Cute monkeys🐒", - "hearts": 2, - "createdAt": "2025-05-20T12:01:18.308Z", - "__v": 0 + "_id": "a837812ccdde410d83431ae2", + "message": "Long chats with best friends \ud83d\udcac", + "hearts": 5, + "createdAt": "2025-05-22T22:29:38.580835Z", + "__v": 0, + "tags": [ + "food" + ] }, { - "_id": "682c6e65951f7a0017130021", - "message": "The weather is nice!", - "hearts": 0, - "createdAt": "2025-05-20T11:58:29.662Z", - "__v": 0 + "_id": "c046b7c46e424fb099fe36dd", + "message": "A productive day at work \ud83d\udcbc", + "hearts": 41, + "createdAt": "2025-05-25T06:29:38.580854Z", + "__v": 0, + "tags": [ + "food" + ] }, { - "_id": "682bfdb4270ca300105af221", - "message": "good vibes and good things", - "hearts": 3, - "createdAt": "2025-05-20T03:57:40.322Z", - "__v": 0 + "_id": "09c98c694a0e442f9a5b67c6", + "message": "Getting lost in a movie \ud83c\udfa5", + "hearts": 48, + "createdAt": "2025-05-18T22:29:38.580875Z", + "__v": 0, + "tags": [ + "friends", + "home" + ] }, { - "_id": "682bab8c12155b00101732ce", - "message": "Berlin baby", - "hearts": 37, - "createdAt": "2025-05-19T22:07:08.999Z", - "__v": 0 + "_id": "2600d2ec6bc046b981529b32", + "message": "Beach walks in the evening \ud83c\udf0a", + "hearts": 9, + "createdAt": "2025-05-26T19:29:38.580913Z", + "__v": 0, + "tags": [ + "food", + "friends" + ] + }, + { + "_id": "fae76c326ca948ee8cef4066", + "message": "Comfort food on a cold day \ud83c\udf72", + "hearts": 15, + "createdAt": "2025-05-17T10:29:38.580929Z", + "__v": 0, + "tags": [ + "friends" + ] + }, + { + "_id": "2a1ca4e9f5c34eb2843d4f45", + "message": "Nature hikes on weekends \ud83e\udd7e", + "hearts": 40, + "createdAt": "2025-05-20T08:29:38.580942Z", + "__v": 0, + "tags": [ + "family" + ] + }, + { + "_id": "c23b95c354cb4376b87f0c58", + "message": "Catching up with old friends \ud83e\uddd3\ud83d\udc75", + "hearts": 15, + "createdAt": "2025-05-18T03:29:38.580979Z", + "__v": 0, + "tags": [ + "family", + "food" + ] + }, + { + "_id": "d58f905bc9a64347a2b778f8", + "message": "Trying a new recipe \ud83c\udf5d", + "hearts": 13, + "createdAt": "2025-05-23T23:29:38.581029Z", + "__v": 0, + "tags": [ + "family" + ] + }, + { + "_id": "d7af831586f6445d8c88414b", + "message": "Writing in a journal \u270d\ufe0f", + "hearts": 10, + "createdAt": "2025-05-23T15:29:38.581054Z", + "__v": 0, + "tags": [ + "home" + ] + }, + { + "_id": "16694e0a6ae947ccb8910ab2", + "message": "Clean laundry smell \ud83d\udc55", + "hearts": 24, + "createdAt": "2025-05-28T00:29:38.581087Z", + "__v": 0, + "tags": [ + "food" + ] + }, + { + "_id": "0465a1a15cc24c9c91581aa2", + "message": "Cozy blankets and tea \ud83d\udecb\ufe0f\ud83c\udf75", + "hearts": 33, + "createdAt": "2025-05-23T18:29:38.581122Z", + "__v": 0, + "tags": [ + "nature" + ] + }, + { + "_id": "ef38b962265446da972a71db", + "message": "Early morning jogs \ud83c\udfc3\u200d\u2640\ufe0f", + "hearts": 12, + "createdAt": "2025-05-19T09:29:38.581165Z", + "__v": 0, + "tags": [ + "wellness", + "home" + ] + }, + { + "_id": "75ebeadadc8e4f56a4c48411", + "message": "Friday night fun \ud83c\udf89", + "hearts": 27, + "createdAt": "2025-05-27T02:29:38.581192Z", + "__v": 0, + "tags": [ + "wellness", + "nature" + ] + }, + { + "_id": "75bb4457d31b4f26a0514aa2", + "message": "Midnight snacks \ud83c\udf6a", + "hearts": 19, + "createdAt": "2025-05-19T10:29:38.581220Z", + "__v": 0, + "tags": [ + "home", + "wellness" + ] + }, + { + "_id": "22173c81b3164b11b8456b8b", + "message": "Board games night \ud83c\udfaf", + "hearts": 12, + "createdAt": "2025-05-24T14:29:38.581238Z", + "__v": 0, + "tags": [ + "wellness" + ] + }, + { + "_id": "916e209a11b44299abe6f4d9", + "message": "City lights and night walks \ud83c\udf03", + "hearts": 43, + "createdAt": "2025-05-27T22:29:38.581256Z", + "__v": 0, + "tags": [ + "entertainment", + "food" + ] + }, + { + "_id": "2916877bff6d4f6893efeb20", + "message": "Seeing dogs at the park \ud83d\udc36", + "hearts": 12, + "createdAt": "2025-05-17T14:29:38.581285Z", + "__v": 0, + "tags": [ + "food" + ] + }, + { + "_id": "f4171193f85c40c8bd4b62af", + "message": "Organizing the closet \ud83e\uddfa", + "hearts": 43, + "createdAt": "2025-05-25T17:29:38.581308Z", + "__v": 0, + "tags": [ + "home" + ] + }, + { + "_id": "3c69c11d64df4e44b26530ba", + "message": "First sip of soda \ud83e\udd64", + "hearts": 14, + "createdAt": "2025-05-18T04:29:38.581328Z", + "__v": 0, + "tags": [ + "nature" + ] + }, + { + "_id": "721b8bded1e0403db8e68897", + "message": "Weekend brunch \ud83c\udf73", + "hearts": 28, + "createdAt": "2025-05-27T10:29:38.581366Z", + "__v": 0, + "tags": [ + "entertainment" + ] + }, + { + "_id": "598891b532a2475a997b9f89", + "message": "Painting and creativity \ud83c\udfa8", + "hearts": 10, + "createdAt": "2025-05-25T09:29:38.581384Z", + "__v": 0, + "tags": [ + "nature" + ] + }, + { + "_id": "142cf087096a4a34b4fd8975", + "message": "Sunlight through the window \u2600\ufe0f", + "hearts": 14, + "createdAt": "2025-05-22T03:29:38.581428Z", + "__v": 0, + "tags": [ + "wellness", + "work" + ] + }, + { + "_id": "a9c15b1fa3404013a7f919c6", + "message": "Solving a puzzle \ud83e\udde9", + "hearts": 39, + "createdAt": "2025-05-22T08:29:38.581453Z", + "__v": 0, + "tags": [ + "nature" + ] + }, + { + "_id": "e3cd420f6271431db0129824", + "message": "Lunch breaks outdoors \ud83c\udf33", + "hearts": 47, + "createdAt": "2025-05-21T10:29:38.581473Z", + "__v": 0, + "tags": [ + "entertainment", + "work" + ] + }, + { + "_id": "3163a957d8dd4213a8c4cedc", + "message": "Silent mornings before the world wakes up \ud83d\udd4a\ufe0f", + "hearts": 32, + "createdAt": "2025-05-26T03:29:38.581513Z", + "__v": 0, + "tags": [ + "home", + "travel" + ] } ] \ No newline at end of file diff --git a/middleware/authMiddleware.js b/middleware/authMiddleware.js new file mode 100644 index 0000000..5697491 --- /dev/null +++ b/middleware/authMiddleware.js @@ -0,0 +1,45 @@ +import { User } from "../models/User.js" + +export const authenticateUser = async (req, res, next) => { + try { + const accessToken = req.header("Authorization") + const user = await User.findOne({ accessToken: accessToken }) + if (user) { + req.user = user + next() + } else { + res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true + }) + } + } catch (error) { + res.status(500).json({ + message: "Internal server error", + error: error.message + }); + } +} + +export const authenticateUserOptional = async (req, res, next) => { + try { + const accessToken = req.header("Authorization") + if (accessToken) { + const user = await User.findOne({ accessToken: accessToken }) + if (user) { + req.user = user + } else { + return res.status(401).json({ + message: "Authentication missing or invalid.", + loggedOut: true + }) + } + } + next() + } catch (error) { + res.status(500).json({ + message: "Internal server error", + error: error.message + }) + } +} \ No newline at end of file diff --git a/models/Like.js b/models/Like.js new file mode 100644 index 0000000..5519652 --- /dev/null +++ b/models/Like.js @@ -0,0 +1,23 @@ +import mongoose from "mongoose" + +const likeSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true + }, + thought: { + type: mongoose.Schema.Types.ObjectId, + ref: "Thought", + required: true + }, + createdAt: { + type: Date, + default: Date.now + } +}) + +// Ensure that a user can only like a thought once +likeSchema.index({ user: 1, thought: 1 }, { unique: true }) + +export const Like = mongoose.model("Like", likeSchema) \ No newline at end of file diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..083a00d --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,32 @@ +import mongoose from "mongoose" + +const thoughtSchema = new mongoose.Schema({ + message: { + type: String, + required: true, + minLength: 5, + maxLength: 140 + }, + hearts: { + type: Number, + default: 0, + min: 0 + }, + tags: { + type: [String], + required: true, + lowercase: true, + enum: ["travel", "food", "family", "friends", "humor", "nature", "wellness", "home", "entertainment", "work", "other"], + default: "other" + }, + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User" + }, + createdAt: { + type: Date, + default: Date.now + } +}) + +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..f68a11a --- /dev/null +++ b/models/User.js @@ -0,0 +1,20 @@ +import mongoose from "mongoose" +import crypto from "crypto" + +const userSchema = new mongoose.Schema({ + userName: { + type: String, + unique: true, + required: 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 bf25bb6..11cff21 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,11 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt-nodejs": "^0.0.3", "cors": "^2.8.5", "express": "^4.17.3", - "nodemon": "^3.0.1" + "express-list-endpoints": "^7.1.1", + "mongoose": "^8.15.1", + "nodemon": "^3.1.10" } } diff --git a/routes/thoughtRoutes.js b/routes/thoughtRoutes.js new file mode 100644 index 0000000..713b6d9 --- /dev/null +++ b/routes/thoughtRoutes.js @@ -0,0 +1,439 @@ +import express from "express" +import mongoose from "mongoose" +import { authenticateUser, authenticateUserOptional } from "../middleware/authMiddleware.js" +import { Like } from "../models/Like.js" +import { Thought } from "../models/Thought.js" +import { User } from "../models/User.js" + +const router = express.Router() + +// get all thoughts +router.get("/", async (req, res) => { + const page = req.query.page || 1 + const limit = req.query.limit || 10 + const sortBy = req.query.sort_by || "-createdAt" // sort on most recent by default + const tag = req.query.tag + const likes = req.query.likes + + const query = {} + if (tag) { + query.tags = tag + } + if (likes) { + query.hearts = { $gte: likes } + } + + try { + const totalCount = await Thought.find(query).countDocuments() + const thoughts = await Thought.find(query).sort(sortBy).skip((page - 1) * limit).limit(limit) + + if (thoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No thoughts found on that query. Try another one." + }) + } + res.status(200).json({ + success: true, + response: { + data: thoughts, + totalCount: totalCount, + currentPage: page, + limit: limit, + } + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Server error while fetching thoughts." + }) + } +}) + +// get most liked messages +router.get("/popular", async (req, res) => { + const page = req.query.page || 1 + const limit = req.query.limit || 10 + const tag = req.query.tag + + const query = {} + if (tag) { + query.tags = tag + } + + try { + const totalCount = await Thought.find(query).countDocuments() + const popularThoughts = await Thought.find(query).sort("-hearts").skip((page - 1) * limit).limit(limit) + + if (popularThoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No thoughts found on that query. Try another one." + }) + } + res.status(200).json({ + success: true, + response: { + data: popularThoughts, + totalCount: totalCount, + currentPage: page, + limit: limit, + } + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch popular thoughts." + }) + } +}) + +// get most recent messages +router.get("/recent", async (req, res) => { + const page = req.query.page || 1 + const limit = req.query.limit || 10 + const tag = req.query.tag + + const query = {} + if (tag) { + query.tags = tag + } + + try { + const totalCount = await Thought.find(query).countDocuments() + const recentThoughts = await Thought.find(query).sort("-createdAt").skip((page - 1) * limit).limit(limit) + + if (recentThoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No thoughts found on that query. Try another one." + }) + } + res.status(200).json({ + success: true, + response: { + data: recentThoughts, + totalCount: totalCount, + currentPage: page, + limit: limit, + } + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch recent thoughts." + }) + } +}) + +// get all thoughts by a specific user +router.get("/user", authenticateUser, async (req, res) => { + const userId = req.user._id.toString() + const page = req.query.page || 1 + const limit = req.query.limit || 10 + + try { + if (!mongoose.Types.ObjectId.isValid(userId)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid user ID format." + }) + } + + const totalCount = await Thought.find({ user: userId }).countDocuments() + const userThoughts = await Thought.find({ user: userId }).sort("-createdAt").skip((page - 1) * limit).limit(limit) + + if (userThoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No thoughts found for this user." + }) + } + res.status(200).json({ + success: true, + response: { + data: userThoughts, + totalCount: totalCount, + currentPage: page, + limit: limit, + } + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch user's thoughts." + }) + } +}) + +// get all thoughts liked by a specific user +router.get("/user/liked", authenticateUser, async (req, res) => { + const user = req.user + const page = req.query.page || 1 + const limit = req.query.limit || 10 + + try { + if (!mongoose.Types.ObjectId.isValid(user._id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid user ID format." + }) + } + + const userLikes = await Like.find({ user: user }) + if (!userLikes || userLikes.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No liked thoughts found for this user." + }) + } + + const likedThoughts = await Thought.find({ _id: { $in: userLikes.map(like => like.thought) } }) + .sort("-createdAt").skip((page - 1) * limit).limit(limit) + + if (likedThoughts.length === 0) { + return res.status(404).json({ + success: false, + response: [], + message: "No liked thoughts found for this user." + }) + } + + res.status(200).json({ + success: true, + response: { + data: likedThoughts, + totalCount: likedThoughts.length, + currentPage: page, + limit: limit, + }, + message: "Successfully fetched liked thoughts." + }) + + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch liked thoughts." + }) + } +}) + +// post a thought +router.post("/", authenticateUser, async (req, res) => { + const { user, message, tags } = req.body + + try { + // validate input + if (!message || message.length < 5 || message.length > 140) { + return res.status(400).json({ + success: false, + response: null, + message: "Message must be between 5 and 140 characters." + }) + } + + const newThought = await new Thought({ user, message, tags }).save() + if (!newThought) { + return res.status(400).json({ + success: false, + response: null, + message: "Failed to post thought." + }) + } + res.status(201).json({ + success: true, + response: newThought, + message: "Thought successfully posted!" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to create thought." + }) + } +}) + +// get one thought by id +router.get("/:id", async (req, res) => { + const { id } = req.params + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + sucess: false, + response: null, + message: "Invalid ID format." + }) + } + + const thought = await Thought.findById(id) + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found!" + }) + } + res.status(200).json({ + success: true, + response: thought + }) + } catch (error) { + return res.status(500).json({ + success: false, + response: error, + message: "Failed to fetch thought." + }) + } +}) + +// delete a thought +router.delete("/:id", authenticateUser, async (req, res) => { + const { id } = req.params + const userId = req.user._id.toString() + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + success: false, + response: null, + message: "Invalid ID format." + }) + } + const thought = await Thought.findByIdAndDelete({ _id: id, user: userId }) + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found or you don't have permission to delete it" + }) + } + res.status(200).json({ + success: true, + response: thought, + message: "Thought successfully deleted!" + }) + } catch (error) { + console.error("Error in DELETE /thoughts/:id route:", error) + res.status(500).json({ + success: false, + message: "Failed to delete thought." + }) + } +}) + +// edit a thought +router.patch("/:id", authenticateUser, async (req, res) => { + const { id } = req.params + const { message } = req.body + const userId = req.user._id.toString() + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + sucess: false, + response: null, + message: "Invalid ID format." + }) + } + if (message.length < 5 || message.length > 140) { + return res.status(400).json({ + success: false, + response: null, + message: "Message must be between 5 and 140 characters." + }) + } + + const thought = await Thought.findByIdAndUpdate({ _id: id, user: userId }, { message }, { new: true, runValidators: true }) + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found!" + }) + } + // only allow users to edit their own thoughts + if (!thought.user === userId) { + return res.status(403).json({ + success: false, + response: null, + message: "User do not have the permission to edit this thought." + }) + } + res.status(200).json({ + success: true, + response: thought, + message: "Thought successfully edited!" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to edit thought." + }) + } +}) + +// like a thought +router.patch("/:id/like", authenticateUserOptional, async (req, res) => { + const { id } = req.params + + try { + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + sucess: false, + response: null, + message: "Invalid ID format." + }) + } + const thought = await Thought.findByIdAndUpdate(id, { $inc: { hearts: 1 } }, { new: true, runValidators: true }) + if (!thought) { + return res.status(404).json({ + success: false, + response: null, + message: "Thought not found!" + }) + } + + // if user is authenticated, create a like entry + if (req.user) { + const existingLike = await Like.findOne({ user: req.user._id, thought: id }) + if (!existingLike) { + await new Like({ user: req.user._id, thought: id }).save() + } else { + return res.status(400).json({ + success: false, + response: null, + message: "You have already liked this thought." + }) + } + } + + res.status(200).json({ + success: true, + response: thought, + message: "Thought successfully liked!" + }) + } catch (error) { + res.status(500).json({ + success: false, + response: error, + message: "Failed to like 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 0000000..33e73cf --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,93 @@ +import express from "express"; +import bcrypt from "bcrypt-nodejs"; +import { User } from "../models/User.js" + +const router = express.Router() + +// endpoint to register a new user +router.post("/", async (req, res) => { + try { + const { userName, password } = req.body + // validate input + if (!userName || !password) { + return res.status(400).json({ + success: false, + message: "User name and password are required", + }) + } + // validate if userName already exists + const existingUser = await User.findOne({ userName: userName.toLowerCase() }) + if (existingUser) { + return res.status(409).json({ + success: false, + message: "User name already exists", + }) + } + // create a new user + const salt = bcrypt.genSaltSync() + const user = new User({ userName: userName.toLowerCase(), password: bcrypt.hashSync(password, salt) }) + user.save() + + res.status(200).json({ + success: true, + message: "User created successfully!", + response: { + id: user._id, + accessToken: user.accessToken + } + }) + } catch (error) { + res.status(400).json({ + success: false, + message: "Failed to create user", + error + }) + } +}) + +// endpoint to log in an existing user +router.post("/login", async (req, res) => { + try { + const { userName, password } = req.body + // validate input + if (!userName || !password) { + return res.status(400).json({ + success: false, + message: "Username and password are required", + }) + } + // validate if user exists + const user = await User.findOne({ userName: userName.toLowerCase() }) + if (!user) { + return res.status(404).json({ + success: false, + message: "User not found", + }) + } + // validate password + if (user && bcrypt.compareSync(password, user.password)) { + res.status(200).json({ + success: true, + message: "Log in successful!", + response: { + id: user._id, + userName: user.userName, + accessToken: user.accessToken + } + }) + } else { + res.status(401).json({ + success: false, + message: "Invalid password", + }) + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to log in", + error, + }) + } +}) + +export default router \ No newline at end of file diff --git a/server.js b/server.js index f47771b..0fbe537 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,16 @@ import cors from "cors" import express from "express" +import listEndpoints from "express-list-endpoints" +import mongoose from "mongoose" + +import userRoutes from "./routes/userRoutes.js" +import thoughtRoutes from "./routes/thoughtRoutes.js" + +// import data from "./data.json" + +// setting up database connection +const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/thoughts" +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: @@ -7,16 +18,36 @@ import express from "express" const port = process.env.PORT || 8080 const app = express() +// seeding data to database +// if (process.env.RESET_DATABASE) { +// const seedDatabase = async () => { +// await Thought.deleteMany({}) +// data.forEach(thought => { +// new Thought(thought).save() +// }) +// } +// seedDatabase() +// } + // Add middlewares to enable cors and json body parsing app.use(cors()) app.use(express.json()) -// Start defining your routes here +// endpoint for documentation of the API app.get("/", (req, res) => { - res.send("Hello Technigo!") + const endpoints = listEndpoints(app) + res.json({ + message: "Welcome to the Happy Thoughts API", + endpoints: endpoints + }) }) +// end point routes +app.use("/users", userRoutes); +app.use("/thoughts", thoughtRoutes); + // Start the server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`) }) +