diff --git a/.github/workflows/docker-build-and-push.yml b/.github/workflows/docker-build-and-push.yml index 96b5e34..bb5253e 100644 --- a/.github/workflows/docker-build-and-push.yml +++ b/.github/workflows/docker-build-and-push.yml @@ -12,21 +12,21 @@ env: jobs: build-and-push: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Log in to Docker Hub uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - + - name: Extract metadata id: meta uses: docker/metadata-action@v5 @@ -41,7 +41,7 @@ jobs: org.opencontainers.image.title=${{ env.IMAGE_NAME }} org.opencontainers.image.description=Acquisitions backend application org.opencontainers.image.vendor=DevOps HandsOn - + - name: Build and push Docker image id: build uses: docker/build-push-action@v5 @@ -53,7 +53,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - + - name: Generate summary run: | echo "🐳 **Docker Image Published Successfully!**" >> $GITHUB_STEP_SUMMARY @@ -74,4 +74,4 @@ jobs: echo "**Pull Command:**" >> $GITHUB_STEP_SUMMARY echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY echo "docker pull ${{ env.REGISTRY }}/${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/lint-and-format.yml b/.github/workflows/lint-and-format.yml index 8d32c3a..0e61ebb 100644 --- a/.github/workflows/lint-and-format.yml +++ b/.github/workflows/lint-and-format.yml @@ -9,20 +9,20 @@ on: jobs: lint-and-format: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' - + - name: Install dependencies run: npm ci - + - name: Run ESLint run: | echo "Running ESLint..." @@ -30,7 +30,7 @@ jobs: echo "::error title=ESLint Issues Found::ESLint found issues. Run 'npm run lint:fix' to automatically fix some issues." exit 1 fi - + - name: Run Prettier Check run: | echo "Checking code formatting with Prettier..." @@ -38,11 +38,11 @@ jobs: echo "::error title=Code Formatting Issues::Code formatting issues found. Run 'npm run format' to fix formatting." exit 1 fi - + - name: Success Summary if: success() run: | echo "✅ All linting and formatting checks passed!" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- ESLint: No issues found" >> $GITHUB_STEP_SUMMARY - echo "- Prettier: Code formatting is correct" >> $GITHUB_STEP_SUMMARY \ No newline at end of file + echo "- Prettier: Code formatting is correct" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f8fbf0..3691149 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,25 +9,25 @@ on: jobs: test: runs-on: ubuntu-latest - + env: NODE_ENV: test NODE_OPTIONS: --experimental-vm-modules DATABASE_URL: postgresql://test:test@localhost:5432/acquisitions_test - + steps: - name: Checkout code uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20.x' cache: 'npm' - + - name: Install dependencies run: npm ci - + - name: Run tests id: run-tests run: | @@ -41,7 +41,7 @@ jobs: echo "::error title=Test Failures::Some tests failed. Check the logs above for details." exit 1 fi - + - name: Upload coverage reports uses: actions/upload-artifact@v4 if: always() @@ -49,26 +49,26 @@ jobs: name: coverage-reports path: coverage/ retention-days: 30 - + - name: Generate test summary if: always() run: | echo "" >> $GITHUB_STEP_SUMMARY echo "## Test Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - + if [ -f "coverage/lcov.info" ]; then echo "📊 **Coverage Report Available**" >> $GITHUB_STEP_SUMMARY echo "Coverage reports have been uploaded as artifacts." >> $GITHUB_STEP_SUMMARY fi - + echo "" >> $GITHUB_STEP_SUMMARY echo "**Environment Variables:**" >> $GITHUB_STEP_SUMMARY echo "- NODE_ENV: $NODE_ENV" >> $GITHUB_STEP_SUMMARY echo "- NODE_OPTIONS: $NODE_OPTIONS" >> $GITHUB_STEP_SUMMARY echo "- DATABASE_URL: [configured]" >> $GITHUB_STEP_SUMMARY - + if [ "${{ steps.run-tests.outputs.test_status }}" = "failed" ]; then echo "" >> $GITHUB_STEP_SUMMARY echo "⚠️ **Action Required:** Fix the failing tests before merging." >> $GITHUB_STEP_SUMMARY - fi \ No newline at end of file + fi diff --git a/WARP.md b/WARP.md index 69f2ed1..802debd 100644 --- a/WARP.md +++ b/WARP.md @@ -5,6 +5,7 @@ This file provides guidance to WARP (warp.dev) when working with code in this re ## Development Commands ### Basic Development + ```bash # Start development server with file watching npm run dev @@ -17,6 +18,7 @@ npm run prod:docker ``` ### Code Quality + ```bash # Lint code npm run lint @@ -32,6 +34,7 @@ npm run format:check ``` ### Database Operations + ```bash # Generate new migration files from schema changes npm run db:generate @@ -44,6 +47,7 @@ npm run db:studio ``` ### Docker Development + ```bash # View development container logs docker logs acquisitions-app-dev @@ -61,6 +65,7 @@ docker-compose -f docker-compose.prod.yml down -v ``` ### Testing Database Connections + ```bash # Connect to Neon Local database directly docker exec acquisitions-neon-local psql -U neon -d neondb @@ -69,6 +74,7 @@ docker exec acquisitions-neon-local psql -U neon -d neondb ## Architecture Overview ### Core Structure + This is a Node.js/Express REST API with a layered architecture: - **Routes** (`src/routes/`) - Express route definitions and HTTP endpoint handlers @@ -81,12 +87,14 @@ This is a Node.js/Express REST API with a layered architecture: - **Config** (`src/config/`) - Application configuration (database, logging, security) ### Database Architecture + - **ORM**: Drizzle ORM with PostgreSQL - **Provider**: Neon Database (serverless PostgreSQL) - **Development**: Uses Neon Local for ephemeral database branches - **Production**: Direct connection to Neon Cloud ### Security Stack + - **Arcjet**: Bot detection, rate limiting, and shield protection - **Helmet**: HTTP security headers - **CORS**: Cross-origin resource sharing configuration @@ -94,7 +102,9 @@ This is a Node.js/Express REST API with a layered architecture: - **bcrypt**: Password hashing ### Path Imports + The project uses Node.js path imports (defined in `package.json`): + ```javascript import logger from '#config/logger.js'; import { users } from '#models/user.model.js'; @@ -102,11 +112,13 @@ import { createUser } from '#services/auth.service.js'; ``` ### Environment Configuration + - `.env.development` - Development environment (with Neon Local) - `.env.production` - Production environment (direct Neon Cloud connection) - `.env.example` - Template for environment variables ### Key Features + - User authentication (signup/signin/signout) with JWT tokens - Role-based access control (admin, user, guest) - Rate limiting based on user roles (admin: 20/min, user: 10/min, guest: 5/min) @@ -115,11 +127,14 @@ import { createUser } from '#services/auth.service.js'; - Database migrations with Drizzle Kit ### Docker Setup + Two environments available: + - **Development**: Multi-container setup with Neon Local proxy for ephemeral databases - **Production**: Optimized single container connecting directly to Neon Cloud ### Development Workflow + 1. Use Docker for consistent development environment 2. Neon Local creates fresh database branches for each development session 3. Hot reload enabled for code changes @@ -127,6 +142,7 @@ Two environments available: 5. ESLint enforces code style with 2-space indentation, single quotes, and semicolons ### API Endpoints + - `GET /` - Health check endpoint - `GET /health` - Detailed health status with uptime and memory usage - `GET /api` - API status endpoint @@ -139,6 +155,7 @@ Two environments available: - `DELETE /api/users/:id` - Delete user (placeholder) ### Database Schema + - **Users table**: id, name, email, password (hashed), role, created_at, updated_at - Default role: "user" -- Supported roles: "user", "admin" \ No newline at end of file +- Supported roles: "user", "admin" diff --git a/drizzle.config.js b/drizzle.config.js index 5336f7c..b8e7641 100644 --- a/drizzle.config.js +++ b/drizzle.config.js @@ -6,5 +6,5 @@ export default { dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL, - } -}; \ No newline at end of file + }, +}; diff --git a/git-issues.md b/git-issues.md index 55d3b68..32a5a5f 100644 --- a/git-issues.md +++ b/git-issues.md @@ -5,9 +5,11 @@ This document provides solutions to common Git issues encountered during develop ## Issue 1: Creating a New Branch from Origin When You Have Uncommitted Changes ### Problem + You have uncommitted changes on your current branch but need to create a new branch from `origin/development` (or any other remote branch). ### Error Scenario + ```bash # You're on branch 03-user-crud with uncommitted changes git status @@ -19,30 +21,39 @@ git status ### Solution: Use Git Stash #### Step 1: Stash Your Current Changes + ```bash git stash push -m "WIP: Description of your work" ``` + This saves your uncommitted changes temporarily. #### Step 2: Fetch Latest Changes from Remote + ```bash git fetch origin ``` + This ensures you have the latest remote branch information. #### Step 3: Create New Branch from Remote Branch + ```bash git checkout -b 04-testing origin/development ``` + This creates and switches to a new branch based on the remote branch. #### Step 4: Push New Branch to Origin + ```bash git push -u origin 04-testing ``` + This pushes the new branch and sets up tracking. #### Step 5: Managing Your Stashed Changes (Optional) + ```bash # View stashed changes git stash list @@ -62,6 +73,7 @@ git stash pop # Applies the most recent stash ``` ### Example Output + ```bash $ git stash push -m "WIP: Testing setup and CRUD implementation" Saved working directory and index state On 03-user-crud: WIP: Testing setup and CRUD implementation @@ -80,15 +92,18 @@ branch '04-testing' set up to track 'origin/04-testing'. ## Issue 2: Windows Environment Variable Issues with npm Scripts ### Problem + npm scripts using Unix-style environment variables don't work on Windows PowerShell. ### Error + ```bash > NODE_OPTIONS=--experimental-vm-modules jest 'NODE_OPTIONS' is not recognized as an internal or external command ``` ### Solution: Use cross-env + ```bash # Install cross-env npm install --save-dev cross-env @@ -104,16 +119,19 @@ npm install --save-dev cross-env ### Common Branch Operations #### List All Branches (Local and Remote) + ```bash git branch -a ``` #### Check Branch Tracking Information + ```bash git branch -vv ``` #### Delete a Branch + ```bash # Delete local branch (safe - only if merged) git branch -d branch-name @@ -126,6 +144,7 @@ git push origin --delete branch-name ``` #### Sync with Remote + ```bash # Fetch all remote changes git fetch origin @@ -142,6 +161,7 @@ git pull --rebase origin branch-name ## Issue 4: Merge Conflicts Resolution ### When Conflicts Occur + ```bash # After a merge conflict git status # Shows conflicted files @@ -152,6 +172,7 @@ git commit -m "Resolve merge conflicts" ``` ### Abort a Merge + ```bash git merge --abort ``` @@ -161,11 +182,13 @@ git merge --abort ## Issue 5: Undoing Changes ### Unstage Files + ```bash git reset HEAD file-name ``` ### Discard Unstaged Changes + ```bash # Single file git checkout -- file-name @@ -175,6 +198,7 @@ git checkout . ``` ### Reset to Previous Commit + ```bash # Soft reset (keeps changes staged) git reset --soft HEAD~1 @@ -191,16 +215,19 @@ git reset --hard HEAD~1 ## Issue 6: Working with Remotes ### Add Remote + ```bash git remote add origin https://github.com/username/repo.git ``` ### Change Remote URL + ```bash git remote set-url origin https://github.com/username/new-repo.git ``` ### View Remotes + ```bash git remote -v ``` @@ -210,6 +237,7 @@ git remote -v ## Issue 7: Git Stash Management ### Useful Stash Commands + ```bash # Stash with message git stash push -m "Work in progress" @@ -238,10 +266,13 @@ git stash clear ## Issue 8: Line Ending Issues on Windows ### Problem + Git warnings about CRLF/LF line endings on Windows. ### Solution + Configure Git to handle line endings automatically: + ```bash # For the current repository git config core.autocrlf true @@ -255,6 +286,7 @@ git config --global core.autocrlf true ## Quick Reference Commands ### Status and History + ```bash git status # Current status git log --oneline # Commit history @@ -262,6 +294,7 @@ git log --graph --oneline # Visual commit history ``` ### Branch Operations + ```bash git branch # List local branches git branch -r # List remote branches @@ -271,6 +304,7 @@ git checkout -b new-branch # Create and switch ``` ### Remote Operations + ```bash git fetch origin # Fetch remote changes git pull # Fetch and merge @@ -294,4 +328,4 @@ git push -u origin branch # Push and set upstream - [Git Documentation](https://git-scm.com/doc) - [GitHub Git Handbook](https://guides.github.com/introduction/git-handbook/) -- [Atlassian Git Tutorials](https://www.atlassian.com/git/tutorials) \ No newline at end of file +- [Atlassian Git Tutorials](https://www.atlassian.com/git/tutorials) diff --git a/readme.md b/readme.md index be8ae10..1708f84 100644 --- a/readme.md +++ b/readme.md @@ -1,72 +1,105 @@ # ⚙️ Setup + ## eslint installation + ```bash npm i eslint @eslint/js prettier eslint-config-prettier eslint-plugin-prettier -D ``` + ## drizzle installation + ```bash npm i @neondatabase/serverless drizzle-orm ``` + ## drizzle kit installation + ```bash npm i -D drizzle-kit ``` + ## winston installation + ```bash npm i winston ``` + ## helmet installation + ```bash npm i helmet ``` + ## morgan installation + ```bash npm i morgan ``` + ## cors. cookie-parser installation + ```bash npm i cors cookie-parser ``` + ## jsonwebtoken and bcrypt installation + ```bash npm i jsonwebtoken bcrypt ``` # ⚙️ db migration + ```bash npm run db:generate ``` + ```bash npm run db:migrate ``` # ⚙️ testing + ## jest installation + ```bash npm i jest @types/jest -D ``` + ## supertest installation + ```bash npm i supertest @types/supertest -D ``` + ## jest configuration + ```bash npx jest --init ``` + ## cross-env installation + ```bash npm install --save-dev cross-env jest supertest ``` + ## jest run + ```bash npm run test ``` # ⭐ run this project + ## dev env + ```bash npm run dev:docker ``` + ## production env + ```bash -npm run prod:docker \ No newline at end of file +npm run prod:docker +``` diff --git a/src/App.js b/src/App.js index 9f5cdfc..d819de6 100644 --- a/src/App.js +++ b/src/App.js @@ -14,7 +14,11 @@ app.use(helmet()); app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); -app.use(morgan('combined' , { stream: { write: (message) => logger.info(message.trim())}})); +app.use( + morgan('combined', { + stream: { write: message => logger.info(message.trim()) }, + }) +); app.use(cookieParser()); app.use(securityMiddleware); @@ -26,17 +30,21 @@ app.get('/', (req, res) => { app.get('/health', (req, res) => { res.status(200).json({ - status: 'OK', timestamp: new Date().toISOString(), uptime: process.uptime(), memoryUsage: process.memoryUsage() + status: 'OK', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memoryUsage: process.memoryUsage(), }); }); app.get('/api', (req, res) => { res.status(200).json({ - status: 'OK', message: 'Acquisitions API is running...' + status: 'OK', + message: 'Acquisitions API is running...', }); }); app.use('/api/auth', authRoutes); app.use('/api/users', userRoutes); -export default app; \ No newline at end of file +export default app; diff --git a/src/config/arcjet.js b/src/config/arcjet.js index 971ae94..127835c 100644 --- a/src/config/arcjet.js +++ b/src/config/arcjet.js @@ -6,11 +6,7 @@ const aj = arcjet({ shield({ mode: 'LIVE' }), detectBot({ mode: 'LIVE', - allow: [ - 'CATEGORY:SEARCH_ENGINE', - 'CATEGORY:PREVIEW', - 'HTTPIE', - ], + allow: ['CATEGORY:SEARCH_ENGINE', 'CATEGORY:PREVIEW', 'HTTPIE'], }), slidingWindow({ mode: 'LIVE', @@ -20,4 +16,4 @@ const aj = arcjet({ ], }); -export default aj; \ No newline at end of file +export default aj; diff --git a/src/config/database.js b/src/config/database.js index f428c97..dc2e924 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -13,4 +13,4 @@ const sql = neon(process.env.DATABASE_URL); const db = drizzle(sql); -export { db, sql }; \ No newline at end of file +export { db, sql }; diff --git a/src/config/logger.js b/src/config/logger.js index 78b7964..7740d0d 100644 --- a/src/config/logger.js +++ b/src/config/logger.js @@ -25,4 +25,4 @@ if (process.env.NODE_ENV !== 'production') { ); } -export default logger; \ No newline at end of file +export default logger; diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index 887cd03..c420219 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -9,29 +9,35 @@ export const singup = async (req, res, next) => { try { const validationResult = signupSchema.safeParse(req.body); if (!validationResult.success) { - return res.status(400).json({ + return res.status(400).json({ message: validationResult.error.message, - details: formatValidationErrors(validationResult.error.errors) + details: formatValidationErrors(validationResult.error.errors), }); } const { name, email, password, role } = validationResult.data; - + const user = await createUser({ name, email, password, role }); - const token = jwttoken.sign({ id: user.id, email: user.email, role: user.role }); + const token = jwttoken.sign({ + id: user.id, + email: user.email, + role: user.role, + }); cookies.set(res, 'token', token); logger.info(`user registered successfully : ${email}`); - res.status(201).json({ - message: 'User registered successfully' , - user: user.id, + res.status(201).json({ + message: 'User registered successfully', + user: user.id, name: user.name, email: user.email, - role: user.role + role: user.role, }); } catch (e) { logger.error('Error in singup', e); if (e.message === 'User with this email already exists') { - return res.status(400).json({ message: 'User with this email already exists'}); + return res + .status(400) + .json({ message: 'User with this email already exists' }); } next(e); } @@ -41,29 +47,33 @@ export const signin = async (req, res, next) => { try { const validationResult = signinSchema.safeParse(req.body); if (!validationResult.success) { - return res.status(400).json({ + return res.status(400).json({ message: validationResult.error.message, - details: formatValidationErrors(validationResult.error.errors) + details: formatValidationErrors(validationResult.error.errors), }); } const { email, password } = validationResult.data; - + const user = await authenticateUser(email, password); - const token = jwttoken.sign({ id: user.id, email: user.email, role: user.role }); + const token = jwttoken.sign({ + id: user.id, + email: user.email, + role: user.role, + }); cookies.set(res, 'token', token); logger.info(`User signed in successfully : ${email}`); - res.status(200).json({ - message: 'User signed in successfully' , - user: user.id, + res.status(200).json({ + message: 'User signed in successfully', + user: user.id, name: user.name, email: user.email, - role: user.role + role: user.role, }); } catch (e) { logger.error('Error in signin', e); if (e.message === 'Invalid email or password') { - return res.status(401).json({ message: 'Invalid email or password'}); + return res.status(401).json({ message: 'Invalid email or password' }); } next(e); } @@ -72,10 +82,10 @@ export const signin = async (req, res, next) => { export const signout = async (req, res, next) => { try { cookies.clear(res, 'token'); - + logger.info('User signed out successfully'); - res.status(200).json({ - message: 'User signed out successfully' + res.status(200).json({ + message: 'User signed out successfully', }); } catch (e) { logger.error('Error in signout', e); diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js index 045ad37..05c39e6 100644 --- a/src/controllers/users.controller.js +++ b/src/controllers/users.controller.js @@ -1,6 +1,14 @@ import logger from '#config/logger.js'; -import { getAllUsers, getUserById, updateUser, deleteUser } from '#services/user.service.js'; -import { userIdSchema, updateUserSchema } from '#validations/users.validation.js'; +import { + getAllUsers, + getUserById, + updateUser, + deleteUser, +} from '#services/user.service.js'; +import { + userIdSchema, + updateUserSchema, +} from '#validations/users.validation.js'; import { formatValidationErrors } from '#utils/format.js'; export const getAllUsersController = async (req, res, next) => { @@ -10,7 +18,7 @@ export const getAllUsersController = async (req, res, next) => { res.status(200).json({ message: 'All users fetched successfully', data: allUsers, - count: allUsers.length + count: allUsers.length, }); } catch (error) { logger.error('Error fetching all users', error); @@ -24,17 +32,17 @@ export const getUserByIdController = async (req, res, next) => { if (!validationResult.success) { return res.status(400).json({ message: validationResult.error.message, - details: formatValidationErrors(validationResult.error.errors) + details: formatValidationErrors(validationResult.error.errors), }); } - + const { id } = validationResult.data; logger.info(`Fetching user with ID: ${id}`); - + const user = await getUserById(id); res.status(200).json({ message: 'User fetched successfully', - data: user + data: user, }); } catch (error) { logger.error('Error fetching user by ID', error); @@ -52,42 +60,42 @@ export const updateUserController = async (req, res, next) => { if (!idValidationResult.success) { return res.status(400).json({ message: idValidationResult.error.message, - details: formatValidationErrors(idValidationResult.error.errors) + details: formatValidationErrors(idValidationResult.error.errors), }); } - + // Validate update data from body const updateValidationResult = updateUserSchema.safeParse(req.body); if (!updateValidationResult.success) { return res.status(400).json({ message: updateValidationResult.error.message, - details: formatValidationErrors(updateValidationResult.error.errors) + details: formatValidationErrors(updateValidationResult.error.errors), }); } - + const { id } = idValidationResult.data; const updates = updateValidationResult.data; - + // Authorization: users can only update their own data, except admins if (req.user.role !== 'admin' && req.user.id !== id) { return res.status(403).json({ - message: 'Access denied. You can only update your own information.' + message: 'Access denied. You can only update your own information.', }); } - + // Only admins can change roles if (updates.role && req.user.role !== 'admin') { return res.status(403).json({ - message: 'Access denied. Only administrators can change user roles.' + message: 'Access denied. Only administrators can change user roles.', }); } - + logger.info(`Updating user with ID: ${id}`); - + const updatedUser = await updateUser(id, updates); res.status(200).json({ message: 'User updated successfully', - data: updatedUser + data: updatedUser, }); } catch (error) { logger.error('Error updating user', error); @@ -107,25 +115,25 @@ export const deleteUserController = async (req, res, next) => { if (!validationResult.success) { return res.status(400).json({ message: validationResult.error.message, - details: formatValidationErrors(validationResult.error.errors) + details: formatValidationErrors(validationResult.error.errors), }); } - + const { id } = validationResult.data; - + // Authorization: users can only delete their own account, except admins if (req.user.role !== 'admin' && req.user.id !== id) { return res.status(403).json({ - message: 'Access denied. You can only delete your own account.' + message: 'Access denied. You can only delete your own account.', }); } - + logger.info(`Deleting user with ID: ${id}`); - + const deletedUser = await deleteUser(id); res.status(200).json({ message: 'User deleted successfully', - data: deletedUser + data: deletedUser, }); } catch (error) { logger.error('Error deleting user', error); diff --git a/src/middleware/auth.middleware.js b/src/middleware/auth.middleware.js index 45cb82b..eccea4b 100644 --- a/src/middleware/auth.middleware.js +++ b/src/middleware/auth.middleware.js @@ -5,10 +5,10 @@ import logger from '#config/logger.js'; export const authenticateToken = (req, res, next) => { try { const token = cookies.get(req, 'token'); - + if (!token) { - return res.status(401).json({ - message: 'Access denied. No token provided.' + return res.status(401).json({ + message: 'Access denied. No token provided.', }); } @@ -17,16 +17,16 @@ export const authenticateToken = (req, res, next) => { next(); } catch (error) { logger.error('Authentication failed', error); - return res.status(401).json({ - message: 'Invalid token' + return res.status(401).json({ + message: 'Invalid token', }); } }; export const requireAdmin = (req, res, next) => { if (!req.user || req.user.role !== 'admin') { - return res.status(403).json({ - message: 'Access denied. Admin role required.' + return res.status(403).json({ + message: 'Access denied. Admin role required.', }); } next(); @@ -34,18 +34,18 @@ export const requireAdmin = (req, res, next) => { export const requireOwnershipOrAdmin = (req, res, next) => { const userId = parseInt(req.params.id); - + if (!req.user) { - return res.status(401).json({ - message: 'Authentication required' + return res.status(401).json({ + message: 'Authentication required', }); } - + if (req.user.role === 'admin' || req.user.id === userId) { return next(); } - - return res.status(403).json({ - message: 'Access denied. You can only access your own data.' + + return res.status(403).json({ + message: 'Access denied. You can only access your own data.', }); -}; \ No newline at end of file +}; diff --git a/src/middleware/security.middleware.js b/src/middleware/security.middleware.js index e3bc2c7..2d7713c 100644 --- a/src/middleware/security.middleware.js +++ b/src/middleware/security.middleware.js @@ -6,40 +6,65 @@ const securityMiddleware = async (req, res, next) => { try { const role = res.user?.role || 'guest'; let limit; - switch(role){ + switch (role) { case 'admin': - limit=20; + limit = 20; break; case 'user': - limit=10; + limit = 10; break; default: - limit=5; + limit = 5; } - const client = aj.withRule(slidingWindow( { - mode: 'LIVE', - interval: '1m', - max: limit, - name: `${role}_req_limit` - })); + const client = aj.withRule( + slidingWindow({ + mode: 'LIVE', + interval: '1m', + max: limit, + name: `${role}_req_limit`, + }) + ); const decision = await client.protect(req); - if(decision.isDenied() && decision.reason.isBot()){ - logger.error('Bot detected:' , {ip: req.ip, userAgent: req.get('User-Agent'), path: req.path}); - return res.status(403).json({ error: 'Forbidden' , message: 'Bot detected' }); + if (decision.isDenied() && decision.reason.isBot()) { + logger.error('Bot detected:', { + ip: req.ip, + userAgent: req.get('User-Agent'), + path: req.path, + }); + return res + .status(403) + .json({ error: 'Forbidden', message: 'Bot detected' }); } - if(decision.isDenied() && decision.reason.isShield()){ - logger.error('Shield detected:' , {ip: req.ip, userAgent: req.get('User-Agent'), path: req.path}); - return res.status(403).json({ error: 'Forbidden' , message: 'Shield detected' }); + if (decision.isDenied() && decision.reason.isShield()) { + logger.error('Shield detected:', { + ip: req.ip, + userAgent: req.get('User-Agent'), + path: req.path, + }); + return res + .status(403) + .json({ error: 'Forbidden', message: 'Shield detected' }); } - if(decision.isDenied() && decision.reason.isRateLimit()){ - logger.error('Rate limit exceeded:' , {ip: req.ip, userAgent: req.get('User-Agent'), path: req.path}); - return res.status(429).json({ error: 'Too Many Requests' , message: 'Rate limit exceeded' }); + if (decision.isDenied() && decision.reason.isRateLimit()) { + logger.error('Rate limit exceeded:', { + ip: req.ip, + userAgent: req.get('User-Agent'), + path: req.path, + }); + return res + .status(429) + .json({ error: 'Too Many Requests', message: 'Rate limit exceeded' }); } next(); } catch (e) { console.error('Arcjet middleware error', e); - return res.status(500).json({ error: 'Internal Server Error' , message: 'something went wrong with security middleware' }); + return res + .status(500) + .json({ + error: 'Internal Server Error', + message: 'something went wrong with security middleware', + }); } }; -export default securityMiddleware; \ No newline at end of file +export default securityMiddleware; diff --git a/src/models/user.model.js b/src/models/user.model.js index fdbb9b5..86499a0 100644 --- a/src/models/user.model.js +++ b/src/models/user.model.js @@ -8,4 +8,4 @@ export const users = pgTable('users', { role: varchar('role', { length: 50 }).notNull().default('user'), created_at: timestamp('created_at').notNull().defaultNow(), updated_at: timestamp('updated_at').notNull().defaultNow(), -}); \ No newline at end of file +}); diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 20c7e26..f444cc2 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -7,4 +7,4 @@ router.post('/sign-up', singup); router.post('/sign-in', signin); router.post('/sign-out', signout); -export default router; \ No newline at end of file +export default router; diff --git a/src/routes/user.routes.js b/src/routes/user.routes.js index 7d1dcbb..321ea88 100644 --- a/src/routes/user.routes.js +++ b/src/routes/user.routes.js @@ -1,6 +1,15 @@ import express from 'express'; -import { getAllUsersController, getUserByIdController, updateUserController, deleteUserController } from '#controllers/users.controller.js'; -import { authenticateToken, requireAdmin, requireOwnershipOrAdmin } from '#middleware/auth.middleware.js'; +import { + getAllUsersController, + getUserByIdController, + updateUserController, + deleteUserController, +} from '#controllers/users.controller.js'; +import { + authenticateToken, + requireAdmin, + requireOwnershipOrAdmin, +} from '#middleware/auth.middleware.js'; const router = express.Router(); @@ -8,8 +17,23 @@ const router = express.Router(); router.get('/', authenticateToken, requireAdmin, getAllUsersController); // Protected routes - users can access their own data, admins can access any -router.get('/:id', authenticateToken, requireOwnershipOrAdmin, getUserByIdController); -router.put('/:id', authenticateToken, requireOwnershipOrAdmin, updateUserController); -router.delete('/:id', authenticateToken, requireOwnershipOrAdmin, deleteUserController); +router.get( + '/:id', + authenticateToken, + requireOwnershipOrAdmin, + getUserByIdController +); +router.put( + '/:id', + authenticateToken, + requireOwnershipOrAdmin, + updateUserController +); +router.delete( + '/:id', + authenticateToken, + requireOwnershipOrAdmin, + deleteUserController +); -export default router; \ No newline at end of file +export default router; diff --git a/src/services/auth.service.js b/src/services/auth.service.js index c1b91d1..8c227f1 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'; import { db } from '#config/database.js'; import { users } from '#models/user.model.js'; -export const hashPassword = async (password) => { +export const hashPassword = async password => { try { return await bcrypt.hash(password, 10); } catch (error) { @@ -22,25 +22,32 @@ export const comparePassword = async (password, hashedPassword) => { } }; -export const createUser = async ({name, email, password, role = 'user'}) => { +export const createUser = async ({ name, email, password, role = 'user' }) => { try { - const existingUser = await db.select().from(users).where(eq(users.email, email)).limit(1); + const existingUser = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); if (existingUser.length > 0) { throw new Error('User with this email already exists'); } const hashedPassword = await hashPassword(password); - const [user] = await db.insert(users).values({ - name, - email, - password: hashedPassword, - role - }).returning({ - id: users.id, - name: users.name, - email: users.email, - role: users.role, - created_at: users.created_at - }); + const [user] = await db + .insert(users) + .values({ + name, + email, + password: hashedPassword, + role, + }) + .returning({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + created_at: users.created_at, + }); logger.info(`User created successfully : ${email}`); return user; } catch (error) { @@ -51,16 +58,20 @@ export const createUser = async ({name, email, password, role = 'user'}) => { export const authenticateUser = async (email, password) => { try { - const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1); + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); if (!user) { throw new Error('Invalid email or password'); } - + const isPasswordValid = await comparePassword(password, user.password); if (!isPasswordValid) { throw new Error('Invalid email or password'); } - + // Return user without password // eslint-disable-next-line no-unused-vars const { password: _, ...userWithoutPassword } = user; diff --git a/src/services/user.service.js b/src/services/user.service.js index 5d9ec87..fb7a6fa 100644 --- a/src/services/user.service.js +++ b/src/services/user.service.js @@ -6,35 +6,41 @@ import { hashPassword } from '#services/auth.service.js'; export const getAllUsers = async () => { try { - return await db.select({ - id: users.id, - name: users.name, - email: users.email, - role: users.role, - created_at: users.created_at, - updated_at: users.updated_at - }).from(users); + return await db + .select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + created_at: users.created_at, + updated_at: users.updated_at, + }) + .from(users); } catch (error) { logger.error('Error fetching all users', error); throw new Error('Error fetching all users'); } }; -export const getUserById = async (id) => { +export const getUserById = async id => { try { - const [user] = await db.select({ - id: users.id, - name: users.name, - email: users.email, - role: users.role, - created_at: users.created_at, - updated_at: users.updated_at - }).from(users).where(eq(users.id, id)).limit(1); - + const [user] = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + role: users.role, + created_at: users.created_at, + updated_at: users.updated_at, + }) + .from(users) + .where(eq(users.id, id)) + .limit(1); + if (!user) { throw new Error('User not found'); } - + return user; } catch (error) { logger.error(`Error fetching user with ID ${id}`, error); @@ -46,28 +52,32 @@ export const updateUser = async (id, updates) => { try { // Check if user exists first const existingUser = await getUserById(id); - + // Prepare update data const updateData = { ...updates }; - + // Hash password if it's being updated if (updates.password) { updateData.password = await hashPassword(updates.password); } - + // Add updated_at timestamp updateData.updated_at = new Date(); - + // Check for email uniqueness if email is being updated if (updates.email && updates.email !== existingUser.email) { - const [emailExists] = await db.select().from(users) - .where(eq(users.email, updates.email)).limit(1); + const [emailExists] = await db + .select() + .from(users) + .where(eq(users.email, updates.email)) + .limit(1); if (emailExists) { throw new Error('Email already exists'); } } - - const [updatedUser] = await db.update(users) + + const [updatedUser] = await db + .update(users) .set(updateData) .where(eq(users.id, id)) .returning({ @@ -76,9 +86,9 @@ export const updateUser = async (id, updates) => { email: users.email, role: users.role, created_at: users.created_at, - updated_at: users.updated_at + updated_at: users.updated_at, }); - + logger.info(`User with ID ${id} updated successfully`); return updatedUser; } catch (error) { @@ -87,20 +97,21 @@ export const updateUser = async (id, updates) => { } }; -export const deleteUser = async (id) => { +export const deleteUser = async id => { try { // Check if user exists first await getUserById(id); - - const [deletedUser] = await db.delete(users) + + const [deletedUser] = await db + .delete(users) .where(eq(users.id, id)) .returning({ id: users.id, name: users.name, email: users.email, - role: users.role + role: users.role, }); - + logger.info(`User with ID ${id} deleted successfully`); return deletedUser; } catch (error) { diff --git a/src/utils/cookies.js b/src/utils/cookies.js index 13410eb..efe11af 100644 --- a/src/utils/cookies.js +++ b/src/utils/cookies.js @@ -3,7 +3,7 @@ export const cookies = { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', - maxAge: 15 * 60 * 1000 + maxAge: 15 * 60 * 1000, }), set: (res, name, value, options = {}) => { res.cookie(name, value, { ...cookies.getOptions(), ...options }); @@ -11,5 +11,5 @@ export const cookies = { clear: (res, name, options = {}) => { res.cookie(name, '', { ...cookies.getOptions(), ...options }); }, - get: (req, name) => req.cookies[name] -}; \ No newline at end of file + get: (req, name) => req.cookies[name], +}; diff --git a/src/utils/format.js b/src/utils/format.js index 12038f3..64c4c32 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -1,5 +1,6 @@ -export const formatValidationErrors = (errors) => { +export const formatValidationErrors = errors => { if (!errors || !errors.issues) return 'validation error'; - if (Array.isArray(errors.issues)) return errors.issues.map(issue => issue.message).join(', '); + if (Array.isArray(errors.issues)) + return errors.issues.map(issue => issue.message).join(', '); return JSON.stringify(errors); -}; \ No newline at end of file +}; diff --git a/src/utils/jwt.js b/src/utils/jwt.js index 45cdbc9..3ec5028 100644 --- a/src/utils/jwt.js +++ b/src/utils/jwt.js @@ -1,11 +1,12 @@ import jwt from 'jsonwebtoken'; import logger from '#config/logger.js'; -const JWT_SECRET = process.env.JWT_SECRET || 'secretKey-please-change-in-production'; +const JWT_SECRET = + process.env.JWT_SECRET || 'secretKey-please-change-in-production'; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '1d'; export const jwttoken = { - sign: (payload) => { + sign: payload => { try { return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); } catch (error) { @@ -13,12 +14,12 @@ export const jwttoken = { throw new Error('Failed to generate JWT token'); } }, - verify: (token) => { + verify: token => { try { return jwt.verify(token, JWT_SECRET); } catch (error) { logger.error('Failed to verify JWT token', error); throw new Error('Failed to verify JWT token'); } - } -}; \ No newline at end of file + }, +}; diff --git a/src/validations/auth.validations.js b/src/validations/auth.validations.js index 0af29f5..6666c43 100644 --- a/src/validations/auth.validations.js +++ b/src/validations/auth.validations.js @@ -2,12 +2,24 @@ import { z } from 'zod'; export const signupSchema = z.object({ name: z.string().max(255).trim().nonempty('Name is required'), - email: z.string().max(255).toLowerCase().trim().nonempty('Email is required').email('Invalid email address'), + email: z + .string() + .max(255) + .toLowerCase() + .trim() + .nonempty('Email is required') + .email('Invalid email address'), password: z.string().min(6).max(255).nonempty('Password is required'), - role: z.enum(['user', 'admin']).default('user') + role: z.enum(['user', 'admin']).default('user'), }); export const signinSchema = z.object({ - email: z.string().max(255).toLowerCase().trim().nonempty('Email is required').email('Invalid email address'), - password: z.string().min(6).max(255).nonempty('Password is required') -}); \ No newline at end of file + email: z + .string() + .max(255) + .toLowerCase() + .trim() + .nonempty('Email is required') + .email('Invalid email address'), + password: z.string().min(6).max(255).nonempty('Password is required'), +}); diff --git a/src/validations/users.validation.js b/src/validations/users.validation.js index d4ebe0e..16fa719 100644 --- a/src/validations/users.validation.js +++ b/src/validations/users.validation.js @@ -1,22 +1,38 @@ import { z } from 'zod'; export const userIdSchema = z.object({ - id: z.string().transform((val) => { + id: z.string().transform(val => { const num = parseInt(val, 10); if (isNaN(num) || num <= 0) { throw new Error('ID must be a positive integer'); } return num; - }) + }), }); -export const updateUserSchema = z.object({ - name: z.string().max(255).trim().nonempty('Name is required').optional(), - email: z.string().max(255).toLowerCase().trim().email('Invalid email address').optional(), - password: z.string().min(6).max(255).nonempty('Password must be at least 6 characters').optional(), - role: z.enum(['user', 'admin']).optional() -}).refine((data) => { - return Object.keys(data).length > 0; -}, { - message: 'At least one field must be provided for update' -}); \ No newline at end of file +export const updateUserSchema = z + .object({ + name: z.string().max(255).trim().nonempty('Name is required').optional(), + email: z + .string() + .max(255) + .toLowerCase() + .trim() + .email('Invalid email address') + .optional(), + password: z + .string() + .min(6) + .max(255) + .nonempty('Password must be at least 6 characters') + .optional(), + role: z.enum(['user', 'admin']).optional(), + }) + .refine( + data => { + return Object.keys(data).length > 0; + }, + { + message: 'At least one field must be provided for update', + } + ); diff --git a/test/app.test.js b/test/app.test.js index 88874ad..2756cc0 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -3,7 +3,7 @@ import app from '#src/App.js'; describe('Api endpoints', () => { describe('GET /health', () => { - it('should return 200 with health check response', async() => { + it('should return 200 with health check response', async () => { const response = await request(app).get('/health'); expect(response.status).toBe(200); expect(response.body).toHaveProperty('status', 'OK'); @@ -14,16 +14,19 @@ describe('Api endpoints', () => { }); describe('GET /api', () => { - it('should return api response', async() => { + it('should return api response', async () => { const response = await request(app).get('/api'); expect(response.status).toBe(200); expect(response.body).toHaveProperty('status', 'OK'); - expect(response.body).toHaveProperty('message', 'Acquisitions API is running...'); + expect(response.body).toHaveProperty( + 'message', + 'Acquisitions API is running...' + ); }); }); describe('GET /', () => { - it('should return hello response', async() => { + it('should return hello response', async () => { const response = await request(app).get('/'); expect(response.status).toBe(200); expect(response.text).toBe('Hello from acquisitions!'); @@ -31,9 +34,9 @@ describe('Api endpoints', () => { }); describe('GET /nonexisting', () => { - it('should return not found response', async() => { + it('should return not found response', async () => { const response = await request(app).get('/nonexisting'); expect(response.status).toBe(404); }); }); -}); \ No newline at end of file +});