diff --git a/.env b/.env new file mode 100644 index 000000000..c2b8eef92 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=123456 +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..508102e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,13 @@ }, "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", "eslint-plugin-node": "^11.1.0", "jest": "^29.7.0", + "nodemon": "^3.1.14", "prettier": "^3.3.2" } }, @@ -1474,10 +1475,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", @@ -2685,6 +2687,19 @@ "dev": true, "peer": true }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", @@ -2877,6 +2892,44 @@ "node": ">=10" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -4889,6 +4942,13 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -5013,6 +5073,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -7423,6 +7496,87 @@ "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8062,6 +8216,13 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -8159,6 +8320,19 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", @@ -8591,6 +8765,32 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sinon": { "version": "9.2.4", "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", @@ -8953,6 +9153,16 @@ "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==" }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -9166,6 +9376,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index bdf079250..bc9fd6e76 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "test:only": "mate-scripts test", "update": "mate-scripts update", "postinstall": "npm run update", - "test": "npm run lint && npm run test:only" + "test": "npm run lint && npm run test:only", + "dev": "nodemon src/index.js" }, "author": "Mate academy", "license": "GPL-3.0", @@ -23,12 +24,13 @@ }, "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", "eslint-plugin-node": "^11.1.0", "jest": "^29.7.0", + "nodemon": "^3.1.14", "prettier": "^3.3.2" }, "mateAcademy": { diff --git a/src/controllers/categoryController.js b/src/controllers/categoryController.js new file mode 100644 index 000000000..58396b0dd --- /dev/null +++ b/src/controllers/categoryController.js @@ -0,0 +1,73 @@ +/* eslint-disable no-console */ +const categoryService = require('../services/categoryService.js'); + +const getAll = async (req, res) => { + const categories = await categoryService.getAll(); + + res.json(categories); +}; + +const getOne = async (req, res) => { + const id = Number(req.params.id); + const category = await categoryService.getById(id); + + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + res.json(category); +}; + +const create = async (req, res) => { + const { name } = req.body; + + if (!name) { + return res.status(400).json({ message: 'Name is required' }); + } + + const category = await categoryService.create(name); + + res.status(201).json(category); +}; + +const remove = async (req, res) => { + const { id } = req.params; + const category = await categoryService.getById(id); + + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + await categoryService.remove(id); + + res.sendStatus(204); +}; + +const update = async (req, res) => { + const id = Number(req.params.id); + const { name } = req.body; + + if (id === undefined) { + return res.status(400).json({ message: 'Id is required' }); + } + + if (!name) { + return res.status(400).json({ message: 'Name is required' }); + } + + const category = await categoryService.update({ id, name }); + + if (!category) { + return res.status(404).json({ message: 'Category not found' }); + } + + res.json(category); +}; + +module.exports = { + getAll, + getOne, + create, + remove, + update, +}; diff --git a/src/controllers/expenseController.js b/src/controllers/expenseController.js new file mode 100644 index 000000000..e09663313 --- /dev/null +++ b/src/controllers/expenseController.js @@ -0,0 +1,158 @@ +/* eslint-disable max-len */ +/* eslint-disable no-console */ + +const expenseService = require('../services/expenseService.js'); +const userService = require('../services/userService.js'); +const categoryService = require('../services/categoryService.js'); +const { parseDate } = require('../helpers/dateHelper.js'); + +const getAll = async (req, res) => { + const { userId, categories, from, to } = req.query; + + const parsedFrom = from ? parseDate(from) : undefined; + const parsedTo = to ? parseDate(to) : undefined; + + if ((from && parsedFrom === null) || (to && parsedTo === null)) { + return res.status(400).send({ error: 'Invalid date format' }); + } + + const expenses = await expenseService.getAll( + userId, + categories, + parsedFrom, + parsedTo, + ); + + res.send(expenses); +}; + +const getOne = async (req, res) => { + const id = Number(req.params.id); + const expense = await expenseService.getById(id); + + if (!expense) { + return res.status(404).json({ message: 'Expense not found' }); + } + + res.send(expense); +}; + +const create = async (req, res) => { + const { userId, spentAt, title, amount, category, note } = req.body; + + if ( + userId === undefined || + !spentAt || + !title || + amount === undefined || + !category + ) { + return res.status(400).json({ message: 'No required data' }); + } + + if (parseDate(spentAt) === null) { + return res.status(400).json({ message: 'Invalid date format' }); + } + + if (!Number.isInteger(Number(amount))) { + return res.status(400).json({ message: 'Invalid amount format' }); + } + + const user = await userService.getById(userId); + + if (!user) { + return res + .status(400) + .json({ message: `User with id=${userId} not found` }); + } + + if (category !== undefined) { + const foundCategory = await categoryService.getByName(category); + + if (!foundCategory) { + return res + .status(400) + .json({ message: `Category ${category} not found` }); + } + } + + const newExpense = await expenseService.create({ + userId: Number(userId), + spentAt, + title, + amount: Number(amount), + category, + note, + }); + + res.status(201).json(newExpense); +}; + +const remove = async (req, res) => { + const id = Number(req.params.id); + const removed = await expenseService.remove(id); + + if (!removed) { + return res.status(404).json({ message: 'Expense not found' }); + } + + res.sendStatus(204); +}; + +const update = async (req, res) => { + const id = Number(req.params.id); + const { spentAt, title, amount, category, note } = req.body; + + const expense = await expenseService.getById(id); + + if (!expense) { + return res.status(404).json({ message: 'Expense not found' }); + } + + if ( + spentAt === undefined && + title === undefined && + amount === undefined && + category === undefined && + note === undefined + ) { + return res.status(400).json({ message: 'No data to update' }); + } + + if (spentAt && parseDate(spentAt) === null) { + return res.status(400).send({ error: 'Invalid date format' }); + } + + if (amount !== undefined && !Number.isInteger(amount)) { + return res.status(400).send({ error: 'Invalid amount format' }); + } + + if (category !== undefined) { + const foundCategory = await categoryService.getByName(category); + + if (!foundCategory) { + return res + .status(400) + .json({ message: `Category ${category} not found` }); + } + } + + const updatedExpense = await expenseService.update({ + id, + spentAt, + title, + amount: amount !== undefined ? Number(amount) : undefined, + category, + note, + }); + + res.status(200).json(updatedExpense); +}; + +module.exports = { + getAll, + getOne, + create, + remove, + update, +}; diff --git a/src/controllers/userController.js b/src/controllers/userController.js new file mode 100644 index 000000000..dfb67e155 --- /dev/null +++ b/src/controllers/userController.js @@ -0,0 +1,73 @@ +/* eslint-disable no-console */ +const userService = require('../services/userService.js'); + +const getAll = async (req, res) => { + const users = await userService.getAll(); + + res.json(users); +}; + +const getOne = async (req, res) => { + const id = Number(req.params.id); + const user = await userService.getById(id); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + res.json(user); +}; + +const create = async (req, res) => { + const { name } = req.body; + + if (!name) { + return res.status(400).json({ message: 'Name is required' }); + } + + const user = await userService.create(name); + + res.status(201).json(user); +}; + +const remove = async (req, res) => { + const { id } = req.params; + const user = await userService.getById(id); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + await userService.remove(id); + + res.sendStatus(204); +}; + +const update = async (req, res) => { + const id = Number(req.params.id); + const { name } = req.body; + + if (id === undefined) { + return res.status(400).json({ message: 'Id is required' }); + } + + if (!name) { + return res.status(400).json({ message: 'Name is required' }); + } + + const user = await userService.update({ id, name }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + res.json(user); +}; + +module.exports = { + getAll, + getOne, + create, + remove, + update, +}; diff --git a/src/createServer.js b/src/createServer.js index 1ea5542d6..7c22a485c 100644 --- a/src/createServer.js +++ b/src/createServer.js @@ -1,7 +1,22 @@ +/* eslint-disable no-console */ 'use strict'; +const express = require('express'); +const cors = require('cors'); +const userRouter = require('./routes/userRoute'); +const expenseRouter = require('./routes/expenseRoute'); +const categoryRouter = require('./routes/categoryRoute'); + const createServer = () => { - // your code goes here + const app = express(); + + app.use(cors()); + app.use(express.json()); + app.use('/users', userRouter); + app.use('/expenses', expenseRouter); + app.use('/categories', categoryRouter); + + return app; }; module.exports = { diff --git a/src/db.js b/src/db.js index 1ba3046cc..1fc8dfbff 100644 --- a/src/db.js +++ b/src/db.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ 'use strict'; const { Sequelize } = require('sequelize'); @@ -14,7 +15,6 @@ const { POSTGRES_PASSWORD, POSTGRES_DB, } = process.env; - /* All credentials setted to default values (exsept password - it is exapmle) 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 || '123456', }); module.exports = { diff --git a/src/helpers/dateHelper.js b/src/helpers/dateHelper.js new file mode 100644 index 000000000..d74ce5c78 --- /dev/null +++ b/src/helpers/dateHelper.js @@ -0,0 +1,17 @@ +const parseDate = (value) => { + if (!value) { + return undefined; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return null; // invalid date + } + + return date; +}; + +module.exports = { + parseDate, +}; diff --git a/src/models/Category.model.js b/src/models/Category.model.js new file mode 100644 index 000000000..0f95ee095 --- /dev/null +++ b/src/models/Category.model.js @@ -0,0 +1,22 @@ +'use strict'; + +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db.js'); + +const Category = sequelize.define( + 'Category', + { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'categories', + timestamps: false, + }, +); + +module.exports = { + Category, +}; diff --git a/src/models/Expense.model.js b/src/models/Expense.model.js index 567e1c3e7..d98b861d6 100644 --- a/src/models/Expense.model.js +++ b/src/models/Expense.model.js @@ -1,9 +1,39 @@ 'use strict'; +const { DataTypes } = require('sequelize'); const { sequelize } = require('../db.js'); const Expense = sequelize.define( - // your code goes here + 'Expense', + { + userId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + spentAt: { + type: DataTypes.DATE, + allowNull: false, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + }, + amount: { + type: DataTypes.INTEGER, + allowNull: false, + }, + category: { + type: DataTypes.STRING, + allowNull: false, + }, + note: { + type: DataTypes.STRING, + }, + }, + { + 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 = { diff --git a/src/models/models.js b/src/models/models.js index b43b55752..3b5ed5dc5 100644 --- a/src/models/models.js +++ b/src/models/models.js @@ -1,11 +1,13 @@ 'use strict'; -const { User } = require('./User.model'); -const { Expense } = require('./Expense.model'); +const { User } = require('./User.model.js'); +const { Expense } = require('./Expense.model.js'); +const { Category } = require('./Category.model.js'); module.exports = { models: { User, Expense, + Category, }, }; diff --git a/src/routes/categoryRoute.js b/src/routes/categoryRoute.js new file mode 100644 index 000000000..4165c406a --- /dev/null +++ b/src/routes/categoryRoute.js @@ -0,0 +1,16 @@ +const express = require('express'); +const categoryController = require('../controllers/categoryController.js'); + +const router = express.Router(); + +router.get('/', categoryController.getAll); + +router.get('/:id', categoryController.getOne); + +router.post('/', categoryController.create); + +router.delete('/:id', categoryController.remove); + +router.patch('/:id', categoryController.update); + +module.exports = router; diff --git a/src/routes/expenseRoute.js b/src/routes/expenseRoute.js new file mode 100644 index 000000000..a8bc6c4fd --- /dev/null +++ b/src/routes/expenseRoute.js @@ -0,0 +1,16 @@ +const express = require('express'); +const expenseController = require('../controllers/expenseController.js'); + +const router = express.Router(); + +router.get('/', expenseController.getAll); + +router.get('/:id', expenseController.getOne); + +router.post('/', expenseController.create); + +router.delete('/:id', expenseController.remove); + +router.patch('/:id', expenseController.update); + +module.exports = router; diff --git a/src/routes/userRoute.js b/src/routes/userRoute.js new file mode 100644 index 000000000..c5e51985d --- /dev/null +++ b/src/routes/userRoute.js @@ -0,0 +1,16 @@ +const express = require('express'); +const userController = require('../controllers/userController.js'); + +const router = express.Router(); + +router.get('/', userController.getAll); + +router.get('/:id', userController.getOne); + +router.post('/', userController.create); + +router.delete('/:id', userController.remove); + +router.patch('/:id', userController.update); + +module.exports = router; diff --git a/src/services/categoryService.js b/src/services/categoryService.js new file mode 100644 index 000000000..43bdc42cd --- /dev/null +++ b/src/services/categoryService.js @@ -0,0 +1,61 @@ +const { + models: { Category }, +} = require('../models/models'); + +const getAll = async () => { + const categories = await Category.findAll(); + + return categories.map((c) => c.toJSON()); +}; + +const getById = async (id) => { + const category = await Category.findByPk(id); + + return category ? category.toJSON() : null; +}; + +const getByName = async (name) => { + const category = await Category.findOne({ where: { name } }); + + return category ? category.toJSON() : null; +}; + +const create = async (name) => { + const category = await Category.create({ name }); + + return category.toJSON(); +}; + +const remove = async (id) => { + const result = await Category.destroy({ + where: { id }, + }); + + return result > 0; +}; + +const update = async ({ id, name }) => { + const category = await Category.findByPk(id); + + if (!category) { + return null; + } + + await category.update({ name }); + + return category.toJSON(); +}; + +const clear = async () => { + await Category.destroy({ where: {} }); +}; + +module.exports = { + getAll, + getById, + getByName, + create, + remove, + update, + clear, +}; diff --git a/src/services/expenseService.js b/src/services/expenseService.js new file mode 100644 index 000000000..2b32a582c --- /dev/null +++ b/src/services/expenseService.js @@ -0,0 +1,117 @@ +/* eslint-disable max-len */ +/* eslint-disable no-console */ +const { Op } = require('sequelize'); +const { + models: { Expense }, +} = require('../models/models'); + +const getAll = async (userId, categories, from, to) => { + const where = {}; + + if (userId !== undefined) { + where.userId = Number(userId); + } + + if (categories) { + const categoriesArray = Array.isArray(categories) + ? categories + : [categories]; + + where.category = { [Op.in]: categoriesArray }; + } + + if (from || to) { + where.spentAt = {}; + + if (from) { + where.spentAt[Op.gte] = new Date(from); + } + + if (to) { + where.spentAt[Op.lte] = new Date(to); + } + } + + const expenses = await Expense.findAll({ where }); + + return expenses.map((e) => e.toJSON()); +}; + +const getById = async (id) => { + const expense = await Expense.findByPk(id); + + return expense ? expense.toJSON() : null; +}; + +const create = async ({ userId, spentAt, title, amount, category, note }) => { + const expense = await Expense.create({ + userId, + spentAt, + title, + amount, + category, + note, + }); + + return expense.toJSON(); +}; + +const remove = async (id) => { + const result = await Expense.destroy({ + where: { id }, + }); + + return result > 0; +}; + +const update = async ({ id, spentAt, title, amount, category, note }) => { + const updates = {}; + + if (spentAt !== undefined) { + updates.spentAt = spentAt; + } + + if (title !== undefined) { + updates.title = title; + } + + if (amount !== undefined) { + updates.amount = amount; + } + + if (category !== undefined) { + updates.category = category; + } + + if (note !== undefined) { + updates.note = note; + } + + // Update returns an array: [affectedCount] + const [affectedCount] = await Expense.update(updates, { + where: { id }, + returning: true, + }); + + if (affectedCount === 0) { + return null; // not found + } + + // fetch updated expense + const updatedExpense = await Expense.findByPk(id); + + return updatedExpense.toJSON(); +}; + +const clear = async () => { + await Expense.destroy({ where: {} }); +}; + +module.exports = { + getAll, + getById, + create, + remove, + clear, + update, +}; diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 000000000..b7a53be69 --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,50 @@ +const { + models: { User }, +} = require('../models/models'); + +const getAll = async () => User.findAll(); + +const getById = async (id) => { + const user = await User.findByPk(id); + + return user ? user.toJSON() : null; +}; + +const create = async (name) => { + const user = await User.create({ name }); + + return user.toJSON(); +}; + +const remove = async (id) => { + const result = await User.destroy({ + where: { id }, + }); + + return result > 0; +}; + +const update = async ({ id, name }) => { + const user = await User.findByPk(id); + + if (!user) { + return null; + } + + await user.update({ name }); + + return user.toJSON(); +}; + +const clear = async () => { + await User.destroy({ where: {} }); +}; + +module.exports = { + getAll, + getById, + create, + remove, + update, + clear, +}; diff --git a/src/setup.js b/src/setup.js new file mode 100644 index 000000000..111113e7e --- /dev/null +++ b/src/setup.js @@ -0,0 +1,6 @@ +const { + models: { Expense, Category }, +} = require('./models/models'); + +Expense.sync({ force: true }); +Category.sync({ force: true }); diff --git a/tests/expense.test.js b/tests/expense.test.js index 2dde0c89f..15c91702e 100644 --- a/tests/expense.test.js +++ b/tests/expense.test.js @@ -5,7 +5,7 @@ const { sequelize } = require('../src/db'); const axios = require('axios'); const https = require('https'); const { - models: { User, Expense }, + models: { User, Expense, Category }, } = require('../src/models/models'); const { Agent } = require('http'); @@ -51,6 +51,12 @@ describe('Expense', () => { User.create({ name: 'John Doe' }), User.create({ name: 'Jane Doe' }), ]); + + await Category.bulkCreate([ + { name: 'Electronics' }, + { name: 'laptops' }, + { name: 'Food' }, + ]); }); beforeEach(async () => { @@ -112,6 +118,7 @@ describe('Expense', () => { title: 'Buy a new laptop', amount: 999, userId: user.id, + category: 'laptops', }; const response = await api.post('/expenses', data);