diff --git a/README.md b/README.md index 0f9f073..f4d97c9 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,19 @@ Install dependencies with `npm install`, then start the server by running `npm r ## 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. +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. https://happyhappyhappyhappy.netlify.app/ + +## Endpoints +GET / -> API docs +GET /thoughts -> list latest 20 +GET /thoughts/:id -> single thought +POST /thoughts/:id/like -> like +POST /signup -> create user +POST /login -> login (returns accessToken) +POST /thoughts -> create (JWT required) +PATCH /thoughts/:id -> update (JWT + author-only) +DELETE /thoughts/:id -> delete (JWT + author-only) + +## Deploy +Deployed on Render: +https://js-project-api-862g.onrender.com/ diff --git a/models/Thought.js b/models/Thought.js new file mode 100644 index 0000000..6e27566 --- /dev/null +++ b/models/Thought.js @@ -0,0 +1,23 @@ +import mongoose from 'mongoose'; + +export const Thought = mongoose.model('Thought', { + message: { + type: String, + required: true, + minlength: 5, + maxlength: 140, + }, + hearts: { + type: Number, + default: 0 + }, + createdAt: { + type: Date, + default: () => new Date(), + }, + createdBy: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + } +}) \ No newline at end of file diff --git a/models/User.js b/models/User.js new file mode 100644 index 0000000..9cc4d05 --- /dev/null +++ b/models/User.js @@ -0,0 +1,38 @@ +import mongoose from 'mongoose'; +import bcrypt from 'bcryptjs'; + + +const userSchema = new mongoose.Schema( + { + email: { + type: String, + required: true, + trim: true, + lowercase: true, + unique: true, + match: [/^\S+@\S+\.\S+$/, 'Invalid email format'] + }, + password: { + type: String, + required: true, + minlength: 6 + }, + + }, + { timestamps: true } // <-- options go here, not inside the fields +); + +// Hash password before saving +userSchema.pre('save', async function (next) { + if (!this.isModified('password')) return next(); // Skip if password not changed + this.password = await bcrypt.hash(this.password, 10); // Salt rounds = 10 + next(); +}); + +// to compare passwords later +userSchema.methods.comparePassword = function (candidatePassword) { + return bcrypt.compare(candidatePassword, this.password); +}; + + +export const User = mongoose.model('User', userSchema); diff --git a/package.json b/package.json index bf25bb6..5916d27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "project-api", "version": "1.0.0", + "type": "module", "description": "Project API", "scripts": { "start": "babel-node server.js", @@ -12,8 +13,24 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcryptjs": "^3.0.2", "cors": "^2.8.5", - "express": "^4.17.3", + "dotenv": "^16.5.0", + "express": "^4.21.2", + "express-list-endpoints": "^7.1.1", + "jsonwebtoken": "^9.0.2", + "mongodb": "^6.18.0", + "mongoose": "^8.16.0", "nodemon": "^3.0.1" - } + }, + "main": "server.js", + "repository": { + "type": "git", + "url": "git+https://github.com/VariaSlu/js-project-api.git" + }, + "keywords": [], + "bugs": { + "url": "https://github.com/VariaSlu/js-project-api/issues" + }, + "homepage": "https://github.com/VariaSlu/js-project-api#readme" } diff --git a/server.js b/server.js index f47771b..3793141 100644 --- a/server.js +++ b/server.js @@ -1,5 +1,14 @@ import cors from "cors" import express from "express" +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; +import { Thought } from "./models/Thought.js" +import { User } from './models/User.js'; +import listEndpoints from 'express-list-endpoints'; +import jwt from 'jsonwebtoken'; + + +dotenv.config(); // 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 +16,240 @@ import express from "express" const port = process.env.PORT || 8080 const app = express() -// Add middlewares to enable cors and json body parsing + +// middlewares to enable cors and json body parsing app.use(cors()) app.use(express.json()) -// Start defining your routes here -app.get("/", (req, res) => { - res.send("Hello Technigo!") -}) +// Connect to MongoDB +mongoose + .connect(process.env.MONGO_URL) + .then(() => { + console.log('Connected to MongoDB!'); + //seed(); // optional + }) + .catch((err) => { + console.error('Mongo error', err); + }); + +// seed one thought if DB is empty (optional) +const seed = async () => { + const existing = await Thought.find(); + if (existing.length === 0) { + await new Thought({ + message: 'This is my very first seeded thought!', + hearts: 0 + }).save(); + console.log('Seeded one thought'); + } +}; + +const auth = (req, res, next) => { + const raw = req.header('Authorization') || ''; + const token = raw.startsWith('Bearer ') ? raw.slice(7) : null; + + console.log('[AUTH] raw header =', JSON.stringify(raw)); + console.log('[AUTH] token len =', token ? token.length : 0); + console.log('[AUTH] token parts =', token ? token.split('.').length : 0); + + if (!token) return res.status(401).json({ error: 'Missing token' }); + + try { + const payload = jwt.verify(token, process.env.JWT_SECRET); + req.userId = payload.userId; + return next(); + } catch (err) { + console.error('JWT verify error:', err.message); + return res.status(401).json({ error: 'Invalid or expired token', details: err.message }); + } +}; + + +//basee route +app.get('/', (req, res) => { + try { + const endpoints = listEndpoints(app).map(e => ({ + path: e.path, + methods: e.methods.sort(), + })); + res.json({ + name: 'Happy Thoughts API', + version: '1.0.0', + endpoints + }); + } catch (err) { + res.status(500).json({ error: 'Failed to list endpoints', details: err.message }); + } +}); + +// Get all thoughts +app.get('/thoughts', async (req, res) => { + try { + const thoughts = await Thought.find() + .sort({ createdAt: -1 }) + .limit(20) + .populate('createdBy', 'email'); + + res.json(thoughts); + } catch (err) { + res.status(500).json({ error: 'Could not fetch thoughts' }); + } +}); + +app.get('/thoughts/:id', async (req, res) => { + const { id } = req.params; + + try { + const thought = await Thought.findById(id); + + if (!thought) { + return res.status(404).json({ error: 'Thought not found' }); + } + + res.json(thought); + } catch (err) { + res.status(400).json({ + error: 'Invalid ID', + details: err.message, + }); + } +}); + + +app.post('/thoughts/:id/like', async (req, res) => { + const { id } = req.params; + + try { + + const updatedThought = await Thought.findByIdAndUpdate( + id, + { $inc: { hearts: 1 } }, + { new: true } //return the updaated document + ); + + if (!updatedThought) { + return res.status(404).json({ error: 'Thought not found' }); + } + + res.json(updatedThought); + + } catch (err) { + res.status(400).json({ + error: 'Invalid ID or request', + details: err.message //show whats wrong + }); + } + +}); + +app.post('/thoughts', auth, async (req, res) => { + const { message } = req.body; + + try { + const newThought = await new Thought({ + message, + createdBy: req.userId + }).save(); + + res.status(201).json(newThought); + } catch (err) { + if (err.name === 'ValidationError') { + return res.status(400).json({ error: 'Validation failed', details: err.message }); + } + res.status(500).json({ error: 'Could not save thought', details: err.message }); + } +}); + +app.patch('/thoughts/:id', auth, async (req, res) => { + const { message } = req.body; + if (typeof message !== 'string' || message.length < 5 || message.length > 140) { + return res.status(400).json({ error: 'Message must be 5–140 chars' }); + } + + try { + const t = await Thought.findById(req.params.id); + if (!t) return res.status(404).json({ error: 'Thought not found' }); + if (String(t.createdBy) !== req.userId) { + return res.status(403).json({ error: 'Not your thought' }); + } + + t.message = message; + await t.save(); + res.json(t); + } catch (err) { + res.status(400).json({ error: 'Invalid ID', details: err.message }); + } +}); + +app.delete('/thoughts/:id', auth, async (req, res) => { + try { + const t = await Thought.findById(req.params.id); + if (!t) return res.status(404).json({ error: 'Thought not found' }); + if (String(t.createdBy) !== req.userId) { + return res.status(403).json({ error: 'Not your thought' }); + } + + await t.deleteOne(); + res.json({ success: true, message: 'Thought deleted' }); + } catch (err) { + res.status(400).json({ error: 'Invalid ID', details: err.message }); + } +}); + +app.post('/signup', async (req, res) => { + const { email, password } = req.body; + + try { + const newUser = await new User({ email, password }).save(); + res.status(201).json({ + email: newUser.email, + _id: newUser._id + }); + } catch (err) { + res.status(400).json({ error: 'Could not create user', details: err.message }); + } +}); + +// LOGIN ROUTE +app.post('/login', async (req, res) => { + const { email, password } = req.body; + + try { + // 1. Check if user exists + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + // 2. Compare passwords + const isMatch = await user.comparePassword(password); + if (!isMatch) { + return res.status(401).json({ error: 'Invalid email or password' }); + } + + // after password check passes + const accessToken = jwt.sign( + { userId: user._id }, + process.env.JWT_SECRET, + { expiresIn: '7d' } + ); + + res.json({ + success: true, + message: 'Login successful', + userId: user._id, + email: user.email, + accessToken + }); + + + } catch (err) { + res.status(500).json({ error: 'Server error', details: err.message }); + } +}); + -// Start the server +// Start server app.listen(port, () => { console.log(`Server running on http://localhost:${port}`) }) diff --git a/thoughts.json b/thoughts.json new file mode 100644 index 0000000..c839f9d --- /dev/null +++ b/thoughts.json @@ -0,0 +1,14 @@ +[ + { + "id": 1, + "message": "I'm happy because I'm learning backend!", + "hearts": 5, + "createdAt": "2025-06-05T09:00:00.000Z" + }, + { + "id": 2, + "message": "Coffee makes everything better.", + "hearts": 2, + "createdAt": "2025-06-05T10:00:00.000Z" + } +] \ No newline at end of file