diff --git a/.github/workflows/test.yml-template b/.github/workflows/test.yml-template new file mode 100644 index 000000000..03d2b7ff2 --- /dev/null +++ b/.github/workflows/test.yml-template @@ -0,0 +1,44 @@ +name: Test + +on: + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: students + POSTGRES_PORT: 5432 + POSTGRES_HOST: localhost + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v2 + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: '20' + - name: Install dependencies + run: npm install + - name: Run tests + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: students + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + run: npm test diff --git a/package-lock.json b/package-lock.json index cd25728ae..03a0a7224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", @@ -1474,10 +1474,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index bdf079250..e7103013c 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "axios": "^1.7.2", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", diff --git a/readme.md b/readme.md index 79342bfbc..c65a99e88 100644 --- a/readme.md +++ b/readme.md @@ -8,68 +8,68 @@ Implement a REST API server using Node.js that manages **Users** and **Expenses* ## Users API ### POST /users -- Create a user -- Body: `{ name }` -- Returns: `201` with created user -- Returns `400` if `name` is missing +- Create a user +- Body: `{ name }` +- Returns: `201` with created user +- Returns `400` if `name` is missing ### GET /users -- Returns all users (`200`) +- Returns all users (`200`) ### GET /users/:id -- Returns a user (`200`) -- Returns `404` if not found +- Returns a user (`200`) +- Returns `404` if not found ### PATCH /users/:id -- Update user name -- Returns updated user (`200`) -- Returns `404` if not found +- Update user name +- Returns updated user (`200`) +- Returns `404` if not found ### DELETE /users/:id -- Deletes user -- Returns `204` -- Returns `404` if not found +- Deletes user +- Returns `204` +- Returns `404` if not found --- ## Expenses API ### POST /expenses -- Create an expense -- Required: `spentAt`, `title`, `amount`, `userId` -- Optional: `category`, `note` -- Returns: `201` with created expense -- Returns `400` if required fields are missing or user not found +- Create an expense +- Required: `spentAt`, `title`, `amount`, `userId` +- Optional: `category`, `note` +- Returns: `201` with created expense +- Returns `400` if required fields are missing or user not found ### GET /expenses -- Returns all expenses (`200`) +- Returns all expenses (`200`) - Supports filters: - `userId` - `from` / `to` (date range) - `categories` (comma-separated) ### GET /expenses/:id -- Returns expense (`200`) -- Returns `404` if not found +- Returns expense (`200`) +- Returns `404` if not found ### PATCH /expenses/:id -- Update expense fields -- Returns updated expense (`200`) -- Returns `404` if not found +- Update expense fields +- Returns updated expense (`200`) +- Returns `404` if not found ### DELETE /expenses/:id -- Deletes expense -- Returns `204` -- Returns `404` if not found +- Deletes expense +- Returns `204` +- Returns `404` if not found --- ## General -- Use JSON for all requests/responses -- Set header: `Content-Type: application/json; charset=utf-8` -- Use proper HTTP status codes (`200`, `201`, `204`, `400`, `404`) -- Persist data using a database (e.g. Sequelize) +- Use JSON for all requests/responses +- Set header: `Content-Type: application/json; charset=utf-8` +- Use proper HTTP status codes (`200`, `201`, `204`, `400`, `404`) +- Persist data using a database (e.g. Sequelize) # Note - You can sync models by running `npm run test` diff --git a/src/createServer.js b/src/createServer.js index 1ea5542d6..1855e3450 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,8 +1,206 @@ 'use strict'; -const createServer = () => { - // your code goes here -}; +const express = require('express'); +const { Op } = require('sequelize'); +const { User } = require('./models/User.model'); +const { Expense } = require('./models/Expense.model'); + +User.hasMany(Expense, { foreignKey: 'userId', constraints: false }); +Expense.belongsTo(User, { foreignKey: 'userId', constraints: false }); + +function createServer() { + const app = express(); + + app.use(express.json()); + + app.post('/users', async (req, res) => { + try { + const { name } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Name is required' }); + } + + const user = await User.create({ name }); + + res.status(201).json(user); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/users', async (req, res) => { + try { + const users = await User.findAll(); + + res.status(200).json(users); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/users/:id', async (req, res) => { + try { + const user = await User.findByPk(req.params.id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.status(200).json(user); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.patch('/users/:id', async (req, res) => { + try { + const { name } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Name is required' }); + } + + const user = await User.findByPk(req.params.id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + await user.update({ name }); + res.status(200).json(user); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/users/:id', async (req, res) => { + try { + const user = await User.findByPk(req.params.id); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + await user.destroy(); + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.post('/expenses', async (req, res) => { + try { + const { userId, spentAt, title, amount, category, note } = req.body; + + if (!userId || !spentAt || !title || amount === undefined) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const user = await User.findByPk(userId); + + if (!user) { + return res.status(400).json({ error: 'User not found' }); + } + + const expense = await Expense.create({ + userId, + spentAt, + title, + amount, + category, + note, + }); + + res.status(201).json(expense); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/expenses', async (req, res) => { + try { + const whereCondition = {}; + + if (req.query.userId) { + whereCondition.userId = req.query.userId; + } + + if (req.query.categories) { + whereCondition.category = { + [Op.in]: req.query.categories.split(','), + }; + } + + if (req.query.from || req.query.to) { + whereCondition.spentAt = {}; + + if (req.query.from) { + whereCondition.spentAt[Op.gte] = new Date(req.query.from); + } + + if (req.query.to) { + whereCondition.spentAt[Op.lte] = new Date(req.query.to); + } + } + + const expenses = await Expense.findAll({ + where: whereCondition, + }); + + res.status(200).json(expenses); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.get('/expenses/:id', async (req, res) => { + try { + const expense = await Expense.findByPk(req.params.id); + + if (!expense) { + return res.status(404).json({ error: 'Expense not found' }); + } + + res.status(200).json(expense); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.patch('/expenses/:id', async (req, res) => { + try { + const expense = await Expense.findByPk(req.params.id); + + if (!expense) { + return res.status(404).json({ error: 'Expense not found' }); + } + + await expense.update(req.body); + + res.status(200).json(expense); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + app.delete('/expenses/:id', async (req, res) => { + try { + const expense = await Expense.findByPk(req.params.id); + + if (!expense) { + return res.status(404).json({ error: 'Expense not found' }); + } + + await expense.destroy(); + res.status(204).send(); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + return app; +} module.exports = { createServer, diff --git a/src/models/Expense.model.js b/src/models/Expense.model.js index 567e1c3e7..e9311495e 100644 --- a/src/models/Expense.model.js +++ b/src/models/Expense.model.js @@ -1,9 +1,34 @@ 'use strict'; +const { DataTypes } = require('sequelize'); const { sequelize } = require('../db.js'); const Expense = sequelize.define( - // your code goes here + 'Expense', + { + spentAt: { + type: DataTypes.DATE, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + amount: { + type: DataTypes.FLOAT, + allowNull: false, + }, + category: { + type: DataTypes.STRING, + }, + note: { + type: DataTypes.TEXT, + }, + }, + { + tableName: 'expenses', + timestamps: false, + }, ); module.exports = { diff --git a/src/models/User.model.js b/src/models/User.model.js index 61861c9e4..bbdddcd3f 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -1,9 +1,20 @@ 'use strict'; +const { DataTypes } = require('sequelize'); const { sequelize } = require('../db.js'); const User = sequelize.define( - // your code goes here + 'User', + { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'users', + timestamps: false, + }, ); module.exports = {