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/.gitignore b/.gitignore index ed48a299d..bd6a178a8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,3 @@ node_modules # MacOS .DS_Store - -# env files -*.env -.env* 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..2e7ad566b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "init": "mate-scripts init", "start": "node src/index.js", + "dev": "node --watch src/index.js", "lint": "npm run format && mate-scripts lint", "format": "prettier --ignore-path .prettierignore --write './src/**/*.{js,ts}'", "test:only": "mate-scripts test", @@ -23,7 +24,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/src/controllers/categories.controller.js b/src/controllers/categories.controller.js new file mode 100644 index 000000000..a20a29a13 --- /dev/null +++ b/src/controllers/categories.controller.js @@ -0,0 +1,73 @@ +const { categoriesService } = require('../services'); + +const create = async (req, res) => { + const { name } = req.body; + + if (!name) { + return res.sendStatus(400); + } + + const category = await categoriesService.create(name); + + res.status(201).json(categoriesService.normalize(category)); +}; + +const getAll = async (req, res) => { + const categories = await categoriesService.getAll(); + + res.send(categories.map((category) => categoriesService.normalize(category))); +}; + +const getOne = async (req, res) => { + const category = await categoriesService.getOne(Number(req.params.id)); + + if (!category) { + res.sendStatus(404); + + return; + } + + res.send(categoriesService.normalize(category)); +}; + +const update = async (req, res) => { + const { id } = req.params; + const { name } = req.body; + const category = await categoriesService.getOne(Number(id)); + + if (!category) { + return res.sendStatus(404); + } + + if (!name) { + return res.sendStatus(400); + } + + const updatedCategory = await categoriesService.update(Number(id), { + name, + }); + + res.json(categoriesService.normalize(updatedCategory)); +}; + +const remove = async (req, res) => { + const categoriesRemoved = await categoriesService.remove( + Number(req.params.id), + ); + + if (!categoriesRemoved) { + return res.sendStatus(404); + } + + res.sendStatus(204); +}; + +const categoriesController = { + getAll, + getOne, + create, + update, + remove, +}; + +exports.categoriesController = categoriesController; diff --git a/src/controllers/expenses.controller.js b/src/controllers/expenses.controller.js new file mode 100644 index 000000000..8949de132 --- /dev/null +++ b/src/controllers/expenses.controller.js @@ -0,0 +1,96 @@ +const { usersService, expensesService } = require('../services'); + +const create = async (req, res) => { + const { userId, spentAt, title, amount, category, note } = req.body; + const user = await usersService.getOne(userId); + + if (!user || !userId || !title || !amount || !spentAt) { + return res.sendStatus(400); + } + + const expense = await expensesService.create({ + userId, + spentAt, + title, + amount, + category, + note, + }); + + res.status(201).json(expensesService.normalize(expense)); +}; + +const getAll = async (req, res) => { + const { userId, categories, from, to } = req.query; + const expenses = await expensesService.getAll({ + userId: Number(userId), + categories, + from, + to, + }); + + res.json(expenses.map((expense) => expensesService.normalize(expense))); +}; + +const getOne = async (req, res) => { + const expense = await expensesService.getOne(Number(req.params.id)); + + if (!expense) { + return res.sendStatus(404); + } + + res.json(expensesService.normalize(expense)); +}; + +const update = async (req, res) => { + const { id } = req.params; + const expense = await expensesService.getOne(Number(id)); + + if (!expense) { + return res.sendStatus(404); + } + + const allowedFields = [ + 'userId', + 'title', + 'amount', + 'spentAt', + 'category', + 'note', + ]; + const updateData = {}; + + allowedFields.forEach((field) => { + if (req.body[field] !== undefined) { + updateData[field] = req.body[field]; + } + }); + + if (Object.keys(updateData).length === 0) { + return res.status(400); + } + + const updatedExpense = await expensesService.update(Number(id), updateData); + + res.json(expensesService.normalize(updatedExpense)); +}; + +const remove = async (req, res) => { + const expensesRemoved = await expensesService.remove(Number(req.params.id)); + + if (!expensesRemoved) { + return res.sendStatus(404); + } + + res.sendStatus(204); +}; + +const expensesController = { + create, + getAll, + getOne, + update, + remove, +}; + +exports.expensesController = expensesController; diff --git a/src/controllers/index.js b/src/controllers/index.js new file mode 100644 index 000000000..2f3fcb48e --- /dev/null +++ b/src/controllers/index.js @@ -0,0 +1,9 @@ +const { usersController } = require('./users.controller'); +const { expensesController } = require('./expenses.controller'); +const { categoriesController } = require('./categories.controller'); + +module.exports = { + usersController, + expensesController, + categoriesController, +}; diff --git a/src/controllers/users.controller.js b/src/controllers/users.controller.js new file mode 100644 index 000000000..423f5df0e --- /dev/null +++ b/src/controllers/users.controller.js @@ -0,0 +1,71 @@ +const { usersService } = require('../services'); + +const create = async (req, res) => { + const { name } = req.body; + + if (!name) { + return res.sendStatus(400); + } + + const user = await usersService.create(name); + + res.status(201).json(usersService.normalize(user)); +}; + +const getAll = async (req, res) => { + const users = await usersService.getAll(); + + res.send(users.map((user) => usersService.normalize(user))); +}; + +const getOne = async (req, res) => { + const user = await usersService.getOne(Number(req.params.id)); + + if (!user) { + res.sendStatus(404); + + return; + } + + res.send(usersService.normalize(user)); +}; + +const update = async (req, res) => { + const { id } = req.params; + const { name } = req.body; + const user = await usersService.getOne(Number(id)); + + if (!user) { + return res.sendStatus(404); + } + + if (!name) { + return res.sendStatus(400); + } + + const updatedUser = await usersService.update(Number(id), { + name, + }); + + res.json(usersService.normalize(updatedUser)); +}; + +const remove = async (req, res) => { + const usersRemoved = await usersService.remove(Number(req.params.id)); + + if (!usersRemoved) { + return res.sendStatus(404); + } + + res.sendStatus(204); +}; + +const usersController = { + getAll, + getOne, + create, + update, + remove, +}; + +exports.usersController = usersController; diff --git a/src/createServer.js b/src/createServer.js index 1ea5542d6..79d101d27 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,7 +1,17 @@ 'use strict'; +const express = require('express'); +const { routes } = require('./routes'); + const createServer = () => { - // your code goes here + const app = express(); + + app.use(express.json()); + app.use('/users', routes.usersRouter); + app.use('/expenses', routes.expensesRouter); + app.use('/categories', routes.categoriesRouter); + + return app; }; module.exports = { diff --git a/src/db.js b/src/db.js index 1ba3046cc..288b9b93c 100644 --- a/src/db.js +++ b/src/db.js @@ -16,7 +16,7 @@ const { } = process.env; /* - All credentials setted to default values (exsept password - it is exapmle) + All credentials are set to default values (except password - it is example) replace if needed with your own */ @@ -26,7 +26,7 @@ const sequelize = new Sequelize({ host: POSTGRES_HOST || 'localhost', dialect: 'postgres', port: POSTGRES_PORT || 5432, - password: POSTGRES_PASSWORD || '123', + password: POSTGRES_PASSWORD || '', }); module.exports = { diff --git a/src/models/Category.model.js b/src/models/Category.model.js new file mode 100644 index 000000000..d5d60a7ee --- /dev/null +++ b/src/models/Category.model.js @@ -0,0 +1,15 @@ +'use strict'; + +const { sequelize } = require('../db'); +const { DataTypes } = require('sequelize'); + +const Category = sequelize.define('Category', { + name: { + type: DataTypes.STRING, + allowNull: false, + }, +}); + +module.exports = { + Category, +}; diff --git a/src/models/Expense.model.js b/src/models/Expense.model.js index 567e1c3e7..d61a37971 100644 --- a/src/models/Expense.model.js +++ b/src/models/Expense.model.js @@ -1,10 +1,32 @@ 'use strict'; -const { sequelize } = require('../db.js'); +const { sequelize } = require('../db'); +const { DataTypes } = require('sequelize'); -const Expense = sequelize.define( - // your code goes here -); +const Expense = sequelize.define('Expense', { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + amount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + spentAt: { + type: DataTypes.DATE, + allowNull: false, + }, + category: { + type: DataTypes.STRING, + }, + note: { + type: DataTypes.STRING, + }, +}); module.exports = { Expense, diff --git a/src/models/User.model.js b/src/models/User.model.js index 61861c9e4..bd145819c 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -1,10 +1,14 @@ 'use strict'; -const { sequelize } = require('../db.js'); +const { sequelize } = require('../db'); +const { DataTypes } = require('sequelize'); -const User = sequelize.define( - // your code goes here -); +const User = sequelize.define('User', { + name: { + type: DataTypes.STRING, + allowNull: false, + }, +}); module.exports = { User, diff --git a/src/models/models.js b/src/models/index.js similarity index 61% rename from src/models/models.js rename to src/models/index.js index b43b55752..da61cf5a1 100644 --- a/src/models/models.js +++ b/src/models/index.js @@ -2,10 +2,10 @@ const { User } = require('./User.model'); const { Expense } = require('./Expense.model'); +const { Category } = require('./Category.model'); module.exports = { - models: { - User, - Expense, - }, + User, + Expense, + Category, }; diff --git a/src/routes/categories.route.js b/src/routes/categories.route.js new file mode 100644 index 000000000..55b492556 --- /dev/null +++ b/src/routes/categories.route.js @@ -0,0 +1,12 @@ +const { Router } = require('express'); +const { categoriesController } = require('../controllers'); + +const categoriesRouter = Router(); + +categoriesRouter.post('/', categoriesController.create); +categoriesRouter.get('/', categoriesController.getAll); +categoriesRouter.get('/:id', categoriesController.getOne); +categoriesRouter.patch('/:id', categoriesController.update); +categoriesRouter.delete('/:id', categoriesController.remove); + +exports.categoriesRouter = categoriesRouter; diff --git a/src/routes/expenses.route.js b/src/routes/expenses.route.js new file mode 100644 index 000000000..b77a4f90c --- /dev/null +++ b/src/routes/expenses.route.js @@ -0,0 +1,12 @@ +const { Router } = require('express'); +const { expensesController } = require('../controllers'); + +const expensesRouter = Router(); + +expensesRouter.post('/', expensesController.create); +expensesRouter.get('/', expensesController.getAll); +expensesRouter.get('/:id', expensesController.getOne); +expensesRouter.delete('/:id', expensesController.remove); +expensesRouter.patch('/:id', expensesController.update); + +exports.expensesRouter = expensesRouter; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 000000000..7ec604c4e --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,9 @@ +const { usersRouter } = require('./users.route'); +const { expensesRouter } = require('./expenses.route'); +const { categoriesRouter } = require('./categories.route'); + +exports.routes = { + usersRouter, + expensesRouter, + categoriesRouter, +}; diff --git a/src/routes/users.route.js b/src/routes/users.route.js new file mode 100644 index 000000000..c4c014c30 --- /dev/null +++ b/src/routes/users.route.js @@ -0,0 +1,12 @@ +const { Router } = require('express'); +const { usersController } = require('../controllers'); + +const usersRouter = Router(); + +usersRouter.post('/', usersController.create); +usersRouter.get('/', usersController.getAll); +usersRouter.get('/:id', usersController.getOne); +usersRouter.patch('/:id', usersController.update); +usersRouter.delete('/:id', usersController.remove); + +exports.usersRouter = usersRouter; diff --git a/src/services/categories.service.js b/src/services/categories.service.js new file mode 100644 index 000000000..b933e1f5f --- /dev/null +++ b/src/services/categories.service.js @@ -0,0 +1,48 @@ +const { Category } = require('../models'); + +const normalize = ({ id, name }) => ({ id, name }); + +const create = async (name) => { + const category = await Category.create({ name }); + + return category; +}; + +const getAll = async () => { + const categories = await Category.findAll(); + + return categories; +}; + +const getOne = (id) => { + const category = Category.findByPk(id); + + return category; +}; + +const update = async (id, { name }) => { + // eslint-disable-next-line no-unused-vars + const [_categoriesUpdated, [updatedCategory]] = await Category.update( + { name }, + { where: { id }, returning: true }, + ); + + return updatedCategory; +}; + +const remove = async (id) => { + const categoriesRemoved = await Category.destroy({ where: { id } }); + + return categoriesRemoved; +}; + +const categoriesService = { + normalize, + getAll, + getOne, + create, + update, + remove, +}; + +exports.categoriesService = categoriesService; diff --git a/src/services/expenses.service.js b/src/services/expenses.service.js new file mode 100644 index 000000000..8aee5ec24 --- /dev/null +++ b/src/services/expenses.service.js @@ -0,0 +1,82 @@ +const { Op } = require('sequelize'); +const { Expense } = require('../models'); + +const normalize = ({ id, userId, title, amount, spentAt, category, note }) => ({ + id, + userId, + title, + amount, + spentAt, + category, + note, +}); + +const create = async (payload) => { + const expense = await Expense.create({ ...payload }); + + return expense; +}; + +const getAll = async (params = {}) => { + const { userId, categories, from, to } = params; + const where = {}; + + if (userId) { + where.userId = userId; + } + + if (categories && categories.length > 0) { + where.category = { + [Op.in]: Array.isArray(categories) ? categories : [categories], + }; + } + + if (from || to) { + where.spentAt = {}; + + if (from) { + where.spentAt[Op.gte] = from; + } + + if (to) { + where.spentAt[Op.lte] = to; + } + } + + const expenses = await Expense.findAll({ where }); + + return expenses; +}; + +const getOne = (id) => { + const expense = Expense.findByPk(id); + + return expense; +}; + +const update = async (id, payload) => { + // eslint-disable-next-line no-unused-vars + const [_expensesUpdated, [updatedExpense]] = await Expense.update(payload, { + where: { id }, + returning: true, + }); + + return updatedExpense; +}; + +const remove = async (id) => { + const expensesRemoved = await Expense.destroy({ where: { id } }); + + return expensesRemoved; +}; + +const expensesService = { + normalize, + create, + getAll, + getOne, + update, + remove, +}; + +exports.expensesService = expensesService; diff --git a/src/services/index.js b/src/services/index.js new file mode 100644 index 000000000..a9e9fabb9 --- /dev/null +++ b/src/services/index.js @@ -0,0 +1,9 @@ +const { usersService } = require('./users.service'); +const { expensesService } = require('./expenses.service'); +const { categoriesService } = require('./categories.service'); + +module.exports = { + usersService, + expensesService, + categoriesService, +}; diff --git a/src/services/users.service.js b/src/services/users.service.js new file mode 100644 index 000000000..f0da4897b --- /dev/null +++ b/src/services/users.service.js @@ -0,0 +1,48 @@ +const { User } = require('../models'); + +const normalize = ({ id, name }) => ({ id, name }); + +const create = async (name) => { + const user = await User.create({ name }); + + return user; +}; + +const getAll = async () => { + const users = await User.findAll(); + + return users; +}; + +const getOne = (id) => { + const user = User.findByPk(id); + + return user; +}; + +const update = async (id, { name }) => { + // eslint-disable-next-line no-unused-vars + const [_usersUpdated, [updatedUser]] = await User.update( + { name }, + { where: { id }, returning: true }, + ); + + return updatedUser; +}; + +const remove = async (id) => { + const usersRemoved = await User.destroy({ where: { id } }); + + return usersRemoved; +}; + +const usersService = { + normalize, + getAll, + getOne, + create, + update, + remove, +}; + +exports.usersService = usersService; diff --git a/src/setup.js b/src/setup.js new file mode 100644 index 000000000..b16180108 --- /dev/null +++ b/src/setup.js @@ -0,0 +1,5 @@ +const { User, Expense, Category } = require('./models'); + +User.sync({ force: true }); +Expense.sync({ force: true }); +Category.sync({ force: true }); diff --git a/tests/expense.test.js b/tests/expense.test.js index 2dde0c89f..ff37e1add 100644 --- a/tests/expense.test.js +++ b/tests/expense.test.js @@ -4,9 +4,7 @@ const { createServer } = require('../src/createServer'); const { sequelize } = require('../src/db'); const axios = require('axios'); const https = require('https'); -const { - models: { User, Expense }, -} = require('../src/models/models'); +const { User, Expense } = require('../src/models'); const { Agent } = require('http'); // this prevents `socket hang up` for Node.js 20.10+ diff --git a/tests/user.test.js b/tests/user.test.js index c03ae3541..f89df5b9b 100644 --- a/tests/user.test.js +++ b/tests/user.test.js @@ -4,9 +4,7 @@ const axios = require('axios'); const https = require('https'); -const { - models: { User }, -} = require('../src/models/models'); +const { User } = require('../src/models'); const { createServer } = require('../src/createServer'); const { sequelize } = require('../src/db'); const { Agent } = require('http');