Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/backend-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
# Step 4: Run unit tests and generate a coverage report
- name: Run Tests with Coverage
run: npm run test:coverage
env:
JWT_SECRET: ci_test_jwt_secret_for_github_actions_min_32_chars
NODE_ENV: test

# Step 5: Upload the coverage report as an artifact
- name: Upload Coverage Report
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# 🌍 myWorld Travel API - Backend


[![Build Status](https://github.com/fraidakis/software-engineering-2-backend/actions/workflows/backend-cicd.yml/badge.svg?branch=main)](https://github.com/fraidakis/software-engineering-2-backend/actions)
[![Version](https://img.shields.io/badge/version-1.6.2-blue.svg)](https://github.com/fraidakis/software-engineering-2-backend)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
[![Test Coverage](https://img.shields.io/badge/coverage-90%25+-brightgreen.svg)](./coverage/index.html)
[![Tests](https://img.shields.io/badge/tests-passing-success.svg)](./tests)
[![Node.js](https://img.shields.io/badge/node-%3E%3D18-turquoise.svg)](https://nodejs.org/)
[![Test Coverage](https://img.shields.io/badge/coverage-90%25+-04C38E.svg)](./coverage/index.html)
[![API](https://img.shields.io/badge/API-REST%20Level%203-orange.svg)](https://github.com/fraidakis/software-engineering-2-backend)

A production-ready RESTful API with HATEOAS support for personalized travel experiences. Discover attractions, get recommendations, navigate, and experience the world with **myWorld**.
Expand Down
20 changes: 17 additions & 3 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import swaggerUi from 'swagger-ui-express';
import yaml from 'js-yaml';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import mongoose from 'mongoose';

// Import middleware
import errorHandler from './middleware/errorHandler.js';
import { requestLogger } from './middleware/logger.js';
import requestId from './middleware/requestId.js';

// Import centralized routes
import routes from './routes/index.js';
Expand Down Expand Up @@ -42,10 +45,10 @@ if (process.env.NODE_ENV === 'production' && !process.env.CORS_ORIGIN) {

const corsOptions = {
origin: (origin, callback) => {
const allowedOrigins = process.env.CORS_ORIGIN
const allowedOrigins = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['*'];

if (allowedOrigins.includes('*')) {
return callback(null, true);
}
Expand All @@ -58,7 +61,9 @@ const corsOptions = {
};

// Middleware
app.use(helmet()); // Security headers
app.use(cors(corsOptions));
app.use(requestId); // Request ID for tracing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);
Expand Down Expand Up @@ -113,10 +118,19 @@ app.get('/', (_req, res) => {

// Health check endpoint
app.get('/health', (_req, res) => {
// Determine database status
let dbStatus = 'in-memory';
if (process.env.USE_MONGODB === 'true') {
const readyState = mongoose.connection.readyState;
dbStatus = readyState === 1 ? 'connected' : readyState === 2 ? 'connecting' : 'disconnected';
}

res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
uptime: process.uptime(),
database: dbStatus,
version: API_VERSION
});
});

Expand Down
47 changes: 47 additions & 0 deletions middleware/rateLimiter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Rate Limiting Middleware
* Protects auth endpoints from brute-force attacks
*/

import rateLimit from 'express-rate-limit';

const isTest = process.env.NODE_ENV === 'test';

/**
* Strict rate limiter for authentication endpoints
* Allows 10 requests per 15 minutes per IP
* Disabled in test environment to avoid test failures
*/
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: isTest ? 0 : 10, // 0 = unlimited in test mode
message: {
success: false,
error: 'TOO_MANY_REQUESTS',
message: 'Too many authentication attempts, please try again later'
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful logins against the limit
skip: () => isTest // Skip rate limiting entirely in test environment
});

/**
* General API rate limiter
* Allows 100 requests per minute per IP
* Disabled in test environment
*/
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: isTest ? 0 : 100, // 0 = unlimited in test mode
message: {
success: false,
error: 'TOO_MANY_REQUESTS',
message: 'Too many requests, please slow down'
},
standardHeaders: true,
legacyHeaders: false,
skip: () => isTest // Skip rate limiting entirely in test environment
});

export default { authLimiter, apiLimiter };
20 changes: 20 additions & 0 deletions middleware/requestId.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Request ID Middleware
* Generates unique request IDs for tracing and log correlation
*/

import { randomUUID } from 'crypto';

/**
* Attaches a unique request ID to each incoming request
* ID is available as req.id and returned in X-Request-ID response header
* If client provides X-Request-ID header, it will be reused for tracing
*/
export const requestId = (req, res, next) => {
const id = req.headers['x-request-id'] || randomUUID();
req.id = id;
res.setHeader('X-Request-ID', id);
next();
};

export default requestId;
25 changes: 18 additions & 7 deletions models/Place.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,33 @@ import mongoose from 'mongoose';

const placeSchema = new mongoose.Schema({
placeId: { type: Number, required: true, unique: true },
name: { type: String, required: true },
name: { type: String, required: true, trim: true },
category: { type: String, required: true },
description: String,
address: String,
city: String,
country: String,
description: { type: String, trim: true },
address: { type: String, trim: true },
city: { type: String, trim: true },
country: { type: String, trim: true },
location: {
latitude: Number,
longitude: Number
},
website: String,
rating: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now }
rating: { type: Number, default: 0, min: 0, max: 5 }
}, {
timestamps: true
});

// Index for better geospatial queries
placeSchema.index({ 'location.latitude': 1, 'location.longitude': 1 });

// Index for category-based filtering
placeSchema.index({ category: 1 });

// Index for city-based queries
placeSchema.index({ city: 1 });

// Text index for search functionality
placeSchema.index({ name: 'text', description: 'text', city: 'text' });

export default mongoose.model('Place', placeSchema);

22 changes: 17 additions & 5 deletions models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ import mongoose from 'mongoose';

const userSchema = new mongoose.Schema({
userId: { type: Number, required: true, unique: true },
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
phone: String,
name: { type: String, required: true, trim: true },
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
phone: { type: String, trim: true },
dateOfBirth: String,
password: { type: String, required: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
Expand All @@ -21,8 +27,14 @@ const userSchema = new mongoose.Schema({
latitude: Number,
longitude: Number
},
activeProfile: Number,
createdAt: { type: Date, default: Date.now }
activeProfile: Number
}, {
timestamps: true // Adds createdAt and updatedAt automatically
});

// Indexes for better query performance
// Note: email already has unique index from unique: true
userSchema.index({ role: 1 });

export default mongoose.model('User', userSchema);

27 changes: 26 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@
"homepage": "https://github.com/fraidakis/software-engineering-2-backend#readme",
"dependencies": {
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^8.0.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.19.3",
Expand All @@ -53,4 +54,4 @@
"nodemon": "^3.0.1",
"supertest": "^6.3.3"
}
}
}
46 changes: 42 additions & 4 deletions routes/authRoutes.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
/**
* Authentication Routes
* Handles login and signup with rate limiting and validation
*/

import express from 'express';
const router = express.Router();
import { body } from 'express-validator';
import { validate } from '../middleware/validation.js';
import { authLimiter } from '../middleware/rateLimiter.js';
import authController from '../controllers/authController.js';

const router = express.Router();

// Validation chains
const loginValidation = [
body('email')
.trim()
.isEmail()
.withMessage('Valid email is required'),
body('password')
.notEmpty()
.withMessage('Password is required')
];

const signupValidation = [
body('name')
.trim()
.notEmpty()
.withMessage('Name is required')
.isLength({ max: 100 })
.withMessage('Name must be 100 characters or less'),
body('email')
.trim()
.isEmail()
.withMessage('Valid email is required')
.normalizeEmail(),
body('password')
.isLength({ min: 6 })
.withMessage('Password must be at least 6 characters')
];

// POST /auth/login - User login
router.post('/login', authController.login);
router.post('/login', authLimiter, loginValidation, validate, authController.login);

// POST /auth/signup - User signup
router.post('/signup', authController.signup);
// POST /auth/signup - User signup
router.post('/signup', authLimiter, signupValidation, validate, authController.signup);

export default router;

10 changes: 6 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ const isProduction = process.env.NODE_ENV === 'production';
// Ensure a default JWT secret is available when not provided via .env
const DEFAULT_JWT_SECRET = 'myworld_secret_key_2025_change_in_production';
if (!process.env.JWT_SECRET) {
process.env.JWT_SECRET = DEFAULT_JWT_SECRET;
if (!isProduction) {
console.warn('\n⚠️ JWT_SECRET not set in environment; using default secret.');
console.warn(' Change this value in production by setting JWT_SECRET in .env\n');
if (isProduction) {
console.error('\n❌ FATAL: JWT_SECRET must be set in production environment\n');
process.exit(1);
}
process.env.JWT_SECRET = DEFAULT_JWT_SECRET;
console.warn('\n⚠️ JWT_SECRET not set in environment; using default secret.');
console.warn(' Change this value in production by setting JWT_SECRET in .env\n');
}

// Database connection module (only loaded if using MongoDB)
Expand Down
Loading