diff --git a/.github/ISSUE_TEMPLATE/issue-de-defecto.md b/.github/ISSUE_TEMPLATE/issue-de-defecto.md index 938e95a4..e2db00f8 100644 --- a/.github/ISSUE_TEMPLATE/issue-de-defecto.md +++ b/.github/ISSUE_TEMPLATE/issue-de-defecto.md @@ -1,15 +1,11 @@ --- name: Issue de Defecto about: Registrar un defecto encontrado -title: Modulo/RFXX/Detalle +title: Módulo/RFXX/Detalle labels: '' assignees: '' --- -# Contenido - -## Test ID - ## Descripción de la tarea ¿Qué esta mal? 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 4ce9cec1..bdaf984e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,15 +23,9 @@ dependencies: firebase-admin: specifier: ^12.0.0 version: 12.1.0 - swagger-jsdoc: - specifier: ^6.2.8 - version: 6.2.8(openapi-types@12.1.3) - swagger-ui-express: - specifier: ^5.0.0 - version: 5.0.0(express@4.19.2) - tsoa: - specifier: ^6.2.1 - version: 6.2.1 + resend: + specifier: ^3.2.0 + version: 3.2.0 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -67,12 +61,6 @@ devDependencies: '@types/sinon': specifier: ^17.0.3 version: 17.0.3 - '@types/swagger-jsdoc': - specifier: ^6.0.4 - version: 6.0.4 - '@types/swagger-ui-express': - specifier: ^4.1.6 - version: 4.1.6 '@types/uuid': specifier: ^9.0.8 version: 9.0.8 @@ -133,9 +121,6 @@ devDependencies: ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@types/node@20.12.7)(typescript@5.4.5) - typedoc: - specifier: ^0.25.13 - version: 0.25.13(typescript@5.4.5) typescript: specifier: ^5.3.3 version: 5.4.5 @@ -147,38 +132,6 @@ packages: engines: {node: '>=0.10.0'} dev: true - /@apidevtools/json-schema-ref-parser@9.1.2: - resolution: {integrity: sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==} - dependencies: - '@jsdevtools/ono': 7.1.3 - '@types/json-schema': 7.0.15 - call-me-maybe: 1.0.2 - js-yaml: 4.1.0 - dev: false - - /@apidevtools/openapi-schemas@2.1.0: - resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} - engines: {node: '>=10'} - dev: false - - /@apidevtools/swagger-methods@3.0.2: - resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} - dev: false - - /@apidevtools/swagger-parser@10.0.3(openapi-types@12.1.3): - resolution: {integrity: sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==} - peerDependencies: - openapi-types: '>=7' - dependencies: - '@apidevtools/json-schema-ref-parser': 9.1.2 - '@apidevtools/openapi-schemas': 2.1.0 - '@apidevtools/swagger-methods': 3.0.2 - '@jsdevtools/ono': 7.1.3 - call-me-maybe: 1.0.2 - openapi-types: 12.1.3 - z-schema: 5.0.5 - dev: false - /@cspotcode/source-map-support@0.8.1: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -391,231 +344,6 @@ packages: dev: false optional: true - /@hapi/accept@6.0.3: - resolution: {integrity: sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/ammo@6.0.1: - resolution: {integrity: sha512-pmL+nPod4g58kXrMcsGLp05O2jF4P2Q3GiL8qYV7nKYEh3cGf+rV4P5Jyi2Uq0agGhVU63GtaSAfBEZOlrJn9w==} - dependencies: - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/b64@6.0.1: - resolution: {integrity: sha512-ZvjX4JQReUmBheeCq+S9YavcnMMHWqx3S0jHNXWIM1kQDxB9cyfSycpVvjfrKcIS8Mh5N3hmu/YKo4Iag9g2Kw==} - dependencies: - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/boom@10.0.1: - resolution: {integrity: sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==} - dependencies: - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/bounce@3.0.1: - resolution: {integrity: sha512-G+/Pp9c1Ha4FDP+3Sy/Xwg2O4Ahaw3lIZFSX+BL4uWi64CmiETuZPxhKDUD4xBMOUZbBlzvO8HjiK8ePnhBadA==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/bourne@3.0.0: - resolution: {integrity: sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w==} - dev: false - - /@hapi/call@9.0.1: - resolution: {integrity: sha512-uPojQRqEL1GRZR4xXPqcLMujQGaEpyVPRyBlD8Pp5rqgIwLhtveF9PkixiKru2THXvuN8mUrLeet5fqxKAAMGg==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/catbox-memory@6.0.1: - resolution: {integrity: sha512-sVb+/ZxbZIvaMtJfAbdyY+QJUQg9oKTwamXpEg/5xnfG5WbJLTjvEn4kIGKz9pN3ENNbIL/bIdctmHmqi/AdGA==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/catbox@12.1.1: - resolution: {integrity: sha512-hDqYB1J+R0HtZg4iPH3LEnldoaBsar6bYp0EonBmNQ9t5CO+1CqgCul2ZtFveW1ReA5SQuze9GPSU7/aecERhw==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/hoek': 11.0.4 - '@hapi/podium': 5.0.1 - '@hapi/validate': 2.0.1 - dev: false - - /@hapi/content@6.0.0: - resolution: {integrity: sha512-CEhs7j+H0iQffKfe5Htdak5LBOz/Qc8TRh51cF+BFv0qnuph3Em4pjGVzJMkI2gfTDdlJKWJISGWS1rK34POGA==} - dependencies: - '@hapi/boom': 10.0.1 - dev: false - - /@hapi/cryptiles@6.0.1: - resolution: {integrity: sha512-9GM9ECEHfR8lk5ASOKG4+4ZsEzFqLfhiryIJ2ISePVB92OHLp/yne4m+zn7z9dgvM98TLpiFebjDFQ0UHcqxXQ==} - engines: {node: '>=14.0.0'} - dependencies: - '@hapi/boom': 10.0.1 - dev: false - - /@hapi/file@3.0.0: - resolution: {integrity: sha512-w+lKW+yRrLhJu620jT3y+5g2mHqnKfepreykvdOcl9/6up8GrQQn+l3FRTsjHTKbkbfQFkuksHpdv2EcpKcJ4Q==} - dev: false - - /@hapi/hapi@21.3.9: - resolution: {integrity: sha512-AT5m+Rb8iSOFG3zWaiEuTJazf4HDYl5UpRpyxMJ3yR+g8tOEmqDv6FmXrLHShdvDOStAAepHGnr1G7egkFSRdw==} - engines: {node: '>=14.15.0'} - dependencies: - '@hapi/accept': 6.0.3 - '@hapi/ammo': 6.0.1 - '@hapi/boom': 10.0.1 - '@hapi/bounce': 3.0.1 - '@hapi/call': 9.0.1 - '@hapi/catbox': 12.1.1 - '@hapi/catbox-memory': 6.0.1 - '@hapi/heavy': 8.0.1 - '@hapi/hoek': 11.0.4 - '@hapi/mimos': 7.0.1 - '@hapi/podium': 5.0.1 - '@hapi/shot': 6.0.1 - '@hapi/somever': 4.1.1 - '@hapi/statehood': 8.1.1 - '@hapi/subtext': 8.1.0 - '@hapi/teamwork': 6.0.0 - '@hapi/topo': 6.0.2 - '@hapi/validate': 2.0.1 - dev: false - - /@hapi/heavy@8.0.1: - resolution: {integrity: sha512-gBD/NANosNCOp6RsYTsjo2vhr5eYA3BEuogk6cxY0QdhllkkTaJFYtTXv46xd6qhBVMbMMqcSdtqey+UQU3//w==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/hoek': 11.0.4 - '@hapi/validate': 2.0.1 - dev: false - - /@hapi/hoek@11.0.4: - resolution: {integrity: sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==} - dev: false - - /@hapi/iron@7.0.1: - resolution: {integrity: sha512-tEZnrOujKpS6jLKliyWBl3A9PaE+ppuL/+gkbyPPDb/l2KSKQyH4lhMkVb+sBhwN+qaxxlig01JRqB8dk/mPxQ==} - dependencies: - '@hapi/b64': 6.0.1 - '@hapi/boom': 10.0.1 - '@hapi/bourne': 3.0.0 - '@hapi/cryptiles': 6.0.1 - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/mimos@7.0.1: - resolution: {integrity: sha512-b79V+BrG0gJ9zcRx1VGcCI6r6GEzzZUgiGEJVoq5gwzuB2Ig9Cax8dUuBauQCFKvl2YWSWyOc8mZ8HDaJOtkew==} - dependencies: - '@hapi/hoek': 11.0.4 - mime-db: 1.52.0 - dev: false - - /@hapi/nigel@5.0.1: - resolution: {integrity: sha512-uv3dtYuB4IsNaha+tigWmN8mQw/O9Qzl5U26Gm4ZcJVtDdB1AVJOwX3X5wOX+A07qzpEZnOMBAm8jjSqGsU6Nw==} - engines: {node: '>=14.0.0'} - dependencies: - '@hapi/hoek': 11.0.4 - '@hapi/vise': 5.0.1 - dev: false - - /@hapi/pez@6.1.0: - resolution: {integrity: sha512-+FE3sFPYuXCpuVeHQ/Qag1b45clR2o54QoonE/gKHv9gukxQ8oJJZPR7o3/ydDTK6racnCJXxOyT1T93FCJMIg==} - dependencies: - '@hapi/b64': 6.0.1 - '@hapi/boom': 10.0.1 - '@hapi/content': 6.0.0 - '@hapi/hoek': 11.0.4 - '@hapi/nigel': 5.0.1 - dev: false - - /@hapi/podium@5.0.1: - resolution: {integrity: sha512-eznFTw6rdBhAijXFIlBOMJJd+lXTvqbrBIS4Iu80r2KTVIo4g+7fLy4NKp/8+UnSt5Ox6mJtAlKBU/Sf5080TQ==} - dependencies: - '@hapi/hoek': 11.0.4 - '@hapi/teamwork': 6.0.0 - '@hapi/validate': 2.0.1 - dev: false - - /@hapi/shot@6.0.1: - resolution: {integrity: sha512-s5ynMKZXYoDd3dqPw5YTvOR/vjHvMTxc388+0qL0jZZP1+uwXuUD32o9DuuuLsmTlyXCWi02BJl1pBpwRuUrNA==} - dependencies: - '@hapi/hoek': 11.0.4 - '@hapi/validate': 2.0.1 - dev: false - - /@hapi/somever@4.1.1: - resolution: {integrity: sha512-lt3QQiDDOVRatS0ionFDNrDIv4eXz58IibQaZQDOg4DqqdNme8oa0iPWcE0+hkq/KTeBCPtEOjDOBKBKwDumVg==} - dependencies: - '@hapi/bounce': 3.0.1 - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/statehood@8.1.1: - resolution: {integrity: sha512-YbK7PSVUA59NArAW5Np0tKRoIZ5VNYUicOk7uJmWZF6XyH5gGL+k62w77SIJb0AoAJ0QdGQMCQ/WOGL1S3Ydow==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/bounce': 3.0.1 - '@hapi/bourne': 3.0.0 - '@hapi/cryptiles': 6.0.1 - '@hapi/hoek': 11.0.4 - '@hapi/iron': 7.0.1 - '@hapi/validate': 2.0.1 - dev: false - - /@hapi/subtext@8.1.0: - resolution: {integrity: sha512-PyaN4oSMtqPjjVxLny1k0iYg4+fwGusIhaom9B2StinBclHs7v46mIW706Y+Wo21lcgulGyXbQrmT/w4dus6ww==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/bourne': 3.0.0 - '@hapi/content': 6.0.0 - '@hapi/file': 3.0.0 - '@hapi/hoek': 11.0.4 - '@hapi/pez': 6.1.0 - '@hapi/wreck': 18.1.0 - dev: false - - /@hapi/teamwork@6.0.0: - resolution: {integrity: sha512-05HumSy3LWfXpmJ9cr6HzwhAavrHkJ1ZRCmNE2qJMihdM5YcWreWPfyN0yKT2ZjCM92au3ZkuodjBxOibxM67A==} - engines: {node: '>=14.0.0'} - dev: false - - /@hapi/topo@6.0.2: - resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==} - dependencies: - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/validate@2.0.1: - resolution: {integrity: sha512-NZmXRnrSLK8MQ9y/CMqE9WSspgB9xA41/LlYR0k967aSZebWr4yNrpxIbov12ICwKy4APSlWXZga9jN5p6puPA==} - dependencies: - '@hapi/hoek': 11.0.4 - '@hapi/topo': 6.0.2 - dev: false - - /@hapi/vise@5.0.1: - resolution: {integrity: sha512-XZYWzzRtINQLedPYlIkSkUr7m5Ddwlu99V9elh8CSygXstfv3UnWIXT0QD+wmR0VAG34d2Vx3olqcEhRRoTu9A==} - dependencies: - '@hapi/hoek': 11.0.4 - dev: false - - /@hapi/wreck@18.1.0: - resolution: {integrity: sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/bourne': 3.0.0 - '@hapi/hoek': 11.0.4 - dev: false - /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -670,10 +398,6 @@ packages: dev: false optional: true - /@jsdevtools/ono@7.1.3: - resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - dev: false - /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -695,6 +419,10 @@ 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'} @@ -810,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: @@ -863,48 +608,6 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true - /@tsoa/cli@6.2.1: - resolution: {integrity: sha512-SS28cvL2uurau2PZbBO8Ks6O9LF497iMlnUfMr7hffbgxh81SftfG+qvddeniNw0ttSB593Mljvv+fPabEbrfQ==} - engines: {node: '>=18.0.0', yarn: '>=1.9.4'} - hasBin: true - dependencies: - '@tsoa/runtime': 6.2.1 - '@types/multer': 1.4.11 - fs-extra: 11.2.0 - glob: 10.3.15 - handlebars: 4.7.8 - merge-anything: 5.1.7 - minimatch: 9.0.4 - ts-deepmerge: 7.0.0 - typescript: 5.4.5 - validator: 13.12.0 - yaml: 2.4.2 - yargs: 17.7.2 - transitivePeerDependencies: - - supports-color - dev: false - - /@tsoa/runtime@6.2.1: - resolution: {integrity: sha512-YOA7ha6W6GQsSr3Pvb5omb5AwizvQd7GUu54Oi2TjNWYOzfczBROZonReMfKBiNULiZBDmEc5r1Hs+Kbbfjgyw==} - engines: {node: '>=18.0.0', yarn: '>=1.9.4'} - dependencies: - '@hapi/boom': 10.0.1 - '@hapi/hapi': 21.3.9 - '@types/koa': 2.15.0 - '@types/multer': 1.4.11 - express: 4.19.2 - reflect-metadata: 0.2.2 - validator: 13.12.0 - transitivePeerDependencies: - - supports-color - dev: false - - /@types/accepts@1.3.7: - resolution: {integrity: sha512-Pay9fq2lM2wXPWbteBsRAGiWH2hig4ZE2asK+mm7kUzlxRTfL961rj89I6zV/E3PcIkDqyuBEcMxFT7rccugeQ==} - dependencies: - '@types/node': 20.12.7 - dev: false - /@types/body-parser@1.19.5: resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} dependencies: @@ -932,19 +635,6 @@ packages: dependencies: '@types/node': 20.12.7 - /@types/content-disposition@0.5.8: - resolution: {integrity: sha512-QVSSvno3dE0MgO76pJhmv4Qyi/j0Yk9pBp0Y7TJ2Tlj+KCgJWY6qX7nnxCOLkZ3VYRSIk1WTxCvwUSdx6CCLdg==} - dev: false - - /@types/cookies@0.9.0: - resolution: {integrity: sha512-40Zk8qR147RABiQ7NQnBzWzDcjKzNrntB5BAmeGCb2p/MIyOE+4BVvc17wumsUqUw00bJYqoXFHYygQnEFh4/Q==} - dependencies: - '@types/connect': 3.4.38 - '@types/express': 4.17.21 - '@types/keygrip': 1.0.6 - '@types/node': 20.12.7 - dev: false - /@types/cors@2.8.17: resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==} dependencies: @@ -973,15 +663,12 @@ packages: '@types/qs': 6.9.15 '@types/serve-static': 1.15.7 - /@types/http-assert@1.5.5: - resolution: {integrity: sha512-4+tE/lwdAahgZT1g30Jkdm9PzFRde0xwxBNUyRsCitRvCQB90iuA2uJYdUnhnANRcqGXaWOGY4FEoxeElNAK2g==} - dev: false - /@types/http-errors@2.0.4: resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true /@types/json5@0.0.29: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} @@ -993,29 +680,6 @@ packages: '@types/node': 20.12.7 dev: false - /@types/keygrip@1.0.6: - resolution: {integrity: sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==} - dev: false - - /@types/koa-compose@3.2.8: - resolution: {integrity: sha512-4Olc63RY+MKvxMwVknCUDhRQX1pFQoBZ/lXcRLP69PQkEpze/0cr8LNqJQe5NFb/b19DWi2a5bTi2VAlQzhJuA==} - dependencies: - '@types/koa': 2.15.0 - dev: false - - /@types/koa@2.15.0: - resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==} - dependencies: - '@types/accepts': 1.3.7 - '@types/content-disposition': 0.5.8 - '@types/cookies': 0.9.0 - '@types/http-assert': 1.5.5 - '@types/http-errors': 2.0.4 - '@types/keygrip': 1.0.6 - '@types/koa-compose': 3.2.8 - '@types/node': 20.12.7 - dev: false - /@types/long@4.0.2: resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} requiresBuild: true @@ -1039,12 +703,6 @@ packages: resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} dev: true - /@types/multer@1.4.11: - resolution: {integrity: sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==} - dependencies: - '@types/express': 4.17.21 - dev: false - /@types/node@20.12.7: resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==} dependencies: @@ -1106,17 +764,6 @@ packages: resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} dev: true - /@types/swagger-jsdoc@6.0.4: - resolution: {integrity: sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==} - dev: true - - /@types/swagger-ui-express@4.1.6: - resolution: {integrity: sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==} - dependencies: - '@types/express': 4.17.21 - '@types/serve-static': 1.15.7 - dev: true - /@types/tough-cookie@4.0.5: resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} requiresBuild: true @@ -1267,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'} @@ -1348,10 +1000,6 @@ packages: engines: {node: '>=12'} dev: false - /ansi-sequence-parser@1.1.1: - resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==} - dev: true - /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1382,6 +1030,7 @@ packages: /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true /array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} @@ -1545,6 +1194,7 @@ packages: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 + dev: true /brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} @@ -1597,10 +1247,6 @@ packages: get-intrinsic: 1.2.4 set-function-length: 1.2.2 - /call-me-maybe@1.0.2: - resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} - dev: false - /callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1701,6 +1347,7 @@ packages: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 dev: false + optional: true /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1720,17 +1367,10 @@ packages: dev: false optional: true - /commander@6.2.0: - resolution: {integrity: sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==} - engines: {node: '>= 6'} - dev: false - - /commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - requiresBuild: true + /commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} dev: false - optional: true /comment-parser@1.4.1: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} @@ -1739,6 +1379,14 @@ packages: /concat-map@0.0.1: 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==} @@ -1884,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'} @@ -1968,6 +1621,34 @@ packages: engines: {node: '>=6.0.0'} dependencies: 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==} @@ -2001,6 +1682,17 @@ packages: 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 @@ -2029,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'} @@ -2350,6 +2047,7 @@ packages: /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + dev: true /etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} @@ -2586,17 +2284,9 @@ packages: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} dev: false - /fs-extra@11.2.0: - resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} - engines: {node: '>=14.14'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.1 - dev: false - /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -2703,29 +2393,18 @@ packages: is-glob: 4.0.3 dev: true - /glob@10.3.15: - resolution: {integrity: sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==} + /glob@10.4.1: + resolution: {integrity: sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==} engines: {node: '>=16 || 14 >=14.18'} hasBin: true dependencies: foreground-child: 3.1.1 - jackspeak: 2.3.6 + jackspeak: 3.1.2 minimatch: 9.0.4 - minipass: 7.1.1 + minipass: 7.1.2 path-scurry: 1.11.1 dev: false - /glob@7.1.6: - resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: false - /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} dependencies: @@ -2819,10 +2498,6 @@ packages: dependencies: get-intrinsic: 1.2.4 - /graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - dev: false - /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true @@ -2840,19 +2515,6 @@ packages: dev: false optional: true - /handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} - engines: {node: '>=0.4.7'} - hasBin: true - dependencies: - minimist: 1.2.8 - neo-async: 2.6.2 - source-map: 0.6.1 - wordwrap: 1.0.0 - optionalDependencies: - uglify-js: 3.17.4 - dev: false - /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true @@ -2892,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'} @@ -2984,6 +2666,7 @@ packages: dependencies: once: 1.4.0 wrappy: 1.0.2 + dev: true /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3164,11 +2847,6 @@ packages: call-bind: 1.0.7 dev: true - /is-what@4.1.16: - resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} - engines: {node: '>=12.13'} - dev: false - /isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} dev: true @@ -3176,8 +2854,8 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - /jackspeak@2.3.6: - resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + /jackspeak@3.1.2: + resolution: {integrity: sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==} engines: {node: '>=14'} dependencies: '@isaacs/cliui': 8.0.2 @@ -3189,11 +2867,33 @@ packages: 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 dependencies: argparse: 2.0.1 + dev: true /jsdoc-type-pratt-parser@4.0.0: resolution: {integrity: sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==} @@ -3227,18 +2927,6 @@ packages: minimist: 1.2.8 dev: true - /jsonc-parser@3.2.1: - resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} - dev: true - - /jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - dev: false - /jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} @@ -3313,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'} @@ -3344,6 +3036,7 @@ packages: /lodash.get@4.4.2: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + dev: true /lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -3353,10 +3046,6 @@ packages: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} dev: false - /lodash.isequal@4.5.0: - resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} - dev: false - /lodash.isinteger@4.0.4: resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} dev: false @@ -3377,10 +3066,6 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true - /lodash.mergewith@4.6.2: - resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} - dev: false - /lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} dev: false @@ -3397,6 +3082,13 @@ 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: @@ -3428,20 +3120,10 @@ packages: lru-cache: 4.0.2 dev: false - /lunr@2.3.9: - resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} - dev: true - /make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} dev: true - /marked@4.3.0: - resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} - engines: {node: '>= 12'} - hasBin: true - dev: true - /mdast-util-from-markdown@2.0.0: resolution: {integrity: sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==} dependencies: @@ -3472,13 +3154,6 @@ packages: engines: {node: '>= 0.6'} dev: false - /merge-anything@5.1.7: - resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} - engines: {node: '>=12.13'} - dependencies: - is-what: 4.1.16 - dev: false - /merge-descriptors@1.0.1: resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} dev: false @@ -3711,6 +3386,7 @@ packages: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 + dev: true /minimatch@5.0.1: resolution: {integrity: sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==} @@ -3719,6 +3395,13 @@ 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'} @@ -3728,8 +3411,8 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - /minipass@7.1.1: - resolution: {integrity: sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==} + /minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} dev: false @@ -3793,10 +3476,6 @@ packages: engines: {node: '>= 0.6'} dev: false - /neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - dev: false - /nise@5.1.9: resolution: {integrity: sha512-qOnoujW4SV6e40dYxJOb3uvuoPHtmLzIk4TFo+j0jPJoC+5Z9xja5qH5JZobEPsa8+YYphMrOSwnrshEhG2qww==} dependencies: @@ -3837,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'} @@ -3912,10 +3599,6 @@ packages: dependencies: wrappy: 1.0.2 - /openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - dev: false - /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -3948,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'} @@ -3961,6 +3651,7 @@ packages: /path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} + dev: true /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} @@ -3975,7 +3666,7 @@ packages: engines: {node: '>=16 || 14 >=14.18'} dependencies: lru-cache: 10.2.2 - minipass: 7.1.1 + minipass: 7.1.2 dev: false /path-to-regexp@0.1.7: @@ -3995,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'} @@ -4081,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'} @@ -4176,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'} @@ -4192,10 +3908,6 @@ packages: picomatch: 2.3.1 dev: true - /reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - dev: false - /regexp.prototype.flags@1.5.2: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} @@ -4210,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'} @@ -4296,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 @@ -4382,15 +4113,6 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - /shiki@0.14.7: - resolution: {integrity: sha512-dNPAPrxSc87ua2sKJ3H5dQ/6ZaY8RNnaAqK+t0eG7p0Soi2ydiqbGOTaZCqaYvA/uZYfS1LJnemt3Q+mSfcPCg==} - dependencies: - ansi-sequence-parser: 1.1.1 - jsonc-parser: 3.2.1 - vscode-oniguruma: 1.7.0 - vscode-textmate: 8.0.0 - dev: true - /side-channel@1.0.6: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} @@ -4443,6 +4165,7 @@ packages: /source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + dev: true /spdx-exceptions@2.5.0: resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} @@ -4585,44 +4308,6 @@ packages: engines: {node: '>= 0.4'} dev: true - /swagger-jsdoc@6.2.8(openapi-types@12.1.3): - resolution: {integrity: sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==} - engines: {node: '>=12.0.0'} - hasBin: true - dependencies: - commander: 6.2.0 - doctrine: 3.0.0 - glob: 7.1.6 - lodash.mergewith: 4.6.2 - swagger-parser: 10.0.3(openapi-types@12.1.3) - yaml: 2.0.0-1 - transitivePeerDependencies: - - openapi-types - dev: false - - /swagger-parser@10.0.3(openapi-types@12.1.3): - resolution: {integrity: sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==} - engines: {node: '>=10'} - dependencies: - '@apidevtools/swagger-parser': 10.0.3(openapi-types@12.1.3) - transitivePeerDependencies: - - openapi-types - dev: false - - /swagger-ui-dist@5.17.10: - resolution: {integrity: sha512-fp8SYeEK216KS1/noDvursUOGojEbkvtckOpOmAGZUjlx/ma7VLD2PLQwyermjlzFrlHI5uCt1V+M1C3qBvRyQ==} - dev: false - - /swagger-ui-express@5.0.0(express@4.19.2): - resolution: {integrity: sha512-tsU9tODVvhyfkNSvf03E6FAk+z+5cU3lXAzMy6Pv4av2Gt2xA0++fogwC4qo19XuFf6hdxevPuVCSKFuMHJhFA==} - engines: {node: '>= v0.10.32'} - peerDependencies: - express: '>=4.0.0 || >=5.0.0-beta' - dependencies: - express: 4.19.2 - swagger-ui-dist: 5.17.10 - dev: false - /synckit@0.8.8: resolution: {integrity: sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -4703,11 +4388,6 @@ packages: typescript: 5.4.5 dev: true - /ts-deepmerge@7.0.0: - resolution: {integrity: sha512-WZ/iAJrKDhdINv1WG6KZIGHrZDar6VfhftG1QJFpVbOYZMYJLJOvZOo1amictRXVdBXZIgBHKswMTXzElngprA==} - engines: {node: '>=14.13.1'} - dev: false - /ts-node-dev@2.0.0(@types/node@20.12.7)(typescript@5.4.5): resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} engines: {node: '>=0.8.0'} @@ -4788,17 +4468,6 @@ packages: /tslib@2.6.2: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} - /tsoa@6.2.1: - resolution: {integrity: sha512-cK+Wmw99IdkVMuNPl8OM+SufIxvS1b5XY9mwjLrTJ4ytwiUkF1AOKvF6pX5k/xDnHXFLCrfHzbgaogj0JJO9EA==} - engines: {node: '>=18.0.0', yarn: '>=1.9.4'} - hasBin: true - dependencies: - '@tsoa/cli': 6.2.1 - '@tsoa/runtime': 6.2.1 - transitivePeerDependencies: - - supports-color - dev: false - /tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} dependencies: @@ -4874,32 +4543,11 @@ packages: possible-typed-array-names: 1.0.0 dev: true - /typedoc@0.25.13(typescript@5.4.5): - resolution: {integrity: sha512-pQqiwiJ+Z4pigfOnnysObszLiU3mVLWAExSPf+Mu06G/qsc3wzbuM56SZQvONhHLncLUhYzOVkjFFpFfL5AzhQ==} - engines: {node: '>= 16'} - hasBin: true - peerDependencies: - typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x - dependencies: - lunr: 2.3.9 - marked: 4.3.0 - minimatch: 9.0.4 - shiki: 0.14.7 - typescript: 5.4.5 - dev: true - /typescript@5.4.5: resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} engines: {node: '>=14.17'} hasBin: true - - /uglify-js@3.17.4: - resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} - engines: {node: '>=0.8.0'} - hasBin: true - requiresBuild: true - dev: false - optional: true + dev: true /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -4919,11 +4567,6 @@ packages: '@types/unist': 3.0.2 dev: true - /universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - dev: false - /unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -4961,24 +4604,11 @@ packages: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} dev: true - /validator@13.12.0: - resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} - engines: {node: '>= 0.10'} - dev: false - /vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} dev: false - /vscode-oniguruma@1.7.0: - resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} - dev: true - - /vscode-textmate@8.0.0: - resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} - dev: true - /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} requiresBuild: true @@ -5036,10 +4666,6 @@ packages: dependencies: isexe: 2.0.0 - /wordwrap@1.0.0: - resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} - dev: false - /workerpool@6.2.1: resolution: {integrity: sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==} dev: true @@ -5080,17 +4706,6 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - /yaml@2.0.0-1: - resolution: {integrity: sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==} - engines: {node: '>= 6'} - dev: false - - /yaml@2.4.2: - resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} - engines: {node: '>= 14'} - hasBin: true - dev: false - /yargs-parser@20.2.4: resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==} engines: {node: '>=10'} @@ -5101,6 +4716,7 @@ packages: engines: {node: '>=12'} requiresBuild: true dev: false + optional: true /yargs-unparser@2.0.0: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} @@ -5138,6 +4754,7 @@ packages: y18n: 5.0.8 yargs-parser: 21.1.1 dev: false + optional: true /yn@3.1.1: resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} @@ -5149,18 +4766,6 @@ packages: engines: {node: '>=10'} requiresBuild: true - /z-schema@5.0.5: - resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} - engines: {node: '>=8.0.0'} - hasBin: true - dependencies: - lodash.get: 4.4.2 - lodash.isequal: 4.5.0 - validator: 13.12.0 - optionalDependencies: - commander: 9.5.0 - dev: false - /zod@3.23.5: resolution: {integrity: sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==} dev: false 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/20240525005051_add_default_chargeable_and_unique_rfc/migration.sql b/prisma/migrations/20240525005051_add_default_chargeable_and_unique_rfc/migration.sql new file mode 100644 index 00000000..d2ec8b6a --- /dev/null +++ b/prisma/migrations/20240525005051_add_default_chargeable_and_unique_rfc/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - A unique constraint covering the columns `[rfc]` on the table `company` will be added. If there are existing duplicate values, this will fail. + - Made the column `is_chargeable` on table `project` required. This step will fail if there are existing NULL values in that column. + +*/ +UPDATE "project" SET "is_chargeable" = false WHERE "is_chargeable" IS NULL; + +-- AlterTable +ALTER TABLE "project" ALTER COLUMN "is_chargeable" SET NOT NULL, +ALTER COLUMN "is_chargeable" SET DEFAULT false; + +-- CreateIndex +CREATE UNIQUE INDEX "company_rfc_key" ON "company"("rfc"); 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 7ede249a..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) @@ -28,7 +20,7 @@ model company { id_form String? @db.Uuid archived Boolean @default(false) constitution_date DateTime? @db.Timestamp(6) - rfc String? @db.VarChar(13) + rfc String? @unique @db.VarChar(13) tax_residence String? @db.VarChar(255) company_direct_contact company_direct_contact? @relation(fields: [id_company_direct_contact], references: [id], onDelete: NoAction, onUpdate: NoAction) form form? @relation(fields: [id_form], references: [id], onDelete: NoAction, onUpdate: NoAction) @@ -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) @@ -209,14 +166,14 @@ model project { end_date DateTime? @db.Date total_hours Decimal? @db.Decimal(8, 2) periodicity String? @db.VarChar(256) - is_chargeable Boolean? + is_chargeable Boolean @default(false) is_archived Boolean @default(false) payed Boolean @default(false) created_at DateTime @default(now()) @db.Timestamp(6) 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 4c933445..83e5d9c8 100644 --- a/src/api/controllers/company.controller.ts +++ b/src/api/controllers/company.controller.ts @@ -4,31 +4,22 @@ 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(), }); /** - * Finds all companies + * Retrieves a unique company by its ID. * - * @param {Request} req - * @param {Response} res - * @returns {Promise} - * @throws {Error} - */ - -/** - * Finds all companies - * - * @param {Request} req - * @param {Response} res + * @param {Request} req - The request object. + * @param {Response} res - The response object. * @returns {Promise} - * @throws {Error} + * @throws {Error} - If an error occurs while retrieving the company. */ 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 }); @@ -38,28 +29,38 @@ async function getUnique(req: Request, res: Response) { } /** - * Receives a request to update a client and validates de data before sending it to the service - * @param req - * @param res + * Updates a client with the provided data. + * + * @param {Request} req - The request object. + * @param {Response} res - The response object. + * @returns {Promise} + * @throws {Error} - If an error occurs while updating the client. */ + async function updateClient(req: Request, res: Response) { try { + const id = req.params.id; const validSchema = updateCompanySchema.parse(req.body); - const updatedCompany = await CompanyService.update(validSchema); + const updatedCompany = await CompanyService.update({ ...validSchema, id }); - res.status(200).json({ data: updatedCompany }); + res.status(200).json(updatedCompany); } catch (error: any) { - res.status(500).json({ message: error.message }); + if (error instanceof z.ZodError) { + res.status(500).json({ error: error.errors[0].message }); + } else res.status(500).json({ error: error.message }); } } /** - * Receives a request to update a client and validates de data before sending it to the service - * @param req - * @param res + * Retrieves all companies from the database. + * + * @param _ - The request object. + * @param res - The response object. + * @returns {Promise} + * @throws {Error} - If an error occurs while retrieving the companies. */ -async function getAll(req: Request, res: Response) { +async function getAll(_: Request, res: Response) { try { const data = await CompanyService.findAll(); res.status(200).json(data); @@ -69,17 +70,17 @@ async function getAll(req: Request, res: Response) { } /** - * Finds all companies + * Creates a company in the database. * - * @param {Request} req - * @param {Response} res + * @param req - The request object. + * @param res - The response object. * @returns {Promise} - * @throws {Error} + * @throws {Error} - If an error occurs while creating the company. */ async function create(req: Request, res: Response) { try { - const company: CompanyEntity = req.body.company; + const company: CompanyEntity = req.body; if (!company) throw new Error('Missing company data in body'); companySchema.parse(company); @@ -92,4 +93,41 @@ async function create(req: Request, res: Response) { } else res.status(500).json({ error: error.message }); } } -export const CompanyController = { getUnique, getAll, create, updateClient }; + +/** + * Retrieves all companies that are not archived. + * + * @param _ - The request object. + * @param res - The response object. + * @returns {Promise} + * @throws {Error} - If an error occurs while retrieving the companies. + */ + +async function getUnarchived(_: Request, res: Response) { + try { + const data = await CompanyService.findUnarchived(); + res.status(200).json(data); + } catch (error: any) { + res.status(500).json({ message: error.message }); + } +} + +/** + * 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 73c13502..b480368c 100644 --- a/src/api/controllers/notification.controller.ts +++ b/src/api/controllers/notification.controller.ts @@ -1,83 +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(), +import { SupportedDepartments } from '../../utils/enums'; +import { zodValidUuid } from '../validators/zod.validator'; + +const notificationSchema = z.object({ + 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 { - 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(req: 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 91a0995e..3119c8d8 100644 --- a/src/api/controllers/project.controller.ts +++ b/src/api/controllers/project.controller.ts @@ -9,19 +9,65 @@ const idSchema = z.object({ }); const createProjectRequestSchema = z.object({ - name: z.string(), + name: z + .string() + .min(1, { + message: 'Title must have at least 1 character', + }) + .max(70, { + message: 'Title must have at most 70 characters', + }), idCompany: z.string().uuid({ message: 'Please provide valid UUID' }), category: z.nativeEnum(ProjectCategory), matter: z.string().optional(), - description: z.string().optional(), + description: z + .string() + .min(1, { + message: 'Description must have at least 1 character', + }) + .max(256, { + message: 'Description must have at most 255 characters', + }) + .optional(), status: z.nativeEnum(ProjectStatus), startDate: z.coerce.date(), endDate: z.coerce.date().nullable(), periodicity: z.nativeEnum(ProjectPeriodicity), - isChargeable: z.boolean(), + isChargeable: z.boolean().optional(), area: z.nativeEnum(SupportedDepartments), }); +const updateProjectRequestSchema = z.object({ + name: z + .string() + .min(1, { + message: 'Title must have at least 1 character', + }) + .max(70, { + message: 'Title must have at most 70 characters', + }) + .optional(), + idCompany: z.string().uuid({ message: 'Please provide valid UUID' }).optional(), + category: z.nativeEnum(ProjectCategory).optional(), + matter: z.string().optional().nullable(), + description: z + .string() + .min(1, { + message: 'Description must have at least 1 character', + }) + .max(256, { + message: 'Description must have at most 255 characters', + }) + .optional() + .nullable(), + status: z.nativeEnum(ProjectStatus).optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().nullable().optional(), + periodicity: z.nativeEnum(ProjectPeriodicity).optional(), + isChargeable: z.boolean().optional(), + area: z.nativeEnum(SupportedDepartments).optional(), +}); + const reportRequestSchema = z.object({ date: z.coerce.date(), }); @@ -43,7 +89,7 @@ async function createProject(req: Request, res: Response) { category: data.category, endDate: data.endDate || null, idCompany: data.idCompany, - isChargeable: data.isChargeable, + isChargeable: data.isChargeable || false, periodicity: data.periodicity, startDate: data.startDate, }); @@ -140,6 +186,7 @@ async function getProjectById(req: Request, res: Response) { */ async function updateProject(req: Request, res: Response) { try { + updateProjectRequestSchema.parse(req.body); const projectData = req.body; const updatedProject = await ProjectService.updateProject({ ...projectData, id: req.params.id }); res.status(200).json({ data: updatedProject }); @@ -164,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, @@ -172,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 5cd135ee..6aa4d158 100644 --- a/src/api/controllers/task.controller.ts +++ b/src/api/controllers/task.controller.ts @@ -30,7 +30,7 @@ const taskSchema = z.object({ message: 'Description must have at least 1 character', }) .max(256, { - message: 'Description must have at most 256 characters', + message: 'Description must have at most 255 characters', }), status: taskStatusSchema, startDate: z.coerce.date({ required_error: 'Start date is required' }), @@ -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' }); @@ -228,7 +224,6 @@ async function updateTask(req: Request, res: Response) { const idTask = req.params.id; const validatedTaskData = validateUpdatedTaskData(idTask, req.body); - console.log(validatedTaskData); const data = await TaskService.updateTask(idTask, { ...validatedTaskData, idEmployee: validatedTaskData.idEmployee, diff --git a/src/api/routes/company.routes.ts b/src/api/routes/company.routes.ts index f43bd155..5c9c5186 100644 --- a/src/api/routes/company.routes.ts +++ b/src/api/routes/company.routes.ts @@ -1,15 +1,16 @@ import { Router } from 'express'; import { SupportedRoles } from '../../utils/enums'; import { CompanyController } from '../controllers/company.controller'; -import { checkAuthToken } from '../middlewares/auth.middleware'; import { checkAuthRole } from '../middlewares/rbac.middleware'; const router = Router(); router.use(checkAuthRole([SupportedRoles.ACCOUNTING, SupportedRoles.LEGAL, SupportedRoles.ADMIN])); router.get('/', CompanyController.getAll); +router.get('/unarchived', CompanyController.getUnarchived); router.get('/:id', CompanyController.getUnique); router.post('/new', CompanyController.create); -router.put('/:id', checkAuthToken, CompanyController.updateClient); +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/api/validators/company.validator.ts b/src/api/validators/company.validator.ts index 7b45abf8..a9746c3f 100644 --- a/src/api/validators/company.validator.ts +++ b/src/api/validators/company.validator.ts @@ -33,17 +33,12 @@ const companySchema = z.object({ }); export const updateCompanySchema = z.object({ - id: zodValidUuid, name: z .string() .min(1, { message: 'Name cannot be empty.' }) .max(70, { message: 'Name must be at most 70 characters long.' }), email: zodValidEmail.optional().nullable(), - phoneNumber: zodValidPhoneNumber - .min(10, { message: 'Phone number must be at least 10 characters.' }) - .max(15, { message: 'Phone number cannot be longer than 15 characters.' }) - .optional() - .nullable(), + phoneNumber: zodValidPhoneNumber.optional().nullable(), landline_phone: z .string() .min(10, { message: 'Landlinephone number must be between 10 and 15 digits long' }) diff --git a/src/api/validators/zod.validator.ts b/src/api/validators/zod.validator.ts index 3619c02a..75fa939f 100644 --- a/src/api/validators/zod.validator.ts +++ b/src/api/validators/zod.validator.ts @@ -2,7 +2,13 @@ import { z } from 'zod'; export const zodValidUuid = z.string().uuid({ message: 'Provided UUID is not valid' }); export const zodValidEmail = z - .union([z.string().email({ message: 'Provided email is not valid.' }), z.string().length(0)]) + .union([ + z + .string() + .email({ message: 'Provided email is not valid.' }) + .max(70, { message: 'Email must be at most 70 characters long.' }), + z.string().length(0), + ]) .optional(); export const zodValidString = z .string() @@ -17,7 +23,7 @@ export const zodValidPhoneNumber = z export const zodValidRfc = z .union([ - z.string().regex(/^[A-Z&Ñ]{3,4}\d{6}(?:[A-Z\d]{3})?$/, { + z.string().regex(/^[a-zA-Z]{3,4}[0-9]{6}[a-zA-Z0-9]{3}$/, { message: 'Provided RFC is not valid', }), z.string().length(0), 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__/home.service.test.ts b/src/core/app/services/__tests__/home.service.test.ts index 2ec8e49c..32d71e8f 100644 --- a/src/core/app/services/__tests__/home.service.test.ts +++ b/src/core/app/services/__tests__/home.service.test.ts @@ -1,9 +1,15 @@ +import { faker } from '@faker-js/faker'; import { Decimal } from '@prisma/client/runtime/library'; -import chai, { expect } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; +import { expect } from 'chai'; import { randomUUID } from 'crypto'; import sinon from 'sinon'; -import { SupportedRoles } from '../../../../utils/enums'; +import { ProjectStatus, SupportedRoles, TaskStatus } from '../../../../utils/enums'; +import { CompanyEntity } from '../../../domain/entities/company.entity'; +import { EmployeeTask } from '../../../domain/entities/employee-task.entity'; +import { EmployeeEntity } from '../../../domain/entities/employee.entity'; +import { ProjectEntity } from '../../../domain/entities/project.entity'; +import { RoleEntity } from '../../../domain/entities/role.entity'; +import { Task } from '../../../domain/entities/task.entity'; import { CompanyRepository } from '../../../infra/repositories/company.repository'; import { EmployeeTaskRepository } from '../../../infra/repositories/employee-task.repository'; import { EmployeeRepository } from '../../../infra/repositories/employee.repository'; @@ -12,170 +18,174 @@ import { RoleRepository } from '../../../infra/repositories/role.repository'; import { TaskRepository } from '../../../infra/repositories/tasks.repository'; import { HomeService } from '../home.service'; -chai.use(chaiAsPromised); - describe('HomeService', () => { - let findAllProjectsStub: sinon.SinonStub; - let findTaskByEmployeeIdStub: sinon.SinonStub; - let findAllTasksStub: sinon.SinonStub; - let findAllCompaniesStub: sinon.SinonStub; - let findEmployeeByEmail: sinon.SinonStub; - let findEmployeeById: sinon.SinonStub; - let findRoleById: sinon.SinonStub; + let findByEmployeeIdStub: sinon.SinonStub; + let findRoleByIdStub: sinon.SinonStub; + let findProjectsByRoleStub: sinon.SinonStub; + let findEmployeeTasksStub: sinon.SinonStub; + let findTasksStub: sinon.SinonStub; + let findCompaniesStub: sinon.SinonStub; beforeEach(() => { - findAllProjectsStub = sinon.stub(ProjectRepository, 'findAllByRole'); - findTaskByEmployeeIdStub = sinon.stub(EmployeeTaskRepository, 'findByEmployeeId'); - findAllTasksStub = sinon.stub(TaskRepository, 'findAll'); - findAllCompaniesStub = sinon.stub(CompanyRepository, 'findAll'); - findEmployeeByEmail = sinon.stub(EmployeeRepository, 'findByEmail'); - findEmployeeById = sinon.stub(EmployeeRepository, 'findById'); - findRoleById = sinon.stub(RoleRepository, 'findById'); + findByEmployeeIdStub = sinon.stub(EmployeeRepository, 'findById'); + findRoleByIdStub = sinon.stub(RoleRepository, 'findById'); + findProjectsByRoleStub = sinon.stub(ProjectRepository, 'findAllByRole'); + findEmployeeTasksStub = sinon.stub(EmployeeTaskRepository, 'findByEmployeeId'); + findTasksStub = sinon.stub(TaskRepository, 'findAll'); + findCompaniesStub = sinon.stub(CompanyRepository, 'findAll'); }); afterEach(() => { sinon.restore(); }); - describe('getHomeInfo', () => { - it('should return the projects an employee has assigned and the companies of those projects', async () => { - const employeeId = randomUUID(); - - const accountingRole = randomUUID(); - - const role = { - title: SupportedRoles.ACCOUNTING, - createdAr: new Date(), + describe('getMyInfo', () => { + it('Should return the user info', async () => { + const employee: EmployeeEntity = { + id: randomUUID(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + imageUrl: faker.image.url(), + createdAt: faker.date.recent(), + idRole: randomUUID(), }; - const employee = { - id: employeeId, - firstName: 'John', - lastName: 'Doe', - email: 'joe.doe@email.com', - imageUrl: 'http://example.com/john.jpg', - createdAt: new Date(), - idRole: accountingRole, + const role: RoleEntity = { + id: employee.idRole, + title: faker.helpers.enumValue(SupportedRoles), + createdAt: faker.date.recent(), }; - const companyId = randomUUID(); - const companyId2 = randomUUID(); - const existingCompanies = [ - { - id: companyId, - name: 'Tecnológico de Monterrey', - email: 'tec@itesm.mx', - phoneNumber: '4421234567', - landlinePhone: '4420987654', - archived: false, - createdAt: new Date(), - }, - { - id: companyId2, - name: 'Oracle', - email: 'support@oracle.com.mx', - phoneNumber: '4421234567', - landlinePhone: '4420987654', - archived: true, - createdAt: new Date(), - }, - ]; - - const projectId = randomUUID(); - const projectId2 = randomUUID(); - const existingProjects = [ - { - id: projectId, - name: 'ITESM Project', - matter: 'SAT', - description: 'ITESM Project description', - status: 'IN PROGRESS', - totalHours: new Decimal(100), - startDate: new Date(), - createdAt: new Date(), - idCompany: companyId, - }, - { - id: projectId2, - name: 'Oracle Cloud', - matter: 'OCI', - description: 'The cloud', - status: 'ACCEPTED', - totalHours: new Decimal(100), - startDate: new Date(), - createdAt: new Date(), - idCompany: companyId2, - }, - ]; - - const taskId = randomUUID(); - const taskId2 = randomUUID(); - const existingTasks = [ - { - id: taskId, - title: 'ITESM task', - description: 'ITESM task description', - status: 'DELAYED', - startDate: new Date(), - workedHours: 100, - createdAt: new Date(), - idProject: projectId, - }, - { - id: taskId2, - title: 'Oracle task', - description: 'ORACLE task description', - status: 'CANCELLED', - startDate: new Date(), - workedHours: 100, - createdAt: new Date(), - idProject: projectId2, - }, - ]; - - const existingEmployeeTasks = [ - { - id: randomUUID(), - createdAt: new Date(), - idEmployee: employeeId, - idTask: taskId, - }, - { + const MIN_PROJECTS = 5; + const MIN_TASKS_PER_PROJECT = 2; + + const companies: CompanyEntity[] = Array.from({ length: 3 }, () => ({ + id: randomUUID(), + name: faker.company.name(), + email: faker.internet.email(), + phoneNumber: faker.phone.number(), + landlinePhone: faker.phone.number(), + archived: faker.datatype.boolean(), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + })); + + const projects: ProjectEntity[] = Array.from({ length: MIN_PROJECTS }, (_, index) => ({ + id: randomUUID(), + name: faker.word.sample(), + matter: faker.lorem.sentence(), + description: faker.lorem.paragraph(), + category: faker.word.words(3), + status: faker.helpers.enumValue(ProjectStatus), + totalHours: new Decimal(faker.number.int()), + startDate: faker.date.recent(), + createdAt: faker.date.recent(), + idCompany: companies[index % companies.length].id, + isChargeable: false, + })); + + const tasks: Task[] = Array.from({ length: projects.length * MIN_TASKS_PER_PROJECT }, (_, index) => ({ + id: randomUUID(), + title: faker.lorem.words(3), + description: faker.lorem.paragraph(), + status: faker.helpers.enumValue(TaskStatus), + startDate: faker.date.recent(), + workedHours: faker.number.int(), + createdAt: faker.date.recent(), + idProject: projects[index % projects.length].id, + })); + + const employeeTasks: EmployeeTask[] = Array.from( + { length: projects.length * MIN_TASKS_PER_PROJECT }, + (_, index) => ({ id: randomUUID(), - createdAt: new Date(), - idEmployee: employeeId, - idTask: taskId2, - }, - ]; - - findEmployeeByEmail.resolves(employee); - findEmployeeById.resolves(employee); - findRoleById.resolves(role); - - findAllProjectsStub.resolves(existingProjects); - findAllCompaniesStub.resolves(existingCompanies); - findAllTasksStub.resolves(existingTasks); - findTaskByEmployeeIdStub.resolves(existingEmployeeTasks); - - const home = { - projects: existingProjects, - companies: existingCompanies, - }; + createdAt: faker.date.recent(), + idEmployee: employee.id, + idTask: tasks[index].id, + }) + ); + + findByEmployeeIdStub.withArgs(employee.id).resolves(employee); + findRoleByIdStub.withArgs(employee.idRole).returns(role); + findProjectsByRoleStub.withArgs(role.title).resolves(projects); + findEmployeeTasksStub.withArgs(employee.id).resolves(employeeTasks); + findTasksStub.resolves(tasks); + findCompaniesStub.resolves(companies); + + const result = await HomeService.getMyInfo(employee.id); + + expect(result).to.be.an('object'); + + expect(result).to.have.property('projects').to.be.an('array'); + expect(result.projects).to.deep.equal(projects); + expect(result.projects).to.have.lengthOf(5); + + expect(result).to.have.property('companies').to.be.an('array'); + expect(result.companies).to.deep.equal(companies); + expect(result.companies).to.have.lengthOf(3); + + sinon.assert.calledOnce(findByEmployeeIdStub); + sinon.assert.calledOnce(findRoleByIdStub); + sinon.assert.calledOnce(findProjectsByRoleStub); + sinon.assert.calledOnce(findEmployeeTasksStub); + sinon.assert.calledOnce(findTasksStub); + sinon.assert.calledOnce(findCompaniesStub); + }); + + it('Should throw an error if the employee does not exist', async () => { + const employeeId = randomUUID(); + + findByEmployeeIdStub.withArgs(employeeId).resolves(null); - const result = await HomeService.getMyInfo(employeeId); + try { + await HomeService.getMyInfo(employeeId); + } catch (error: any) { + expect(error).to.be.an('error'); + expect(error.message).to.equal('Error: Requested Employee was not found'); + } - expect(result).to.eql(home); - expect(findAllCompaniesStub.calledOnce).to.be.true; - expect(findAllProjectsStub.calledOnce).to.be.true; - expect(findAllTasksStub.calledOnce).to.be.true; - expect(findTaskByEmployeeIdStub.calledOnce).to.be.true; + sinon.assert.calledOnce(findByEmployeeIdStub); }); - it('should throw an error if the employee id does not exist', async () => { - const errorMessage = 'An unexpected error occurred'; - findTaskByEmployeeIdStub.rejects(new Error(errorMessage)); + it('Should throw an error if an error occurs', async () => { + const employeeId = randomUUID(); + + findByEmployeeIdStub.withArgs(employeeId).throws(new Error('An unexpected error occurred')); + + try { + await HomeService.getMyInfo(employeeId); + } catch (error: any) { + expect(error).to.be.an('error'); + expect(error.message).to.equal('Error: An unexpected error occurred'); + } + + sinon.assert.calledOnce(findByEmployeeIdStub); + }); + + it('Should throw an error if the role does not exist', async () => { + const employee: EmployeeEntity = { + id: randomUUID(), + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + imageUrl: faker.image.url(), + createdAt: faker.date.recent(), + idRole: randomUUID(), + }; + + findByEmployeeIdStub.withArgs(employee.id).resolves(employee); + findRoleByIdStub.withArgs(employee.idRole).resolves(null); + + try { + await HomeService.getMyInfo(employee.id); + } catch (error: any) { + expect(error).to.be.an('error'); + expect(error.message).to.equal('Error: Requested Role was not found'); + } - await expect(HomeService.getMyInfo(randomUUID())).to.be.rejectedWith(Error, errorMessage); + sinon.assert.calledOnce(findByEmployeeIdStub); + sinon.assert.calledOnce(findRoleByIdStub); }); }); }); 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 1ec2a589..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() { @@ -368,6 +406,7 @@ function prepareMockProject() { startDate: faker.date.recent(), createdAt: faker.date.recent(), idCompany: companyId, + isChargeable: false, }; const updatedProject = { diff --git a/src/core/app/services/__tests__/task.services.test.ts b/src/core/app/services/__tests__/task.service.test.ts similarity index 88% rename from src/core/app/services/__tests__/task.services.test.ts rename to src/core/app/services/__tests__/task.service.test.ts index 4c5c803b..40c7cfaa 100644 --- a/src/core/app/services/__tests__/task.services.test.ts +++ b/src/core/app/services/__tests__/task.service.test.ts @@ -36,7 +36,8 @@ describe('Task Service', () => { name: 'Project', status: ProjectStatus.ACCEPTED, category: 'Internal', - startDate: new Date(), + startDate: new Date('05-01-2021'), + endDate: new Date('05-01-2042'), area: 'Legal', createdAt: new Date(), idCompany: randomUUID(), @@ -48,7 +49,8 @@ describe('Task Service', () => { title: 'Zombie', description: 'Zombie', status: TaskStatus.DONE, - startDate: new Date(), + startDate: new Date('05-01-2024'), + endDate: new Date('05-02-2024'), createdAt: new Date(), idProject: idProject, employeeFirstName: faker.person.firstName(), @@ -60,36 +62,14 @@ describe('Task Service', () => { description: 'The Nether is a dimension that is supposedly located below the Mother Rock in Minecraft. Its appearance is similar to the idea of hell, with many dark rocks and lava and magma plaguing the entire setting.', status: TaskStatus.POSTPONED, - startDate: new Date(), + startDate: new Date('05-01-2024'), + endDate: new Date('05-02-2024'), createdAt: new Date(), idProject: idProject, employeeFirstName: faker.person.firstName(), employeeLastName: faker.person.lastName(), }, ]; - - beforeEach(() => { - taskRepositoryStub = sinon.stub(TaskRepository, 'createTask'); - projectRepositoryStub = sinon.stub(ProjectRepository, 'findById'); - taskFetchRepositoryStub = sinon.stub(TaskRepository, 'findTasksByProjectId'); - employeeRepositoryStub = sinon.stub(EmployeeRepository, 'findById'); - employeeTaskRepositoryStub = sinon.stub(EmployeeTaskRepository, 'create'); - employeeTaskFindByIdStub = sinon.stub(EmployeeTaskRepository, 'findByEmployeeId'); - fetchMultipleTasksByIdsStub = sinon.stub(TaskRepository, 'findTasksById'); - deleteTaskStub = sinon.stub(TaskRepository, 'deleteTaskById'); - findTaskByIdStub = sinon.stub(TaskRepository, 'findTaskById'); - deleteEmployeeTaskStub = sinon.stub(EmployeeTaskRepository, 'deleteByTaskId'); - validateEmployeeTaskStub = sinon.stub(EmployeeTaskRepository, 'validateEmployeeTask'); - updateTaskRepositoryStub = sinon.stub(TaskRepository, 'updateTask'); - updateTaskStatusRepositoryStub = sinon.stub(TaskRepository, 'updateTaskStatus'); - updateTaskEndDateRepositoryStub = sinon.stub(TaskRepository, 'updateTaskEndDate'); - findRoleByEmailStub = sinon.stub(RoleRepository, 'findByEmail'); - findTasksByIdsStub = sinon.stub(EmployeeTaskRepository, 'findByTaskIds'); - }); - - afterEach(() => { - sinon.restore(); - }); const idRole = randomUUID(); const role = { id: idRole, @@ -107,15 +87,14 @@ describe('Task Service', () => { idRole: idRole, }; - const projectID = randomUUID(); const task: BareboneTask = { title: faker.lorem.words(3), description: faker.lorem.words(10), status: faker.helpers.arrayElement(Object.values(TaskStatus)), - startDate: faker.date.recent(), - endDate: faker.date.future(), - workedHours: faker.number.int(), - idProject: projectID, + startDate: new Date('05-01-2024'), + endDate: new Date('05-02-2024'), + workedHours: faker.number.int() % 1000, + idProject: idProject, idEmployee: randomUUID(), }; @@ -124,8 +103,8 @@ describe('Task Service', () => { title: task.title, description: task.description, status: task.status, - startDate: task.startDate, - endDate: task.endDate ?? undefined, + startDate: new Date('05-01-2024'), + endDate: new Date('05-02-2024'), workedHours: task.workedHours ?? undefined, createdAt: new Date(), idProject: task.idProject, @@ -136,8 +115,8 @@ describe('Task Service', () => { title: createdTask.title, description: createdTask.description, status: createdTask.status, - startDate: createdTask.startDate, - endDate: createdTask.endDate ?? undefined, + startDate: new Date('05-01-2024'), + endDate: new Date('05-02-2024'), workedHours: createdTask.workedHours, idProject: createdTask.idProject, idEmployee: randomUUID(), @@ -149,61 +128,89 @@ describe('Task Service', () => { idTask: updatedTask.id, }; + beforeEach(() => { + taskRepositoryStub = sinon.stub(TaskRepository, 'createTask'); + projectRepositoryStub = sinon.stub(ProjectRepository, 'findById'); + taskFetchRepositoryStub = sinon.stub(TaskRepository, 'findTasksByProjectId'); + employeeRepositoryStub = sinon.stub(EmployeeRepository, 'findById'); + employeeTaskRepositoryStub = sinon.stub(EmployeeTaskRepository, 'create'); + employeeTaskFindByIdStub = sinon.stub(EmployeeTaskRepository, 'findByEmployeeId'); + fetchMultipleTasksByIdsStub = sinon.stub(TaskRepository, 'findTasksById'); + deleteTaskStub = sinon.stub(TaskRepository, 'deleteTaskById'); + findTaskByIdStub = sinon.stub(TaskRepository, 'findTaskById'); + deleteEmployeeTaskStub = sinon.stub(EmployeeTaskRepository, 'deleteByTaskId'); + validateEmployeeTaskStub = sinon.stub(EmployeeTaskRepository, 'validateEmployeeTask'); + updateTaskRepositoryStub = sinon.stub(TaskRepository, 'updateTask'); + updateTaskStatusRepositoryStub = sinon.stub(TaskRepository, 'updateTaskStatus'); + updateTaskEndDateRepositoryStub = sinon.stub(TaskRepository, 'updateTaskEndDate'); + findRoleByEmailStub = sinon.stub(RoleRepository, 'findByEmail'); + findTasksByIdsStub = sinon.stub(EmployeeTaskRepository, 'findByTaskIds'); + }); + + afterEach(() => { + sinon.restore(); + }); + describe('createTask', () => { it('Should create missing attributes and send them to the repository', async () => { - projectRepositoryStub.resolves({ id: projectID }); + projectRepositoryStub.resolves(project); 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(projectID).resolves(null); + 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('Error: Requested Project ID was not found'); + expect(error.message).to.equal('Requested Project ID was not found'); } }); it('Should throw an error if the task already exists', async () => { - projectRepositoryStub.resolves({ id: projectID }); + 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('Error: Task already exists'); + expect(error.message).to.equal('Task already exists'); } }); it('Should throw an error if the employee is not found', async () => { - projectRepositoryStub.resolves({ id: projectID }); + 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('Error: Requested Employee was not found'); + expect(error.message).to.equal('Requested Employee was not found'); } }); it('Should throw an error if an error occurs when assigning the task to the employee', async () => { - projectRepositoryStub.resolves({ id: projectID }); + projectRepositoryStub.resolves(project); 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: Error assigning a task to an employee'); + expect(error.message).to.equal('Error assigning a task to an employee'); } }); }); @@ -278,7 +285,7 @@ describe('Task Service', () => { endDate: faker.date.future(), workedHours: faker.number.int(), createdAt: new Date(), - idProject: projectID, + idProject: idProject, })); employeeRepositoryStub.resolves({ id: employeeId }); @@ -299,7 +306,7 @@ describe('Task Service', () => { try { await TaskService.getTasksAssignedToEmployee(employeeId); } catch (error: any) { - expect(error.message).to.equal('Error: Requested Employee was not found'); + expect(error.message).to.equal('Requested Employee was not found'); } }); @@ -311,7 +318,7 @@ describe('Task Service', () => { try { await TaskService.getTasksAssignedToEmployee(employeeId); } catch (error: any) { - expect(error.message).to.equal('Error: Requested Task assigned to employee was not found'); + expect(error.message).to.equal('Requested Task assigned to employee was not found'); } }); @@ -340,7 +347,7 @@ describe('Task Service', () => { await TaskService.getTasksAssignedToEmployee(employeeId); } catch (error: any) { expect(error).to.be.an('error'); - expect(error.message).to.equal('Error: Could not fetch tasks'); + expect(error.message).to.equal('Could not fetch tasks'); } }); }); @@ -362,7 +369,7 @@ describe('Task Service', () => { try { await TaskService.deleteTask(randomUUID()); } catch (error: any) { - expect(error.message).to.equal('Error: Requested Task was not found'); + expect(error.message).to.equal('Requested Task was not found'); } expect(findTaskByIdStub.calledOnce).to.be.true; @@ -376,7 +383,7 @@ describe('Task Service', () => { await TaskService.deleteTask(randomUUID()); } catch (error: any) { expect(error).to.be.an('error'); - expect(error.message).to.equal('Error: Could not delete task'); + expect(error.message).to.equal('Could not delete task'); } expect(deleteTaskStub.calledOnce).to.be.true; @@ -389,7 +396,7 @@ describe('Task Service', () => { validateEmployeeTaskStub.resolves({ idEmployee: randomUUID(), idTask: randomUUID() }); employeeTaskRepositoryStub.resolves(updatedEmployeeTask); deleteEmployeeTaskStub.resolves({ idTask: createdTask.id }); - projectRepositoryStub.resolves({ id: createdTask.idProject }); + projectRepositoryStub.resolves(project); updateTaskRepositoryStub.resolves(updatedTask); employeeRepositoryStub.resolves({ id: randomUUID() }); @@ -403,7 +410,7 @@ describe('Task Service', () => { try { await TaskService.updateTask('', updatedTask); } catch (error: any) { - expect(error.message).to.equal('Error: Task ID is not valid'); + expect(error.message).to.equal('Task ID is not valid'); } }); @@ -415,7 +422,7 @@ describe('Task Service', () => { try { await TaskService.updateTask(createdTask.id, invalidEmployeeTask); } catch (error: any) { - expect(error.message).to.equal('Error: Error assigning a task to an employee'); + expect(error.message).to.equal('Error assigning a task to an employee'); } }); }); @@ -469,6 +476,7 @@ describe('TaskService', () => { startDate: new Date(), createdAt: new Date(), idCompany: randomUUID(), + area: 'Legal', }; const roleId = randomUUID(); @@ -483,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, @@ -516,6 +524,7 @@ describe('TaskService', () => { projectName: existingProject.name, employeeFirstName: existingEmployee.firstName, employeeLastName: existingEmployee.lastName, + employeeId: employeeId, }; findTaskByIdStub.resolves(existingTask); diff --git a/src/core/app/services/company.service.ts b/src/core/app/services/company.service.ts index 40b59fcc..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 @@ -59,25 +58,29 @@ async function findAll(): Promise { * @returns {Promise} a promise that resolves to the updated company entity */ async function update(body: UpdateCompanyBody): Promise { - const company = await CompanyRepository.findById(body.id); + try { + const company = await CompanyRepository.findById(body.id); - if (!company) throw new NotFoundError('Company not found'); + if (!company) throw new NotFoundError('Company not found'); - return await CompanyRepository.update({ - id: company.id, - name: body.name ?? company.name, - email: body.email, - phoneNumber: body.phoneNumber, - landlinePhone: body.landlinePhone, - archived: body.archived, - constitutionDate: body.constitutionDate, - rfc: body.rfc, - taxResidence: body.taxResidence, - idCompanyDirectContact: company.idCompanyDirectContact, - idForm: company.idForm, - createdAt: company.createdAt, - updatedAt: new Date(), - }); + return await CompanyRepository.update({ + id: company.id, + name: body.name ?? company.name, + email: body.email, + phoneNumber: body.phoneNumber, + landlinePhone: body.landlinePhone, + archived: body.archived, + constitutionDate: body.constitutionDate, + rfc: body.rfc, + taxResidence: body.taxResidence, + idCompanyDirectContact: company.idCompanyDirectContact, + idForm: company.idForm, + createdAt: company.createdAt, + updatedAt: new Date(), + }); + } catch (error: any) { + throw new Error(error.message); + } } /** @@ -102,4 +105,37 @@ async function archiveClient(id: string): Promise { } } -export const CompanyService = { findAll, findById, update, create, archiveClient }; +/** + * @brief Retrieves all companies that are not archived. + * + * @returns {Promise} + * @throws {Error} - If an error occurs while retrieving the companies. + */ +async function findUnarchived(): Promise { + try { + const data = await CompanyRepository.findUnarchived(); + return data; + } catch (error: any) { + throw new Error(error.message); + } +} + +/** + * @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/home.service.ts b/src/core/app/services/home.service.ts index 153419c8..91306d72 100644 --- a/src/core/app/services/home.service.ts +++ b/src/core/app/services/home.service.ts @@ -1,3 +1,4 @@ +import { NotFoundError } from '../../errors/not-found.error'; import { CompanyRepository } from '../../infra/repositories/company.repository'; import { EmployeeTaskRepository } from '../../infra/repositories/employee-task.repository'; import { EmployeeRepository } from '../../infra/repositories/employee.repository'; @@ -17,13 +18,15 @@ import { Home } from '../interfaces/home.interface'; async function getMyInfo(idEmployee: string): Promise { try { const employee = await EmployeeRepository.findById(idEmployee); + if (!employee) throw new NotFoundError('Employee'); + const role = await RoleRepository.findById(employee.idRole); + if (!role) throw new NotFoundError('Role'); const projects = await ProjectRepository.findAllByRole(role.title); const employeeTask = await EmployeeTaskRepository.findByEmployeeId(idEmployee); const tasks = await TaskRepository.findAll(); const companies = await CompanyRepository.findAll(); - const projectsIds: string[] = []; const companiesIds: string[] = []; const homeInfo: Home = { projects: [], companies: [] }; @@ -53,8 +56,7 @@ async function getMyInfo(idEmployee: string): Promise { return homeInfo; } catch (error: unknown) { - console.log(error); - throw new Error('An unexpected error occurred'); + throw new Error(error as string); } } 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(); +}; + /** * 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 @@ -29,6 +28,9 @@ async function createProject(data: CreateProjectData): Promise { throw new NotFoundError('Project not found'); } + const startDate = body.startDate ?? project.startDate; + const endDate = body.endDate ?? null; + + if (endDate !== null && !areDatesValid(startDate, endDate)) { + throw new Error('Start date must be before end date'); + } + return await ProjectRepository.updateProject({ id: project.id, name: body.name ?? project.name, idCompany: body.idCompany ?? project.idCompany, category: body.category ?? project.category, - matter: body.matter ?? project.matter, - description: body.description ?? project.description, - startDate: body.startDate ?? project.startDate, - endDate: body.endDate ?? null, + matter: body.matter ?? null, + description: body.description ?? null, + startDate, + endDate, periodicity: body.periodicity ?? project.periodicity, area: body.area ?? project.area, payed: body.payed, @@ -169,6 +178,27 @@ async function updateProjectStatus(projectId: string, newStatus: ProjectStatus): } } +/** + * @description Function to delete a project by id + * @param id + * @returns {ProjectEntity} - 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 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, @@ -176,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 eb259ae3..636467c5 100644 --- a/src/core/app/services/task.service.ts +++ b/src/core/app/services/task.service.ts @@ -1,5 +1,7 @@ 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'; import { NotFoundError } from '../../errors/not-found.error'; @@ -9,6 +11,17 @@ 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 + * @param {Date} start Start date + * @param {Date} end End date + * @returns {boolean} If dates are valid + */ +const areDatesValid = (start: Date, end: Date): boolean => { + return new Date(start).getTime() <= new Date(end).getTime(); +}; /** * Gets all tasks from a unique project using the repository. @@ -28,11 +41,7 @@ async function getTasksFromProject(projectId: string, email: string): Promise { +async function createTask(newTask: BareboneTask, employeeEmitterEmail: string): Promise { try { - if ((await ProjectRepository.findById(newTask.idProject)) === null) { - throw new NotFoundError('Project ID '); + const project = await ProjectRepository.findById(newTask.idProject); + if (project === null) { + throw new NotFoundError('Project ID'); } 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)) { + throw new Error('Start date must be before end date'); + } + if (task.workedHours !== undefined && task.workedHours < 0) { + throw new Error('Worked hours must be greater than or equal to 0'); + } + if (task.workedHours !== undefined && task.workedHours > 1000) { + throw new Error('Worked hours must be lower than or equal to 1000'); + } + + if (newTask.endDate && project.endDate && !dateSmallerOrEqualThanOther(newTask.endDate, project.endDate)) + throw new Error("Task's end date cannot be after the project's end date"); + + if (newTask.startDate && project.startDate && !dateSmallerOrEqualThanOther(project.startDate, newTask.startDate)) + throw new Error("Task's start date cannot be before the project's start date"); + + if (newTask.startDate && project.endDate && !dateSmallerOrEqualThanOther(newTask.startDate, project.endDate)) + throw new Error("Task's start date cannot be after the project's end date"); + const createdTask = await TaskRepository.createTask(task); if (!createdTask) { throw new Error('Task already exists'); @@ -109,21 +133,27 @@ 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; } catch (error: any) { - throw new Error(error); + throw new Error(error.message); } } @@ -142,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'); } @@ -199,7 +229,7 @@ async function getTasksAssignedToEmployee(employeeId: string): Promise { return tasks; } catch (error: any) { - throw new Error(error); + throw new Error(error.message); } } @@ -221,7 +251,7 @@ async function deleteTask(id: string): Promise { await EmployeeTaskRepository.deleteByTaskId(id); await TaskRepository.deleteTaskById(id); } catch (error: any) { - throw new Error(error); + throw new Error(error.message); } } @@ -237,15 +267,46 @@ async function deleteTask(id: string): Promise { */ async function updateTask(idTask: string, task: UpdatedTask): Promise { try { - if ((await TaskRepository.findTaskById(idTask)) === null) { + const prevTask = await TaskRepository.findTaskById(idTask); + if (prevTask === null) { throw new Error('Task ID is not valid'); } + const project = await ProjectRepository.findById(prevTask.idProject); + if (project === null) { + throw new NotFoundError('Project ID'); + } + + if (task.endDate && project.endDate && !dateSmallerOrEqualThanOther(task.endDate, project.endDate)) + throw new Error("Task's end date cannot be afer the project's end date"); + + if (task.startDate && project.startDate && !dateSmallerOrEqualThanOther(project.startDate, task.startDate)) + throw new Error("Task's start date cannot be before the project's start date"); + + if (task.startDate && project.endDate && !dateSmallerOrEqualThanOther(task.startDate, project.endDate)) + throw new Error("Task's start date cannot be after the project's end date"); + const status = task.status; if (status === TaskStatus.DONE) { task.endDate = new Date(); } + if ( + task.startDate !== null && + task.endDate !== null && + task.startDate !== undefined && + task.endDate !== undefined && + !areDatesValid(task.startDate, task.endDate) + ) { + throw new Error('Start date must be before end date'); + } + if (task.workedHours !== undefined && task.workedHours < 0) { + throw new Error('Worked hours must be greater than or equal to 0'); + } + if (task.workedHours !== undefined && task.workedHours > 1000) { + throw new Error('Worked hours must be lower than or equal to 1000'); + } + if (task.idEmployee) { const employee = await EmployeeRepository.findById(task.idEmployee); if (!employee) { @@ -274,7 +335,7 @@ async function updateTask(idTask: string, task: UpdatedTask): Promise { const updatedTask = await TaskRepository.updateTask(idTask, task); return updatedTask; } catch (error: any) { - throw new Error(error); + throw new Error(error.message); } } @@ -297,7 +358,7 @@ async function updateTaskStatus(idTask: string, status: TaskStatus): 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 ff18753e..0c4fabc8 100644 --- a/src/core/infra/repositories/company.repository.ts +++ b/src/core/infra/repositories/company.repository.ts @@ -64,7 +64,10 @@ async function create(company: CompanyEntity, uuid: string, date: Date): Promise return mapCompanyEntityFromDbModel(res); } catch (error: any) { // P2002 = Prisma Error code for unique constraints - if (error.code == 'P2002' && error.meta.target[0] == 'email') throw new Error('Email already registered.'); + if (error.code == 'P2002') { + if (error.meta.target[0] == 'email') throw new Error('Email already registered.'); + } + if (error.meta.target[0] == 'rfc') throw new Error('RFC already registered.'); throw new Error(error); } } @@ -139,6 +142,16 @@ async function archiveClient(id: string, archived: boolean): Promise} + * @throws {Error} - If an error occurs while updating the company + * @throws {Error} - If the email is already registered + * @throws {Error} - If the RFC is already registered + */ + async function update(company: CompanyEntity): Promise { try { const updatedCompany = await Prisma.company.update({ @@ -162,8 +175,75 @@ async function update(company: CompanyEntity): Promise { return mapCompanyEntityFromDbModel(updatedCompany); } catch (error: any) { + if (error.code == 'P2002' && (error.meta?.target as string[])[0] === 'email') + throw new Error('Email already registered.'); + if (error.meta.target[0] == 'rfc') throw new Error('RFC already registered.'); throw new Error(`${RESOURCE_NAME} repository error: ${error.message}`); } } -export const CompanyRepository = { findAll, findById, update, create, archiveClient, getArchivedStatus }; +/** + * @brief retrieves all companies that are not archived + * + * @returns {Promise} + * @throws {Error} - If an error occurs while retrieving the companies + */ + +async function findUnarchived(): Promise { + try { + const data: Array = await Prisma.$queryRaw` + SELECT c.*, + COUNT(DISTINCT p.id) as total_projects, + SUM(CASE WHEN p.is_chargeable THEN t.worked_hours ELSE 0 END) AS chargeable_hours, + SUM(CASE WHEN p.is_chargeable AND p.area='Accounting' THEN t.worked_hours ELSE 0 END) AS accounting_hours, + SUM(CASE WHEN p.is_chargeable AND p.area='Legal' THEN t.worked_hours ELSE 0 END) AS legal_hours + FROM company c + LEFT JOIN project p ON c.id = p.id_company + LEFT JOIN task t ON p.id = t.id_project + WHERE c.archived = false + GROUP BY c.id + ORDER BY c.name ASC; + `; + + return data.map(mapCompanyEntityFromDbModel); + } catch (error: any) { + throw new Error(`${RESOURCE_NAME} repository error: ${error.message}`); + } +} + +/** + * @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, + update, + create, + 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; +} diff --git a/src/utils/methods.ts b/src/utils/methods.ts new file mode 100644 index 00000000..b76ab8b8 --- /dev/null +++ b/src/utils/methods.ts @@ -0,0 +1,29 @@ +/** + * @description Validates if a date is smaller or equal than another + * @param date1 Date | string + * @param date2 Date | string + * @returns boolean + */ +export function dateSmallerOrEqualThanOther( + date1: Date | string | null | undefined, + date2: Date | string | null | undefined +): boolean { + if (!date1 || !date2) throw new Error('Missing date'); + + const d1 = new Date(date1); + const d2 = new Date(date2); + + d1.setUTCMilliseconds(0); + d1.setUTCSeconds(0); + d1.setUTCMinutes(0); + d1.setUTCHours(0); + + d2.setUTCMilliseconds(0); + d2.setUTCSeconds(0); + d2.setUTCMinutes(0); + d2.setUTCHours(0); + + if (d1.getTime() > d2.getTime()) return false; + + return true; +}