diff --git a/.env b/.env new file mode 100644 index 000000000..571aa3c6c --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=Mylifeuncut2015 +POSTGRES_DB=postgres 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..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/src/createServer.js b/src/createServer.js index 1ea5542d6..f6d5f0bb3 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,7 +1,385 @@ 'use strict'; +const express = require('express'); +const { Op } = require('sequelize'); +const { models } = require('./models/models'); + +const { User, Expense, Category } = models; + +const expenseInclude = [ + { + model: Category, + as: 'category', + attributes: ['id', 'name'], + required: false, + }, +]; + +const serializeExpense = (expense) => { + const plainExpense = expense.get({ plain: true }); + const categoryName = plainExpense.category?.name; + + delete plainExpense.categoryId; + delete plainExpense.category; + + if (categoryName !== undefined) { + plainExpense.category = categoryName; + } + + return plainExpense; +}; + +const getCategoryId = async ({ categoryId, category }) => { + if (categoryId === undefined && category === undefined) { + return undefined; + } + + if (categoryId !== undefined) { + const categoryById = await Category.findByPk(Number(categoryId)); + + return categoryById ? categoryById.id : null; + } + + if (!category) { + return null; + } + + const [categoryByName] = await Category.findOrCreate({ + where: { name: category }, + defaults: { name: category }, + }); + + return categoryByName.id; +}; + const createServer = () => { - // your code goes here + const app = express(); + + app.use(express.json()); + + app.post('/users', async (req, res) => { + const { name } = req.body; + + if (!name) { + res.status(400).send('Name is required'); + + return; + } + + const newUser = await User.create({ name }); + + res.status(201).json(newUser); + }); + + app.get('/users', async (_req, res) => { + const users = await User.findAll(); + + res.json(users); + }); + + app.get('/users/:userId', async (req, res) => { + const userId = Number(req.params.userId); + const user = await User.findByPk(userId); + + if (!user) { + res.status(404).send('User not found'); + + return; + } + + res.json(user); + }); + + app.patch('/users/:userId', async (req, res) => { + const userId = Number(req.params.userId); + const user = await User.findByPk(userId); + + if (!user) { + res.status(404).send('User not found'); + + return; + } + + if (!req.body.name) { + res.status(400).send('Name is required'); + + return; + } + + await user.update({ + name: req.body.name, + }); + + res.json(user); + }); + + app.delete('/users/:userId', async (req, res) => { + const userId = Number(req.params.userId); + const user = await User.findByPk(userId); + + if (!user) { + res.status(404).send('User not found'); + + return; + } + + await user.destroy(); + + res.status(204).send(); + }); + + app.post('/expenses', async (req, res) => { + const { userId, spentAt, title, amount, categoryId, category, note } = + req.body; + + if (!userId || !spentAt || !title || amount === undefined) { + res.status(400).send('Required fields are missing'); + + return; + } + + const user = await User.findByPk(Number(userId)); + + if (!user) { + res.status(400).send('User not found'); + + return; + } + + const resolvedCategoryId = await getCategoryId({ categoryId, category }); + + if ( + (categoryId !== undefined || category !== undefined) && + !resolvedCategoryId + ) { + res.status(400).send('Category not found'); + + return; + } + + const newExpense = await Expense.create({ + userId: Number(userId), + spentAt, + title, + amount, + categoryId: resolvedCategoryId, + note, + }); + + const createdExpense = await Expense.findByPk(newExpense.id, { + include: expenseInclude, + }); + + res.status(201).json(serializeExpense(createdExpense)); + }); + + app.get('/expenses', async (req, res) => { + const { userId, from, to, categories } = req.query; + const where = {}; + const include = [...expenseInclude]; + + if (userId) { + where.userId = Number(userId); + } + + if (from || to) { + where.spentAt = {}; + } + + if (from) { + where.spentAt[Op.gte] = new Date(from); + } + + if (to) { + where.spentAt[Op.lte] = new Date(to); + } + + if (categories) { + include[0] = { + ...include[0], + required: true, + where: { + name: { + [Op.in]: categories.split(','), + }, + }, + }; + } + + const expenses = await Expense.findAll({ + where, + include, + }); + + res.json(expenses.map(serializeExpense)); + }); + + app.get('/expenses/:expenseId', async (req, res) => { + const expenseId = Number(req.params.expenseId); + const expense = await Expense.findByPk(expenseId, { + include: expenseInclude, + }); + + if (!expense) { + res.status(404).send('Expense not found'); + + return; + } + + res.json(serializeExpense(expense)); + }); + + app.patch('/expenses/:expenseId', async (req, res) => { + const expenseId = Number(req.params.expenseId); + const expense = await Expense.findByPk(expenseId, { + include: expenseInclude, + }); + + if (!expense) { + res.status(404).send('Expense not found'); + + return; + } + + const { userId, spentAt, title, amount, categoryId, category, note } = + req.body; + const updatedFields = {}; + + if (userId !== undefined) { + const user = await User.findByPk(Number(userId)); + + if (!user) { + res.status(400).send('User not found'); + + return; + } + + updatedFields.userId = Number(userId); + } + + if (spentAt !== undefined) { + updatedFields.spentAt = spentAt; + } + + if (title !== undefined) { + updatedFields.title = title; + } + + if (amount !== undefined) { + updatedFields.amount = amount; + } + + const resolvedCategoryId = await getCategoryId({ categoryId, category }); + + if (categoryId !== undefined || category !== undefined) { + if (!resolvedCategoryId && category !== null) { + res.status(400).send('Category not found'); + + return; + } + + updatedFields.categoryId = resolvedCategoryId; + } + + if (note !== undefined) { + updatedFields.note = note; + } + + await expense.update(updatedFields); + + const updatedExpense = await Expense.findByPk(expenseId, { + include: expenseInclude, + }); + + res.json(serializeExpense(updatedExpense)); + }); + + app.delete('/expenses/:expenseId', async (req, res) => { + const expenseId = Number(req.params.expenseId); + const expense = await Expense.findByPk(expenseId); + + if (!expense) { + res.status(404).send('Expense not found'); + + return; + } + + await expense.destroy(); + + res.status(204).send(); + }); + + app.post('/categories', async (req, res) => { + const { name } = req.body; + + if (!name) { + res.status(400).send('Name is required'); + + return; + } + + const category = await Category.create({ name }); + + res.status(201).json(category); + }); + + app.get('/categories', async (_req, res) => { + const categories = await Category.findAll(); + + res.json(categories); + }); + + app.get('/categories/:categoryId', async (req, res) => { + const categoryId = Number(req.params.categoryId); + const category = await Category.findByPk(categoryId); + + if (!category) { + res.status(404).send('Category not found'); + + return; + } + + res.json(category); + }); + + app.patch('/categories/:categoryId', async (req, res) => { + const categoryId = Number(req.params.categoryId); + const { name } = req.body; + const category = await Category.findByPk(categoryId); + + if (!category) { + res.status(404).send('Category not found'); + + return; + } + + if (!name) { + res.status(400).send('Name is required'); + + return; + } + + await category.update({ name }); + + res.json(category); + }); + + app.delete('/categories/:categoryId', async (req, res) => { + const categoryId = Number(req.params.categoryId); + const category = await Category.findByPk(categoryId); + + if (!category) { + res.status(404).send('Category not found'); + + return; + } + + await category.destroy(); + + res.status(204).send(); + }); + + return app; }; module.exports = { diff --git a/src/db.js b/src/db.js index 1ba3046cc..141cac7df 100644 --- a/src/db.js +++ b/src/db.js @@ -15,18 +15,13 @@ const { POSTGRES_DB, } = process.env; -/* - All credentials setted to default values (exsept password - it is exapmle) - replace if needed with your own -*/ - const sequelize = new Sequelize({ database: POSTGRES_DB || 'postgres', username: POSTGRES_USER || 'postgres', 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..e74bdf6d7 --- /dev/null +++ b/src/models/Category.model.js @@ -0,0 +1,28 @@ +'use strict'; + +const { sequelize } = require('../db.js'); +const { DataTypes } = require('sequelize'); + +const Category = sequelize.define( + 'Category', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + }, + { + tableName: 'categories', + timestamps: false, + }, +); + +module.exports = { + Category, +}; diff --git a/src/models/Expense.model.js b/src/models/Expense.model.js index 567e1c3e7..365404526 100644 --- a/src/models/Expense.model.js +++ b/src/models/Expense.model.js @@ -1,9 +1,48 @@ 'use strict'; const { sequelize } = require('../db.js'); +const { DataTypes } = require('sequelize'); const Expense = sequelize.define( - // your code goes here + 'Expense', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + field: 'user_id', + }, + spentAt: { + type: DataTypes.DATE, + allowNull: false, + field: 'spent_at', + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + amount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + categoryId: { + type: DataTypes.INTEGER, + allowNull: true, + field: 'category_id', + }, + note: { + type: DataTypes.STRING, + allowNull: true, + }, + }, + { + tableName: 'expenses', + timestamps: false, + }, ); module.exports = { diff --git a/src/models/User.model.js b/src/models/User.model.js index 61861c9e4..30fcce4dd 100644 --- a/src/models/User.model.js +++ b/src/models/User.model.js @@ -1,11 +1,33 @@ 'use strict'; const { sequelize } = require('../db.js'); +const { DataTypes } = require('sequelize'); const User = sequelize.define( - // your code goes here + 'User', + { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'users', + timestamps: false, + }, ); +User.beforeBulkDestroy((options) => { + if (options.truncate) { + options.cascade = true; + } +}); + module.exports = { User, }; diff --git a/src/models/models.js b/src/models/models.js index b43b55752..66f876a16 100644 --- a/src/models/models.js +++ b/src/models/models.js @@ -2,10 +2,34 @@ const { User } = require('./User.model'); const { Expense } = require('./Expense.model'); +const { Category } = require('./Category.model'); + +User.hasMany(Expense, { + foreignKey: 'userId', + as: 'expenses', + onDelete: 'CASCADE', +}); + +Category.hasMany(Expense, { + foreignKey: 'categoryId', + as: 'expenses', + onDelete: 'SET NULL', +}); + +Expense.belongsTo(User, { + foreignKey: 'userId', + as: 'user', +}); + +Expense.belongsTo(Category, { + foreignKey: 'categoryId', + as: 'category', +}); module.exports = { models: { User, Expense, + Category, }, };