diff --git a/.github/ISSUE_TEMPLATE/issue-de-defecto.md b/.github/ISSUE_TEMPLATE/issue-de-defecto.md index fe6db441..e2db00f8 100644 --- a/.github/ISSUE_TEMPLATE/issue-de-defecto.md +++ b/.github/ISSUE_TEMPLATE/issue-de-defecto.md @@ -1,7 +1,7 @@ --- name: Issue de Defecto about: Registrar un defecto encontrado -title: MOD-RF00-ID00/Eliminar tarea cuando se elimina usario +title: Mรณdulo/RFXX/Detalle labels: '' assignees: '' --- diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index d672f92c..3951bbbe 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - develop + - MBI jobs: build: @@ -50,3 +51,4 @@ jobs: env: CI: true FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }} + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} diff --git a/package.json b/package.json index 6129d52c..17747eb4 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "firebase-admin": "^12.0.0", + "resend": "^3.2.0", "uuid": "^9.0.1", "zod": "^3.23.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9553a9a7..bdaf984e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: firebase-admin: specifier: ^12.0.0 version: 12.1.0 + resend: + specifier: ^3.2.0 + version: 3.2.0 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -361,6 +364,18 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} dev: true + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: false + /@jridgewell/resolve-uri@3.1.2: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -404,6 +419,17 @@ packages: fastq: 1.17.1 dev: true + /@one-ini/wasm@0.1.1: + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + dev: false + + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: false + optional: true + /@pkgr/core@0.1.1: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -512,6 +538,23 @@ packages: dev: false optional: true + /@react-email/render@0.0.12: + resolution: {integrity: sha512-S8WRv/PqECEi6x0QJBj0asnAb5GFtJaHlnByxLETLkgJjc76cxMYDH4r9wdbuJ4sjkcbpwP3LPnVzwS+aIjT7g==} + engines: {node: '>=18.0.0'} + dependencies: + html-to-text: 9.0.5 + js-beautify: 1.15.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@selderee/plugin-htmlparser2@0.11.0: + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + dev: false + /@sinonjs/commons@2.0.0: resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} dependencies: @@ -871,6 +914,11 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + dev: false + /abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -947,12 +995,22 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + /ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + dev: false + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} dependencies: color-convert: 2.0.1 + /ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + dev: false + /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -1083,7 +1141,6 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - dev: true /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1143,7 +1200,6 @@ packages: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} dependencies: balanced-match: 1.0.2 - dev: true /braces@3.0.2: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} @@ -1311,6 +1367,11 @@ packages: dev: false optional: true + /commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + dev: false + /comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} @@ -1320,6 +1381,13 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + dev: false + /content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1368,7 +1436,6 @@ packages: path-key: 3.1.1 shebang-command: 2.0.0 which: 2.0.2 - dev: true /data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} @@ -1465,6 +1532,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: false + /define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -1551,6 +1623,33 @@ packages: esutils: 2.0.3 dev: true + /dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + dev: false + + /domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + dev: false + + /domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + dependencies: + domelementtype: 2.3.0 + dev: false + + /domutils@3.1.0: + resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==} + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dev: false + /dotenv@16.4.5: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} @@ -1573,12 +1672,27 @@ packages: xtend: 4.0.2 dev: true + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: false + /ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} dependencies: safe-buffer: 5.2.1 dev: false + /editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.6.0 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -1586,6 +1700,10 @@ packages: /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: false + /encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -1603,6 +1721,11 @@ packages: dev: false optional: true + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: false + /es-abstract@1.23.3: resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} engines: {node: '>= 0.4'} @@ -2128,6 +2251,14 @@ packages: is-callable: 1.2.7 dev: true + /foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + dev: false + /form-data@2.5.1: resolution: {integrity: sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==} engines: {node: '>= 0.12'} @@ -2262,6 +2393,18 @@ packages: is-glob: 4.0.3 dev: true + /glob@10.4.1: + resolution: {integrity: sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==} + engines: {node: '>=16 || 14 >=14.18'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 3.1.2 + minimatch: 9.0.4 + minipass: 7.1.2 + path-scurry: 1.11.1 + dev: false + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} dependencies: @@ -2411,6 +2554,26 @@ packages: hasBin: true dev: true + /html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + dev: false + + /htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.1.0 + entities: 4.5.0 + dev: false + /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -2690,12 +2853,41 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - dev: true + + /jackspeak@3.1.2: + resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: false /jose@4.15.5: resolution: {integrity: sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==} dev: false + /js-beautify@1.15.1: + resolution: {integrity: sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==} + engines: {node: '>=14'} + hasBin: true + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.4.1 + js-cookie: 3.0.5 + nopt: 7.2.1 + dev: false + + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + dev: false + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: false + /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -2809,6 +3001,10 @@ packages: json-buffer: 3.0.1 dev: true + /leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + dev: false + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2886,12 +3082,24 @@ packages: resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} dev: false + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + /loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} dependencies: get-func-name: 2.0.2 dev: true + /lru-cache@10.2.2: + resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} + engines: {node: 14 || >=16.14} + dev: false + /lru-cache@4.0.2: resolution: {integrity: sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==} dependencies: @@ -3187,16 +3395,27 @@ packages: brace-expansion: 2.0.1 dev: true + /minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: false + /minimatch@9.0.4: resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} engines: {node: '>=16 || 14 >=14.17'} dependencies: brace-expansion: 2.0.1 - dev: true /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + /minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + dev: false + /mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} dev: false @@ -3297,6 +3516,14 @@ packages: engines: {node: '>= 6.13.0'} dev: false + /nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + dependencies: + abbrev: 2.0.0 + dev: false + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3404,6 +3631,13 @@ packages: callsites: 3.1.0 dev: true + /parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + dev: false + /parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3422,12 +3656,19 @@ packages: /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - dev: true /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: true + /path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + dependencies: + lru-cache: 10.2.2 + minipass: 7.1.2 + dev: false + /path-to-regexp@0.1.7: resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} dev: false @@ -3445,6 +3686,10 @@ packages: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true + /peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + dev: false + /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -3531,6 +3776,10 @@ packages: dependencies: '@prisma/engines': 5.13.0 + /proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + dev: false + /proto3-json-serializer@2.0.1: resolution: {integrity: sha512-8awBvjO+FwkMd6gNoGFZyqkHZXCFd54CIYTb6De7dPaufGJ2XNW+QUNqbMr8MaAocMdb+KpsD4rxEOaTBDCffA==} engines: {node: '>=14.0.0'} @@ -3626,6 +3875,23 @@ packages: strip-json-comments: 2.0.1 dev: false + /react-dom@18.2.0(react@18.2.0): + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.2 + dev: false + + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false + /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -3656,6 +3922,13 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + /resend@3.2.0: + resolution: {integrity: sha512-lDHhexiFYPoLXy7zRlJ8D5eKxoXy6Tr9/elN3+Vv7PkUoYuSSD1fpiIfa/JYXEWyiyN2UczkCTLpkT8dDPJ4Pg==} + engines: {node: '>=18'} + dependencies: + '@react-email/render': 0.0.12 + dev: false + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3742,6 +4015,18 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + dependencies: + loose-envify: 1.4.0 + dev: false + + /selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + dependencies: + parseley: 0.12.1 + dev: false + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3823,12 +4108,10 @@ packages: engines: {node: '>=8'} dependencies: shebang-regex: 3.0.0 - dev: true /shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - dev: true /side-channel@1.0.6: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} @@ -3839,6 +4122,11 @@ packages: get-intrinsic: 1.2.4 object-inspect: 1.13.1 + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: false + /simple-concat@1.0.1: resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} dev: false @@ -3921,6 +4209,15 @@ packages: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + dev: false + /string.prototype.trim@1.2.9: resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} engines: {node: '>= 0.4'} @@ -3961,6 +4258,13 @@ packages: dependencies: ansi-regex: 5.0.1 + /strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.0.1 + dev: false + /strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4361,7 +4665,6 @@ packages: hasBin: true dependencies: isexe: 2.0.0 - dev: true /workerpool@6.2.1: resolution: {integrity: sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==} @@ -4375,6 +4678,15 @@ packages: string-width: 4.2.3 strip-ansi: 6.0.1 + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + dev: false + /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} diff --git a/prisma/migrations/20240522045959_expenses_update_fields/migration.sql b/prisma/migrations/20240522045959_expenses_update_fields/migration.sql new file mode 100644 index 00000000..831297b5 --- /dev/null +++ b/prisma/migrations/20240522045959_expenses_update_fields/migration.sql @@ -0,0 +1,22 @@ +/* + Warnings: + + - You are about to drop the column `id_file` on the `expense` table. All the data in the column will be lost. + - You are about to drop the column `total_amount` on the `expense_report` table. All the data in the column will be lost. + - You are about to drop the `file` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `title` to the `expense_report` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "expense" DROP CONSTRAINT "expense_id_file_fkey"; + +-- AlterTable +ALTER TABLE "expense" DROP COLUMN "id_file", +ADD COLUMN "url_file" VARCHAR(512); + +-- AlterTable +ALTER TABLE "expense_report" DROP COLUMN "total_amount", +ADD COLUMN "title" VARCHAR(70) NOT NULL; + +-- DropTable +DROP TABLE "file"; \ No newline at end of file diff --git a/prisma/migrations/20240524081944_add_missing_fields_for_expense_and_expense_report/migration.sql b/prisma/migrations/20240524081944_add_missing_fields_for_expense_and_expense_report/migration.sql new file mode 100644 index 00000000..b1550c8d --- /dev/null +++ b/prisma/migrations/20240524081944_add_missing_fields_for_expense_and_expense_report/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "expense" ADD COLUMN "supplier" VARCHAR(70); + +-- AlterTable +ALTER TABLE "expense_report" ADD COLUMN "url_voucher" VARCHAR(512); \ No newline at end of file diff --git a/prisma/migrations/20240524224602_delete_company_constraints/migration.sql b/prisma/migrations/20240524224602_delete_company_constraints/migration.sql new file mode 100644 index 00000000..0d41e4f6 --- /dev/null +++ b/prisma/migrations/20240524224602_delete_company_constraints/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "project" DROP CONSTRAINT "project_id_company_fkey"; + +-- DropForeignKey +ALTER TABLE "task" DROP CONSTRAINT "task_id_project_fkey"; + +-- AddForeignKey +ALTER TABLE "project" ADD CONSTRAINT "project_id_company_fkey" FOREIGN KEY ("id_company") REFERENCES "company"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "task" ADD CONSTRAINT "task_id_project_fkey" FOREIGN KEY ("id_project") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/prisma/migrations/20240531222406_delete_notifications/migration.sql b/prisma/migrations/20240531222406_delete_notifications/migration.sql new file mode 100644 index 00000000..8dcb1932 --- /dev/null +++ b/prisma/migrations/20240531222406_delete_notifications/migration.sql @@ -0,0 +1,31 @@ +/* + Warnings: + + - You are about to drop the column `device_token` on the `employee` table. All the data in the column will be lost. + - You are about to drop the column `total_amount` on the `expense_report` table. All the data in the column will be lost. + - You are about to drop the `comment` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `employee_notification` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `notification` table. If the table is not empty, all the data it contains will be lost. + - Added the required column `title` to the `expense_report` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "comment" DROP CONSTRAINT "comment_id_employee_fkey"; + +-- DropForeignKey +ALTER TABLE "employee_notification" DROP CONSTRAINT "employee_notification_id_employee_fkey"; + +-- DropForeignKey +ALTER TABLE "employee_notification" DROP CONSTRAINT "employee_notification_id_notification_fkey"; + +-- AlterTable +ALTER TABLE "employee" DROP COLUMN "device_token"; + +-- DropTable +DROP TABLE "comment"; + +-- DropTable +DROP TABLE "employee_notification"; + +-- DropTable +DROP TABLE "notification"; diff --git a/prisma/migrations/20240602034740_fix_expense_report_fields/migration.sql b/prisma/migrations/20240602034740_fix_expense_report_fields/migration.sql new file mode 100644 index 00000000..4dfb8e7c --- /dev/null +++ b/prisma/migrations/20240602034740_fix_expense_report_fields/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `category` on the `expense` table. All the data in the column will be lost. + - You are about to drop the column `justification` on the `expense` table. All the data in the column will be lost. + - You are about to drop the column `status` on the `expense` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `expense_report` table. All the data in the column will be lost. + +*/ + +-- AlterTable +ALTER TABLE "expense" DROP COLUMN "category", +DROP COLUMN "justification", +DROP COLUMN "status"; + +-- AlterTable +ALTER TABLE "expense_report" DROP COLUMN "description"; diff --git a/prisma/migrations/20240604045735_change_on_delete_actions_expense_entities/migration.sql b/prisma/migrations/20240604045735_change_on_delete_actions_expense_entities/migration.sql new file mode 100644 index 00000000..4cf3052b --- /dev/null +++ b/prisma/migrations/20240604045735_change_on_delete_actions_expense_entities/migration.sql @@ -0,0 +1,14 @@ +-- DropForeignKey +ALTER TABLE "expense" DROP CONSTRAINT "expense_id_report_fkey"; + +-- DropForeignKey +ALTER TABLE "expense_report" DROP CONSTRAINT "expense_report_id_employee_fkey"; + +-- AlterTable +ALTER TABLE "expense_report" ALTER COLUMN "id_employee" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "expense" ADD CONSTRAINT "expense_id_report_fkey" FOREIGN KEY ("id_report") REFERENCES "expense_report"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "expense_report" ADD CONSTRAINT "expense_report_id_employee_fkey" FOREIGN KEY ("id_employee") REFERENCES "employee"("id") ON DELETE SET NULL ON UPDATE NO ACTION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0dfb9389..3fd03113 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,5 +1,6 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" + previewFeatures = ["omitApi"] } datasource db { @@ -7,15 +8,6 @@ datasource db { url = env("DATABASE_URL") } -model comment { - id String @id @db.Uuid - message String @db.VarChar(255) - created_at DateTime @default(now()) @db.Timestamp(6) - updated_at DateTime? @updatedAt @db.Timestamp(6) - id_employee String @db.Uuid - employee employee @relation(fields: [id_employee], references: [id], onDelete: NoAction, onUpdate: NoAction) -} - model company { id String @id @db.Uuid name String @db.VarChar(255) @@ -64,25 +56,12 @@ model employee { updated_at DateTime? @updatedAt @db.Timestamp(6) id_department String? @db.Uuid id_role String @db.Uuid - device_token String? @db.VarChar(255) - comment comment[] department department? @relation(fields: [id_department], references: [id], onDelete: NoAction, onUpdate: NoAction) role role @relation(fields: [id_role], references: [id], onDelete: NoAction, onUpdate: NoAction) - employee_notification employee_notification[] employee_task employee_task[] expense_report expense_report[] } -model employee_notification { - id String @id @db.Uuid - created_at DateTime @default(now()) @db.Timestamp(6) - updated_at DateTime? @updatedAt @db.Timestamp(6) - id_employee String @db.Uuid - id_notification String @db.Uuid - employee employee @relation(fields: [id_employee], references: [id], onDelete: NoAction, onUpdate: NoAction) - notification notification @relation(fields: [id_notification], references: [id], onDelete: NoAction, onUpdate: NoAction) -} - model employee_task { id String @id @db.Uuid created_at DateTime @default(now()) @db.Timestamp(6) @@ -96,41 +75,28 @@ model employee_task { model expense { id String @id @db.Uuid title String @db.VarChar(70) - justification String @db.VarChar(255) + supplier String? @db.VarChar(70) total_amount Decimal @db.Decimal(18, 2) - status String? @db.VarChar(256) - category String? @db.VarChar(70) date DateTime @db.Date created_at DateTime @default(now()) @db.Timestamp(6) updated_at DateTime? @updatedAt @db.Timestamp(6) id_report String @db.Uuid - id_file String? @db.Uuid - file file? @relation(fields: [id_file], references: [id], onDelete: NoAction, onUpdate: NoAction) - expense_report expense_report @relation(fields: [id_report], references: [id], onDelete: NoAction, onUpdate: NoAction) + url_file String? @db.VarChar(512) + expense_report expense_report @relation(fields: [id_report], references: [id], onDelete: Cascade, onUpdate: NoAction) } model expense_report { - id String @id @db.Uuid - description String @db.VarChar(255) - start_date DateTime @db.Date - end_date DateTime? @db.Date - status String? @db.VarChar(256) - total_amount Decimal? @db.Decimal(8, 2) - created_at DateTime @default(now()) @db.Timestamp(6) - updated_at DateTime? @updatedAt @db.Timestamp(6) - id_employee String @db.Uuid - expense expense[] - employee employee @relation(fields: [id_employee], references: [id], onDelete: NoAction, onUpdate: NoAction) -} - -model file { id String @id @db.Uuid - description String? @db.VarChar(256) - format String @default(".zip") @db.VarChar(256) - url String @unique @db.VarChar(256) + title String @db.VarChar(70) + start_date DateTime @db.Date + end_date DateTime? @db.Date + status String? @db.VarChar(256) created_at DateTime @default(now()) @db.Timestamp(6) updated_at DateTime? @updatedAt @db.Timestamp(6) + id_employee String? @db.Uuid + url_voucher String? @db.VarChar(512) expense expense[] + employee employee? @relation(fields: [id_employee], references: [id], onDelete: SetNull, onUpdate: NoAction) } model form { @@ -189,15 +155,6 @@ model form { company company[] } -model notification { - id String @id @db.Uuid - title String @db.VarChar(70) - body String @db.VarChar(256) - created_at DateTime @default(now()) @db.Timestamp(6) - updated_at DateTime? @updatedAt @db.Timestamp(6) - employee_notification employee_notification[] -} - model project { id String @id @db.Uuid name String @db.VarChar(70) @@ -216,7 +173,7 @@ model project { updated_at DateTime? @updatedAt @db.Timestamp(6) id_company String @db.Uuid area String? @db.VarChar(256) - company company @relation(fields: [id_company], references: [id], onDelete: NoAction, onUpdate: NoAction) + company company @relation(fields: [id_company], references: [id], onDelete: Cascade, onUpdate: NoAction) task task[] } @@ -241,5 +198,5 @@ model task { updated_at DateTime? @updatedAt @db.Timestamp(6) id_project String @db.Uuid employee_task employee_task[] - project project @relation(fields: [id_project], references: [id], onDelete: NoAction, onUpdate: NoAction) + project project @relation(fields: [id_project], references: [id], onDelete: Cascade, onUpdate: NoAction) } diff --git a/src/api/controllers/company.controller.ts b/src/api/controllers/company.controller.ts index 26ef1f41..83e5d9c8 100644 --- a/src/api/controllers/company.controller.ts +++ b/src/api/controllers/company.controller.ts @@ -4,8 +4,8 @@ import { CompanyService } from '../../core/app/services/company.service'; import { CompanyEntity } from '../../core/domain/entities/company.entity'; import { companySchema, updateCompanySchema } from '../validators/company.validator'; -const reportSchema = z.object({ - id: z.string().uuid().min(1, { message: 'clientId cannot be empty' }), +const idSchema = z.object({ + id: z.string().uuid(), }); /** @@ -19,7 +19,7 @@ const reportSchema = z.object({ async function getUnique(req: Request, res: Response) { try { - const { id } = reportSchema.parse({ id: req.params.id }); + const { id } = idSchema.parse({ id: req.params.id }); const data = await CompanyService.findById(id); res.status(200).json({ data }); @@ -112,4 +112,22 @@ async function getUnarchived(_: Request, res: Response) { } } -export const CompanyController = { getUnique, getAll, create, updateClient, getUnarchived }; +/** + * Finds deleteCompany + * + * @param {Request} req + * @param {Response} res + * @returns {Promise} + * @throws {Error} + */ + +async function deleteCompany(req: Request, res: Response) { + try { + const { id } = idSchema.parse({ id: req.params.id }); + await CompanyService.deleteCompanyById(id); + res.status(200).send(); + } catch (error: any) { + res.status(500).json({ message: 'Internal server error occurred.' }); + } +} +export const CompanyController = { getUnique, getAll, create, updateClient, getUnarchived, deleteCompany }; diff --git a/src/api/controllers/employee.controller.ts b/src/api/controllers/employee.controller.ts index 978fb682..a4e4e3b8 100644 --- a/src/api/controllers/employee.controller.ts +++ b/src/api/controllers/employee.controller.ts @@ -45,7 +45,7 @@ async function signIn(req: Request, res: Response) { * @param req * @param res */ -async function getAllEmployees(req: Request, res: Response) { +async function getAllEmployees(_: Request, res: Response) { try { const employees = await EmployeeService.getAllEmployees(); res.status(200).json({ data: employees }); diff --git a/src/api/controllers/expense.controller.ts b/src/api/controllers/expense.controller.ts new file mode 100644 index 00000000..efcf5e3c --- /dev/null +++ b/src/api/controllers/expense.controller.ts @@ -0,0 +1,153 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { Request, Response } from 'express'; +import { z } from 'zod'; +import { ExpenseService } from '../../core/app/services/expense.service'; +import { ExpenseReportStatus } from '../../utils/enums'; + +const idSchema = z.object({ + id: z.string().uuid(), +}); + +const createExpenseReportSchema = z.object({ + title: z.string().max(70).min(1), + status: z.nativeEnum(ExpenseReportStatus), + startDate: z.coerce.date(), + expenses: z + .array( + z.object({ + title: z.string().max(70).min(1), + supplier: z.string().max(70).min(1).nullable(), + totalAmount: z.number().transform(value => new Decimal(value)), + date: z.coerce.date(), + urlFile: z.string().max(512).min(1).nullable(), + }) + ) + .max(30), +}); + +/** + * A function that handles the request to obtain expense reports + * ADMIN && ACCOUNTING can see every report and their author + * LEGAL can only see their reports + * + * @param req HTTP Request + * @param res Server response + */ +async function getExpenses(req: Request, res: Response) { + try { + const data = await ExpenseService.getExpenses(req.body.auth.email); + if (data) { + res.status(200).json(data); + } + } catch (error: any) { + res.status(500).json({ message: error.message }); + } +} + +/** + * A function that handles the request to obtain expense report details by its id + * @param req HTTP Request + * @param res Server response + */ +async function getReportById(req: Request, res: Response) { + try { + const { id } = idSchema.parse({ id: req.params.id }); + const expenseDetails = await ExpenseService.getReportById(id, req.body.auth.email); + if (expenseDetails) { + res.status(200).json(expenseDetails); + } + } catch (error: any) { + if (error.message === 'Unauthorized employee') { + res.status(403).json({ message: error.message }); + } else { + res.status(500).json({ message: error.message }); + } + } +} + +/** + * A function to delete an expense report + * @param req HTTP Request + * @param res Server response + * + */ + +async function deleteReport(req: Request, res: Response) { + try { + const { id } = idSchema.parse({ id: req.params.id }); + const expenseReport = await ExpenseService.deleteReport(id); + res.status(200).json({ data: expenseReport }); + } catch (error: any) { + res.status(500).json({ message: 'Internal server error occurred' }); + } +} + +/** + * @description A function that updates the expense status + * @param req HTTP Request + * @param res Server response + */ +async function updateStatusById(req: Request, res: Response) { + try { + const { id } = idSchema.parse({ id: req.params.id }); + const { status } = req.body; + + const updatedExpense = await ExpenseService.updateStatusById(id, status); + res.status(200).json(updatedExpense); + } catch (error: any) { + if (error.message === 'Unauthorized employee') { + res.status(403).json({ message: error.message }); + } else { + res.status(500).json({ message: error.message }); + } + } +} + +/** + * @description A function that updates the expense payment file (url_voucher) + * @param req HTTP Request + * @param res Server response + */ +async function updatePaymentFileUrlById(req: Request, res: Response) { + try { + const { id } = idSchema.parse({ id: req.params.id }); + const { urlVoucher } = req.body; + + const updatedExpense = await ExpenseService.updatePaymentFileUrlById(id, urlVoucher); + res.status(200).json(updatedExpense); + } catch (error: any) { + if (error.message === 'Unauthorized employee') { + res.status(403).json({ message: error.message }); + } else { + res.status(500).json({ message: error.message }); + } + } +} + +/** + * A function that handles the request to create a new expense report + * + * @param req HTTP Request + * @param res Server response + * + * @returns {Promise} + */ + +async function createExpenseReport(req: Request, res: Response) { + try { + const parsedExpenseSchema = createExpenseReportSchema.parse(req.body); + const data = await ExpenseService.createExpenseReport(req.body.auth.email, parsedExpenseSchema); + res.status(200).json(data); + } catch (error: any) { + res.status(500).json({ message: error.message }); + } +} + +export const ExpenseController = { + getExpenses, + getReportById, + createExpenseReport, + updateStatusById, + updatePaymentFileUrlById, + deleteReport, +}; diff --git a/src/api/controllers/notification.controller.ts b/src/api/controllers/notification.controller.ts index 505434ac..b480368c 100644 --- a/src/api/controllers/notification.controller.ts +++ b/src/api/controllers/notification.controller.ts @@ -1,104 +1,50 @@ import { Request, Response } from 'express'; -import * as z from 'zod'; +import { z } from 'zod'; import { NotificationService } from '../../core/app/services/notification.service'; - -/** - * @brief Schema for the user device token - * - * @param email: string - Email of the employee - * @param deviceToken: string - Token of the device - * - * @return {z.ZodObject} - The schema for the user device token - */ - -const userToken = z.object({ - email: z.string().email(), - deviceToken: z.string(), -}); - -/** - * @brief Schema for notification - * - * @param id: uuid - Id of notification - * @param title: string - Title of notification - * @param body: string - Body of notification - * @param createdAt: Date - Date of creation of notification - * @param updatedAt: Date - Most recent date of update of notification - * - * @return {z.ZodObject} - The schema for notification - */ +import { SupportedDepartments } from '../../utils/enums'; +import { zodValidUuid } from '../validators/zod.validator'; const notificationSchema = z.object({ - id: z.string().uuid(), - title: z.string(), - body: z.string(), - createdAt: z.coerce.date(), - updatedAt: z.coerce.date().optional(), + departmentTitle: z.nativeEnum(SupportedDepartments).refine( + val => { + return val === SupportedDepartments.ACCOUNTING || val === SupportedDepartments.LEGAL; + }, + { + message: "departmentTitle must be either 'Accounting' or 'Legal'", + } + ), + projectId: zodValidUuid, }); /** - * @brief Function that calls the service to save the token of the employee - * - * @param req: Request - * @param res: Response - * - * @return status 200 if the token was saved successfully, 500 if an error occurred + * Method in charge of sending a notification to a department when the event is trigger. + * @param req {Request} - The request object + * @param res {Response} - The response object + * @returns */ - -async function saveToken(req: Request, res: Response) { +async function sendNotificationToDepartment(req: Request, res: Response) { try { - const data = { - email: req.body.auth.email, - deviceToken: req.body.deviceToken, - }; - const parsed = userToken.parse(data); - const deviceToken = await NotificationService.saveToken(parsed); - - res.status(200).json({ message: 'Device Token registered successfully.', deviceToken }); - } catch (error: any) { - res.status(500).json({ message: 'Internal server error occurred.', error }); - } -} + const parsed = notificationSchema.parse(req.body); -/** - * Creates a new notification and sends it as a response - * - * @param {Request} req - The request object - * @param {Response} res - The response object - * - * @returns {Promise} A promise that resolves to void - * - * @throws {Error} If an unexpected error occurs - */ + const response = await NotificationService.sendProjectStatusUpdateNotification( + req.body.auth.email, + parsed.departmentTitle, + parsed.projectId + ); -async function createNotification(req: Request, res: Response) { - try { - notificationSchema.parse(req.body); - const data = await NotificationService.createNotification(req.body); - res.status(201).json({ data }); - } catch (error: any) { - res.status(500).json({ message: error.message }); - } -} + if (response === 'Cannot send email to the same department') { + return res.status(400).json({ message: 'Cannot send email to the same department' }); + } -/** - * Gets the notificacion data from the service and sends it as a response - * - * @param {Request} req - The request object - * @param {Response} res - The response object - * - * @returns {Promise} A promise that resolves to void - * - * @throws {Error} If an unexpected error occurs - */ + if (response === 'Failed to send email') { + return res.status(400).json({ message: 'Failed to send email' }); + } -async function getAllNotifications(_: Request, res: Response) { - try { - const data = await NotificationService.getAllNotifications(); - res.status(200).json({ data }); + res.status(200).json({ message: 'Notification sent successfully' }); } catch (error: any) { - res.status(500).json({ message: error.message }); + console.error(error); // TODO: Delete this + res.status(500).json({ message: `Internal server error: ${error}` }); } } -export const NotificationController = { saveToken, createNotification, getAllNotifications }; +export const NotificationController = { sendNotificationToDepartment }; diff --git a/src/api/controllers/project.controller.ts b/src/api/controllers/project.controller.ts index 977bfb81..3119c8d8 100644 --- a/src/api/controllers/project.controller.ts +++ b/src/api/controllers/project.controller.ts @@ -211,6 +211,21 @@ async function updateProjectStatus(req: Request, res: Response) { } } +/** A function that deletes a project + * @param req HTTP Request + * @param res Server response + */ + +async function deleteProject(req: Request, res: Response) { + try { + const id = req.params.id; + const project = await ProjectService.deleteProjectById(id); + res.status(200).json({ data: project }); + } catch (error: any) { + res.status(500).json({ message: 'Internal server error occurred.' }); + } +} + export const ProjectController = { getReportData, createProject, @@ -219,4 +234,5 @@ export const ProjectController = { updateProject, updateProjectStatus, getDepartmentProjects, + deleteProject, }; diff --git a/src/api/controllers/task.controller.ts b/src/api/controllers/task.controller.ts index 0941f665..6aa4d158 100644 --- a/src/api/controllers/task.controller.ts +++ b/src/api/controllers/task.controller.ts @@ -56,11 +56,10 @@ const idProjectSchema = z.object({ */ function validateTaskData(data: BareboneTask) { const bodyTask = taskSchema.parse(data); - const status = data.status as TaskStatus; return { ...bodyTask, - status: status, + status: data.status as TaskStatus, workedHours: Number(bodyTask.workedHours) || 0.0, endDate: bodyTask.endDate || null, idEmployee: bodyTask.idEmployee, @@ -81,10 +80,7 @@ function validateTaskData(data: BareboneTask) { async function createTask(req: Request, res: Response) { try { const validatedTaskData = validateTaskData(req.body); - const payloadTask = await TaskService.createTask({ - ...validatedTaskData, - idEmployee: validatedTaskData.idEmployee || '', - }); + const payloadTask = await TaskService.createTask(validatedTaskData, req.body.auth.email); if (!payloadTask) { return res.status(409).json({ message: 'Task already exists' }); diff --git a/src/api/routes/company.routes.ts b/src/api/routes/company.routes.ts index b404b90d..5c9c5186 100644 --- a/src/api/routes/company.routes.ts +++ b/src/api/routes/company.routes.ts @@ -11,5 +11,6 @@ router.get('/unarchived', CompanyController.getUnarchived); router.get('/:id', CompanyController.getUnique); router.post('/new', CompanyController.create); router.put('/:id', CompanyController.updateClient); +router.delete('/delete/:id', checkAuthRole([SupportedRoles.ADMIN]), CompanyController.deleteCompany); export { router as CompanyRouter }; diff --git a/src/api/routes/expense.routes.ts b/src/api/routes/expense.routes.ts new file mode 100644 index 00000000..d66fd981 --- /dev/null +++ b/src/api/routes/expense.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { SupportedRoles } from '../../utils/enums'; +import { ExpenseController } from '../controllers/expense.controller'; +import { checkAuthRole } from '../middlewares/rbac.middleware'; + +const router = Router(); + +router.use(checkAuthRole([SupportedRoles.ADMIN, SupportedRoles.ACCOUNTING, SupportedRoles.LEGAL])); +router.get('/', ExpenseController.getExpenses); +router.post('/create', ExpenseController.createExpenseReport); +router.get('/report/:id', ExpenseController.getReportById); +router.delete('/report/delete/:id', ExpenseController.deleteReport); + +router.use(checkAuthRole([SupportedRoles.ADMIN, SupportedRoles.ACCOUNTING])); +router.put('/report/status/:id', ExpenseController.updateStatusById); +router.put('/report/payment/:id', ExpenseController.updatePaymentFileUrlById); + +export { router as ExpenseRouter }; diff --git a/src/api/routes/index.routes.ts b/src/api/routes/index.routes.ts index 52a8b38d..ab0dabf8 100644 --- a/src/api/routes/index.routes.ts +++ b/src/api/routes/index.routes.ts @@ -4,6 +4,7 @@ import { AdminRouter } from './admin.routes'; import { CompanyRouter } from './company.routes'; import { DepartmentRouter } from './department.routes'; import { EmployeeRouter } from './employee.routes'; +import { ExpenseRouter } from './expense.routes'; import { HomeRouter } from './home.routes'; import { NotificationRouter } from './notification.routes'; import { ProjectRouter } from './project.routes'; @@ -13,6 +14,9 @@ const baseRouter = Router(); const V1_PATH = '/api/v1'; +// Health check +baseRouter.use(`${V1_PATH}/health`, (_req, res) => res.send('OK')); + baseRouter.use(checkAuthToken); //Auth @@ -39,7 +43,7 @@ baseRouter.use(`${V1_PATH}/company`, CompanyRouter); // Notification baseRouter.use(`${V1_PATH}/notification`, NotificationRouter); -// Health check -baseRouter.use(`${V1_PATH}/health`, (_req, res) => res.send('OK')); +// Expense +baseRouter.use(`${V1_PATH}/expense`, ExpenseRouter); export { baseRouter }; diff --git a/src/api/routes/notification.routes.ts b/src/api/routes/notification.routes.ts index ad3a5f22..d4fc0754 100644 --- a/src/api/routes/notification.routes.ts +++ b/src/api/routes/notification.routes.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; +import { SupportedRoles } from '../../utils/enums'; import { NotificationController } from '../controllers/notification.controller'; +import { checkAuthRole } from '../middlewares/rbac.middleware'; const router = Router(); -router.get('/', NotificationController.getAllNotifications); -router.post('/token', NotificationController.saveToken); -router.post('/create', NotificationController.createNotification); +router.use(checkAuthRole([SupportedRoles.ACCOUNTING, SupportedRoles.LEGAL, SupportedRoles.ADMIN])); + +router.post('/send/deparment', NotificationController.sendNotificationToDepartment); export { router as NotificationRouter }; diff --git a/src/api/routes/project.routes.ts b/src/api/routes/project.routes.ts index 3e02c60b..4683647b 100644 --- a/src/api/routes/project.routes.ts +++ b/src/api/routes/project.routes.ts @@ -13,5 +13,6 @@ router.get('/:clientId', ProjectController.getProjectsClient); router.post('/create', ProjectController.createProject); router.put('/edit/:id', ProjectController.updateProject); router.put('/details/:id', ProjectController.updateProjectStatus); +router.delete('/delete/:id', ProjectController.deleteProject); export { router as ProjectRouter }; diff --git a/src/core/app/interfaces/project.interface.ts b/src/core/app/interfaces/project.interface.ts index daa51d8a..23309ed9 100644 --- a/src/core/app/interfaces/project.interface.ts +++ b/src/core/app/interfaces/project.interface.ts @@ -19,3 +19,17 @@ export interface UpdateProjectBody { createdAt: Date; updatedAt?: Date | null; } + +export interface CreateProjectData { + name: string; + matter: string | null; + description: string | null; + area: string; + status: string; + category: string; + endDate: Date | null; + idCompany: string; + isChargeable: boolean; + periodicity: string | null; + startDate: Date; +} diff --git a/src/core/app/services/__tests__/company.service.test.ts b/src/core/app/services/__tests__/company.service.test.ts index 07e50bc5..25a16c2d 100644 --- a/src/core/app/services/__tests__/company.service.test.ts +++ b/src/core/app/services/__tests__/company.service.test.ts @@ -14,6 +14,7 @@ describe('CompanyService', () => { let findCompanyByIdStub: sinon.SinonStub; let archiveClientdStub: sinon.SinonStub; let getArchivedStatusStub: sinon.SinonStub; + let deleteCompanyByIdStub: sinon.SinonStub; beforeEach(() => { findAllCompaniesStub = sinon.stub(CompanyRepository, 'findAll'); @@ -21,6 +22,7 @@ describe('CompanyService', () => { findCompanyByIdStub = sinon.stub(CompanyRepository, 'findById'); archiveClientdStub = sinon.stub(CompanyRepository, 'archiveClient'); getArchivedStatusStub = sinon.stub(CompanyRepository, 'getArchivedStatus'); + deleteCompanyByIdStub = sinon.stub(CompanyRepository, 'deleteCompanyById'); }); afterEach(() => { @@ -85,6 +87,27 @@ describe('CompanyService', () => { expect(updatedCompany.archived).to.be.true; }); + describe('deleteCompanyById', () => { + const companyId = randomUUID(); + const nonExistentCompanyId = randomUUID(); + + it('should delete a company from the repository', async () => { + deleteCompanyByIdStub.withArgs(companyId).resolves(); + + await CompanyService.deleteCompanyById(companyId); + + expect(deleteCompanyByIdStub.calledOnceWith(companyId)).to.be.true; + }); + + it('should throw an error if the company does not exist', async () => { + deleteCompanyByIdStub.withArgs(nonExistentCompanyId).rejects(new Error('Company not found')); + + await expect(CompanyService.deleteCompanyById(nonExistentCompanyId)).to.be.rejectedWith('Company not found'); + + expect(deleteCompanyByIdStub.calledOnceWith(nonExistentCompanyId)).to.be.true; + }); + }); + it('should get a single company', async () => { const idCompany1 = randomUUID(); const company = { diff --git a/src/core/app/services/__tests__/expense.service.test.ts b/src/core/app/services/__tests__/expense.service.test.ts new file mode 100644 index 00000000..855797f0 --- /dev/null +++ b/src/core/app/services/__tests__/expense.service.test.ts @@ -0,0 +1,510 @@ +import { faker } from '@faker-js/faker'; +import { expect } from 'chai'; +import { randomUUID } from 'crypto'; +import { default as Sinon, default as sinon } from 'sinon'; +import { ExpenseReportStatus, SupportedRoles } from '../../../../utils/enums'; + +import { Decimal } from '@prisma/client/runtime/library'; +import { EmployeeRepository } from '../../../infra/repositories/employee.repository'; +import { ExpenseRepository } from '../../../infra/repositories/expense.repository'; +import { RoleRepository } from '../../../infra/repositories/role.repository'; +import { ExpenseService } from '../expense.service'; + +describe('ExpenseService', () => { + let findEmployeeByEmailStub: Sinon.SinonStub; + let findRoleByEmailStub: Sinon.SinonStub; + let findExpenseByIdStub: Sinon.SinonStub; + let findExpenseByEmployeeIdStub: Sinon.SinonStub; + let findAllExpensesStub: Sinon.SinonStub; + let deleteExpenseReportStub: Sinon.SinonStub; + let createExpenseReportStub: Sinon.SinonStub; + let createExpenseStub: Sinon.SinonStub; + let updateStatusByIdStub: Sinon.SinonStub; + let updatePaymentFileUrlByIdStub: Sinon.SinonStub; + + beforeEach(() => { + findEmployeeByEmailStub = sinon.stub(EmployeeRepository, 'findByEmail'); + findRoleByEmailStub = sinon.stub(RoleRepository, 'findByEmail'); + findExpenseByIdStub = sinon.stub(ExpenseRepository, 'findById'); + findExpenseByEmployeeIdStub = sinon.stub(ExpenseRepository, 'findByEmployeeId'); + findAllExpensesStub = sinon.stub(ExpenseRepository, 'findAll'); + deleteExpenseReportStub = sinon.stub(ExpenseRepository, 'deleteReport'); + createExpenseReportStub = sinon.stub(ExpenseRepository, 'createExpenseReport'); + createExpenseStub = sinon.stub(ExpenseRepository, 'createExpense'); + updateStatusByIdStub = sinon.stub(ExpenseRepository, 'updateStatusById'); + updatePaymentFileUrlByIdStub = sinon.stub(ExpenseRepository, 'updatePaymentFileUrlById'); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('getReportById', () => { + it('Should return the expense report details', async () => { + const reportId = randomUUID(); + const userEmail = faker.internet.email(); + const userId = randomUUID(); + + const employee = { + id: userId, + email: userEmail, + name: faker.lorem.words(2), + role: SupportedRoles.ADMIN, + }; + const role = { + title: SupportedRoles.ADMIN, + }; + const expenses = [ + { + id: randomUUID(), + title: faker.lorem.words(3), + justification: faker.lorem.words(10), + totalAmount: faker.number.float(), + date: new Date(), + createdAt: new Date(), + idReport: reportId, + }, + ]; + const existingReport = { + id: reportId, + title: faker.lorem.words(3), + description: faker.lorem.words(10), + startDate: new Date(), + endDate: new Date(), + status: ExpenseReportStatus.PENDING, + createdAt: new Date(), + idEmployee: userId, + expenses: expenses, + }; + + findEmployeeByEmailStub.resolves(employee); + findRoleByEmailStub.resolves(role); + findExpenseByIdStub.resolves(existingReport); + + const res = await ExpenseService.getReportById(reportId, userEmail); + + expect(res).to.exist; + expect(res).to.be.equal(existingReport); + expect(res.id).to.equal(reportId); + expect(res.expenses?.length).to.equal(expenses.length); + }); + }); + + describe('getExpenses', () => { + const adminRoleId = randomUUID(); + const legalRoleId = randomUUID(); + const adminEmployeeId = randomUUID(); + const legalEmployeeId = randomUUID(); + + const adminRole = { + id: adminRoleId, + title: SupportedRoles.ADMIN, + }; + + const legalRole = { + id: legalRoleId, + title: SupportedRoles.LEGAL, + }; + + const adminEmployee = { + id: adminEmployeeId, + firstName: faker.lorem.words(2), + lastName: faker.lorem.words(3), + email: faker.internet.email(), + idRole: adminRoleId, + }; + + const legalEmployee = { + id: legalEmployeeId, + firstName: faker.lorem.words(2), + lastName: faker.lorem.words(3), + email: faker.internet.email(), + idRole: legalRoleId, + }; + + const adminExpenseReportId = randomUUID(); + const legalExpenseReportId = randomUUID(); + + const expenseReportAdmin = { + id: adminExpenseReportId, + title: faker.lorem.words(4), + description: faker.lorem.words(8), + startDate: new Date(), + idEmployee: adminEmployeeId, + totalAmount: 0, + }; + + const expenseReportLegal = { + id: legalExpenseReportId, + title: faker.lorem.words(4), + description: faker.lorem.words(8), + startDate: new Date(), + idEmployee: legalEmployeeId, + totalAmount: 0, + }; + + const adminExpenses = Array.from({ length: 4 }, () => ({ + id: randomUUID(), + title: faker.lorem.words(4), + justification: faker.lorem.words(8), + totalAmount: 10, + date: new Date(), + idReport: adminExpenseReportId, + })); + + const legalExpenses = Array.from({ length: 5 }, () => ({ + id: randomUUID(), + title: faker.lorem.words(4), + justification: faker.lorem.words(8), + totalAmount: 100, + date: new Date(), + idReport: adminExpenseReportId, + })); + + it('LEGAL: Should return their expense reports', async () => { + findRoleByEmailStub.resolves(legalRole); + findEmployeeByEmailStub.resolves(legalEmployee); + findExpenseByEmployeeIdStub.resolves([ + { + ...expenseReportLegal, + employeeFirstName: legalEmployee.firstName, + employeeLastName: legalEmployee.lastName, + expenses: legalExpenses, + }, + ]); + + const result = await ExpenseService.getExpenses(legalEmployee.email); + + expect(result).to.eql([ + { + ...expenseReportLegal, + employeeFirstName: legalEmployee.firstName, + employeeLastName: legalEmployee.lastName, + expenses: legalExpenses, + totalAmount: new Decimal(500), + }, + ]); + expect(findRoleByEmailStub.calledOnce).to.be.true; + expect(findEmployeeByEmailStub.calledOnce).to.be.true; + expect(findExpenseByEmployeeIdStub.calledOnce).to.be.true; + }); + + it('ADMIN/ACCOUNTING: Should return all expense reports', async () => { + findRoleByEmailStub.resolves(adminRole); + findEmployeeByEmailStub.resolves(adminEmployee); + findAllExpensesStub.resolves([ + { + ...expenseReportLegal, + employeeFirstName: legalEmployee.firstName, + employeeLastName: legalEmployee.lastName, + expenses: legalExpenses, + }, + { + ...expenseReportAdmin, + employeeFirstName: adminEmployee.firstName, + employeeLastName: adminEmployee.lastName, + expenses: adminExpenses, + }, + ]); + + const result = await ExpenseService.getExpenses(adminEmployee.email); + + expect(result).to.eql([ + { + ...expenseReportLegal, + employeeFirstName: legalEmployee.firstName, + employeeLastName: legalEmployee.lastName, + expenses: legalExpenses, + totalAmount: new Decimal(500), + }, + { + ...expenseReportAdmin, + employeeFirstName: adminEmployee.firstName, + employeeLastName: adminEmployee.lastName, + expenses: adminExpenses, + totalAmount: new Decimal(40), + }, + ]); + expect(findRoleByEmailStub.calledOnce).to.be.true; + expect(findEmployeeByEmailStub.calledOnce).to.be.true; + expect(findAllExpensesStub.calledOnce).to.be.true; + }); + + it('Should throw an error if employee is not found', async () => { + const errorMessage = 'Employee not found'; + findRoleByEmailStub.rejects(new Error(errorMessage)); + + return await expect(ExpenseService.getExpenses(faker.internet.email())).to.be.rejectedWith(Error, errorMessage); + }); + + it('LEGAL: Should throw an error if data is not found', async () => { + const errorMessage = 'An unexpected error occurred'; + + findRoleByEmailStub.resolves(legalRole); + findEmployeeByEmailStub.resolves(legalEmployee); + findExpenseByEmployeeIdStub.rejects(new Error(errorMessage)); + + return await expect(ExpenseService.getExpenses(legalEmployee.email)).to.be.rejectedWith(Error, errorMessage); + }); + + it('ADMIN/ACCOUNTING: Should throw an error if data is not found', async () => { + const errorMessage = 'An unexpected error occurred'; + + findRoleByEmailStub.resolves(adminRole); + findEmployeeByEmailStub.resolves(adminEmployee); + findAllExpensesStub.rejects(new Error(errorMessage)); + + return await expect(ExpenseService.getExpenses(adminEmployee.email)).to.be.rejectedWith(Error, errorMessage); + }); + }); + + describe('getReportById', () => { + it('Should return the expense report details', async () => { + const reportId = randomUUID(); + const userEmail = faker.internet.email(); + const userId = randomUUID(); + + const employee = { + id: userId, + email: userEmail, + name: faker.lorem.words(2), + role: SupportedRoles.ADMIN, + }; + const role = { + title: SupportedRoles.ADMIN, + }; + const expenses = [ + { + id: randomUUID(), + title: faker.lorem.words(3), + justification: faker.lorem.words(10), + totalAmount: faker.number.float(), + date: new Date(), + createdAt: new Date(), + idReport: reportId, + }, + ]; + const existingReport = { + id: reportId, + title: faker.lorem.words(3), + description: faker.lorem.words(10), + startDate: new Date(), + endDate: new Date(), + status: ExpenseReportStatus.PENDING, + createdAt: new Date(), + idEmployee: userId, + expenses: expenses, + }; + + findEmployeeByEmailStub.resolves(employee); + findRoleByEmailStub.resolves(role); + findExpenseByIdStub.resolves(existingReport); + + const res = await ExpenseService.getReportById(reportId, userEmail); + + expect(res).to.exist; + expect(res).to.be.equal(existingReport); + expect(res.id).to.equal(reportId); + expect(res.expenses?.length).to.equal(expenses.length); + }); + }); + + describe('deleteExpenseReport', () => { + const reportId = randomUUID(); + + it('Should throw an error if expense report is not deleted', async () => { + findExpenseByIdStub.resolves(reportId); + deleteExpenseReportStub.resolves(null); + + const result = await ExpenseService.deleteReport(reportId); + + expect(result).to.eql(null); + expect(deleteExpenseReportStub.calledOnce).to.be.true; + }); + + it('Should delete the expense report', async () => { + findExpenseByIdStub.resolves(reportId); + deleteExpenseReportStub.resolves(reportId); + + await ExpenseService.deleteReport(reportId); + + expect(deleteExpenseReportStub.calledOnceWith(reportId)).to.be.true; + }); + }); + + const userEmail = faker.internet.email(); + const userId = randomUUID(); + + const employee = { + id: userId, + email: userEmail, + name: faker.lorem.words(2), + role: SupportedRoles.ADMIN, + }; + + const newExpenseReport = { + id: randomUUID(), + title: faker.lorem.words(3), + status: ExpenseReportStatus.PENDING, + startDate: new Date(), + expenses: [ + { + id: randomUUID(), + title: faker.lorem.words(3), + supplier: faker.lorem.words(2), + totalAmount: new Decimal(10.005), + date: new Date(), + urlFile: faker.internet.url(), + createdAt: new Date('2021-01-01T00:00:00Z'), + }, + { + id: randomUUID(), + title: faker.lorem.words(3), + supplier: faker.lorem.words(2), + totalAmount: new Decimal(10.005), + date: new Date(), + urlFile: faker.internet.url(), + createdAt: new Date('2021-01-01T00:00:00Z'), + }, + ], + }; + + const createdExpenseReport = { + id: newExpenseReport.id, + title: newExpenseReport.title, + startDate: newExpenseReport.startDate, + status: ExpenseReportStatus.PENDING, + idEmployee: userId, + expenses: [ + { + id: newExpenseReport.expenses[0].id, + title: newExpenseReport.expenses[0].title, + supplier: newExpenseReport.expenses[0].supplier, + totalAmount: new Decimal(newExpenseReport.expenses[0].totalAmount), + date: newExpenseReport.expenses[0].date, + createdAt: newExpenseReport.expenses[0].createdAt, + idReport: newExpenseReport.id, + urlFile: newExpenseReport.expenses[0].urlFile, + }, + { + id: newExpenseReport.expenses[1].id, + title: newExpenseReport.expenses[1].title, + supplier: newExpenseReport.expenses[1].supplier, + totalAmount: new Decimal(newExpenseReport.expenses[1].totalAmount), + date: newExpenseReport.expenses[1].date, + createdAt: newExpenseReport.expenses[1].createdAt, + idReport: newExpenseReport.id, + urlFile: newExpenseReport.expenses[1].urlFile, + }, + ], + }; + + describe('createExpenseReport', () => { + it('Should create a new expense report', async () => { + findEmployeeByEmailStub.resolves(employee); + createExpenseReportStub.resolves(createdExpenseReport); + createExpenseStub.onCall(0).resolves(createdExpenseReport.expenses[0]); + createExpenseStub.onCall(1).resolves(createdExpenseReport.expenses[1]); + + const res = await ExpenseService.createExpenseReport(userEmail, newExpenseReport); + + expect(res).to.exist; + expect(res).to.deep.equal(createdExpenseReport); + + res.expenses?.forEach((expense, index) => { + expect(expense.id).to.equal(createdExpenseReport.expenses[index].id); + expect(expense.title).to.equal(createdExpenseReport.expenses[index].title); + expect(expense.supplier).to.equal(createdExpenseReport.expenses[index].supplier); + expect(expense.totalAmount.toString()).to.equal(createdExpenseReport.expenses[index].totalAmount.toString()); + expect(expense.date).to.deep.equal(createdExpenseReport.expenses[index].date); + expect(expense.createdAt).to.deep.equal(createdExpenseReport.expenses[index].createdAt); + expect(expense.idReport).to.equal(createdExpenseReport.expenses[index].idReport); + expect(expense.urlFile).to.equal(createdExpenseReport.expenses[index].urlFile); + }); + }); + + it('Should throw an error if employee is not found', async () => { + const errorMessage = 'Employee not found'; + findEmployeeByEmailStub.rejects(new Error(errorMessage)); + + await expect(ExpenseService.createExpenseReport(faker.internet.email(), newExpenseReport)).to.be.rejectedWith( + Error, + errorMessage + ); + }); + }); + + describe('updateStatusById', () => { + it('Should update the status to Payed', async () => { + const employeeId = randomUUID(); + const reportId = randomUUID(); + const updatedExpense = { + id: reportId, + title: faker.lorem.words(3), + description: faker.lorem.words(10), + startDate: new Date(), + createdAt: new Date(), + idEmployee: employeeId, + status: ExpenseReportStatus.PAYED, + }; + + updateStatusByIdStub.resolves(updatedExpense); + + const res = await ExpenseService.updateStatusById(reportId, ExpenseReportStatus.PAYED); + + expect(res).to.exist; + expect(res).to.be.equal(updatedExpense); + expect(res.id).to.be.equal(reportId); + expect(res.status).to.be.equal(ExpenseReportStatus.PAYED); + }); + + it('Should throw an error if the status is not valid', async () => { + const employeeId = randomUUID(); + const reportId = randomUUID(); + const updatedExpense = { + id: reportId, + title: faker.lorem.words(3), + description: faker.lorem.words(10), + startDate: new Date(), + createdAt: new Date(), + idEmployee: employeeId, + status: ExpenseReportStatus.PAYED, + }; + + updateStatusByIdStub.resolves(updatedExpense); + + try { + await ExpenseService.updateStatusById(reportId, 'mystatus' as ExpenseReportStatus); + } catch (error: any) { + expect(error.message).to.equal('Invalid status'); + } + }); + }); + + describe('updatePaymentFileUrlByIdStub', () => { + it('Should update the url voucher', async () => { + const employeeId = randomUUID(); + const reportId = randomUUID(); + const url = 'https://drive.google.com'; + const updatedExpense = { + id: reportId, + title: faker.lorem.words(3), + description: faker.lorem.words(10), + startDate: new Date(), + createdAt: new Date(), + idEmployee: employeeId, + status: ExpenseReportStatus.PAYED, + urlVoucher: url, + }; + + updatePaymentFileUrlByIdStub.resolves(updatedExpense); + + const res = await ExpenseService.updatePaymentFileUrlById(reportId, url); + + expect(res).to.exist; + expect(res).to.be.equal(updatedExpense); + expect(res.id).to.be.equal(reportId); + expect(res.urlVoucher).to.be.equal(url); + }); + }); +}); diff --git a/src/core/app/services/__tests__/project-report.service.test.ts b/src/core/app/services/__tests__/project-report.service.test.ts index 9f0613eb..75879702 100644 --- a/src/core/app/services/__tests__/project-report.service.test.ts +++ b/src/core/app/services/__tests__/project-report.service.test.ts @@ -58,6 +58,7 @@ describe('ProjectReportService', () => { startDate: new Date(), createdAt: new Date(), idCompany: companyId, + area: 'Legal', }; const roleId = randomUUID(); diff --git a/src/core/app/services/__tests__/project.service.test.ts b/src/core/app/services/__tests__/project.service.test.ts index c60a303e..ed83778c 100644 --- a/src/core/app/services/__tests__/project.service.test.ts +++ b/src/core/app/services/__tests__/project.service.test.ts @@ -28,6 +28,7 @@ describe('ProjectService', () => { let findEmployeeByEmailStub: sinon.SinonStub; let findRoleByIdStub: sinon.SinonStub; let findRoleByEmailStub: sinon.SinonStub; + let deleteProjectByIdStub: sinon.SinonStub; beforeEach(() => { createProjectStub = sinon.stub(ProjectRepository, 'createProject'); @@ -38,6 +39,7 @@ describe('ProjectService', () => { findEmployeeByEmailStub = sinon.stub(EmployeeRepository, 'findByEmail'); findRoleByIdStub = sinon.stub(RoleRepository, 'findById'); findRoleByEmailStub = sinon.stub(RoleRepository, 'findByEmail'); + deleteProjectByIdStub = sinon.stub(ProjectRepository, 'deleteProjectById'); }); afterEach(() => { @@ -218,7 +220,7 @@ describe('ProjectService', () => { it('Should return the project information and client name acording its id', async () => { const role = { title: SupportedRoles.ADMIN, - createdAr: new Date(), + createdAt: new Date(), }; const employee = { @@ -242,6 +244,7 @@ describe('ProjectService', () => { startDate: new Date(), createdAt: new Date(), totalHours: 20, + area: 'Legal', }; findProjectByIdStub.resolves(existingProject); @@ -260,7 +263,7 @@ describe('ProjectService', () => { it('Should return the project information acording its id', async () => { const role = { title: SupportedRoles.ADMIN, - createdAr: new Date(), + createdAt: new Date(), }; const employee = { @@ -292,10 +295,12 @@ describe('ProjectService', () => { createdAt: new Date(), totalHours: 28, idCompany: companyId, + area: 'Legal', }; findProjectByIdStub.resolves(existingProject); findCompanyByIdStub.resolves(existingCompany); + findRoleByEmailStub.resolves(role); const res = await ProjectService.getProjectById(projectId, employee.email); const res2 = await CompanyService.findById(companyId); @@ -350,6 +355,39 @@ describe('ProjectService', () => { expect(res).to.equal(mockProject.updateProjectStatus.status); }); }); + + describe('deleteProjectByID', () => { + it('it should throw an error if project is not deleted from DB', async () => { + const mockProject = prepareMockProject(); + + beforeEach(() => { + updateProjectStatusStub = sinon.stub(ProjectRepository, 'deleteProjectById'); + }); + + afterEach(() => { + sinon.restore(); + }); + + findProjectByIdStub.resolves(mockProject.original.id); + deleteProjectByIdStub.resolves(null); + + const res = await ProjectService.deleteProjectById(mockProject.original.id); + + expect(res).to.eql(null); + expect(deleteProjectByIdStub.calledOnce); + }); + }); + + it('it should delete project by ID', async () => { + const mockProject2 = prepareMockProject(); + + findProjectByIdStub.resolves(mockProject2.original.id); + deleteProjectByIdStub.resolves(mockProject2.original.id); + + await ProjectService.deleteProjectById(mockProject2.original.id); + + expect(deleteProjectByIdStub.calledOnceWith(mockProject2.original.id)).to.be.true; + }); }); function prepareMockProject() { diff --git a/src/core/app/services/__tests__/task.services.test.ts b/src/core/app/services/__tests__/task.service.test.ts similarity index 96% rename from src/core/app/services/__tests__/task.services.test.ts rename to src/core/app/services/__tests__/task.service.test.ts index 8b31aa2e..40c7cfaa 100644 --- a/src/core/app/services/__tests__/task.services.test.ts +++ b/src/core/app/services/__tests__/task.service.test.ts @@ -157,17 +157,19 @@ describe('Task Service', () => { taskRepositoryStub.resolves(createdTask); employeeRepositoryStub.resolves({ id: task.idEmployee }); employeeTaskRepositoryStub.resolves({ id: randomUUID() }); + const emitterEmail = faker.internet.email(); - const result = await TaskService.createTask(task); + const result = await TaskService.createTask(task, emitterEmail); expect(result).to.deep.equal(createdTask); }); it('Should throw an error if the project ID is not valid', async () => { projectRepositoryStub.withArgs(idProject).resolves(null); + const emitterEmail = faker.internet.email(); try { - await TaskService.createTask(task); + await TaskService.createTask(task, emitterEmail); } catch (error: any) { expect(error.message).to.equal('Requested Project ID was not found'); } @@ -176,9 +178,10 @@ describe('Task Service', () => { it('Should throw an error if the task already exists', async () => { projectRepositoryStub.resolves(project); taskRepositoryStub.resolves(null); + const emitterEmail = faker.internet.email(); try { - await TaskService.createTask(task); + await TaskService.createTask(task, emitterEmail); } catch (error: any) { expect(error.message).to.equal('Task already exists'); } @@ -188,9 +191,10 @@ describe('Task Service', () => { projectRepositoryStub.resolves(project); taskRepositoryStub.resolves(createdTask); employeeRepositoryStub.resolves(null); + const emitterEmail = faker.internet.email(); try { - await TaskService.createTask(task); + await TaskService.createTask(task, emitterEmail); } catch (error: any) { expect(error.message).to.equal('Requested Employee was not found'); } @@ -201,9 +205,10 @@ describe('Task Service', () => { taskRepositoryStub.resolves(createdTask); employeeRepositoryStub.resolves({ id: task.idEmployee }); employeeTaskRepositoryStub.resolves(null); + const emitterEmail = faker.internet.email(); try { - await TaskService.createTask(task); + await TaskService.createTask(task, emitterEmail); } catch (error: any) { expect(error.message).to.equal('Error assigning a task to an employee'); } @@ -471,6 +476,7 @@ describe('TaskService', () => { startDate: new Date(), createdAt: new Date(), idCompany: randomUUID(), + area: 'Legal', }; const roleId = randomUUID(); @@ -485,7 +491,7 @@ describe('TaskService', () => { id: employeeId, firstName: 'John', lastName: 'Doe', - email: 'john.doe@example.com', + email: 'john.doe@outlook.com', imageUrl: 'http://example.com/john.jpg', createdAt: new Date(), idRole: roleId, diff --git a/src/core/app/services/company.service.ts b/src/core/app/services/company.service.ts index 750be4f0..4bbc8bea 100644 --- a/src/core/app/services/company.service.ts +++ b/src/core/app/services/company.service.ts @@ -3,7 +3,6 @@ import { CompanyEntity } from '../../domain/entities/company.entity'; import { NotFoundError } from '../../errors/not-found.error'; import { CompanyRepository } from '../../infra/repositories/company.repository'; import { UpdateCompanyBody } from '../interfaces/company.interface'; - /** * Gets all data from a unique company * @returns {Promise} a promise that resolves a unique company entity @@ -121,4 +120,22 @@ async function findUnarchived(): Promise { } } -export const CompanyService = { findAll, findById, update, create, archiveClient, findUnarchived }; +/** + * @brief Delete a client + * + * @param id + * @param email + * @returns {Promise} + */ +async function deleteCompanyById(id: string): Promise { + try { + return await CompanyRepository.deleteCompanyById(id); + } catch (error: any) { + if (error.message === 'Company not found') { + throw new Error('Company not found'); + } + throw new Error('An unexpected error occurred'); + } +} + +export const CompanyService = { findAll, findById, update, create, archiveClient, findUnarchived, deleteCompanyById }; diff --git a/src/core/app/services/employee.service.ts b/src/core/app/services/employee.service.ts index 2d45ad2f..3a391fb4 100644 --- a/src/core/app/services/employee.service.ts +++ b/src/core/app/services/employee.service.ts @@ -110,6 +110,15 @@ async function getAllEmployees(): Promise { return await EmployeeRepository.findAll(); } +async function getAllEmployeesByDepartment(department: SupportedDepartments): Promise { + const departmentEntity = await DepartmentRepository.findByTitle(department); + if (!departmentEntity) { + throw new NotFoundError(`Department '${department}' not found`); + } + + return await EmployeeRepository.findByDepartment(departmentEntity.id); +} + /** * Function for finding the role of the employee by email, used in the middleware * @@ -156,4 +165,10 @@ async function deleteEmployeeById(id: string): Promise { } } -export const EmployeeService = { signIn, getAllEmployees, findRoleByEmail, deleteEmployeeById }; +export const EmployeeService = { + signIn, + getAllEmployees, + getAllEmployeesByDepartment, + findRoleByEmail, + deleteEmployeeById, +}; diff --git a/src/core/app/services/expense.service.ts b/src/core/app/services/expense.service.ts new file mode 100644 index 00000000..ea40780a --- /dev/null +++ b/src/core/app/services/expense.service.ts @@ -0,0 +1,222 @@ +import { Decimal } from '@prisma/client/runtime/library'; +import { randomUUID } from 'crypto'; +import { ExpenseReportStatus, SupportedRoles } from '../../../utils/enums'; +import { ExpenseReport, NewExpenseReport } from '../../domain/entities/expense.entity'; +import { EmployeeRepository } from '../../infra/repositories/employee.repository'; +import { ExpenseRepository } from '../../infra/repositories/expense.repository'; +import { RoleRepository } from '../../infra/repositories/role.repository'; + +/** + * @param email the email of the user + * @returns {Promise} a promise that resolves the expense records + * @throws {Error} if an unexpected error occurs + */ + +async function getExpenses(email: string): Promise { + try { + const role = await RoleRepository.findByEmail(email); + const employee = await EmployeeRepository.findByEmail(email); + + if (!role || !employee) { + throw new Error('Employee not found'); + } + + let data; + if (role.title.toUpperCase() === SupportedRoles.LEGAL.toUpperCase()) { + data = await ExpenseRepository.findByEmployeeId(employee.id); + } else if ( + role.title.toUpperCase() === SupportedRoles.ADMIN.toUpperCase() || + role.title.toUpperCase() === SupportedRoles.ACCOUNTING.toUpperCase() + ) { + data = await ExpenseRepository.findAll(); + } + + if (!data) { + throw new Error('An unexpected error occurred'); + } + + for (let i = 0; i < data.length; i++) { + let totalAmount = new Decimal(0); + data[i].expenses?.forEach(record => { + totalAmount = totalAmount.add(record.totalAmount); + }); + data[i].totalAmount = totalAmount; + } + return data; + } catch (error: any) { + if (error.message === 'Employee not found') { + throw new Error('Employee not found'); + } else { + throw new Error('An unexpected error occurred'); + } + } +} + +/** + * + * @param getReportById the id of the expense report we want the details + * @param email the email of the user + * @returns {Promise} a promise that resolves the details of the expense report + * @throws {Error} if an unexpected error occurs + */ + +async function getReportById(reportId: string, email: string): Promise { + try { + const [employee, role, expenseReport] = await Promise.all([ + EmployeeRepository.findByEmail(email), + RoleRepository.findByEmail(email), + ExpenseRepository.findById(reportId), + ]); + + if ( + role.title.toUpperCase() != SupportedRoles.ADMIN.toUpperCase() && + role.title.toUpperCase() != SupportedRoles.ACCOUNTING.toUpperCase() && + expenseReport.idEmployee != employee?.id + ) { + throw new Error('Unauthorized employee'); + } + + let totalAmount = new Decimal(0); + if (expenseReport.expenses) { + expenseReport.expenses.forEach(expense => { + totalAmount = totalAmount.add(expense.totalAmount); + }); + } + expenseReport.totalAmount = totalAmount; + + return expenseReport; + } catch (error: any) { + if (error.message === 'Unauthorized employee') { + throw error; + } + throw new Error('An unexpected error occurred'); + } +} + +/** + * @description Function to delete an expense by id + * @param id + * @param returns {ExpenseEntity} - Deleted expense + * @throws {Error} - If the expense is not found + * @throws {Error} - If an unexpected error occurs + * + */ + +async function deleteReport(reportId: string): Promise { + try { + const expenseReport = await ExpenseRepository.findById(reportId); + if (!expenseReport) { + throw new Error('Expense report not found'); + } + + return await ExpenseRepository.deleteReport(reportId); + } catch (error: unknown) { + throw new Error('An unexpected error occurred'); + } +} + +/** + * Function that handles the request to create a new expense report + * + * @param userEmail the email of the user + * @param data the data of the new expense report + * @returns {Promise} a promise that resolves the created expense report + * @throws {Error} if an unexpected error occurs + * + */ + +async function createExpenseReport(userEmail: string, data: NewExpenseReport): Promise { + try { + const employee = await EmployeeRepository.findByEmail(userEmail); + if (!employee) { + throw new Error('Employee not found'); + } + const idEmployee = employee.id; + + const expenseReport = await ExpenseRepository.createExpenseReport({ + id: randomUUID(), + title: data.title, + status: ExpenseReportStatus.PENDING, + startDate: data.startDate, + idEmployee: idEmployee, + }); + + const promiseExpenses = data.expenses.map(expense => + ExpenseRepository.createExpense({ + id: randomUUID(), + title: expense.title, + supplier: expense.supplier, + totalAmount: expense.totalAmount, + date: expense.date, + createdAt: new Date(), + idReport: expenseReport.id, + urlFile: expense.urlFile, + }) + ); + + const expenses = await Promise.all(promiseExpenses); + + const createdExpenseReport = { + ...expenseReport, + expenses, + }; + + return createdExpenseReport; + } catch (error: any) { + if (error.message === 'Employee not found') { + throw error; + } else { + throw new Error(error.message); + } + } +} + +/** + * @param id The id of the expense to be updated + * @param status The new status + * @returns {Promise} a promise that resolves the details of the expense report + * @throws {Error} if an unexpected error occurs + * + */ + +async function updateStatusById(id: string, status: ExpenseReportStatus): Promise { + try { + const expenseReportStatus = Object.values(ExpenseReportStatus) as string[]; + if (!expenseReportStatus.includes(status)) throw new Error('Invalid status'); + + const updatedExpense = await ExpenseRepository.updateStatusById(id, status); + return updatedExpense; + } catch (error: any) { + if (error.message === 'Unauthorized employee') { + throw error; + } + throw new Error(error.message); + } +} + +/** + * @param id The id of the expense to be updated + * @param urlVoucher The voucher url for the payment file + * @returns {Promise} a promise that resolves the details of the expense report + * @throws {Error} if an unexpected error occurs + */ +async function updatePaymentFileUrlById(id: string, urlVoucher: string): Promise { + try { + const updatedExpense = await ExpenseRepository.updatePaymentFileUrlById(id, urlVoucher); + return updatedExpense; + } catch (error: any) { + if (error.message === 'Unauthorized employee') { + throw error; + } + throw new Error(error.message); + } +} + +export const ExpenseService = { + getExpenses, + getReportById, + deleteReport, + createExpenseReport, + updateStatusById, + updatePaymentFileUrlById, +}; diff --git a/src/core/app/services/notification.service.ts b/src/core/app/services/notification.service.ts index 0dc49cda..ec70d3ae 100644 --- a/src/core/app/services/notification.service.ts +++ b/src/core/app/services/notification.service.ts @@ -1,68 +1,86 @@ -import { Notification } from '../../domain/entities/notification.entity'; -import { NotificationRepository } from '../../infra/repositories/notification.repository'; +import { + notifyAssignedTaskEmailTemplate, + notifyOtherDeparmentEmailTemplate, +} from '../../../utils/email/email.templates'; +import { SupportedDepartments } from '../../../utils/enums'; +import { Task } from '../../domain/entities/task.entity'; +import { EmailProvider } from '../../infra/providers/resend.provider'; +import { DepartmentRepository } from '../../infra/repositories/department.repository'; +import { EmployeeRepository } from '../../infra/repositories/employee.repository'; +import { ProjectRepository } from '../../infra/repositories/project.repository'; /** - * @brief Interface for the userToken - * - * @param email: string - * @param deviceToken: string - * - * @returns userToken + * This method is used to validate if the sender is from the same department. + * @param email - Emitter email + * @param departmentTitle - Department title + * @returns boolean */ -export interface userToken { - email: string; - deviceToken: string; +async function validateSenderDepartment(email: string, departmentTitle: SupportedDepartments): Promise { + const employee = await EmployeeRepository.findByEmail(email); + const department = await DepartmentRepository.findByTitle(departmentTitle); + + if (employee?.idDepartment === department.id) { + return false; + } + + return true; } /** - * @brief Function that saves the token of the employee - * - * @param body: userToken + * This method is used for sending a notification when the task is created and its assigned to the user. + * @param userId {string} - The user id to whom the task is assigned + * @param task {Task} - The task that is assigned to the user */ - -async function saveToken(body: userToken) { - try { - return await NotificationRepository.saveToken(body.email, body.deviceToken); - } catch (error) { - throw new Error('Error saving token.' + error); +async function sendAssignedTaskNotification(userId: string, task: Task): Promise { + const employee = await EmployeeRepository.findById(userId); + if (!employee) { + throw new Error('Employee not found'); } + + const { subject, body } = notifyAssignedTaskEmailTemplate(employee.firstName, employee.lastName, task); + + await EmailProvider.sendEmail([employee.email], subject, body); } /** - * Creates notification data in the repository - * - * @param {Notification} notification - The notification entity - * - * @returns {Promise} A promise that resolves to the created - * - * @throws {Error} If an unexpected error occurs + * This method is used for sending a notification to another department when the project status is updated. + * @param departmentTitle {SupportedDepartments} - The department to which the notification is to be sent + * @param projectId {string} - The project id for which the status is updated */ +async function sendProjectStatusUpdateNotification( + emitterEmail: string, + departmentTitle: SupportedDepartments, + projectId: string +): Promise { + const isSenderValid = await validateSenderDepartment(emitterEmail, departmentTitle); + if (!isSenderValid) { + return 'Cannot send email to the same department'; + } -async function createNotification(notification: Notification): Promise { - try { - const notificationRecord = await NotificationRepository.createNotification(notification); - return notificationRecord; - } catch (error: any) { - throw new Error('Error creating notification.' + error); + const project = await ProjectRepository.findById(projectId); + if (!project) { + throw new Error('Project not found'); } -} -/** - * Gets notification data from the repository - * - * @returns {Promise} A promise that resolves to an array of - * notification entities - * - * @throws {Error} If an unexpected error occurs - */ + const deparment = await DepartmentRepository.findByTitle(departmentTitle); + const employees = await EmployeeRepository.findByDepartment(deparment.id); + if (!employees) { + throw new Error('Employees not found'); + } + + const emailList = employees.map(employee => employee.email); -async function getAllNotifications(): Promise { - try { - const notificationRecords = await NotificationRepository.findAllNotifications(); - return notificationRecords; - } catch (error: any) { - throw new Error('Error getting notifications.' + error); + const deparmentSubject = + departmentTitle === SupportedDepartments.ACCOUNTING ? SupportedDepartments.LEGAL : SupportedDepartments.ACCOUNTING; + const { subject, body } = notifyOtherDeparmentEmailTemplate(deparmentSubject, project); + + const response = await EmailProvider.sendEmail(emailList, subject, body); + + if (!response.error) { + return 'Email sent successfully'; } + + return 'Failed to send email'; } -export const NotificationService = { saveToken, getAllNotifications, createNotification }; +export const NotificationService = { sendAssignedTaskNotification, sendProjectStatusUpdateNotification }; diff --git a/src/core/app/services/project-report.service.ts b/src/core/app/services/project-report.service.ts index b8f2c6e9..f2fb1a73 100644 --- a/src/core/app/services/project-report.service.ts +++ b/src/core/app/services/project-report.service.ts @@ -1,5 +1,6 @@ import { Decimal } from '@prisma/client/runtime/library'; -import { SupportedRoles, TaskStatus } from '../../../utils/enums'; +import { TaskStatus } from '../../../utils/enums'; +import { isAuthorized } from '../../../utils/is-authorize-deparment'; import { CompanyRepository } from '../../infra/repositories/company.repository'; import { EmployeeTaskRepository } from '../../infra/repositories/employee-task.repository'; import { EmployeeRepository } from '../../infra/repositories/employee.repository'; @@ -77,11 +78,7 @@ async function getReport(id: string, email: string, date?: Date): Promise { return new Date(start).getTime() <= new Date(end).getTime(); }; -interface CreateProjectData { - name: string; - matter: string | null; - description: string | null; - area: string; - status: string; - category: string; - endDate: Date | null; - idCompany: string; - isChargeable: boolean; - periodicity: string | null; - startDate: Date; -} /** * A function that calls the repository to create a project in the database * @param data The data required to create a project in the database @@ -119,11 +107,11 @@ async function getProjectById(projectId: string, email: string): Promise { + try { + const project = await ProjectRepository.findById(id); + if (!project) { + throw new Error('Project not found'); + } + + return await ProjectRepository.deleteProjectById(id); + } catch (error: unknown) { + throw new Error('An unexpected error occurred'); + } +} + export const ProjectService = { createProject, findProjectsClient, @@ -197,4 +206,5 @@ export const ProjectService = { updateProject, updateProjectStatus, getDepartmentProjects, + deleteProjectById, }; diff --git a/src/core/app/services/task.service.ts b/src/core/app/services/task.service.ts index d9716677..636467c5 100644 --- a/src/core/app/services/task.service.ts +++ b/src/core/app/services/task.service.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; -import { SupportedRoles, TaskStatus } from '../../../utils/enums'; +import { TaskStatus } from '../../../utils/enums'; +import { isAuthorized } from '../../../utils/is-authorize-deparment'; import { dateSmallerOrEqualThanOther } from '../../../utils/methods'; import { EmployeeTask } from '../../domain/entities/employee-task.entity'; import { BareboneTask, ProjectDetailsTask, Task, UpdatedTask } from '../../domain/entities/task.entity'; @@ -10,6 +11,7 @@ import { ProjectRepository } from '../../infra/repositories/project.repository'; import { RoleRepository } from '../../infra/repositories/role.repository'; import { TaskRepository } from '../../infra/repositories/tasks.repository'; import { TaskDetail } from '../interfaces/task.interface'; +import { NotificationService } from './notification.service'; /** * @description Validates that a start date should be before than an end date @@ -39,11 +41,7 @@ async function getTasksFromProject(projectId: string, email: string): Promise { +async function createTask(newTask: BareboneTask, employeeEmitterEmail: string): Promise { try { const project = await ProjectRepository.findById(newTask.idProject); if (project === null) { @@ -101,14 +99,9 @@ async function createTask(newTask: BareboneTask): Promise { const task: Task = { id: randomUUID(), - title: newTask.title, - description: newTask.description, - status: newTask.status, - startDate: newTask.startDate, - endDate: newTask.endDate, + ...newTask, workedHours: newTask.workedHours ?? undefined, createdAt: new Date(), - idProject: newTask.idProject, }; if (task.endDate !== null && task.endDate !== undefined && !areDatesValid(task.startDate, task.endDate)) { @@ -140,16 +133,22 @@ async function createTask(newTask: BareboneTask): Promise { if (!employee) { throw new NotFoundError('Employee'); } + const newEmployeeTask: EmployeeTask = { id: randomUUID(), createdAt: new Date(), idEmployee: employee.id, idTask: createdTask.id as string, }; + const assignedTask = await EmployeeTaskRepository.create(newEmployeeTask); if (!assignedTask) { throw new Error('Error assigning a task to an employee'); } + + if (employeeEmitterEmail !== employee.email) { + await NotificationService.sendAssignedTaskNotification(newTask.idEmployee, createdTask); + } } return createdTask; @@ -173,7 +172,7 @@ async function findUnique(id: string, email: string): Promise { const employeeTask = await EmployeeTaskRepository.findAll(); const role = await RoleRepository.findByEmail(email); - if (role.title != SupportedRoles.ADMIN && role.title != project.area) { + if (!isAuthorized(role.title, project.area!)) { throw new Error('Unauthorized employee'); } diff --git a/src/core/domain/entities/employee.entity.ts b/src/core/domain/entities/employee.entity.ts index 3164ca1b..f249f375 100644 --- a/src/core/domain/entities/employee.entity.ts +++ b/src/core/domain/entities/employee.entity.ts @@ -10,7 +10,6 @@ * @param updatedAt?: Date - Date when the employee was updated (optional) * @param idDepartment?: string - ID of the department where the employee belongs (optional) * @param idRole: string - ID of the role of the employee - * @param deviceToken?: string - Device token of the employee (optional) * * @return void * @@ -62,9 +61,4 @@ export interface EmployeeEntity { * @param idRole string: The id of the role of the employee. */ idRole: string; - - /** - * @param deviceToken string: The device token of the employee. - */ - deviceToken?: string; } diff --git a/src/core/domain/entities/expense.entity.ts b/src/core/domain/entities/expense.entity.ts new file mode 100644 index 00000000..b15873be --- /dev/null +++ b/src/core/domain/entities/expense.entity.ts @@ -0,0 +1,248 @@ +import { employee, expense } from '@prisma/client'; +import { Decimal } from '@prisma/client/runtime/library'; +import { ExpenseReportStatus } from '../../../utils/enums/index'; + +/** + * @brief This class is used to define the structure of the Expense entity + * + * @param id: string - Unique identifier of the expense + * @param title: string - Expense title + ~ @param supplier: string - Expense supplier + * @param totalAmount: Decimal - Expense amount + * @param date: Date - Expense date + * @param createdAt: Date - Expense creation date + * @param updatedAt?: Date - Expense update date (optional) + * @param idReport: string - Unique identifier of expense report associated + * @param urlFile?: string - URL of the file associated with the expense (optional) + * + * @return void + * + * @description The structure is based on the MER, and there's the idea of using custom data types, like UUID. + */ + +export interface ExpenseEntity { + /** + * @param id: string - Expense id + */ + id: string; + /** + * @param title: string - Expense title + */ + title: string; + /** + * @param supplier: string - Expense supplier + */ + supplier: string | null; + /** + * @param totalAmount: Decimal - Expense amount + */ + totalAmount: Decimal; + /** + * @param date: Date - Expense date + */ + date: Date; + /** + * @param createdAt: Date - Expense creation date + */ + createdAt: Date; + /** + * @param updatedAt: Date - Expense update date (optional) + */ + updatedAt?: Date | null; + /** + * @param idReport: string - Expense report id + */ + idReport: string; + /** + * @param urlFile: string - URL of the file associated with the expense (optional) + */ + urlFile?: string | null; +} + +/** + * @brief This class is used to define the structure of the Expense Report entity + * + * @param id: string - Unique identifier of the expense report + * @param title: string - Expense Report title + * @param startDate: Date - Expense Report start date + * @param endDate?: Date - Expense Report end date (optional) + + @param status?: ExpenseReportStatus - Expense Report status (optional) + * @param createdAt?: Date - Expense Report creation date (optional) + * @param updatedAt?: Date - Expense Report update date (optional) + * @param url_voucher?: string - URL of the voucher associated with the expense report (optional) + * @param idEmployee: string - Unique identifier of the employee associated + * @param employeeFirstName?: string - Employee first name (optional) + * @param employeeLastName?: string - Employee last name (optional) + * @param expenses?: ExpenseEntity[] - Array of expenses associated with the report (optional) + * @param totalAmount?: Decimal - Total amount of the expenses associated with the report (optional) + * + * @return void + * + * @description The structure is based on the MER, and there's the idea of using custom data types, like UUID. + */ + +export interface ExpenseReport { + /** + * @param id: string - Expense report id + */ + id: string; + /** + * @param title: string - Expense report title + */ + title: string; + /** + * @param startDate: Date - Expense report start date + */ + startDate: Date; + /** + * @param endDate: Date - Expense report end date + */ + endDate?: Date | null; + /** + * @param status: ExpenseReportStatus - Expense report status + */ + status?: ExpenseReportStatus | null; + /** + * @param createdAt: Date - Expense report creation date + */ + createdAt?: Date | null; + /** + * @param updatedAt: Date - Expense report update date + */ + updatedAt?: Date | null; + /** + * @param urlVoucher: string - URL of the voucher associated with the expense report + */ + urlVoucher?: string | null; + /** + * @param idEmployee: string - Employee id + */ + idEmployee: string | null; + /** + * @param employeeFirstName: string - Employee first name + */ + employeeFirstName?: string; + /** + * @param employeeLastName: string - Employee last name + */ + employeeLastName?: string; + /** + * @param expenses: ExpenseEntity[] - Array of expenses associated with the report + */ + expenses?: ExpenseEntity[]; + + /** + * @param totalAmount: Decimal - Total amount of the expenses associated with the report + */ + totalAmount?: Decimal; +} + +/** + * @brief This class is used to define the structure of the Expense Report Raw Data from the db. + * + * @param id: string - Unique identifier of the expense report + * @param title: string - Expense Report title + * @param description: string - Expense Report description + * @param start_date: Date - Expense Report start date + * @param end_date?: Date - Expense Report end date (optional) + + @param status?: string - Expense Report status (optional) + * @param createdAt: Date - Expense Report creation date + * @param updatedAt?: Date - Expense Report update date (optional) + * @param url_voucher?: string - URL of the voucher associated with the expense report (optional) + * @param id_employee: string - Unique identifier of the employee associated + * @param employee?: employee - Employee information associated with the report (optional) + * @param expense?: expense[] - Array of expenses associated with the report (optional) + * @param totalAmount?: Decimal - Total amount of the expenses associated with the report (optional) + * + * @return void + * + * @description The structure is based on the MER, and there's the idea of using custom data types, like UUID. + */ + +export interface RawExpenseReport { + /** + * @param id: string - Expense report id + */ + id: string; + /** + * @param title: string - Expense report title + */ + title: string; + /** + * @param startDate: Date - Expense report start date + */ + start_date: Date; + /** + * @param endDate: Date - Expense report end date + */ + end_date?: Date | null; + /** + * @param status: string - Expense report status + */ + status?: string | null; + /** + * @param createdAt: Date - Expense report creation date + */ + createdAt?: Date | null; + /** + * @param updatedAt: Date - Expense report update date + */ + updatedAt?: Date | null; + /** + * @param url_voucher: string - URL of the voucher associated with the expense report + */ + url_voucher?: string | null; + /** + * @param idEmployee: string - Employee id + */ + id_employee: string | null; + /** + * @param employee: employee - Employee information associated with the report + */ + employee?: employee | null; + /** + * @param expense: expense[] - Array of expenses associated with the report + */ + expense?: expense[] | null; +} + +/** + * @brief This class is used to define the structure of the New Expense entity + * + * @param title: string - Expense title + * @param supplier: string - Expense supplier + * @param totalAmount: Decimal - Expense amount + * @param date: Date - Expense date + * @param urlFile: string - URL of the file associated with the expense + * + * @return void + * + * @description The structure is based on the MER, and there's the idea of using custom data types, like UUID. + */ + +export interface NewExpenseEntity { + title: string; + supplier: string | null; + totalAmount: Decimal; + date: Date; + urlFile: string | null; +} + +/** + * @brief This class is used to define the structure of the New Expense Report entity + * + * @param title: string - Expense Report title + * @param status: ExpenseReportStatus - Expense Report status + * @param startDate: Date - Expense Report start date + * @param expenses: NewExpenseEntity[] - Array of expenses associated with the report + * + * @return void + * + * @description The structure is based on the MER, and there's the idea of using custom data types, like UUID. + */ +export interface NewExpenseReport { + title: string; + status: ExpenseReportStatus; + startDate: Date; + expenses: NewExpenseEntity[]; +} diff --git a/src/core/domain/entities/notification.entity.ts b/src/core/domain/entities/notification.entity.ts deleted file mode 100644 index 29aa0a08..00000000 --- a/src/core/domain/entities/notification.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @brief This class establishes the structure of the Notification entity - * - * @param id: string - * @param title: string - * @param body: string - * @param createdAt: Date - * @param updatedAt: Date - * - * @return void - * - * @description The structure contains the data of the Notification schema - * - */ - -export interface Notification { - /** - * @param id: string - Unique identifier of the notification - */ - id: string; - - /** - * @param title: string - Notification title - */ - title: string; - - /** - * @param body: string - Notification body - */ - body: string; - - /** - * @param createdAt: Date - Creation date of the notification - */ - createdAt: Date; - - /** - * @param updatedAt: Date - Last update date of the notification - */ - updatedAt?: Date; -} diff --git a/src/core/domain/entities/task.entity.ts b/src/core/domain/entities/task.entity.ts index 7fe79373..ed460c07 100644 --- a/src/core/domain/entities/task.entity.ts +++ b/src/core/domain/entities/task.entity.ts @@ -64,7 +64,6 @@ export interface Task { /** * @param updatedAt: Date - Last modification date (optional) */ - updatedAt?: Date; /** diff --git a/src/core/infra/mappers/employee-entity-from-db-model.mapper.ts b/src/core/infra/mappers/employee-entity-from-db-model.mapper.ts index 9e912a5a..4fbeb953 100644 --- a/src/core/infra/mappers/employee-entity-from-db-model.mapper.ts +++ b/src/core/infra/mappers/employee-entity-from-db-model.mapper.ts @@ -12,6 +12,5 @@ export function mapEmployeeEntityFromDbModel(model: employee): EmployeeEntity { updatedAt: model.updated_at ? model.updated_at : undefined, idDepartment: model.id_department ? model.id_department : undefined, idRole: model.id_role, - deviceToken: model.device_token ? model.device_token : undefined, }; } diff --git a/src/core/infra/mappers/employee-notif-from-db-mapper.ts b/src/core/infra/mappers/employee-notif-from-db-mapper.ts deleted file mode 100644 index 5bbdf666..00000000 --- a/src/core/infra/mappers/employee-notif-from-db-mapper.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { employee_notification } from '@prisma/client'; -import { EmployeeNotification } from '../../domain/entities/employee-notification.entity'; - -/** - * Maps a notification entity from a database model. - * - * @param model The database model. - * @returns The notification entity. - * - */ - -export function mapEmployeeNotificationEntityFromDbModel(model: employee_notification): EmployeeNotification { - return { - id: model.id, - idEmployee: model.id_employee, - idNotification: model.id_notification, - createdAt: model.created_at, - updatedAt: model.updated_at ? model.updated_at : undefined, - }; -} diff --git a/src/core/infra/mappers/expense-entity-from-db-model.mapper.ts b/src/core/infra/mappers/expense-entity-from-db-model.mapper.ts new file mode 100644 index 00000000..2dd54f0f --- /dev/null +++ b/src/core/infra/mappers/expense-entity-from-db-model.mapper.ts @@ -0,0 +1,32 @@ +import { expense } from '@prisma/client'; +import { ExpenseReportStatus } from '../../../utils/enums/index'; +import { ExpenseEntity, ExpenseReport, RawExpenseReport } from '../../domain/entities/expense.entity'; + +export function mapExpenseEntityFromDbModel(model: expense): ExpenseEntity { + return { + id: model.id, + title: model.title, + supplier: model.supplier ? model.supplier : '', + totalAmount: model.total_amount, + date: model.date, + createdAt: model.created_at, + updatedAt: model.updated_at ? model.updated_at : undefined, + idReport: model.id_report, + urlFile: model.url_file ? model.url_file : '', + }; +} + +export function mapExpenseReportEntityFromDbModel(model: RawExpenseReport): ExpenseReport { + return { + id: model.id, + title: model.title, + startDate: model.start_date, + endDate: model.end_date ? model.end_date : undefined, + status: model.status as ExpenseReportStatus, + urlVoucher: model.url_voucher ? model.url_voucher : '', + idEmployee: model.id_employee, + employeeFirstName: model.employee?.first_name ? model.employee.first_name : '', + employeeLastName: model.employee?.last_name ? model.employee.last_name : '', + expenses: model.expense ? model.expense.map(mapExpenseEntityFromDbModel) : [], + }; +} diff --git a/src/core/infra/mappers/notification-entity-from-db-model-mapper.ts b/src/core/infra/mappers/notification-entity-from-db-model-mapper.ts deleted file mode 100644 index 049c1d1e..00000000 --- a/src/core/infra/mappers/notification-entity-from-db-model-mapper.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { notification } from '@prisma/client'; -import { Notification } from '../../domain/entities/notification.entity'; - -/** - * Maps a notification entity from a database model. - * - * @param model The database model. - * @returns The notification entity. - * - */ - -export function mapNotificationEntityFromDbModel(model: notification): Notification { - return { - id: model.id, - title: model.title, - body: model.body, - createdAt: model.created_at, - updatedAt: model.updated_at ? model.updated_at : undefined, - }; -} diff --git a/src/core/infra/providers/resend.provider.ts b/src/core/infra/providers/resend.provider.ts new file mode 100644 index 00000000..0c67539c --- /dev/null +++ b/src/core/infra/providers/resend.provider.ts @@ -0,0 +1,46 @@ +import { Resend } from 'resend'; +import { EnvConfigKeys } from '../../../utils/constants'; + +interface ResendApiResponse { + data: CreateEmailResponseSuccess | null; + error: string | null; +} + +interface CreateEmailResponseSuccess { + /** The ID of the newly created email. */ + id: string; +} + +const resend = new Resend(process.env[EnvConfigKeys.RESEND_API_KEY]); + +/** + * Sends an email using Resend to the specified email addresses. + * The email contains the subject and body, the body can be in HTML format. + * + * @param emailTo [string] - Array of emails to send the message to. + * @param subject string - Subject of the email. + * @param body string - Body of the email. + * @warining There's a max of 50 emails per request and 100 emails per day. + * @returns data - The data returned from the email provider. + */ +async function sendEmail(emailTo: string[], subject: string, body: string): Promise { + try { + const emailsFormatted = emailTo.map(email => `${email}`); + const { data, error } = await resend.emails.send({ + from: `Link Bridge <${process.env[EnvConfigKeys.RESEND_EMAIL_FROM]}>`, + to: emailsFormatted, + subject, + html: body, + }); + + if (error) { + return { data: null, error: error.message }; + } + + return { data, error: null }; + } catch (error: unknown) { + return { data: null, error: 'An unexpected error occurred' }; + } +} + +export const EmailProvider = { sendEmail }; diff --git a/src/core/infra/repositories/company.repository.ts b/src/core/infra/repositories/company.repository.ts index b141f3d0..0c4fabc8 100644 --- a/src/core/infra/repositories/company.repository.ts +++ b/src/core/infra/repositories/company.repository.ts @@ -211,6 +211,32 @@ async function findUnarchived(): Promise { } } +/** + * @brief deletes a company by id + * + * @param id + * @returns {Promise} + */ + +async function deleteCompanyById(id: string): Promise { + try { + const data = await Prisma.company.delete({ + where: { + id: id, + }, + }); + + if (!data) { + throw new NotFoundError(RESOURCE_NAME); + } + + return mapCompanyEntityFromDbModel(data); + } catch (error: unknown) { + console.error(error); + throw new Error(`${RESOURCE_NAME} repository error`); + } +} + export const CompanyRepository = { findAll, findById, @@ -219,4 +245,5 @@ export const CompanyRepository = { archiveClient, getArchivedStatus, findUnarchived, + deleteCompanyById, }; diff --git a/src/core/infra/repositories/employee.repository.ts b/src/core/infra/repositories/employee.repository.ts index ff955c14..ab1cad2c 100644 --- a/src/core/infra/repositories/employee.repository.ts +++ b/src/core/infra/repositories/employee.repository.ts @@ -15,7 +15,7 @@ async function findAll(): Promise { return data.map(mapEmployeeEntityFromDbModel); } catch (error: unknown) { - throw new Error(`Failed to fetch all employees: ${error}`); + throw new Error(`${RESOURCE_NAME} Failed to fetch all employees: ${error}`); } } @@ -33,8 +33,7 @@ async function findById(id: string): Promise { return mapEmployeeEntityFromDbModel(data); } catch (error: unknown) { - console.log(error); - throw new Error('Employee repository error'); + throw new Error(`${RESOURCE_NAME} repository error`); } } @@ -59,6 +58,15 @@ async function updateRoleById(id: string, roleId: string): Promise} The deleted employee + * @throws If the employee is not found + * @throws If an unexpected error occurs + * + */ + async function deleteEmployeeById(id: string): Promise { try { const data = await Prisma.employee.delete({ @@ -95,6 +103,24 @@ async function findByEmail(email: string): Promise { } } +async function findByDepartment(departmentId: string): Promise { + try { + const data = await Prisma.employee.findMany({ + where: { + id_department: departmentId, + }, + }); + + if (data.length === 0) { + throw new NotFoundError(RESOURCE_NAME); + } + + return data.map(mapEmployeeEntityFromDbModel); + } catch (error: unknown) { + throw new Error(`Failed to fetch employees by department: ${error}`); + } +} + async function existByEmail(email: string): Promise { try { const data = await Prisma.employee.findUnique({ @@ -136,6 +162,7 @@ export const EmployeeRepository = { findAll, findByEmail, findById, + findByDepartment, existByEmail, updateRoleById, deleteEmployeeById, diff --git a/src/core/infra/repositories/expense.repository.ts b/src/core/infra/repositories/expense.repository.ts new file mode 100644 index 00000000..8f9353d7 --- /dev/null +++ b/src/core/infra/repositories/expense.repository.ts @@ -0,0 +1,229 @@ +import { Prisma } from '../../..'; +import { ExpenseReportStatus } from '../../../utils/enums'; +import { ExpenseEntity, ExpenseReport } from '../../domain/entities/expense.entity'; +import { NotFoundError } from '../../errors/not-found.error'; +import { + mapExpenseEntityFromDbModel, + mapExpenseReportEntityFromDbModel, +} from '../mappers/expense-entity-from-db-model.mapper'; + +const RESOURCE_NAME = 'Expense report'; + +/** + * Finds a expense report by employeeId + * @version 1.0.0 + * @returns {Promise} a promise that resolves in a expense report entity. + */ +async function findAll(): Promise { + try { + const data = await Prisma.expense_report.findMany({ + include: { + expense: true, + employee: true, + }, + }); + + if (!data) { + throw new NotFoundError(RESOURCE_NAME); + } + + return data.map(mapExpenseReportEntityFromDbModel); + } catch (error: unknown) { + throw new Error(`${RESOURCE_NAME} repository error`); + } +} + +/** + * Finds a expense report by id + * @version 1.0.0 + * @returns {Promise} a promise that resolves in a expense report entity. + */ +async function findById(id: string): Promise { + try { + const data = await Prisma.expense_report.findUnique({ + where: { + id: id, + }, + include: { + expense: true, + employee: true, + }, + }); + + if (!data) { + throw new NotFoundError(RESOURCE_NAME); + } + + return mapExpenseReportEntityFromDbModel(data); + } catch (error: unknown) { + throw new Error(`${RESOURCE_NAME} repository error`); + } +} + +/** + * Finds a expense report by employeeId + * @version 1.0.0 + * @returns {Promise} a promise that resolves in a expense report entity. + */ +async function findByEmployeeId(id: string): Promise { + try { + const data = await Prisma.expense_report.findMany({ + where: { + id_employee: id, + }, + include: { + expense: true, + employee: true, + }, + }); + + if (!data) { + throw new NotFoundError(RESOURCE_NAME); + } + + return data.map(mapExpenseReportEntityFromDbModel); + } catch (error: unknown) { + throw new Error(`${RESOURCE_NAME} repository error`); + } +} + +/** + * Creates a new expense report in the database + * @version 1.0.0 + * @returns {Promise} a promise that resolves in a expense report entity. + */ +async function createExpenseReport(data: ExpenseReport): Promise { + try { + const expenseReport = await Prisma.expense_report.create({ + data: { + id: data.id, + title: data.title, + status: data.status, + start_date: data.startDate, + id_employee: data.idEmployee!, + }, + include: { + employee: true, + }, + }); + + return mapExpenseReportEntityFromDbModel(expenseReport); + } catch (error: any) { + throw new Error(error.message); + } +} + +/** + * Creates a new expense in the database and associates it with an expense report + * @version 1.0.0 + * @returns {Promise} a promise that resolves in a expense entity. + */ + +async function createExpense(data: ExpenseEntity): Promise { + try { + const expense = await Prisma.expense.create({ + data: { + id: data.id, + title: data.title, + supplier: data.supplier, + total_amount: data.totalAmount, + date: data.date, + id_report: data.idReport, + url_file: data.urlFile, + }, + }); + + return mapExpenseEntityFromDbModel(expense); + } catch (error: any) { + throw new Error(error.message); + } +} + +/** + * Deletes a expense report + * @version 1.0.0 + * @returns {Promise} + */ + +async function deleteReport(reportId: string): Promise { + try { + const data = await Prisma.expense_report.delete({ + where: { + id: reportId, + }, + include: { expense: true, employee: true }, + }); + if (!data) { + throw new NotFoundError(RESOURCE_NAME); + } + + return mapExpenseReportEntityFromDbModel(data); + } catch (error: unknown) { + throw new Error(`${RESOURCE_NAME} repository error`); + } +} + +/** + * Updates a expense's status + * @version 1.0.0 + * @returns {Promise} a promise that resolves in a expense + */ +async function updateStatusById(id: string, status: ExpenseReportStatus): Promise { + try { + const data = await Prisma.expense_report.update({ + where: { + id: id, + }, + data: { + status: status, + }, + }); + if (!data) { + throw new NotFoundError(RESOURCE_NAME); + } + + return mapExpenseReportEntityFromDbModel(data); + } catch (error: any) { + if (error.code == 'P2025' && error.meta.cause == 'Record to update not found.') + throw new Error('Expense not found'); + throw new Error('An unexpected error occurred'); + } +} + +/** + * Updates a expense's payment url (url_voucher) + * @version 1.0.0 + * @returns {Promise} a promise that resolves in a expense + */ +async function updatePaymentFileUrlById(id: string, urlVoucher: string): Promise { + try { + const data = await Prisma.expense_report.update({ + where: { + id: id, + }, + data: { + url_voucher: urlVoucher, + }, + }); + if (!data) { + throw new NotFoundError(RESOURCE_NAME); + } + + return mapExpenseReportEntityFromDbModel(data); + } catch (error: any) { + if (error.code == 'P2025' && error.meta.cause == 'Record to update not found.') + throw new Error('Expense not found'); + throw new Error('An unexpected error occurred'); + } +} + +export const ExpenseRepository = { + findAll, + findById, + findByEmployeeId, + createExpenseReport, + createExpense, + updateStatusById, + updatePaymentFileUrlById, + deleteReport, +}; diff --git a/src/core/infra/repositories/notification.repository.ts b/src/core/infra/repositories/notification.repository.ts deleted file mode 100644 index b4ccb82e..00000000 --- a/src/core/infra/repositories/notification.repository.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Prisma } from '../../..'; -import { Notification } from '../../domain/entities/notification.entity'; -import { NotFoundError } from '../../errors/not-found.error'; -import { mapNotificationEntityFromDbModel } from '../mappers/notification-entity-from-db-model-mapper'; - -/** - * @brief Name of the resource used in the notification repository operations. - */ - -const RESOURCE_NAME = 'Notification'; - -/** - * @brief Function that sets the token of an employee - * - * @param email: string - * @param token: string - * - * @return Promise. True if the token was saved successfully, false otherwise. - */ - -async function saveToken(email: string, token: string): Promise { - try { - const data = await Prisma.employee.update({ - where: { - email: email, - }, - data: { - device_token: token, - }, - }); - - return !!data; - } catch (error: unknown) { - throw new Error(`Failed to assign user Device Token: ${error}`); - } -} - -/** - * @brief Creates a new notification in the database. - * - * @param notification: Notification - New notification to be created. - * @return {Promise} - Created notification. - */ - -async function createNotification(notification: Notification): Promise { - try { - const data = await Prisma.notification.create({ - data: { - id: notification.id, - title: notification.title, - body: notification.body, - created_at: notification.createdAt, - updated_at: notification.updatedAt, - }, - }); - - return mapNotificationEntityFromDbModel(data); - } catch (error: unknown) { - throw new Error(`${RESOURCE_NAME} repository error`); - } -} - -/** - * @brief Finds all notifications in the database. - * - * @return {Promise} - List of notifications. - */ - -async function findAllNotifications(): Promise { - try { - const data = await Prisma.notification.findMany(); - - if (!data) { - throw new NotFoundError(RESOURCE_NAME); - } - - return data.map(mapNotificationEntityFromDbModel); - } catch (error: unknown) { - throw new Error(`${RESOURCE_NAME} repository error`); - } -} - -export const NotificationRepository = { saveToken, findAllNotifications, createNotification }; diff --git a/src/core/infra/repositories/project.repository.ts b/src/core/infra/repositories/project.repository.ts index a4bae2d8..8d26041a 100644 --- a/src/core/infra/repositories/project.repository.ts +++ b/src/core/infra/repositories/project.repository.ts @@ -1,6 +1,6 @@ import { Decimal } from '@prisma/client/runtime/library'; import { Prisma } from '../../..'; -import { ProjectStatus, SupportedRoles } from '../../../utils/enums'; +import { ProjectStatus, SupportedDepartments, SupportedRoles } from '../../../utils/enums'; import { ProjectEntity } from '../../domain/entities/project.entity'; import { NotFoundError } from '../../errors/not-found.error'; import { mapProjectEntityFromDbModel } from '../mappers/project-entity-from-db-model-mapper'; @@ -14,44 +14,45 @@ const RESOURCE_NAME = 'Project info'; */ async function findAllByRole(role: SupportedRoles): Promise { try { - type PrismaProjectsRes = ReturnType; - let projects: PrismaProjectsRes; - let doneProjects: PrismaProjectsRes; - let res: Awaited; - - if (role === SupportedRoles.ADMIN) { - projects = Prisma.project.findMany({ - where: { - NOT: { status: ProjectStatus.DONE }, - }, - orderBy: { status: 'desc' }, - }); - doneProjects = Prisma.project.findMany({ where: { status: ProjectStatus.DONE } }); - res = (await Promise.all([projects, doneProjects])).flat(); - } else { - projects = Prisma.project.findMany({ - where: { - area: role, - NOT: { status: ProjectStatus.DONE }, - }, - orderBy: { status: 'desc' }, - }); - doneProjects = Prisma.project.findMany({ - where: { - status: ProjectStatus.DONE, - area: role, - }, - orderBy: { status: 'desc' }, - }); - res = (await Promise.all([projects, doneProjects])).flat(); + const roleToDepartment = { + [SupportedRoles.ADMIN]: {}, + [SupportedRoles.LEGAL]: { area: { in: [SupportedDepartments.LEGAL, SupportedDepartments.LEGAL_AND_ACCOUNTING] } }, + [SupportedRoles.ACCOUNTING]: { + area: { in: [SupportedDepartments.ACCOUNTING, SupportedDepartments.LEGAL_AND_ACCOUNTING] }, + }, + [SupportedRoles.WITHOUT_ROLE]: null, + }; + + const departmentCriteria = roleToDepartment[role]; + if (departmentCriteria === null) { + return []; } - if (!res) throw new NotFoundError(`${RESOURCE_NAME} error`); - return res.map(mapProjectEntityFromDbModel); + const projects = await Prisma.project.findMany({ + where: { + ...departmentCriteria, + status: { + not: ProjectStatus.DONE, + }, + }, + orderBy: { status: 'desc' }, + }); + + const doneProjects = await Prisma.project.findMany({ + where: { + ...departmentCriteria, + status: ProjectStatus.DONE, + }, + orderBy: { status: 'desc' }, + }); + + const allProjects = [...projects, ...doneProjects]; + return allProjects.map(mapProjectEntityFromDbModel); } catch (error: unknown) { throw new Error(`${RESOURCE_NAME} repository error`); } } + /** * Finds a project status by id * @version 1.0.0 @@ -205,6 +206,33 @@ async function updateProjectStatus(projectId: string, newStatus: ProjectStatus): } } +/** + * A function that deletes a project from the database + * @param id ID of the project to delete + * @returns {Promise} the deleted project + * @throws {Error} if the project is not found + * @throws {Error} if an unexpected error occurs + * + */ + +async function deleteProjectById(id: string): Promise { + try { + const data = await Prisma.project.delete({ + where: { + id: id, + }, + }); + + if (!data) { + throw new NotFoundError(`${RESOURCE_NAME}`); + } + + return mapProjectEntityFromDbModel(data); + } catch (error: unknown) { + throw new Error(`${RESOURCE_NAME}An unexpected error occurred`); + } +} + export const ProjectRepository = { findProjectStatusById, findById, @@ -213,4 +241,5 @@ export const ProjectRepository = { updateProject, updateProjectStatus, findAllByRole, + deleteProjectById, }; diff --git a/src/index.ts b/src/index.ts index 92b030a2..e82f31f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ const app: Express = express(); const HOST: string = process.env[EnvConfigKeys.HOST] || 'localhost'; const PORT: number = process.env[EnvConfigKeys.PORT] ? parseInt(process.env[EnvConfigKeys.PORT]) : 4000; -const CLIENT_URL = process.env[EnvConfigKeys.CLIENT_URL]; +export const CLIENT_URL = process.env[EnvConfigKeys.CLIENT_URL]; app.use( cors({ diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 3ef39d54..6e3d17e1 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -23,4 +23,6 @@ export enum EnvConfigKeys { FIREBASE_MESSAGING_SENDER_ID = 'FIREBASE_MESSAGING_SENDER_ID', FIREBASE_APP_ID = 'FIREBASE_APP_ID', FIREBASE_PRIVATE_KEY = 'FIREBASE_PRIVATE_KEY', + RESEND_API_KEY = 'RESEND_API_KEY', + RESEND_EMAIL_FROM = 'RESEND_EMAIL_FROM', } diff --git a/src/utils/email/email.templates.ts b/src/utils/email/email.templates.ts new file mode 100644 index 00000000..0dc7a3f1 --- /dev/null +++ b/src/utils/email/email.templates.ts @@ -0,0 +1,241 @@ +import { CLIENT_URL } from '../..'; +import { ProjectEntity } from '../../core/domain/entities/project.entity'; +import { Task } from '../../core/domain/entities/task.entity'; + +interface EmailPayload { + subject: string; + body: string; +} + +export function notifyAssignedTaskEmailTemplate(firstName: string, lastName: string, task: Task): EmailPayload { + return { + subject: `๐Ÿ“š You have a New Task!`, + body: ` + + + + + + + + + New Message + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + + + + + + + +

๐Ÿ“š New task!

Hi, ${firstName} ${lastName}

You have been assigned a new task in LinkBridge. Please review the details below: 

Title: ${task.title}
Description: ${task.description}
Start Date: ${task.startDate}
End Date: ${task.endDate || ''}
Worked Hours: ${task.workedHours || ''}

Click here!
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+
+ + + `, + }; +} + +export function notifyOtherDeparmentEmailTemplate(deparmentTitle: string, project: ProjectEntity): EmailPayload { + const subject = `๐Ÿ“š The ${deparmentTitle} deparment has finish!`; + const body = ` + + + + + + + + + New Message + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+ + + + + + + + + + +

โœ… New project!

Hi,

The ${deparmentTitle} deparment has finished their activities in the ${project.name} project. Please review the details below: 

Click here!
+ + + + +
+ + + + +
+ + + + +
+ + + + +
+
+ + + `; + return { subject, body }; +} diff --git a/src/utils/enums/index.ts b/src/utils/enums/index.ts index e402f2f0..731d962f 100644 --- a/src/utils/enums/index.ts +++ b/src/utils/enums/index.ts @@ -9,6 +9,7 @@ export enum SupportedDepartments { WITHOUT_DEPARTMENT = 'Without department', LEGAL = 'Legal', ACCOUNTING = 'Accounting', + LEGAL_AND_ACCOUNTING = 'Legal and accounting', } export enum TaskStatus { @@ -57,3 +58,10 @@ export enum ProjectPeriodicity { TWELVE_MONTHS = '12 months', WHEN_NEEDED = 'When needed', } + +export enum ExpenseReportStatus { + ACCEPTED = 'Accepted', + PAYED = 'Payed', + PENDING = 'Pending', + REJECTED = 'Rejected', +} diff --git a/src/utils/is-authorize-deparment.ts b/src/utils/is-authorize-deparment.ts new file mode 100644 index 00000000..c733e01e --- /dev/null +++ b/src/utils/is-authorize-deparment.ts @@ -0,0 +1,23 @@ +import { SupportedDepartments, SupportedRoles } from './enums'; + +export function isAuthorized(roleTitle: string, projectArea: string): boolean { + const normalizedRole = roleTitle.toUpperCase(); + const normalizedProjectArea = projectArea.toUpperCase(); + + if (normalizedRole === SupportedRoles.ADMIN.toUpperCase()) { + return true; + } + + if (normalizedRole === normalizedProjectArea) { + return true; + } + + if (normalizedProjectArea === SupportedDepartments.LEGAL_AND_ACCOUNTING.toUpperCase()) { + return ( + normalizedRole === SupportedRoles.LEGAL.toUpperCase() || + normalizedRole === SupportedRoles.ACCOUNTING.toUpperCase() + ); + } + + return false; +}