diff --git a/README.md b/README.md index 0f9f073..88c484f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,7 @@ -# Project API +# 🧠 Projekt: 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. +[Hola-Happy-Server](https://hola-happy-server.onrender.com) -## Getting started +[Hola-Happy-App](https://holahappythoughts.netlify.app/) -Install dependencies with `npm install`, then start the server by running `npm run dev` -## View it live - -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. diff --git a/controllers/thoughtController.js b/controllers/thoughtController.js new file mode 100644 index 0000000..532597b --- /dev/null +++ b/controllers/thoughtController.js @@ -0,0 +1,186 @@ +import { Thought } from '../models/Thoughts.js'; +import mongoose from 'mongoose'; + +export const listAllThoughts = async (req, res) => { + const sortBy = req.query.sortBy || 'createdAt'; + const sortDir = req.query.sortDir === 'ascending' ? 1 : -1; + + try { + const thoughts = await Thought.find() + .sort({ [sortBy]: sortDir }) + .populate('createdBy', '_id email'); + res.json(thoughts); + } catch (error) { + console.error('Mongoose error on listAllThoughts:', error); + res.status(500).json({ error: 'Could not fetch thoughts from database' }); + } +}; + +export const getOneThought = async (req, res) => { + const { id } = req.params; + + try { + let thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ + error: 'Thought not found', + requestedId: id, + }); + } + thought = await thought.populate('createdBy', '_id email'); + res.json(thought); + } catch (error) { + console.error('Mongoose error on getOneThought:', error); + res.status(400).json({ error: 'Invalid ID format or other error' }); + } +}; + +export const addThought = async (req, res) => { + const { message } = req.body; + + // Validate message length + if (!message || message.length < 4 || message.length > 140) { + return res.status(400).json({ + error: 'Message is required and must be between 5 and 140 characters', + }); + } + + try { + const newThought = await Thought.create({ + message, + createdBy: req.user._id, + // likes and createdAt will be set by defaults in the model + }); + + const populatedThought = await newThought.populate( + 'createdBy', + '_id email' + ); + res.status(201).json(populatedThought); + } catch (error) { + console.error('Mongoose error on addThought:', error); + if (error.name === 'ValidationError') { + res + .status(400) + .json({ error: 'Validation failed', details: error.errors }); + } else { + res.status(400).json({ error: 'Could not add your thought' }); + } + } +}; + +export const likeThought = async (req, res) => { + const { id } = req.params; + const userId = req.user?._id; + + try { + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ error: 'Thought not found' }); + } + + if (userId) { + const userIdStr = userId.toString(); + // Convert likedBy to array of strings for logic + let likedByStrArr = thought.likedBy.map((id) => id.toString()); + console.log('Before toggle:', { userIdStr, likedBy: likedByStrArr }); + const hasLiked = likedByStrArr.includes(userIdStr); + + if (hasLiked) { + likedByStrArr = likedByStrArr.filter((id) => id !== userIdStr); + console.log('User unliked. After removal:', likedByStrArr); + } else { + likedByStrArr.push(userIdStr); + console.log('User liked. After addition:', likedByStrArr); + } + thought.likes = likedByStrArr.length; + console.log('Final likedBy and likes:', { + likedBy: likedByStrArr, + likes: thought.likes, + }); + // Convert likedBy back to ObjectIds before saving + thought.likedBy = likedByStrArr.map( + (id) => new mongoose.Types.ObjectId(id) + ); + + const updatedThought = await thought.save(); + const populatedThought = await updatedThought.populate( + 'createdBy', + '_id email' + ); + return res.status(200).json(populatedThought); + } + + // Guests should not be able to like + return res.status(401).json({ error: 'Authentication required to like' }); + } catch (error) { + console.error('Error in likeThought:', error); + res + .status(500) + .json({ error: 'Could not toggle like', details: error.message }); + } +}; + +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({ error: 'Thought not found' }); + } + + // Kontrollera att den inloggade anvĂ€ndaren Ă€ger tanken + if (thought.createdBy.toString() !== req.user._id.toString()) { + return res + .status(403) + .json({ error: 'You are not allowed to update this thought' }); + } + + thought.message = message; + const updatedThought = await thought.save(); + const populatedThought = await updatedThought.populate( + 'createdBy', + '_id email' + ); + res.status(200).json(populatedThought); + } catch (err) { + res + .status(500) + .json({ error: 'Could not update thought', details: err.message }); + } +}; + +export const removeThought = async (req, res) => { + const { id } = req.params; + + try { + // Validate if the ID is a valid MongoDB ObjectId + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ error: 'Invalid thought ID format' }); + } + + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ error: 'Thought not found' }); + } + + if (thought.createdBy.toString() !== req.user._id.toString()) { + return res + .status(403) + .json({ error: 'You are not allowed to delete this thought' }); + } + + await thought.deleteOne(); + res.status(200).json({ message: 'Thought deleted successfully' }); + } catch (err) { + res + .status(500) + .json({ error: 'Could not delete thought', details: err.message }); + } +}; diff --git a/controllers/userController.js b/controllers/userController.js new file mode 100644 index 0000000..0231f90 --- /dev/null +++ b/controllers/userController.js @@ -0,0 +1,63 @@ +import User from '../models/Users.js'; +import bcrypt from 'bcrypt'; +import crypto from 'crypto'; + +export const signup = async (req, res) => { + const { email, password } = req.body; + + try { + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + const existingUser = await User.findOne({ email }); + if (existingUser) { + return res.status(400).json({ error: 'Email already exists' }); + } + const salt = bcrypt.genSaltSync(); + const hashedPassword = bcrypt.hashSync(password, salt); + const newUser = await new User({ email, password: hashedPassword }).save(); + res.status(201).json({ + email: newUser.email, + accessToken: newUser.accessToken, + id: newUser._id, + }); + } catch (err) { + res + .status(500) + .json({ error: 'Internal server error', details: err.message }); + } +}; + +export const login = async (req, res) => { + const { email, password } = req.body; + + try { + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + const passwordMatch = bcrypt.compareSync(password, user.password); + if (!passwordMatch) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + res.status(200).json({ + email: user.email, + accessToken: user.accessToken, + id: user._id, + }); + } catch (err) { + res + .status(500) + .json({ error: 'Internal server error', details: err.message }); + } +}; + +export const logout = async (req, res) => { + try { + req.user.accessToken = crypto.randomBytes(64).toString('hex'); + await req.user.save(); + res.status(200).json({ message: 'Logged out successfully' }); + } catch (err) { + res.status(500).json({ error: 'Could not log out', details: err.message }); + } +}; 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/middlewares/authenticateUser.js b/middlewares/authenticateUser.js new file mode 100644 index 0000000..cdfd1a6 --- /dev/null +++ b/middlewares/authenticateUser.js @@ -0,0 +1,28 @@ +import User from '../models/Users.js'; + +const authenticateUser = async (req, res, next) => { + const authHeader = req.header('Authorization'); + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Missing or invalid token' }); + } + + const accessToken = authHeader.replace('Bearer ', ''); + + try { + const user = await User.findOne({ accessToken }); + + if (!user) { + return res.status(401).json({ error: 'Invalid token' }); + } + + req.user = user; + next(); + } catch (err) { + res + .status(500) + .json({ error: 'Internal server error', details: err.message }); + } +}; + +export default authenticateUser; diff --git a/models/Thoughts.js b/models/Thoughts.js new file mode 100644 index 0000000..4fab534 --- /dev/null +++ b/models/Thoughts.js @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; + +const thoughtSchema = new mongoose.Schema({ + message: { + type: String, + required: true, + minlength: 5, + maxlength: 140, + }, + likes: { + type: Number, + default: 0, + }, + likedBy: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + }, + ], + createdAt: { + type: Date, + default: () => new Date(), // ger dagens datum + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: false, + }, +}); + +export const Thought = mongoose.model('Thought', thoughtSchema); diff --git a/models/Users.js b/models/Users.js new file mode 100644 index 0000000..a60aa73 --- /dev/null +++ b/models/Users.js @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; +import crypto from 'crypto'; +import bcrypt from 'bcrypt'; + +const userSchema = new mongoose.Schema({ + email: { + type: String, + required: true, + unique: true, + minlength: 3, + }, + password: { + type: String, + required: true, + minlength: 6, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(64).toString('hex'), + }, + thoughts: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'Thought', + }, + ], +}); + +const User = mongoose.model('User', userSchema); + +export default User; diff --git a/package.json b/package.json index bf25bb6..b26d4ef 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,23 @@ "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" + "start": "node server.js", + "dev": "nodemon server.js" }, "author": "", "license": "ISC", "dependencies": { - "@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", + "mongoose": "^8.15.1", + "uuid": "^11.1.0" + }, + "devDependencies": { "nodemon": "^3.0.1" } } diff --git a/routes/happyThoughtsRouter.js b/routes/happyThoughtsRouter.js new file mode 100644 index 0000000..cc53fc8 --- /dev/null +++ b/routes/happyThoughtsRouter.js @@ -0,0 +1,23 @@ +import express from 'express'; +import authenticateUser from '../middlewares/authenticateUser.js'; + +import { + listAllThoughts, + getOneThought, + addThought, + likeThought, + updateThought, + removeThought, +} from '../controllers/thoughtController.js'; + +const happyRouter = express.Router(); + +//KOMIHÅG ATT URL börjar med /api/thoughts +happyRouter.get('/', listAllThoughts); +happyRouter.get('/:id', getOneThought); +happyRouter.post('/', authenticateUser, addThought); +happyRouter.post('/:id/likes', authenticateUser, likeThought); +happyRouter.put('/:id', authenticateUser, updateThought); +happyRouter.delete('/:id', authenticateUser, removeThought); + +export default happyRouter; diff --git a/routes/userRoutes.js b/routes/userRoutes.js new file mode 100644 index 0000000..e1bfae6 --- /dev/null +++ b/routes/userRoutes.js @@ -0,0 +1,14 @@ +import express from 'express'; +import bcrypt from 'bcrypt'; +import User from '../models/Users.js'; +import authenticateUser from '../middlewares/authenticateUser.js'; +import crypto from 'crypto'; +import { signup, login, logout } from '../controllers/userController.js'; + +const userRoutes = express.Router(); + +userRoutes.post('/signup', signup); +userRoutes.post('/login', login); +userRoutes.post('/logout', authenticateUser, logout); + +export default userRoutes; diff --git a/server.js b/server.js index f47771b..8422d69 100644 --- a/server.js +++ b/server.js @@ -1,22 +1,65 @@ -import cors from "cors" -import express from "express" +import cors from 'cors'; +import express from 'express'; +import expressListEndpoints from 'express-list-endpoints'; +import dotenv from 'dotenv'; +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 -const app = express() +import happyRouter from './routes/happyThoughtsRouter.js'; +import userRoutes from './routes/userRoutes.js'; -// Add middlewares to enable cors and json body parsing -app.use(cors()) -app.use(express.json()) +// Ladda in .env-filen +dotenv.config(); -// Start defining your routes here -app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) +//moongoose +const mongoUrl = + process.env.MONGO_URL || 'mongodb://127.0.0.1:27017/happyThoughts'; +mongoose + .connect(mongoUrl) + .then(() => { + console.log('Connected to MongoDB Atlas'); + }) + .catch((error) => { + console.error('MongoDB connection error:', error); + }); -// Start the server +mongoose.connection.on('error', (error) => { + console.error('MongoDB connection error:', error); +}); + +const port = process.env.PORT || 8080; + +// Skapa en Express-app +const app = express(); + +// Middleware +app.use(cors()); // Aktivera CORS +app.use(express.json()); // Aktivera JSON-parsing + +// Rot-endpoint: lista alla endpoints requriement +function ListEndpointsHandler(req, res) { + const endpoints = expressListEndpoints(app); + res.json(endpoints); +} +app.get('/', ListEndpointsHandler); + +//HappyRouter montering +app.use('/api/thoughts', happyRouter); +app.use('/', userRoutes); + +// Hantera 404 +app.use((req, res) => { + res.status(404).json({ error: 'Not Found' }); +}); + +//Felhantering +app.use((err, req, res, next) => { + console.error('đŸ’„ Server Error:', err); + res.status(err.status || 500).json({ + error: err.message || 'Internal Server Error', + }); +}); + +// Starta servern app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) + console.log(`Server running on http://localhost:${port}`); +}); diff --git a/testService.js b/testService.js new file mode 100644 index 0000000..013f38e --- /dev/null +++ b/testService.js @@ -0,0 +1,124 @@ +import assert from 'assert'; + +import { + getAllThoughts, + getOneThought, + addThought, + incrementLike, + decrementLike, + updateThought, + removeThought, +} from './services/thoughtService.js'; + +////////////////////// ASSERT TESTING //////////////////////// + +// 1) Initial count +const initialCount = getAllThoughts().length; + +// 2) LĂ€gg till en ny thought +const newThought = addThought({ message: 'Test thought' }); +assert.strictEqual( + getAllThoughts().length, + initialCount + 1, + `addThought: förvĂ€ntade length ${initialCount + 1}, men fick ${ + getAllThoughts().length + }` +); + +// 3) HĂ€mta just den thought +const fetched = getOneThought(newThought._id); +assert.ok(fetched, 'getOneThought: thought ska finnas'); +assert.strictEqual( + fetched.message, + 'Test thought', + `getOneThought: förvĂ€ntade message 'Test thought', men fick '${fetched.message}'` +); + +// 4) Testa incrementLike +incrementLike(newThought._id); +assert.strictEqual( + getOneThought(newThought._id).hearts, + 1, + 'incrementLike: hearts borde vara 1' +); + +// 5) Testa decrementLike (fĂ„r inte gĂ„ under 0) +decrementLike(newThought._id); +assert.strictEqual( + getOneThought(newThought._id).hearts, + 0, + 'decrementLike: hearts borde vara tillbaka till 0' +); +decrementLike(newThought._id); // ytterligare en decrement +assert.strictEqual( + getOneThought(newThought._id).hearts, + 0, + 'decrementLike: hearts borde inte gĂ„ under 0' +); + +// 6) Testa updateThought +updateThought(newThought._id, { message: 'Updated thought' }); +assert.strictEqual( + getOneThought(newThought._id).message, + 'Updated thought', + 'updateThought: message uppdaterades inte korrekt' +); + +// 7) Testa removeThought +const removed = removeThought(newThought._id); + +// 7.1) FörvĂ€ntar oss ett objekt, inte bara true +assert.ok( + removed && removed._id === newThought._id, + `removeThought: förvĂ€ntade borttaget objekt med _id ${newThought._id}, men fick ${removed}` +); + +// 7.2) Efter borttagningen ska getOneThought ge null +assert.strictEqual( + getOneThought(newThought._id), + null, + 'getOneThought: borde returnera null för borttagen thought' +); + +// 7.3) Totalt antal ska ha minskat med 1 (till initialCount igen) +assert.strictEqual( + getAllThoughts().length, + initialCount, + `removeThought: förvĂ€ntade length ${initialCount}, men fick ${ + getAllThoughts().length + }` +); + +console.log('🎉 Alla service-tester gick igenom utan fel!'); + +//////////////////////// MANUAL TESTING //////////////////////// + +// // 1) Börja med en ren mock-array +// console.log('Initial:', getAllThoughts()); + +// // 2) LĂ€gg till en ny +// const newT = addThought({ message: 'Testmeddelande' }); +// console.log('Efter add:', getAllThoughts()); + +// // 3) Gilla och ogilla +// incrementLike(newT._id); +// console.log( +// '✅ Efter +1 like:', +// getAllThoughts().find((t) => t._id === newT._id) +// ); +// decrementLike(newT._id); +// console.log( +// ' ✅ Efter -1 like:', +// getAllThoughts().find((t) => t._id === newT._id) +// ); + +// // 4) Uppdatera +// updateThought(newT._id, { message: 'Uppdaterat meddelande' }); +// console.log( +// ' ✅ Efter update:', +// getAllThoughts().find((t) => t._id === newT._id) +// ); + +// // 5) Ta bort +// removeThought(newT._id); +// console.log(' ✅ Efter remove:', getAllThoughts());