diff --git a/.gitignore b/.gitignore index f1ff414..b21d4b0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ node_modules .env.development.local .env.test.local .env.production.local -package-lock.json \ No newline at end of file +package-lock.json +instructions.txt +instructions_full.txt +refactoring.txt \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7a2dece --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 80 +} \ No newline at end of file diff --git a/README.md b/README.md index 0f9f073..6f347ba 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,163 @@ -# Project API +# 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. +A REST API for managing happy thoughts with user authentication, filtering, sorting, and pagination. -## Getting started +## Live API -Install dependencies with `npm install`, then start the server by running `npm run dev` +๐ŸŒ **Production URL**: https://friendlytwitter-api.onrender.com +Live full website: https://friendlytwitter.netlify.app/ -## View it live +## Endpoints -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. +### Documentation + +- `GET /` - List all available endpoints + +### Thoughts + +- `GET /thoughts` - Get all thoughts (with optional filters, sorting, pagination) +- `GET /thoughts/:id` - Get single thought by ID +- `POST /thoughts` - Create a new thought (authenticated) +- `PUT /thoughts/:id` - Update a thought (authenticated, owner only) +- `DELETE /thoughts/:id` - Delete a thought (authenticated, owner only) +- `POST /thoughts/:id/like` - Like/unlike a thought (authenticated) + +### Authentication + +- `POST /auth/signup` - Register a new user +- `POST /auth/login` - Login user +- `GET /auth/me` - Get current user profile (authenticated) + +### Users + +- `GET /users/:id/thoughts` - Get thoughts by specific user (authenticated) +- `GET /users/me/thoughts` - Get thoughts created by the current user (authenticated) +- `GET /users/me/likes` - Get thoughts liked by the current user (authenticated) + +## Query Parameters + +**GET /thoughts** supports: + +- `page` - Page number for pagination (default: 1) +- `limit` - Results per page, max 100 (default: 20) +- `category` - Filter by category (case-insensitive) +- `sort` - Sort by: `hearts`, `createdAt`, `updatedAt`, `category` (use `-` prefix for descending) +- `minHearts` - Filter thoughts with minimum number of hearts +- `newerThan` - Filter thoughts created after specific date (ISO format) + +**GET /users/me/thoughts** supports the same parameters as GET /thoughts + +**GET /users/me/likes** supports: + +- `page` - Page number for pagination (default: 1) +- `limit` - Results per page, max 100 (default: 20) +- `sort` - Sort by: `hearts`, `createdAt`, `updatedAt`, `category` (use `-` prefix for descending) + +## Authentication + +Include the JWT token in the Authorization header: + +``` +Authorization: Bearer YOUR_JWT_TOKEN +``` + +## Examples + +### Get API Documentation + +```bash +curl https://friendlytwitter-api.onrender.com/ +``` + +### Get All Thoughts + +```bash +curl https://friendlytwitter-api.onrender.com/thoughts +``` + +### Get Thoughts with Pagination + +```bash +curl https://friendlytwitter-api.onrender.com/thoughts?page=1&limit=5 +``` + +### Filter and Sort Thoughts + +```bash +curl https://friendlytwitter-api.onrender.com/thoughts?category=Food&sort=-hearts&minHearts=5 +``` + +### Register a New User + +```bash +curl -X POST https://friendlytwitter-api.onrender.com/auth/signup \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"SecurePass123","name":"John Doe"}' +``` + +### Login + +```bash +curl -X POST https://friendlytwitter-api.onrender.com/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user@example.com","password":"SecurePass123"}' +``` + +### Create a Thought (Authenticated) + +```bash +curl -X POST https://friendlytwitter-api.onrender.com/thoughts \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{"message":"This is my happy thought!","category":"General"}' +``` + +### Like a Thought + +```bash +curl -X POST https://friendlytwitter-api.onrender.com/thoughts/THOUGHT_ID/like \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +### Get My Thoughts + +```bash +curl https://friendlytwitter-api.onrender.com/users/me/thoughts \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +### Get My Thoughts with Filters + +```bash +curl "https://friendlytwitter-api.onrender.com/users/me/thoughts?category=Food&sort=-hearts&page=1&limit=10" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +### Get Thoughts I've Liked + +```bash +curl https://friendlytwitter-api.onrender.com/users/me/likes \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +## Response Format + +### Thoughts List Response + +```json +{ + "thoughts": [...], + "total": 123, + "pagination": { + "currentPage": 1, + "totalPages": 7, + "totalCount": 123, + "hasNextPage": true, + "hasPrevPage": false + }, + "filters": { + "category": "Food", + "sort": "-hearts" + } +} +``` 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/data/thoughts.json b/data/thoughts.json new file mode 100644 index 0000000..8cea4ff --- /dev/null +++ b/data/thoughts.json @@ -0,0 +1,146 @@ +[ + { + "message": "Berlin baby", + "category": "Travel", + "user": { + "name": "Emma Johnson", + "email": "emma.johnson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "My family!", + "category": "Family", + "user": { + "name": "Marcus Rodriguez", + "email": "marcus.rodriguez@example.com", + "password": "SecurePass123" + } + }, + { + "message": "The smell of coffee in the morning....", + "category": "Food", + "user": { + "name": "Sophie Chen", + "email": "sophie.chen@example.com", + "password": "SecurePass123" + } + }, + { + "message": "Newly washed bedlinen, kids that sleeps through the night.. FINGERS CROSSED ๐Ÿคž๐Ÿผ", + "category": "Family", + "user": { + "name": "Marcus Rodriguez", + "email": "marcus.rodriguez@example.com", + "password": "SecurePass123" + } + }, + { + "message": "I am happy that I feel healthy and have energy again", + "category": "Health", + "user": { + "name": "Alex Thompson", + "email": "alex.thompson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "cold beer", + "category": "Food", + "user": { + "name": "James Wilson", + "email": "james.wilson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "My friend is visiting this weekend! <3", + "category": "Friends", + "user": { + "name": "Emma Johnson", + "email": "emma.johnson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "A good joke: Why did the scarecrow win an award? Because he was outstanding in his field!", + "category": "Humor", + "user": { + "name": "James Wilson", + "email": "james.wilson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "Tacos and tequila๐ŸŒฎ๐Ÿน", + "category": "Food", + "user": { + "name": "Sophie Chen", + "email": "sophie.chen@example.com", + "password": "SecurePass123" + } + }, + { + "message": "Netflix and late night ice-cream๐Ÿฆ", + "category": "Entertainment", + "user": { + "name": "Alex Thompson", + "email": "alex.thompson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "Summer is coming...", + "category": "Weather", + "user": { + "name": "Emma Johnson", + "email": "emma.johnson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "Exercise? I thought you said extra fries! ๐ŸŸ๐Ÿ˜‚", + "category": "Humor", + "user": { + "name": "Marcus Rodriguez", + "email": "marcus.rodriguez@example.com", + "password": "SecurePass123" + } + }, + { + "message": "I'm on a seafood diet. I see food, and I eat it.", + "category": "Food", + "user": { + "name": "James Wilson", + "email": "james.wilson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "Cute monkeys๐Ÿ’", + "category": "Animals", + "user": { + "name": "Sophie Chen", + "email": "sophie.chen@example.com", + "password": "SecurePass123" + } + }, + { + "message": "The weather is nice!", + "category": "Weather", + "user": { + "name": "Alex Thompson", + "email": "alex.thompson@example.com", + "password": "SecurePass123" + } + }, + { + "message": "good vibes and good things", + "category": "General", + "user": { + "name": "Emma Johnson", + "email": "emma.johnson@example.com", + "password": "SecurePass123" + } + } +] \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..819436f --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js' +import node from 'eslint-plugin-node' +import prettier from 'eslint-config-prettier' + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + console: 'readonly', + process: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + Buffer: 'readonly', + global: 'readonly', + }, + }, + plugins: { + node, + }, + rules: { + 'no-console': 'off', + 'node/no-unsupported-features/es-syntax': 'off', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, + prettier, +] diff --git a/package.json b/package.json index bf25bb6..8740bf0 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,15 @@ "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": "babel-node src/app.js", + "dev": "nodemon src/app.js --exec babel-node", + "lint": "eslint .", + "format": "prettier --write .", + "seed": "babel-node scripts/seedThoughtsAPI.js", + "test": "NODE_ENV=test jest --detectOpenHandles", + "test:watch": "NODE_ENV=test jest --watch --detectOpenHandles" }, "author": "", "license": "ISC", @@ -12,8 +18,41 @@ "@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", + "express-rate-limit": "^7.5.0", + "express-validator": "^7.2.1", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.15.1", + "node-fetch": "^3.3.2", "nodemon": "^3.0.1" + }, + "devDependencies": { + "babel-jest": "^30.0.0-beta.3", + "eslint": "^9.27.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-security": "^3.0.1", + "jest": "^29.7.0", + "mongodb-memory-server": "^10.1.4", + "prettier": "^3.5.3", + "supertest": "^7.1.1" + }, + "jest": { + "testEnvironment": "node", + "transform": { + "^.+\\.js$": "babel-jest" + }, + "testMatch": [ + "**/__tests__/**/*.test.js", + "**/?(*.)+(spec|test).js" + ], + "setupFilesAfterEnv": [ + "/tests/setup.js" + ] } } diff --git a/pull_request_template.md b/pull_request_template.md deleted file mode 100644 index fb9fdc3..0000000 --- a/pull_request_template.md +++ /dev/null @@ -1 +0,0 @@ -Please include your Render link here. \ No newline at end of file diff --git a/scripts/seedThoughtsAPI.js b/scripts/seedThoughtsAPI.js new file mode 100644 index 0000000..768fbe8 --- /dev/null +++ b/scripts/seedThoughtsAPI.js @@ -0,0 +1,166 @@ +import fs from 'fs' +import path from 'path' +import dotenv from 'dotenv' + +// Import fetch for Node.js environments +import fetch from 'node-fetch' + +// Load environment variables +dotenv.config() + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080' + +/** + * Fetches thoughts data from JSON file + * @returns {Array} Array of thought objects + */ +const loadThoughtsData = () => { + try { + const thoughtsPath = path.join(process.cwd(), 'data', 'thoughts.json') + const thoughtsData = JSON.parse(fs.readFileSync(thoughtsPath, 'utf8')) + console.log(`๐Ÿ“– Loaded ${thoughtsData.length} thoughts from JSON file`) + return thoughtsData + } catch (error) { + console.error('โŒ Error loading thoughts data:', error.message) + process.exit(1) + } +} + +/** + * Creates a single thought via API + * @param {Object} thought - Thought object with message and category + * @returns {Promise} Created thought object + */ +const createThought = async (thought) => { + try { + const response = await fetch(`${API_BASE_URL}/thoughts?allowAnonymous=true`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(thought), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(`API Error: ${response.status} ${response.statusText} - ${errorData.details || errorData.error}`) + } + + const createdThought = await response.json() + return createdThought + } catch (error) { + console.error(`โŒ Failed to create thought: "${thought.message}"`) + console.error(` Error: ${error.message}`) + throw error + } +} + +/** + * Checks if API server is running + * @returns {Promise} True if server is accessible + */ +const checkAPIHealth = async () => { + try { + const response = await fetch(`${API_BASE_URL}/`, { + method: 'GET', + }) + return response.ok + } catch { + return false + } +} + +/** + * Gets current count of thoughts in database + * @returns {Promise} Number of existing thoughts + */ +const getExistingThoughtsCount = async () => { + try { + const response = await fetch(`${API_BASE_URL}/thoughts`) + if (response.ok) { + const data = await response.json() + return data.pagination?.totalCount || 0 + } + return 0 + } catch (error) { + console.warn('โš ๏ธ Could not get existing thoughts count:', error.message) + return 0 + } +} + +/** + * Seeds thoughts through the API + */ +const seedThoughtsViaAPI = async () => { + console.log('๐ŸŒฑ Happy Thoughts API Seeder') + console.log('============================') + + // Check if API is running + console.log('๐Ÿ” Checking API server status...') + const isAPIRunning = await checkAPIHealth() + if (!isAPIRunning) { + console.error('โŒ API server is not running or not accessible') + console.error(` Please make sure the server is running on ${API_BASE_URL}`) + process.exit(1) + } + console.log('โœ… API server is running') + + // Check existing thoughts + const existingCount = await getExistingThoughtsCount() + if (existingCount > 0) { + console.log(`โ„น๏ธ Found ${existingCount} existing thoughts in database`) + console.log(' Proceeding to add new thoughts...') + } + + // Load thoughts data + const thoughtsToCreate = loadThoughtsData() + + // Create thoughts via API + console.log('๐Ÿš€ Creating thoughts via API...') + let successCount = 0 + let errorCount = 0 + + for (let i = 0; i < thoughtsToCreate.length; i++) { + const thought = thoughtsToCreate[i] + try { + console.log(` [${i + 1}/${thoughtsToCreate.length}] Creating: "${thought.message.substring(0, 50)}${thought.message.length > 50 ? '...' : ''}"`) + + await createThought(thought) + successCount++ + + // Small delay to avoid overwhelming the server + await new Promise(resolve => { + setTimeout(resolve, 100) + }) + + } catch { + errorCount++ + // Continue with next thought + } + } + + // Final report + console.log('\n๐Ÿ“Š Seeding Results:') + console.log(` โœ… Successfully created: ${successCount} thoughts`) + if (errorCount > 0) { + console.log(` โŒ Failed to create: ${errorCount} thoughts`) + } + + // Get final count + const finalCount = await getExistingThoughtsCount() + console.log(` ๐Ÿ“ˆ Total thoughts in database: ${finalCount}`) + + if (errorCount === 0) { + console.log('\n๐ŸŽ‰ All thoughts seeded successfully!') + } else { + console.log('\nโš ๏ธ Seeding completed with some errors') + } + + process.exit(0) +} + +// Run the seeder +seedThoughtsViaAPI().catch((error) => { + console.error('๐Ÿ’ฅ Fatal error during seeding:', error) + process.exit(1) +}) \ No newline at end of file diff --git a/server.js b/server.js deleted file mode 100644 index f47771b..0000000 --- a/server.js +++ /dev/null @@ -1,22 +0,0 @@ -import cors from "cors" -import express from "express" - -// 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() - -// Add 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!") -}) - -// Start the server -app.listen(port, () => { - console.log(`Server running on http://localhost:${port}`) -}) diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..0fd9643 --- /dev/null +++ b/src/app.js @@ -0,0 +1,141 @@ +import cors from 'cors' +import express from 'express' +import helmet from 'helmet' +import dotenv from 'dotenv' +import connectDB from './config/db.js' +import indexRoutes from './index.js' +import thoughtsRoutes from './routes/thoughts.js' +import authRoutes from './routes/auth.js' +import usersRoutes from './routes/users.js' + +// Load environment variables +dotenv.config() + +// App configuration +const port = process.env.PORT || 8080 +const app = express() + +/** + * Sets up security and basic middleware + * @param {express.Application} app - Express application instance + */ +const setupMiddleware = (app) => { + app.use(helmet()) + app.use(cors()) + app.use(express.json()) +} + +/** + * Sets up API routes + * @param {express.Application} app - Express application instance + */ +const setupRoutes = (app) => { + app.use('/', indexRoutes) + app.use('/thoughts', thoughtsRoutes) + app.use('/auth', authRoutes) + app.use('/users', usersRoutes) +} + +/** + * Sets up 404 handler for unknown endpoints + * @param {express.Application} app - Express application instance + */ +const setup404Handler = (app) => { + app.use('*', (req, res) => { + res.status(404).json({ + error: 'Endpoint not found', + details: `The requested endpoint ${req.method} ${req.originalUrl} does not exist`, + }) + }) +} + +/** + * Determines error status and message based on error type + * @param {Error} err - The error object + * @returns {Object} Error details with status, error, and details + */ +const getErrorDetails = (err) => { + let status = 500 + let error = 'Internal Server Error' + let details = err.message || 'An unexpected error occurred' + + if (err.name === 'ValidationError') { + status = 422 + error = 'Validation Error' + details = Object.values(err.errors).map((e) => e.message) + } else if (err.name === 'CastError') { + status = 400 + error = 'Bad Request' + details = 'Invalid ID format' + } else if (err.code === 11000) { + status = 409 + error = 'Conflict' + const field = Object.keys(err.keyPattern)[0] + details = `${field} already exists` + } else if (err.name === 'JsonWebTokenError') { + status = 401 + error = 'Unauthorized' + details = 'Invalid access token' + } else if (err.name === 'TokenExpiredError') { + status = 401 + error = 'Unauthorized' + details = 'Access token has expired' + } else if (err.status || err.statusCode) { + status = err.status || err.statusCode + error = err.name || error + details = err.message || details + } + + return { status, error, details } +} + +/** + * Sets up global error handling middleware + * @param {express.Application} app - Express application instance + */ +const setupErrorHandler = (app) => { + app.use((err, req, res, _next) => { + console.error('Global error handler caught:', err) + + const { status, error, details } = getErrorDetails(err) + + // Don't expose internal error details in production + const finalDetails = process.env.NODE_ENV === 'production' && status === 500 + ? 'An internal server error occurred' + : details + + res.status(status).json({ + error, + details: finalDetails, + }) + }) +} + +/** + * Starts the Express server after connecting to MongoDB + */ +const startServer = async () => { + try { + await connectDB() + + app.listen(port, () => { + if (process.env.NODE_ENV !== 'test') { + console.error(`Server running on http://localhost:${port}`) + } + }) + } catch (error) { + console.error('Failed to start server:', error) + process.exit(1) + } +} + +// Setup application +setupMiddleware(app) +setupRoutes(app) +setup404Handler(app) +setupErrorHandler(app) + +// Start server +startServer() + +export default app diff --git a/src/config/db.js b/src/config/db.js new file mode 100644 index 0000000..96f4b31 --- /dev/null +++ b/src/config/db.js @@ -0,0 +1,54 @@ +import mongoose from 'mongoose' +import dotenv from 'dotenv' + +// Load environment variables +dotenv.config() + +/** + * Sets up MongoDB connection event handlers + */ +const setupConnectionEvents = () => { + mongoose.connection.on('error', (err) => { + console.error('MongoDB connection error:', err) + }) + + mongoose.connection.on('disconnected', () => { + console.error('MongoDB disconnected') + }) +} + +/** + * Sets up graceful shutdown handling for MongoDB connection + */ +const setupGracefulShutdown = () => { + process.on('SIGINT', async () => { + try { + await mongoose.connection.close() + process.exit(0) + } catch (err) { + console.error('Error closing MongoDB connection:', err) + process.exit(1) + } + }) +} + +/** + * Connects to MongoDB database + * @returns {Promise} + */ +const connectDB = async () => { + try { + const mongoUrl = + process.env.MONGO_URL || 'mongodb://localhost:27017/happy-thoughts' + + await mongoose.connect(mongoUrl) + + setupConnectionEvents() + setupGracefulShutdown() + } catch (error) { + console.error('Failed to connect to MongoDB:', error.message) + process.exit(1) + } +} + +export default connectDB diff --git a/src/controllers/authController.js b/src/controllers/authController.js new file mode 100644 index 0000000..ec09299 --- /dev/null +++ b/src/controllers/authController.js @@ -0,0 +1,134 @@ +import jwt from 'jsonwebtoken' +import User from '../models/User.js' + +// Generate JWT token +const generateToken = (userId) => { + const jwtSecret = + process.env.JWT_SECRET || 'fallback-secret-change-in-production' + return jwt.sign({ userId }, jwtSecret, { expiresIn: '24h' }) +} + +const createErrorResponse = (status, error, details) => ({ + status, + json: { error, details }, +}) + +const createSuccessResponse = (status, data) => ({ + status, + json: data, +}) + +const handleValidationError = (error) => { + const validationErrors = Object.values(error.errors).map(err => err.message) + return createErrorResponse(422, 'Validation failed', validationErrors) +} + +const handleDuplicateUserError = () => + createErrorResponse(409, 'Conflict', 'User with this email already exists') + +const handleServerError = (message) => + createErrorResponse(500, 'Internal Server Error', message) + +// POST /signup - Register new user +export const signup = async (req, res) => { + try { + const { email, password, name } = req.body + + // Check if user already exists + const existingUser = await User.findOne({ email }) + if (existingUser) { + const errorResponse = handleDuplicateUserError() + return res.status(errorResponse.status).json(errorResponse.json) + } + + // Create new user (password will be hashed by pre-save hook) + const user = new User({ email, password, name }) + await user.save() + + // Generate JWT token + const token = generateToken(user._id) + + // Return user data and token (password excluded by toJSON method) + const successResponse = createSuccessResponse(201, { + message: 'User created successfully', + user: user.toJSON(), + accessToken: token, + }) + + res.status(successResponse.status).json(successResponse.json) + } catch (error) { + let errorResponse + + if (error.name === 'ValidationError') { + errorResponse = handleValidationError(error) + } else if (error.code === 11000) { + errorResponse = handleDuplicateUserError() + } else { + errorResponse = handleServerError('Failed to create user') + } + + res.status(errorResponse.status).json(errorResponse.json) + } +} + +// POST /login - Authenticate user +export const login = async (req, res) => { + try { + const { email, password } = req.body + + // Validate required fields + if (!email || !password) { + const errorResponse = createErrorResponse(400, 'Bad Request', 'Email and password are required') + return res.status(errorResponse.status).json(errorResponse.json) + } + + // Find user by email + const user = await User.findOne({ email }) + if (!user) { + const errorResponse = createErrorResponse(401, 'Unauthorized', 'Invalid email or password') + return res.status(errorResponse.status).json(errorResponse.json) + } + + // Compare password using the user model method + const isPasswordValid = await user.comparePassword(password) + if (!isPasswordValid) { + const errorResponse = createErrorResponse(401, 'Unauthorized', 'Invalid email or password') + return res.status(errorResponse.status).json(errorResponse.json) + } + + // Generate JWT token + const token = generateToken(user._id) + + // Return user data and token + const successResponse = createSuccessResponse(200, { + message: 'Login successful', + user: user.toJSON(), + accessToken: token, + }) + + res.status(successResponse.status).json(successResponse.json) + } catch { + const errorResponse = handleServerError('Failed to authenticate user') + res.status(errorResponse.status).json(errorResponse.json) + } +} + +// GET /me - Get current user profile (requires authentication) +export const getProfile = async (req, res) => { + try { + // req.user is set by auth middleware + const user = await User.findById(req.user.userId) + if (!user) { + const errorResponse = createErrorResponse(404, 'Not Found', 'User not found') + return res.status(errorResponse.status).json(errorResponse.json) + } + + const successResponse = createSuccessResponse(200, { + user: user.toJSON(), + }) + res.status(successResponse.status).json(successResponse.json) + } catch { + const errorResponse = handleServerError('Failed to get user profile') + res.status(errorResponse.status).json(errorResponse.json) + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4f17de4 --- /dev/null +++ b/src/index.js @@ -0,0 +1,13 @@ +import express from 'express' +import listEndpoints from 'express-list-endpoints' + +const router = express.Router() + +const getApiDocumentation = (req, res) => { + const endpoints = listEndpoints(req.app) + res.json(endpoints) +} + +router.get('/', getApiDocumentation) + +export default router diff --git a/src/middleware/authMiddleware.js b/src/middleware/authMiddleware.js new file mode 100644 index 0000000..3bb0c38 --- /dev/null +++ b/src/middleware/authMiddleware.js @@ -0,0 +1,105 @@ +import jwt from 'jsonwebtoken' +import User from '../models/User.js' + +const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-in-production' + +const extractTokenFromHeader = (authHeader) => { + return authHeader && authHeader.startsWith('Bearer ') + ? authHeader.slice(7) + : null +} + +const createUserObject = (user) => ({ + userId: user._id, + email: user.email, + name: user.name, +}) + +const createErrorResponse = (status, error, details) => ({ + status, + json: { error, details }, +}) + +const handleJwtError = (error) => { + if (error.name === 'JsonWebTokenError') { + return createErrorResponse(401, 'Unauthorized', 'Invalid access token') + } + if (error.name === 'TokenExpiredError') { + return createErrorResponse(401, 'Unauthorized', 'Access token has expired') + } + return createErrorResponse(500, 'Internal Server Error', 'Failed to authenticate token') +} + +// Middleware to verify JWT token and attach user to request +export const authenticateToken = async (req, res, next) => { + try { + const token = extractTokenFromHeader(req.headers.authorization) + + if (!token) { + const errorResponse = createErrorResponse(401, 'Unauthorized', 'Access token is required') + return res.status(errorResponse.status).json(errorResponse.json) + } + + const decoded = jwt.verify(token, JWT_SECRET) + const userId = decoded.userId || decoded.sub || decoded.id + + const user = await User.findById(userId) + if (!user) { + const errorResponse = createErrorResponse(401, 'Unauthorized', 'User not found') + return res.status(errorResponse.status).json(errorResponse.json) + } + + req.user = createUserObject(user) + next() + } catch (error) { + const errorResponse = handleJwtError(error) + res.status(errorResponse.status).json(errorResponse.json) + } +} + +// Optional middleware - allows authenticated and unauthenticated users +export const optionalAuth = async (req, res, next) => { + try { + const token = extractTokenFromHeader(req.headers.authorization) + + if (!token) { + req.user = null + return next() + } + + const decoded = jwt.verify(token, JWT_SECRET) + const userId = decoded.userId || decoded.sub || decoded.id + + const user = await User.findById(userId) + req.user = user ? createUserObject(user) : null + + next() + } catch { + req.user = null + next() + } +} + +// Middleware to check if user owns a resource +export const requireOwnership = (getResourceUserId) => { + return (req, res, next) => { + try { + const resourceUserId = getResourceUserId(req) + + if (!req.user) { + const errorResponse = createErrorResponse(401, 'Unauthorized', 'Authentication required') + return res.status(errorResponse.status).json(errorResponse.json) + } + + if (req.user.userId.toString() !== resourceUserId.toString()) { + const errorResponse = createErrorResponse(403, 'Forbidden', 'You can only access your own resources') + return res.status(errorResponse.status).json(errorResponse.json) + } + + next() + } catch { + const errorResponse = createErrorResponse(500, 'Internal Server Error', 'Failed to verify ownership') + res.status(errorResponse.status).json(errorResponse.json) + } + } +} diff --git a/src/middleware/rateLimiting.js b/src/middleware/rateLimiting.js new file mode 100644 index 0000000..4b23a96 --- /dev/null +++ b/src/middleware/rateLimiting.js @@ -0,0 +1,57 @@ +import rateLimit from 'express-rate-limit' + +// Configuration constants +const FIFTEEN_MINUTES = 15 * 60 * 1000 +const ONE_MINUTE = 1 * 60 * 1000 + +// Skip rate limiting in test environment and development (temporarily disabled for frontend integration) +const skip = () => true // Temporarily disabled for development + +// Helper function to create rate limit error response +const createRateLimitHandler = (message) => (req, res) => { + res.status(429).json({ + error: 'Too Many Requests', + details: message, + }) +} + +// Helper function to create common rate limit configuration +const createRateLimitConfig = (windowMs, max, message) => ({ + windowMs, + max, + message: { + error: 'Too Many Requests', + details: message, + }, + standardHeaders: true, + legacyHeaders: false, + skip, + handler: createRateLimitHandler(message), +}) + +// Rate limiter for authentication routes (signup, login) +export const authRateLimit = rateLimit( + createRateLimitConfig( + FIFTEEN_MINUTES, + 5, + 'Too many authentication attempts, please try again in 15 minutes' + ) +) + +// General rate limiter for API requests +export const generalRateLimit = rateLimit( + createRateLimitConfig( + FIFTEEN_MINUTES, + 100, + 'Too many requests, please try again later' + ) +) + +// Stricter rate limiter for thought creation +export const thoughtCreationRateLimit = rateLimit( + createRateLimitConfig( + ONE_MINUTE, + 5, + 'Too many thoughts created, please wait a minute before posting again' + ) +) diff --git a/src/middleware/validation.js b/src/middleware/validation.js new file mode 100644 index 0000000..d2a2529 --- /dev/null +++ b/src/middleware/validation.js @@ -0,0 +1,202 @@ +import { body, validationResult } from 'express-validator' +import mongoose from 'mongoose' + +// Validation constants +const EMAIL_REGEX = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ +const PASSWORD_MIN_LENGTH = 6 +const NAME_MAX_LENGTH = 50 +const MESSAGE_MIN_LENGTH = 5 +const MESSAGE_MAX_LENGTH = 140 + +// Query validation constants +const VALID_SORT_FIELDS = ['hearts', 'createdAt', 'updatedAt', 'category', '_id', 'message'] +const MAX_LIMIT = 100 + +// Helper function for email validation +const createEmailValidation = () => + body('email') + .isEmail() + .withMessage('Please provide a valid email address') + .matches(EMAIL_REGEX) + .withMessage( + 'Email format is invalid. Please use a standard email format like user@example.com' + ) + .normalizeEmail() + .trim() + +// Helper function for password validation with strength requirements +const createStrongPasswordValidation = () => + body('password') + .isLength({ min: PASSWORD_MIN_LENGTH }) + .withMessage( + `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` + ) + .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) + .withMessage( + 'Password must contain at least one uppercase letter, one lowercase letter, and one number' + ) + +// Helper function for simple password validation (login) +const createPasswordValidation = () => + body('password').notEmpty().withMessage('Password is required').trim() + +// Query validation helper functions +const validatePositiveInteger = (value, name, min = 1, max = Infinity) => { + if (!value) return null + + const num = parseInt(value) + if (isNaN(num) || num < min || num > max) { + if (max === Infinity) { + return `${name} must be a positive integer >= ${min}` + } + return `${name} must be a positive integer between ${min} and ${max}` + } + return null +} + +const validateSortField = (sort) => { + if (!sort) return null + + const field = sort.startsWith('-') ? sort.slice(1) : sort + if (!VALID_SORT_FIELDS.includes(field)) { + return `sort field must be one of: ${VALID_SORT_FIELDS.join(', ')} (use - prefix for descending order)` + } + return null +} + +const validateCategory = (category) => { + if (!category) return null + + if (category.trim().length === 0) { + return 'category cannot be empty' + } + return null +} + +const validateDate = (dateString, fieldName) => { + if (!dateString) return null + + const date = new Date(dateString) + if (isNaN(date.getTime())) { + return `${fieldName} must be a valid date (ISO 8601 format recommended, e.g., 2024-01-01T00:00:00Z)` + } + return null +} + +// Validation rules for user signup +export const validateSignup = [ + createEmailValidation(), + createStrongPasswordValidation(), + body('name') + .optional() + .trim() + .isLength({ min: 1, max: NAME_MAX_LENGTH }) + .withMessage(`Name must be between 1 and ${NAME_MAX_LENGTH} characters`) + .escape(), +] + +// Validation rules for user login +export const validateLogin = [ + createEmailValidation(), + createPasswordValidation(), +] + +// Validation rules for creating/updating thoughts +export const validateThought = [ + body('message') + .notEmpty() + .withMessage('Message is required') + .isLength({ min: MESSAGE_MIN_LENGTH, max: MESSAGE_MAX_LENGTH }) + .withMessage( + `Message must be between ${MESSAGE_MIN_LENGTH} and ${MESSAGE_MAX_LENGTH} characters` + ) + .trim() + .escape(), +] + +// Query validation for thoughts listing +export const validateThoughtsQuery = (req, res, next) => { + const { page, limit, sort, category, minHearts, newerThan } = req.query + const errors = [] + + const pageError = validatePositiveInteger(page, 'page') + if (pageError) errors.push(pageError) + + const limitError = validatePositiveInteger(limit, 'limit', 1, MAX_LIMIT) + if (limitError) errors.push(limitError) + + const sortError = validateSortField(sort) + if (sortError) errors.push(sortError) + + const categoryError = validateCategory(category) + if (categoryError) errors.push(categoryError) + + const heartsError = validatePositiveInteger(minHearts, 'minHearts', 0) + if (heartsError) errors.push(heartsError) + + const dateError = validateDate(newerThan, 'newerThan') + if (dateError) errors.push(dateError) + + if (errors.length > 0) { + return res.status(400).json({ + error: 'Bad query parameters', + details: errors, + }) + } + + next() +} + +// ID validation for thoughts +export const validateThoughtId = (req, res, next) => { + const { id } = req.params + + if (!id?.trim()) { + return res.status(400).json({ + error: 'Bad request', + details: 'ID parameter cannot be empty', + }) + } + + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + error: 'Bad request', + details: 'Invalid thought ID format', + }) + } + + next() +} + +// Helper function to format validation errors +const formatValidationError = (error) => ({ + field: error.path, + message: error.msg, + value: error.value, +}) + +// Middleware to handle validation errors +export const handleValidationErrors = (req, res, next) => { + const errors = validationResult(req) + + if (!errors.isEmpty()) { + const errorMessages = errors.array().map(formatValidationError) + + return res.status(422).json({ + error: 'Validation Error', + details: errorMessages, + }) + } + + next() +} + +// Combined validation middleware for signup +export const signupValidation = [...validateSignup, handleValidationErrors] + +// Combined validation middleware for login +export const loginValidation = [...validateLogin, handleValidationErrors] + +// Combined validation middleware for thoughts +export const thoughtValidation = [...validateThought, handleValidationErrors] diff --git a/src/models/Thought.js b/src/models/Thought.js new file mode 100644 index 0000000..c73884f --- /dev/null +++ b/src/models/Thought.js @@ -0,0 +1,112 @@ +import mongoose from 'mongoose' + +// Constants for data integrity (not validation limits - those are in middleware) +const MIN_HEARTS = 0 + +const CATEGORIES = [ + 'Travel', + 'Family', + 'Food', + 'Health', + 'Friends', + 'Humor', + 'Entertainment', + 'Weather', + 'Animals', + 'General', +] + +// Helper functions +const createUserObjectId = (userId) => new mongoose.Types.ObjectId(userId) + +const isUserInArray = (userArray, userId) => { + const userObjectId = createUserObjectId(userId) + return userArray.some((id) => id.equals(userObjectId)) +} + +const removeUserFromArray = (userArray, userId) => { + const userObjectId = createUserObjectId(userId) + return userArray.filter((id) => !id.equals(userObjectId)) +} + +const thoughtSchema = new mongoose.Schema( + { + message: { + type: String, + required: [true, 'Message is required'], + trim: true, + }, + hearts: { + type: Number, + default: 0, + min: [MIN_HEARTS, 'Hearts cannot be negative'], + }, + category: { + type: String, + required: [true, 'Category is required'], + enum: { + values: CATEGORIES, + message: 'Category must be one of the predefined values', + }, + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + default: null, + }, + likedBy: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + }, + ], + }, + { + timestamps: true, + } +) + +// Virtual for likes count based on likedBy array length +thoughtSchema.virtual('likesCount').get(function () { + return this.likedBy ? this.likedBy.length : 0 +}) + +// Ensure virtual fields are serialized +thoughtSchema.set('toJSON', { virtuals: true }) +thoughtSchema.set('toObject', { virtuals: true }) + +// Index for better performance on common queries +thoughtSchema.index({ createdAt: -1 }) // Most recent first +thoughtSchema.index({ hearts: -1 }) // Most liked first +thoughtSchema.index({ category: 1 }) // Category filtering +thoughtSchema.index({ owner: 1 }) // User's thoughts + +// Instance method to toggle like from a user +thoughtSchema.methods.toggleLike = function (userId) { + const isLiked = isUserInArray(this.likedBy, userId) + + if (isLiked) { + this.likedBy = removeUserFromArray(this.likedBy, userId) + this.hearts = Math.max(MIN_HEARTS, this.hearts - 1) + } else { + this.likedBy.push(createUserObjectId(userId)) + this.hearts += 1 + } + + return this.save() +} + +// Static method to find thoughts by category +thoughtSchema.statics.findByCategory = function (category) { + return this.find({ category: new RegExp(category, 'i') }) +} + +// Pre-save middleware to ensure hearts matches likedBy length +thoughtSchema.pre('save', function (next) { + this.hearts = this.likedBy ? this.likedBy.length : 0 + next() +}) + +const Thought = mongoose.model('Thought', thoughtSchema) + +export default Thought diff --git a/src/models/User.js b/src/models/User.js new file mode 100644 index 0000000..d6b78a3 --- /dev/null +++ b/src/models/User.js @@ -0,0 +1,54 @@ +import mongoose from 'mongoose' +import bcrypt from 'bcrypt' + +// Constants for data integrity +const SALT_ROUNDS = 12 + +const userSchema = new mongoose.Schema( + { + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + lowercase: true, + trim: true, + }, + password: { + type: String, + required: [true, 'Password is required'], + }, + name: { + type: String, + required: [true, 'Name is required'], + trim: true, + }, + }, + { + timestamps: true, + } +) + +userSchema.pre('save', async function (next) { + if (!this.isModified('password')) return next() + + try { + this.password = await bcrypt.hash(this.password, SALT_ROUNDS) + next() + } catch (error) { + next(error) + } +}) + +userSchema.methods.comparePassword = async function (candidatePassword) { + return bcrypt.compare(candidatePassword, this.password) +} + +userSchema.methods.toJSON = function () { + const userObject = this.toObject() + delete userObject.password + return userObject +} + +const User = mongoose.model('User', userSchema) + +export default User diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..fd2445e --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,18 @@ +import express from 'express' +import { signup, login, getProfile } from '../controllers/authController.js' +import { authenticateToken } from '../middleware/authMiddleware.js' +import { signupValidation, loginValidation } from '../middleware/validation.js' +import { authRateLimit } from '../middleware/rateLimiting.js' + +const router = express.Router() + +// POST /auth/signup - Register new user (with validation and rate limiting) +router.post('/signup', authRateLimit, signupValidation, signup) + +// POST /auth/login - Authenticate user (with validation and rate limiting) +router.post('/login', authRateLimit, loginValidation, login) + +// GET /auth/me - Get current user profile (requires authentication) +router.get('/me', authenticateToken, getProfile) + +export default router diff --git a/src/routes/thoughts.js b/src/routes/thoughts.js new file mode 100644 index 0000000..3dce461 --- /dev/null +++ b/src/routes/thoughts.js @@ -0,0 +1,340 @@ +import express from 'express' +import Thought from '../models/Thought.js' +import { + validateThoughtsQuery, + validateThoughtId, + thoughtValidation, +} from '../middleware/validation.js' +import { authenticateToken } from '../middleware/authMiddleware.js' +import { thoughtCreationRateLimit } from '../middleware/rateLimiting.js' + +const router = express.Router() + +const ALLOWED_SORT_FIELDS = ['createdAt', 'updatedAt', 'hearts', 'category'] + +const createErrorResponse = (status, error, details) => ({ + status, + json: { error, details }, +}) + +const handleCastError = () => + createErrorResponse(400, 'Bad Request', 'Invalid thought ID format') + +const handleServerError = (action) => + createErrorResponse(500, 'Internal Server Error', `Failed to ${action}`) + +const buildFilterQuery = ({ category, minHearts, newerThan }) => { + const query = {} + + if (category) { + query.category = new RegExp(category, 'i') + } + + if (minHearts) { + const minHeartsNum = parseInt(minHearts) + if (!isNaN(minHeartsNum) && minHeartsNum >= 0) { + query.hearts = { $gte: minHeartsNum } + } + } + + if (newerThan) { + const date = new Date(newerThan) + if (date instanceof Date && !isNaN(date)) { + query.createdAt = { $gte: date } + } + } + + return query +} + +const buildSortObject = (sort) => { + if (!sort) return { createdAt: -1 } + + const isDescending = sort.startsWith('-') + const sortField = isDescending ? sort.substring(1) : sort + + if (!ALLOWED_SORT_FIELDS.includes(sortField)) { + return { createdAt: -1 } + } + + return { [sortField]: isDescending ? -1 : 1 } +} + +const calculatePagination = (page, limit) => { + const pageNum = parseInt(page) || 1 + const limitNum = parseInt(limit) || 20 + const skip = (pageNum - 1) * limitNum + + return { pageNum, limitNum, skip } +} + +const createPaginationMetadata = (pageNum, totalCount, limitNum) => { + const totalPages = Math.ceil(totalCount / limitNum) + return { + currentPage: pageNum, + totalPages, + totalCount, + hasNextPage: pageNum < totalPages, + hasPrevPage: pageNum > 1, + } +} + +const checkOwnership = (thought, userId) => { + if (!thought.owner || thought.owner.toString() !== userId.toString()) { + return false + } + return true +} + +router.get('/', validateThoughtsQuery, async (req, res) => { + try { + const { page, limit, category, sort, minHearts, newerThan } = req.query + + const query = buildFilterQuery({ category, minHearts, newerThan }) + const sortObj = buildSortObject(sort) + const { pageNum, limitNum, skip } = calculatePagination(page, limit) + + const thoughts = await Thought.find(query) + .sort(sortObj) + .skip(skip) + .limit(limitNum) + .populate('owner', 'name email') + .exec() + + const total = await Thought.countDocuments(query) + const pagination = createPaginationMetadata(pageNum, total, limitNum) + + res.status(200).json({ + thoughts, + total, + pagination, + filters: { category, minHearts, newerThan, sort }, + }) + } catch { + const errorResponse = handleServerError('fetch thoughts') + res.status(errorResponse.status).json(errorResponse.json) + } +}) + +router.post( + '/', + thoughtCreationRateLimit, + thoughtValidation, + authenticateToken, + async (req, res) => { + try { + const { message, category = 'General' } = req.body + + if (!message || message.trim().length === 0) { + const errorResponse = createErrorResponse( + 400, + 'Bad Request', + 'Message is required' + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + const thoughtData = { + message: message.trim(), + category, + owner: req.user.userId, + hearts: 0, + likedBy: [], + } + + const newThought = new Thought(thoughtData) + const savedThought = await newThought.save() + + const populatedThought = await Thought.findById(savedThought._id) + .populate('owner', 'name email') + .exec() + + res.status(201).json(populatedThought) + } catch (error) { + if (error.name === 'ValidationError') { + const errorResponse = createErrorResponse( + 422, + 'Validation Error', + Object.values(error.errors).map((e) => e.message) + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + const errorResponse = handleServerError('create thought') + res.status(errorResponse.status).json(errorResponse.json) + } + } +) + +router.get('/:id', validateThoughtId, async (req, res) => { + try { + const { id } = req.params + + const thought = await Thought.findById(id) + .populate('owner', 'name email') + .exec() + + if (!thought) { + const errorResponse = createErrorResponse( + 404, + 'Not found', + `Thought with ID '${id}' does not exist` + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + res.status(200).json(thought) + } catch (error) { + if (error.name === 'CastError') { + const errorResponse = handleCastError() + return res.status(errorResponse.status).json(errorResponse.json) + } + + const errorResponse = handleServerError('fetch thought') + res.status(errorResponse.status).json(errorResponse.json) + } +}) + +router.post('/:id/like', authenticateToken, validateThoughtId, async (req, res) => { + try { + const { id } = req.params + const userId = req.user.userId + + const thought = await Thought.findById(id) + + if (!thought) { + const errorResponse = createErrorResponse( + 404, + 'Not found', + `Thought with ID '${id}' does not exist` + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + const hasLiked = thought.likedBy.some(id => id.toString() === userId.toString()) + + const updateOperation = hasLiked + ? { $pull: { likedBy: userId }, $inc: { hearts: -1 } } + : { $addToSet: { likedBy: userId }, $inc: { hearts: 1 } } + + const updatedThought = await Thought.findByIdAndUpdate(id, updateOperation, { + new: true, + }).populate('owner', 'name email') + + res.status(200).json(updatedThought) + } catch (error) { + if (error.name === 'CastError') { + const errorResponse = handleCastError() + return res.status(errorResponse.status).json(errorResponse.json) + } + + const errorResponse = handleServerError('toggle like') + res.status(errorResponse.status).json(errorResponse.json) + } +}) + +router.put('/:id', authenticateToken, validateThoughtId, async (req, res) => { + try { + const { id } = req.params + const { message } = req.body + const userId = req.user.userId + + if (!message || message.trim().length === 0) { + const errorResponse = createErrorResponse( + 400, + 'Bad Request', + 'Message is required' + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + const thought = await Thought.findById(id) + + if (!thought) { + const errorResponse = createErrorResponse( + 404, + 'Not found', + `Thought with ID '${id}' does not exist` + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + if (!checkOwnership(thought, userId)) { + const errorResponse = createErrorResponse( + 403, + 'Forbidden', + 'You can only edit your own thoughts' + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + const updatedThought = await Thought.findByIdAndUpdate( + id, + { message: message.trim(), updatedAt: new Date() }, + { new: true } + ).populate('owner', 'name email') + + res.status(200).json(updatedThought) + } catch (error) { + if (error.name === 'CastError') { + const errorResponse = handleCastError() + return res.status(errorResponse.status).json(errorResponse.json) + } + + if (error.name === 'ValidationError') { + const errorResponse = createErrorResponse( + 422, + 'Validation Error', + Object.values(error.errors).map((e) => e.message) + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + const errorResponse = handleServerError('update thought') + res.status(errorResponse.status).json(errorResponse.json) + } +}) + +router.delete('/:id', authenticateToken, validateThoughtId, async (req, res) => { + try { + const { id } = req.params + const userId = req.user.userId + + const thought = await Thought.findById(id) + + if (!thought) { + const errorResponse = createErrorResponse( + 404, + 'Not found', + `Thought with ID '${id}' does not exist` + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + if (!checkOwnership(thought, userId)) { + const errorResponse = createErrorResponse( + 403, + 'Forbidden', + 'You can only delete your own thoughts' + ) + return res.status(errorResponse.status).json(errorResponse.json) + } + + await Thought.findByIdAndDelete(id) + + res.status(200).json({ + message: 'Thought deleted successfully', + deletedThought: { id: thought._id, message: thought.message }, + }) + } catch (error) { + if (error.name === 'CastError') { + const errorResponse = handleCastError() + return res.status(errorResponse.status).json(errorResponse.json) + } + + const errorResponse = handleServerError('delete thought') + res.status(errorResponse.status).json(errorResponse.json) + } +}) + +export default router diff --git a/src/routes/users.js b/src/routes/users.js new file mode 100644 index 0000000..de5573f --- /dev/null +++ b/src/routes/users.js @@ -0,0 +1,146 @@ +import express from 'express' +import Thought from '../models/Thought.js' +import { authenticateToken } from '../middleware/authMiddleware.js' +import { validateThoughtsQuery } from '../middleware/validation.js' + +const router = express.Router() + +const ALLOWED_SORT_FIELDS = ['createdAt', 'updatedAt', 'hearts', 'category'] + +const createErrorResponse = (details) => ({ + error: 'Internal Server Error', + details, +}) + +const buildFilterQuery = ({ category, minHearts, newerThan, userId }) => { + const query = { owner: userId } + + if (category) { + query.category = new RegExp(category, 'i') + } + + if (minHearts) { + const minHeartsNum = parseInt(minHearts) + if (!isNaN(minHeartsNum) && minHeartsNum >= 0) { + query.hearts = { $gte: minHeartsNum } + } + } + + if (newerThan) { + const date = new Date(newerThan) + if (date instanceof Date && !isNaN(date)) { + query.createdAt = { $gte: date } + } + } + + return query +} + +const buildSortObject = (sort) => { + if (!sort) return { createdAt: -1 } + + const isDescending = sort.startsWith('-') + const sortField = isDescending ? sort.substring(1) : sort + + if (!ALLOWED_SORT_FIELDS.includes(sortField)) { + return { createdAt: -1 } + } + + return { [sortField]: isDescending ? -1 : 1 } +} + +const calculatePagination = (page, limit) => { + const pageNum = parseInt(page) || 1 + const limitNum = parseInt(limit) || 20 + const skip = (pageNum - 1) * limitNum + + return { pageNum, limitNum, skip } +} + +const createPaginationMetadata = (pageNum, totalCount, limitNum) => { + const totalPages = Math.ceil(totalCount / limitNum) + return { + currentPage: pageNum, + totalPages, + totalCount, + hasNextPage: pageNum < totalPages, + hasPrevPage: pageNum > 1, + } +} + +const fetchUserThoughts = async (userId, query, sortObj, skip, limitNum) => { + const thoughts = await Thought.find(query) + .sort(sortObj) + .skip(skip) + .limit(limitNum) + .populate('owner', 'name email') + .exec() + + const totalCount = await Thought.countDocuments(query) + return { thoughts, totalCount } +} + +// STRETCH-02: GET /users/me/likes - return thoughts liked by the authenticated user +router.get('/me/likes', authenticateToken, async (req, res) => { + try { + const userId = req.user.userId + const { page, limit, sort } = req.query + + const sortObj = buildSortObject(sort) + const { pageNum, limitNum, skip } = calculatePagination(page, limit) + + const query = { likedBy: userId } + const { thoughts, totalCount } = await fetchUserThoughts( + userId, + query, + sortObj, + skip, + limitNum + ) + + const pagination = createPaginationMetadata(pageNum, totalCount, limitNum) + + res.status(200).json({ + likedThoughts: thoughts, + total: totalCount, + pagination, + }) + } catch { + const errorResponse = createErrorResponse('Failed to fetch liked thoughts') + res.status(500).json(errorResponse) + } +}) + +// GET /users/me/thoughts - return thoughts created by the authenticated user +router.get('/me/thoughts', authenticateToken, validateThoughtsQuery, async (req, res) => { + try { + const userId = req.user.userId + const { page, limit, sort, category, minHearts, newerThan } = req.query + + const query = buildFilterQuery({ category, minHearts, newerThan, userId }) + const sortObj = buildSortObject(sort) + const { pageNum, limitNum, skip } = calculatePagination(page, limit) + + const thoughts = await Thought.find(query) + .sort(sortObj) + .skip(skip) + .limit(limitNum) + .populate('owner', 'name email') + .exec() + + const total = await Thought.countDocuments(query) + const pagination = createPaginationMetadata(pageNum, total, limitNum) + + res.status(200).json({ + thoughts, + total, + pagination, + filters: { category, minHearts, newerThan, sort }, + }) + } catch { + const errorResponse = createErrorResponse('Failed to fetch user thoughts') + res.status(500).json(errorResponse) + } +}) + +export default router diff --git a/src/services/dataService.js b/src/services/dataService.js new file mode 100644 index 0000000..af6442d --- /dev/null +++ b/src/services/dataService.js @@ -0,0 +1,37 @@ +import fs from 'fs' +import path from 'path' + +// Cache for thoughts data to avoid repeated file reads +let thoughtsCache = null + +/** + * Loads thoughts data from JSON file with caching + * @returns {Array} Array of thought objects + * @throws {Error} If file cannot be read or parsed + */ +export const loadThoughtsData = () => { + if (!thoughtsCache) { + try { + const filePath = path.join(process.cwd(), 'data', 'thoughts.json') + const fileContent = fs.readFileSync(filePath, 'utf8') + thoughtsCache = JSON.parse(fileContent) + } catch (error) { + console.error('Failed to load thoughts data:', error.message) + throw new Error('Unable to load thoughts data') + } + } + return thoughtsCache +} + +/** + * Gets all thoughts from the data source + * @returns {Array} Array of thought objects + */ +export const getThoughts = () => { + return loadThoughtsData() +} + +export const getThoughtById = (id) => { + const thoughts = getThoughts() + return thoughts.find((thought) => thought._id === id) +} diff --git a/src/utils/apiDocs.js b/src/utils/apiDocs.js new file mode 100644 index 0000000..e31ad40 --- /dev/null +++ b/src/utils/apiDocs.js @@ -0,0 +1,51 @@ +const SORT_FIELDS = ['hearts', 'createdAt', 'updatedAt', 'category', '_id', 'message'] +const MAX_LIMIT = 100 + +const createEndpointDoc = (description, options = {}) => ({ + description, + ...options, +}) + +const createFilterDoc = (type, description, validation = null) => ({ + type, + description, + ...(validation && { validation }), +}) + +export const getApiDocumentation = () => ({ + 'Happy Thoughts API': { + 'GET /': createEndpointDoc('API routes overview'), + + 'GET /thoughts': createEndpointDoc('Get all thoughts with filtering and pagination', { + filters: { + page: createFilterDoc('integer', 'Page number for pagination', 'minimum: 1'), + limit: createFilterDoc('integer', `Results per page`, `1-${MAX_LIMIT}`), + category: createFilterDoc('string', 'Filter thoughts by category'), + minHearts: createFilterDoc('integer', 'Filter thoughts with minimum hearts', 'minimum: 0'), + newerThan: createFilterDoc('string', 'Filter thoughts newer than date', 'ISO 8601 format'), + sort: createFilterDoc('string', `Sort by: ${SORT_FIELDS.join(', ')}`, 'use - prefix for descending'), + }, + }), + + 'GET /thoughts/:id': createEndpointDoc('Get single thought by ID', { + parameters: { + id: createFilterDoc('string', 'Thought ID', 'MongoDB ObjectId format'), + }, + }), + + 'POST /auth/signup': createEndpointDoc('Create new user account', { + body: { + email: createFilterDoc('string', 'User email address', 'valid email format'), + password: createFilterDoc('string', 'User password', 'minimum 8 characters'), + name: createFilterDoc('string', 'User display name'), + }, + }), + + 'POST /auth/login': createEndpointDoc('Authenticate user', { + body: { + email: createFilterDoc('string', 'User email address'), + password: createFilterDoc('string', 'User password'), + }, + }), + }, +}) diff --git a/src/utils/thoughtsHelper.js b/src/utils/thoughtsHelper.js new file mode 100644 index 0000000..88134a5 --- /dev/null +++ b/src/utils/thoughtsHelper.js @@ -0,0 +1,33 @@ +export const filterThoughts = (thoughts, { category }) => { + if (!thoughts || !category) return thoughts || [] + + return thoughts.filter((thought) => + thought.category?.toLowerCase() === category.toLowerCase() + ) +} + +export const sortThoughts = (thoughts, sortParam) => { + if (!thoughts || !sortParam) return thoughts || [] + + const isDescending = sortParam.startsWith('-') + const field = isDescending ? sortParam.slice(1) : sortParam + + return [...thoughts].sort((a, b) => { + const valueA = field === 'createdAt' ? new Date(a[field]) : a[field] + const valueB = field === 'createdAt' ? new Date(b[field]) : b[field] + + const comparison = valueA > valueB ? 1 : valueA < valueB ? -1 : 0 + return isDescending ? -comparison : comparison + }) +} + +export const paginateThoughts = (thoughts, { page, limit }) => { + if (!thoughts) return [] + if (!page && !limit) return thoughts + + const currentPage = Math.max(1, parseInt(page) || 1) + const itemsPerPage = Math.max(1, parseInt(limit) || 20) + const startIndex = (currentPage - 1) * itemsPerPage + + return thoughts.slice(startIndex, startIndex + itemsPerPage) +}