diff --git a/backend/Dockerfile b/backend/Dockerfile index 115a26875..3ea0c0a61 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,42 @@ -FROM --platform=linux/amd64 node:16.15.1-alpine As development +FROM --platform=linux/amd64 node:20@sha256:a4d1de4c7339eabcf78a90137dfd551b798829e3ef3e399e0036ac454afa1291 As development + +# Install latest chrome dev package and fonts to support major charsets (Chinese, Japanese, Arabic, Hebrew, Thai and a few others) +# Note: this installs the necessary libs to make the bundled version of Chromium that Puppeteer +# installs, work. +RUN apt-get update \ + && apt-get install -y wget gnupg \ + && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ + && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ + && apt-get update \ + && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# If running Docker >= 1.13.0 use docker run's --init arg to reap zombie processes, otherwise +# uncomment the following lines to have `dumb-init` as PID 1 +# ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_x86_64 /usr/local/bin/dumb-init +# RUN chmod +x /usr/local/bin/dumb-init +# ENTRYPOINT ["dumb-init", "--"] + +# Uncomment to skip the chromium download when installing puppeteer. If you do, +# you'll need to launch puppeteer with: +# browser.launch({executablePath: 'google-chrome-stable'}) +# ENV PUPPETEER_SKIP_DOWNLOAD true + +# # Install puppeteer so it's available in the container. +# RUN npm init -y && \ +# npm i puppeteer \ +# # Add user so we don't need --no-sandbox. +# # same layer as npm install to keep re-chowned files from using up several hundred MBs more space +# && groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ +# && mkdir -p /home/pptruser/Downloads \ +# && chown -R pptruser:pptruser /home/pptruser \ +# && chown -R pptruser:pptruser /node_modules \ +# && chown -R pptruser:pptruser /package.json \ +# && chown -R pptruser:pptruser /package-lock.json + +# Run everything after as non-privileged user. +# USER pptruser WORKDIR /usr/src/app @@ -12,7 +50,7 @@ COPY . . RUN npm run build -FROM --platform=linux/amd64 node:16.15.1-alpine as production +FROM --platform=linux/amd64 node:20@sha256:a4d1de4c7339eabcf78a90137dfd551b798829e3ef3e399e0036ac454afa1291 as production ARG NODE_ENV=production ENV NODE_ENV=${NODE_ENV} @@ -25,4 +63,6 @@ RUN npm install --only=production COPY --from=development /usr/src/app/dist ./dist + + CMD ["node", "dist/main"] \ No newline at end of file diff --git a/backend/nest-cli.json b/backend/nest-cli.json index 7c274a3af..a7dcbac32 100644 --- a/backend/nest-cli.json +++ b/backend/nest-cli.json @@ -4,7 +4,10 @@ "sourceRoot": "src", "compilerOptions": { "deleteOutDir": true, - "assets": ["modules/mail/templates/**/*"], + "assets": [ + "modules/mail/templates/**/*", + "modules/documents/templates/**/*" + ], "plugins": [ { "name": "@nestjs/swagger/plugin", diff --git a/backend/package-lock.json b/backend/package-lock.json index 4969430fa..1c73e76d8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -47,6 +47,7 @@ "passport-jwt": "^4.0.1", "pg": "^8.12.0", "pino-http": "^10.2.0", + "puppeteer": "^23.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20", @@ -1181,7 +1182,6 @@ "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", - "dev": true, "dependencies": { "@babel/highlight": "^7.24.7", "picocolors": "^1.0.0" @@ -1432,7 +1432,6 @@ "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", - "devOptional": true, "engines": { "node": ">=6.9.0" } @@ -1463,7 +1462,6 @@ "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "dev": true, "dependencies": { "@babel/helper-validator-identifier": "^7.24.7", "chalk": "^2.4.2", @@ -1478,7 +1476,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "dependencies": { "color-convert": "^1.9.0" }, @@ -1490,7 +1487,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -1504,7 +1500,6 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, "dependencies": { "color-name": "1.1.3" } @@ -1512,14 +1507,12 @@ "node_modules/@babel/highlight/node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "node_modules/@babel/highlight/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, "engines": { "node": ">=4" } @@ -1528,7 +1521,6 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, "dependencies": { "has-flag": "^3.0.0" }, @@ -3446,6 +3438,48 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@puppeteer/browsers": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.1.tgz", + "integrity": "sha512-uK7o3hHkK+naEobMSJ+2ySYyXtQkBxIH8Gn4MK9ciePjNV+Pf+PgY/W7iPzn2MTjl3stcYB5AlcTmPYw7AXDwA==", + "dependencies": { + "debug": "^4.3.6", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.4.0", + "semver": "^7.6.3", + "tar-fs": "^3.0.6", + "unbzip2-stream": "^1.4.3", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@puppeteer/browsers/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/@redis/bloom": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", @@ -4190,6 +4224,11 @@ "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==" }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -4578,6 +4617,15 @@ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", "dev": true }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.16.0.tgz", @@ -5086,6 +5134,38 @@ "node": ">=0.8" } }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/ajv": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", @@ -5276,6 +5356,17 @@ "integrity": "sha512-9Z3vxQ+berkL/JJo0dK+EY3Lp0s3NtSnP3VCLsh5HDcZPrh0M+KQRK5sWhUeyPPH+/RCxZqOxLMR+YC6vlviEQ==", "optional": true }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -5304,6 +5395,11 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.6.6", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.6.tgz", + "integrity": "sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg==" + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -5437,6 +5533,47 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/bare-events": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz", + "integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==", + "optional": true + }, + "node_modules/bare-fs": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.1.tgz", + "integrity": "sha512-W/Hfxc/6VehXlsgFtbB5B4xFcsCl+pAh30cYhoFyXErf6oGrwjh8SwiPAdHgpmWonKuYpZgGywN0SXt7dgsADA==", + "optional": true, + "dependencies": { + "bare-events": "^2.0.0", + "bare-path": "^2.0.0", + "bare-stream": "^2.0.0" + } + }, + "node_modules/bare-os": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.0.tgz", + "integrity": "sha512-v8DTT08AS/G0F9xrhyLtepoo9EJBJ85FRSMbu1pQUlAf6A8T0tEEQGMVObWeqpjhSPXsE0VGlluFBJu2fdoTNg==", + "optional": true + }, + "node_modules/bare-path": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz", + "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==", + "optional": true, + "dependencies": { + "bare-os": "^2.1.0" + } + }, + "node_modules/bare-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.2.0.tgz", + "integrity": "sha512-+o9MG5bPRRBlkVSpfFlMag3n7wMaIZb4YZasU2+/96f+3HTQ4F9DKQeu3K/Sjz1W0umu6xvVq1ON0ipWdMlr3A==", + "optional": true, + "dependencies": { + "streamx": "^2.18.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -5456,6 +5593,14 @@ } ] }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5604,7 +5749,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -5624,6 +5768,14 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -5890,6 +6042,19 @@ "node": ">=6.0" } }, + "node_modules/chromium-bidi": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.4.tgz", + "integrity": "sha512-8zoq6ogmhQQkAKZVKO2ObFTl4uOkqoX1PlKQX3hZQ5E9cbUotcAb7h4pTNVAGGv8Z36PF3CtdOriEp/Rz82JqQ==", + "dependencies": { + "mitt": "3.0.1", + "urlpattern-polyfill": "10.0.0", + "zod": "3.23.8" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -6436,6 +6601,14 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "engines": { + "node": ">= 14" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -6523,6 +6696,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -6589,6 +6775,11 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "optional": true }, + "node_modules/devtools-protocol": { + "version": "0.0.1330662", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1330662.tgz", + "integrity": "sha512-pzh6YQ8zZfz3iKlCvgzVCu22NdpZ8hNmwU6WnQjNVquh0A9iVosPtNLWDwaWVGyrntQlltPFztTMK5Cg6lfCuw==" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -6855,6 +7046,14 @@ "node": ">=8.10.0" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.17.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.0.tgz", @@ -6880,6 +7079,14 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "engines": { + "node": ">=6" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -6889,7 +7096,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -6957,11 +7163,47 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, "engines": { "node": ">=0.8.0" } }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/eslint": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", @@ -7276,7 +7518,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -7504,6 +7745,60 @@ "node": ">=4" } }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/extract-zip/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7515,6 +7810,11 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -7591,6 +7891,14 @@ "bser": "2.1.1" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7968,6 +8276,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-uri": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.3.tgz", + "integrity": "sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4", + "fs-extra": "^11.2.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/get-uri/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/get-uri/node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/get-uri/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/glob": { "version": "10.3.12", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.12.tgz", @@ -8070,8 +8426,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/graphemer": { "version": "1.4.0", @@ -8299,6 +8654,72 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -8479,6 +8900,23 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/ip-address/node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -8490,8 +8928,7 @@ "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -9489,8 +9926,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -9503,6 +9939,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -9523,8 +9964,7 @@ "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, "node_modules/json-schema-traverse": { "version": "1.0.0", @@ -9559,7 +9999,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, "dependencies": { "universalify": "^2.0.0" }, @@ -9783,8 +10222,7 @@ "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/linkify-it": { "version": "5.0.0", @@ -10248,6 +10686,11 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + }, "node_modules/mjml": { "version": "4.15.3", "resolved": "https://registry.npmjs.org/mjml/-/mjml-4.15.3.tgz", @@ -10806,6 +11249,14 @@ "pino-http": "^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -11163,6 +11614,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz", + "integrity": "sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg==", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.5", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-proxy-agent/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/pac-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", @@ -11193,7 +11695,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -11364,6 +11865,11 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, "node_modules/pg": { "version": "8.12.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", @@ -11448,8 +11954,7 @@ "node_modules/picocolors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==" }, "node_modules/picomatch": { "version": "4.0.1", @@ -11734,6 +12239,14 @@ "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/promise": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", @@ -11799,6 +12312,53 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", + "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.3", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.1", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -11928,6 +12488,15 @@ "integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==", "optional": true }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -11945,6 +12514,88 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-23.2.0.tgz", + "integrity": "sha512-MP7kLOdCfx1BJaGN5sgRo5fTYwAyGrlwWtrNphjKcwv/HO91+m90gbbwpRHbGl0rCvrmylq6vljn+zrjukniVg==", + "hasInstallScript": true, + "dependencies": { + "@puppeteer/browsers": "2.3.1", + "chromium-bidi": "0.6.4", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1330662", + "puppeteer-core": "23.2.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "23.2.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.2.0.tgz", + "integrity": "sha512-OFyPp2oolGSesx6ZrpmorE5tCaCKY1Z5e/h8f6sB0NpiezenB72jdWBdOrvBO/bUXyq14XyGJsDRUsv0ZOPdZA==", + "dependencies": { + "@puppeteer/browsers": "2.3.1", + "chromium-bidi": "0.6.4", + "debug": "^4.3.6", + "devtools-protocol": "0.0.1330662", + "typed-query-selector": "^2.12.0", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-core/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/pure-rand": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", @@ -11994,6 +12645,11 @@ } ] }, + "node_modules/queue-tick": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", + "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", @@ -12589,9 +13245,9 @@ } }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "bin": { "semver": "bin/semver.js" }, @@ -12753,6 +13409,62 @@ "node": "*" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", + "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz", + "integrity": "sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw==", + "dependencies": { + "agent-base": "^7.1.1", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/sonic-boom": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.0.1.tgz", @@ -12856,6 +13568,19 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.19.0.tgz", + "integrity": "sha512-5z6CNR4gtkPbwlxyEqoDGDmWIzoNJqCBt4Eac1ICP9YaIT08ct712cFj0u1rx4F8luAuL+3Qc+RFIdI4OX00kg==", + "dependencies": { + "fast-fifo": "^1.3.2", + "queue-tick": "^1.0.1", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -13105,6 +13830,29 @@ "node": ">=6" } }, + "node_modules/tar-fs": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz", + "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^2.1.1", + "bare-path": "^2.1.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/terser": { "version": "5.31.2", "resolved": "https://registry.npmjs.org/terser/-/terser-5.31.2.tgz", @@ -13239,6 +13987,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.1.1.tgz", + "integrity": "sha512-8zll7REEv4GDD3x4/0pW+ppIxSNs7H1J10IKFZsuOMscumCdM2a+toDGLPA3T+1+fLBql4zbt5z83GEQGGV5VA==", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -13274,8 +14030,7 @@ "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" }, "node_modules/tlds": { "version": "1.252.0", @@ -13572,6 +14327,11 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==" + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -13793,6 +14553,15 @@ "node": ">=8" } }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -13802,7 +14571,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "engines": { "node": ">= 10.0.0" } @@ -13859,6 +14627,11 @@ "punycode": "^2.1.0" } }, + "node_modules/urlpattern-polyfill": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz", + "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14311,6 +15084,26 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xlsx": { "version": "0.18.5", "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", @@ -14377,6 +15170,15 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -14396,6 +15198,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index ba6aa9f44..5f43f9eba 100644 --- a/backend/package.json +++ b/backend/package.json @@ -64,6 +64,7 @@ "passport-jwt": "^4.0.1", "pg": "^8.12.0", "pino-http": "^10.2.0", + "puppeteer": "^23.2.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20", diff --git a/backend/src/api/_mobile/documents/documents-contract.controller.ts b/backend/src/api/_mobile/documents/documents-contract.controller.ts new file mode 100644 index 000000000..fc380974d --- /dev/null +++ b/backend/src/api/_mobile/documents/documents-contract.controller.ts @@ -0,0 +1,99 @@ +import { Body, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; +import { Controller } from '@nestjs/common'; +import { ApiBearerAuth, ApiParam } from '@nestjs/swagger'; +import { MobileJwtAuthGuard } from 'src/modules/auth/guards/jwt-mobile.guard'; +import { ExtractUser } from 'src/common/decorators/extract-user.decorator'; +import { IRegularUserModel } from 'src/modules/user/models/regular-user.model'; +import { UuidValidationPipe } from 'src/infrastructure/pipes/uuid.pipe'; +import { SignDocumentContractByVolunteerUsecase } from 'src/usecases/documents/new_contracts/sign-document-contract-by-volunteer.usecase'; +import { RejectDocumentContractByVolunteerUsecase } from 'src/usecases/documents/new_contracts/reject-document-contact-by-volunteer.usecase'; +import { SignDocumentContractByVolunteerDto } from './dto/SignDocumentContractByVolunteer.dto'; +import { RejectDocumentContractByVolunteerDto } from './dto/RejectDocumentContractByVolunteer.dto'; +import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { GetManyDocumentContractsByVolunteerUsecase } from 'src/usecases/documents/new_contracts/get-many-document-contracts-by-volunteer.usecase'; +import { GetManyContractsByVolunteerDto } from './dto/GetManyContractsByVolunteer.dto'; +import { DocumentContractListViewItemPresenter } from 'src/api/documents/presenters/document-contract-list-view-item.presenter'; +import { PaginatedPresenter } from 'src/infrastructure/presenters/generic-paginated.presenter'; +import { GetOneDocumentContractForVolunteerUsecase } from 'src/usecases/documents/new_contracts/get-one-document-contract-for-volunteer.usecase'; + +// @UseGuards(MobileJwtAuthGuard, ContractVolunteerGuard) +@UseGuards(MobileJwtAuthGuard) +@ApiBearerAuth() +@Controller('mobile/documents/contracts') +export class MobileDocumentsContractController { + constructor( + private readonly signDocumentContractByVolunteerUsecase: SignDocumentContractByVolunteerUsecase, + private readonly rejectDocumentContractByVolunteerUsecase: RejectDocumentContractByVolunteerUsecase, + private readonly getManyDocumentContractsByVolunteerUsecase: GetManyDocumentContractsByVolunteerUsecase, + private readonly getOneDocumentContractForVolunteerUsecase: GetOneDocumentContractForVolunteerUsecase, + ) {} + + // Get all contracts for a volunteer + @Get() + async findMany( + @ExtractUser() { id: userId }: IRegularUserModel, + @Query() query: GetManyContractsByVolunteerDto, + ): Promise> { + const contracts = + await this.getManyDocumentContractsByVolunteerUsecase.execute({ + ...query, + userId, + }); + + return new PaginatedPresenter({ + ...contracts, + items: contracts.items.map( + (contract) => new DocumentContractListViewItemPresenter(contract), + ), + }); + } + + @Get(':contractId') + async findOne( + @ExtractUser() { id }: IRegularUserModel, + @Param('contractId', UuidValidationPipe) contractId: string, + @Query('organizationId', UuidValidationPipe) organizationId: string, + ): Promise { + const contract = + await this.getOneDocumentContractForVolunteerUsecase.execute({ + documentContractId: contractId, + userId: id, + organizationId, + }); + + return new DocumentContractListViewItemPresenter(contract); + } + + @ApiParam({ name: 'contractId', type: 'string' }) + @Patch(':contractId/sign') + async sign( + @Body() body: SignDocumentContractByVolunteerDto, + @ExtractUser() { id }: IRegularUserModel, + @Param('contractId', UuidValidationPipe) contractId: string, + ): Promise { + const contract = await this.signDocumentContractByVolunteerUsecase.execute({ + contractId, + userId: id, + organizationId: body.organizationId, // TODO: can use activeOrganization but is a bit danger if the mobile doesn't set it + volunteerSignatureBase64: body.volunteerSignatureBase64, + legalGuardianSignatureBase64: body.legalGuardianSignatureBase64, + }); + + return contract; + } + + @ApiParam({ name: 'contractId', type: 'string' }) + @Patch(':contractId/reject') + async reject( + @Body() body: RejectDocumentContractByVolunteerDto, + @ExtractUser() { id }: IRegularUserModel, + @Param('contractId', UuidValidationPipe) contractId: string, + ): Promise { + await this.rejectDocumentContractByVolunteerUsecase.execute({ + contractId, + userId: id, + organizationId: body.organizationId, + rejectionReason: body.reason, + }); + } +} diff --git a/backend/src/api/_mobile/documents/dto/GetManyContractsByVolunteer.dto.ts b/backend/src/api/_mobile/documents/dto/GetManyContractsByVolunteer.dto.ts new file mode 100644 index 000000000..e9eaf2310 --- /dev/null +++ b/backend/src/api/_mobile/documents/dto/GetManyContractsByVolunteer.dto.ts @@ -0,0 +1,12 @@ +import { IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { BasePaginationFilterDto } from 'src/infrastructure/base/base-pagination-filter.dto'; + +export class GetManyContractsByVolunteerDto extends BasePaginationFilterDto { + @ApiProperty({ + description: 'The ID of the organization', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsUUID() + organizationId: string; +} diff --git a/backend/src/api/_mobile/documents/dto/RejectDocumentContractByVolunteer.dto.ts b/backend/src/api/_mobile/documents/dto/RejectDocumentContractByVolunteer.dto.ts new file mode 100644 index 000000000..70c3a0676 --- /dev/null +++ b/backend/src/api/_mobile/documents/dto/RejectDocumentContractByVolunteer.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RejectDocumentContractByVolunteerDto { + @ApiProperty({ description: 'Organization ID' }) + @IsString() + @IsUUID() + organizationId: string; + + @ApiProperty({ description: 'Reason for rejecting the contract' }) + @IsString() + @IsOptional() + reason?: string; +} diff --git a/backend/src/api/_mobile/documents/dto/SignDocumentContractByVolunteer.dto.ts b/backend/src/api/_mobile/documents/dto/SignDocumentContractByVolunteer.dto.ts new file mode 100644 index 000000000..843847ef3 --- /dev/null +++ b/backend/src/api/_mobile/documents/dto/SignDocumentContractByVolunteer.dto.ts @@ -0,0 +1,19 @@ +import { IsString, IsOptional, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SignDocumentContractByVolunteerDto { + @ApiProperty({ description: 'The ID of the organization' }) + @IsUUID() + organizationId: string; + + @ApiProperty({ description: 'Base64 encoded volunteer signature' }) + @IsString() + volunteerSignatureBase64: string; + + @ApiPropertyOptional({ + description: 'Base64 encoded legal guardian signature', + }) + @IsOptional() + @IsString() + legalGuardianSignatureBase64?: string; +} diff --git a/backend/src/api/_mobile/user/dto/update-user-personal-data.dto.ts b/backend/src/api/_mobile/user/dto/update-user-personal-data.dto.ts index 2c9b09b02..eb4e7ecae 100644 --- a/backend/src/api/_mobile/user/dto/update-user-personal-data.dto.ts +++ b/backend/src/api/_mobile/user/dto/update-user-personal-data.dto.ts @@ -1,13 +1,56 @@ +import { Type } from 'class-transformer'; import { IsDate, IsNotEmpty, + IsObject, + IsOptional, IsString, Length, MaxLength, MinLength, + ValidateNested, } from 'class-validator'; +class LegalGuardianDto { + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsString() + @IsNotEmpty() + @Length(13) + cnp: string; + + @IsString() + @IsNotEmpty() + @MinLength(2) + @MaxLength(100) + address: string; + + @IsString() + @IsNotEmpty() + @Length(2) + identityDocumentSeries: string; + + @IsString() + @IsNotEmpty() + @Length(6) + identityDocumentNumber: string; + + @IsString() + email: string; + + @IsString() + phone: string; +} export class UpdateUserPersonalDataDto { + @IsString() + @IsNotEmpty() + @Length(13) + cnp: string; + @IsString() @IsNotEmpty() @Length(2) @@ -29,4 +72,15 @@ export class UpdateUserPersonalDataDto { @IsDate() identityDocumentExpirationDate: Date; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + identityDocumentIssuedBy: string; + + @IsObject() + @IsOptional() + @ValidateNested() + @Type(() => LegalGuardianDto) + legalGuardian?: LegalGuardianDto; } diff --git a/backend/src/api/_mobile/user/presenters/user-personal-data.presenter.ts b/backend/src/api/_mobile/user/presenters/user-personal-data.presenter.ts index fde70765a..117fa443b 100644 --- a/backend/src/api/_mobile/user/presenters/user-personal-data.presenter.ts +++ b/backend/src/api/_mobile/user/presenters/user-personal-data.presenter.ts @@ -1,6 +1,9 @@ import { Expose } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; -import { IUserPersonalDataModel } from 'src/modules/user/models/user-personal-data.model'; +import { + LegalGuardianIdentityData, + IUserPersonalDataModel, +} from 'src/modules/user/models/user-personal-data.model'; export class UserPersonalDataPresenter { constructor(personalData: IUserPersonalDataModel) { @@ -11,6 +14,9 @@ export class UserPersonalDataPresenter { this.identityDocumentExpirationDate = personalData.identityDocumentExpirationDate; this.address = personalData.address; + this.cnp = personalData.cnp; + this.identityDocumentIssuedBy = personalData.identityDocumentIssuedBy; + this.legalGuardian = personalData.legalGuardian; } @Expose() @@ -20,6 +26,13 @@ export class UserPersonalDataPresenter { }) id: string; + @Expose() + @ApiProperty({ + description: 'The cnp of the user', + example: '1234567890123', + }) + cnp: string; + @Expose() @ApiProperty({ description: 'The identity document series', @@ -48,4 +61,12 @@ export class UserPersonalDataPresenter { @Expose() @ApiProperty({ description: 'The identity document expiration date' }) identityDocumentExpirationDate: Date; + + @Expose() + @ApiProperty({ description: 'The identity document issued by' }) + identityDocumentIssuedBy: string; + + @Expose() + @ApiProperty({ description: 'The legal guardian of the user' }) + legalGuardian: LegalGuardianIdentityData; } diff --git a/backend/src/api/_mobile/volunteer/volunteer.controller.ts b/backend/src/api/_mobile/volunteer/volunteer.controller.ts index 687873cfc..1df02df11 100644 --- a/backend/src/api/_mobile/volunteer/volunteer.controller.ts +++ b/backend/src/api/_mobile/volunteer/volunteer.controller.ts @@ -35,9 +35,8 @@ export class MobileVolunteerController { async getVolunteerProfile( @Param('id', UuidValidationPipe) volunteerId: string, ): Promise { - const volunteer = await this.getVolunteerProfileUsecase.execute( - volunteerId, - ); + const volunteer = + await this.getVolunteerProfileUsecase.execute(volunteerId); return new VolunteerPresenter(volunteer); } @@ -46,9 +45,8 @@ export class MobileVolunteerController { async getVolunteerOrganizationStats( @Param('id', UuidValidationPipe) volunteerId: string, ): Promise { - const volunteer = await this.getVolunteerOrganizationStatusUsecase.execute( - volunteerId, - ); + const volunteer = + await this.getVolunteerOrganizationStatusUsecase.execute(volunteerId); return new VolunteerStatsPresenter(volunteer); } diff --git a/backend/src/api/api.module.ts b/backend/src/api/api.module.ts index 6483f3826..bcce72497 100644 --- a/backend/src/api/api.module.ts +++ b/backend/src/api/api.module.ts @@ -31,6 +31,9 @@ import { MobileStatisticsController } from './_mobile/statistics/statistics.cont import { MobileAnouncementsController } from './_mobile/anouncements/anouncements.controller'; import { MobileSettingsController } from './_mobile/settings/settings-controller'; import { MobileNewsController } from './_mobile/news/news.controller'; +import { DocumentTemplateController } from './documents/document-template.controller'; +import { DocumentContractController } from './documents/document-contract.controller'; +import { MobileDocumentsContractController } from './_mobile/documents/documents-contract.controller'; @Module({ imports: [UseCaseModule], @@ -52,6 +55,8 @@ import { MobileNewsController } from './_mobile/news/news.controller'; DashboardController, TemplateController, ContractController, + DocumentTemplateController, + DocumentContractController, // Mobile MobileRegularUserController, MobileAccessRequestController, @@ -67,6 +72,7 @@ import { MobileNewsController } from './_mobile/news/news.controller'; MobileAnouncementsController, MobileSettingsController, MobileNewsController, + MobileDocumentsContractController, ], }) export class ApiModule {} diff --git a/backend/src/api/auth/presenters/user.presenter.ts b/backend/src/api/auth/presenters/user.presenter.ts index 9e0fdce15..95ff7e354 100644 --- a/backend/src/api/auth/presenters/user.presenter.ts +++ b/backend/src/api/auth/presenters/user.presenter.ts @@ -1,6 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; import { differenceInYears } from 'date-fns'; +import { UserPersonalDataPresenter } from 'src/api/_mobile/user/presenters/user-personal-data.presenter'; import { CityPresenter } from 'src/api/location/presenters/city.presenter'; import { SEX } from 'src/modules/user/enums/user.enum'; import { IRegularUserModel } from 'src/modules/user/models/regular-user.model'; @@ -15,6 +16,9 @@ export class RegularUserPresenter { this.sex = user.sex; this.profilePicture = user.profilePicture; this.location = user.location ? new CityPresenter(user.location) : null; + this.userPersonalData = user.userPersonalData + ? new UserPersonalDataPresenter(user.userPersonalData) + : null; } @Expose() @@ -52,6 +56,10 @@ export class RegularUserPresenter { @ApiProperty({ description: 'The users location' }) location: CityPresenter; + @Expose() + @ApiProperty({ description: 'The user personal data' }) + userPersonalData?: UserPersonalDataPresenter; + private calculateAge = (birthday: Date): number => { return birthday ? differenceInYears(new Date(), new Date(birthday)) : 0; }; diff --git a/backend/src/api/documents/document-contract.controller.ts b/backend/src/api/documents/document-contract.controller.ts new file mode 100644 index 000000000..b5ef1aef4 --- /dev/null +++ b/backend/src/api/documents/document-contract.controller.ts @@ -0,0 +1,104 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { CreateDocumentContractUsecase } from 'src/usecases/documents/new_contracts/create-document-contract.usecase'; +import { CreateDocumentContractDto } from './dto/create-document-contract.dto'; +import { IAdminUserModel } from 'src/modules/user/models/admin-user.model'; +import { ExtractUser } from 'src/common/decorators/extract-user.decorator'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { WebJwtAuthGuard } from 'src/modules/auth/guards/jwt-web.guard'; +import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { GetManyDocumentContractsUsecase } from 'src/usecases/documents/new_contracts/get-many-document-contracts.usecase'; +import { DocumentContractListViewItemPresenter } from './presenters/document-contract-list-view-item.presenter'; +import { + ApiPaginatedResponse, + PaginatedPresenter, +} from 'src/infrastructure/presenters/generic-paginated.presenter'; +import { GetManyDocumentContractsDto } from './dto/get-many-document-contracts.dto'; +import { UuidValidationPipe } from 'src/infrastructure/pipes/uuid.pipe'; +import { ApproveDocumentContractByNgoUsecase } from 'src/usecases/documents/new_contracts/approve-document-contract-by-ngo.usecase'; +import { SignDocumentContractByNgoUsecase } from 'src/usecases/documents/new_contracts/sign-document-contract-by-ngo.usecase'; +import { RejectDocumentContractByNgoUsecase } from 'src/usecases/documents/new_contracts/reject-document-contract-by-ngo.usecase'; +import { RejectDocumentContractByNgoDTO } from './dto/reject-document-contract.dto'; + +@ApiBearerAuth() +@UseGuards(WebJwtAuthGuard) +@Controller('documents/contracts') +export class DocumentContractController { + constructor( + private readonly createDocumentContractUsecase: CreateDocumentContractUsecase, + private readonly getManyDocumentContractsUsecase: GetManyDocumentContractsUsecase, + private readonly approveDocumentContractByNgoUsecase: ApproveDocumentContractByNgoUsecase, + private readonly rejectDocumentContractByNgoUsecase: RejectDocumentContractByNgoUsecase, + private readonly signDocumentContractByNGO: SignDocumentContractByNgoUsecase, + ) {} + + @Post() + async createDocumentContract( + @Body() createDocumentContractDto: CreateDocumentContractDto, + @ExtractUser() { organizationId, id: adminId }: IAdminUserModel, + ): Promise { + const documentContract = await this.createDocumentContractUsecase.execute({ + ...createDocumentContractDto, + organizationId, + createdByAdminId: adminId, + }); + + return documentContract; + } + + @Get('') + @ApiPaginatedResponse(DocumentContractListViewItemPresenter) + async getDocumentContracts( + @Query() filters: GetManyDocumentContractsDto, + @ExtractUser() { organizationId }: IAdminUserModel, + ): Promise> { + const contracts = await this.getManyDocumentContractsUsecase.execute({ + ...filters, + organizationId, + }); + + return new PaginatedPresenter({ + ...contracts, + items: contracts.items.map( + (contract) => new DocumentContractListViewItemPresenter(contract), + ), + }); + } + + @Patch(':id/approve') + async approveDocumentContract( + @Param('id', UuidValidationPipe) id: string, + @ExtractUser() { organizationId }: IAdminUserModel, + ): Promise { + await this.approveDocumentContractByNgoUsecase.execute(id, organizationId); + } + + @Patch(':id/sign') + async signDocumentContract( + @Param('id', UuidValidationPipe) id: string, + @ExtractUser() { organizationId }: IAdminUserModel, + ): Promise { + await this.signDocumentContractByNGO.execute(id, organizationId); + } + + @Patch(':id/reject') + async rejectDocumentContract( + @Param('id', UuidValidationPipe) id: string, + @ExtractUser() { organizationId }: IAdminUserModel, + @Body() { rejectionReason }: RejectDocumentContractByNgoDTO, + ): Promise { + await this.rejectDocumentContractByNgoUsecase.execute({ + documentContractId: id, + organizationId, + rejectionReason: rejectionReason, + }); + } +} diff --git a/backend/src/api/documents/document-template.controller.ts b/backend/src/api/documents/document-template.controller.ts new file mode 100644 index 000000000..e07a20605 --- /dev/null +++ b/backend/src/api/documents/document-template.controller.ts @@ -0,0 +1,98 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ExtractUser } from 'src/common/decorators/extract-user.decorator'; +import { IAdminUserModel } from 'src/modules/user/models/admin-user.model'; +import { CreateDocumentTemplateDto } from './dto/create-document-template.dto'; +import { ApiBearerAuth, ApiBody, ApiParam } from '@nestjs/swagger'; +import { DocumentTemplatePresenter } from './presenters/document-template.presenter'; +import { UuidValidationPipe } from 'src/infrastructure/pipes/uuid.pipe'; +import { WebJwtAuthGuard } from 'src/modules/auth/guards/jwt-web.guard'; +import { CreateDocumentTemplateUsecase } from 'src/usecases/documents/new_contracts/create-document-template.usecase'; +import { GetOneDocumentTemplateUseCase } from 'src/usecases/documents/new_contracts/get-one-document-template.usecase'; +import { GetManyDocumentTemplatesDto } from './dto/get-many-document-templates.dto'; +import { DocumentTemplateListViewItemPresenter } from './presenters/document-template-list-view-item.presenter'; +import { GetManyDocumentTemplatesUsecase } from 'src/usecases/documents/new_contracts/get-many-document-templates.usecase'; +import { + ApiPaginatedResponse, + PaginatedPresenter, +} from 'src/infrastructure/presenters/generic-paginated.presenter'; +import { DeleteDocumentTemplateUsecase } from 'src/usecases/documents/new_contracts/delete-document-template.usecase'; + +@ApiBearerAuth() +@UseGuards(WebJwtAuthGuard) +@Controller('documents/templates') +export class DocumentTemplateController { + constructor( + private readonly createDocumentTemplateUsecase: CreateDocumentTemplateUsecase, + private readonly getOneDocumentTemplateUsecase: GetOneDocumentTemplateUseCase, + private readonly getManyDocumentTemplatesUsecase: GetManyDocumentTemplatesUsecase, + private readonly deleteDocumentTemplateUsecase: DeleteDocumentTemplateUsecase, + ) {} + + @ApiBody({ type: CreateDocumentTemplateDto }) + @Post() + async create( + @Body() payload: CreateDocumentTemplateDto, + @ExtractUser() { organizationId, id: adminId }: IAdminUserModel, + ): Promise { + const newDocumentTemplate = + await this.createDocumentTemplateUsecase.execute({ + ...payload, + createdByAdminId: adminId, + organizationId, + }); + return new DocumentTemplatePresenter(newDocumentTemplate); + } + + @ApiParam({ name: 'id', type: 'string' }) + @Get(':id') + async getOne( + @Param('id', UuidValidationPipe) id: string, + @ExtractUser() { organizationId }: IAdminUserModel, + ): Promise { + const documentTemplate = await this.getOneDocumentTemplateUsecase.execute( + { id }, + organizationId, + ); + + return new DocumentTemplatePresenter(documentTemplate); + } + + @ApiParam({ name: 'id', type: 'string' }) + @Delete(':id') + async delete( + @Param('id', UuidValidationPipe) id: string, + @ExtractUser() { organizationId }: IAdminUserModel, + ): Promise { + return this.deleteDocumentTemplateUsecase.execute(id, organizationId); + } + + @Get() + @ApiPaginatedResponse(DocumentTemplateListViewItemPresenter) + async getMany( + @Query() query: GetManyDocumentTemplatesDto, + @ExtractUser() { organizationId }: IAdminUserModel, + ): Promise> { + const documentTemplates = + await this.getManyDocumentTemplatesUsecase.execute({ + ...query, + organizationId, + }); + + return new PaginatedPresenter({ + ...documentTemplates, + items: documentTemplates.items.map( + (documentTemplate) => + new DocumentTemplateListViewItemPresenter(documentTemplate), + ), + }); + } +} diff --git a/backend/src/api/documents/dto/create-document-contract.dto.ts b/backend/src/api/documents/dto/create-document-contract.dto.ts new file mode 100644 index 000000000..751806414 --- /dev/null +++ b/backend/src/api/documents/dto/create-document-contract.dto.ts @@ -0,0 +1,34 @@ +import { IsDate, IsString, MaxLength, MinDate } from 'class-validator'; +import { IsDateGreaterThanOrEqualTo } from 'src/common/validators/is-date-gte.validator'; + +export class CreateDocumentContractDto { + @IsString() + @MaxLength(9) + documentNumber: string; + + @IsDate() + @MinDate(() => new Date(), { + message: 'Document date must be greater than or equal to the current date', + }) + documentDate: Date; + + @IsDate() + @IsDateGreaterThanOrEqualTo('documentDate', { + message: + 'Document start date must be greater than or equal to the document date', + }) + documentStartDate: Date; + + @IsDate() + @IsDateGreaterThanOrEqualTo('documentStartDate', { + message: + 'Document end date must be greater than or equal to the document start date', + }) + documentEndDate: Date; + + @IsString() + volunteerId: string; + + @IsString() + documentTemplateId: string; +} diff --git a/backend/src/api/documents/dto/create-document-template.dto.ts b/backend/src/api/documents/dto/create-document-template.dto.ts new file mode 100644 index 000000000..04c3780ad --- /dev/null +++ b/backend/src/api/documents/dto/create-document-template.dto.ts @@ -0,0 +1,52 @@ +import { Type } from 'class-transformer'; +import { + IsNotEmpty, + IsNotEmptyObject, + IsObject, + IsString, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; +import { IDocumentTemplateOrganizationData } from 'src/modules/documents/models/document-template.model'; + +class DocumentTemplateOrganizationDataDto + implements IDocumentTemplateOrganizationData +{ + @IsString() + @IsNotEmpty() + officialName: string; + + @IsString() + @IsNotEmpty() + registeredOffice: string; // Sediu social + + @IsString() + @IsNotEmpty() + CUI: string; + + @IsString() + @IsNotEmpty() + legalRepresentativeName: string; + + @IsString() + @IsNotEmpty() + legalRepresentativeRole: string; +} + +export class CreateDocumentTemplateDto { + @IsString() + @MaxLength(250) + @MinLength(2) + name: string; + + @IsObject() + @IsNotEmptyObject() + @ValidateNested() + @Type(() => DocumentTemplateOrganizationDataDto) + organizationData: IDocumentTemplateOrganizationData; + + @IsString() + @IsNotEmpty() + documentTerms: string; +} diff --git a/backend/src/api/documents/dto/get-many-document-contracts.dto.ts b/backend/src/api/documents/dto/get-many-document-contracts.dto.ts new file mode 100644 index 000000000..d03b5a126 --- /dev/null +++ b/backend/src/api/documents/dto/get-many-document-contracts.dto.ts @@ -0,0 +1,21 @@ +import { IsDate, IsEnum, IsOptional, IsString } from 'class-validator'; +import { BasePaginationFilterDto } from 'src/infrastructure/base/base-pagination-filter.dto'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; + +export class GetManyDocumentContractsDto extends BasePaginationFilterDto { + @IsOptional() + @IsString() + volunteerId?: string; + + @IsOptional() + @IsEnum(DocumentContractStatus) + status?: DocumentContractStatus; + + @IsDate() + @IsOptional() + startDate?: Date; + + @IsDate() + @IsOptional() + endDate?: Date; +} diff --git a/backend/src/api/documents/dto/get-many-document-templates.dto.ts b/backend/src/api/documents/dto/get-many-document-templates.dto.ts new file mode 100644 index 000000000..52fe19b82 --- /dev/null +++ b/backend/src/api/documents/dto/get-many-document-templates.dto.ts @@ -0,0 +1,3 @@ +import { BasePaginationFilterDto } from 'src/infrastructure/base/base-pagination-filter.dto'; + +export class GetManyDocumentTemplatesDto extends BasePaginationFilterDto {} diff --git a/backend/src/api/documents/dto/reject-document-contract.dto.ts b/backend/src/api/documents/dto/reject-document-contract.dto.ts new file mode 100644 index 000000000..46455c8b0 --- /dev/null +++ b/backend/src/api/documents/dto/reject-document-contract.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class RejectDocumentContractByNgoDTO { + @IsString() + @IsOptional() + rejectionReason: string; +} diff --git a/backend/src/api/documents/presenters/document-contract-list-view-item.presenter.ts b/backend/src/api/documents/presenters/document-contract-list-view-item.presenter.ts new file mode 100644 index 000000000..5c79d3270 --- /dev/null +++ b/backend/src/api/documents/presenters/document-contract-list-view-item.presenter.ts @@ -0,0 +1,89 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; +import { IDocumentContractListViewModel } from 'src/modules/documents/models/document-contract-list-view.model'; + +export class DocumentContractListViewItemPresenter { + constructor(item: IDocumentContractListViewModel) { + this.documentId = item.documentId; + this.documentNumber = item.documentNumber; + this.documentStartDate = item.documentStartDate; + this.documentEndDate = item.documentEndDate; + this.documentFilePath = item.documentFilePath; + this.status = item.status; + this.volunteerId = item.volunteerId; + this.volunteerName = item.volunteerName; + this.organizationId = item.organizationId; + this.organizationName = item.organizationName; + } + + @Expose() + @ApiProperty({ + description: 'The uuid of the template', + example: '525dcdf9-4117-443e-a0c3-bf652cdc5c1b', + }) + documentId: string; + + @Expose() + @ApiProperty({ + description: 'The document number', + example: '123456', + }) + documentNumber: string; + + @Expose() + @ApiProperty({ + description: 'The document start date', + example: '2021-01-01', + }) + documentStartDate: Date; + + @Expose() + @ApiProperty({ + description: 'The document end date', + example: '2021-01-01', + }) + documentEndDate: Date; + + @Expose() + @ApiProperty({ + description: 'The document file path', + example: 'https://example.com/document.pdf', + }) + documentFilePath: string; + + @Expose() + @ApiProperty({ + description: 'The document status', + example: 'CREATED', + }) + status: DocumentContractStatus; + + @Expose() + @ApiProperty({ + description: 'The volunteer id', + example: '525dcdf9-4117-443e-a0c3-bf652cdc5c1b', + }) + volunteerId: string; + + @Expose() + @ApiProperty({ + description: 'The volunteer name', + example: 'John Doe', + }) + volunteerName: string; + + @Expose() + @ApiProperty({ + description: 'The organization id', + example: '525dcdf9-4117-443e-a0c3-bf652cdc5c1b', + }) + organizationId: string; + + @Expose() + @ApiProperty({ + description: 'The organization name', + example: 'John Doe', + }) + organizationName: string; +} diff --git a/backend/src/api/documents/presenters/document-template-list-view-item.presenter.ts b/backend/src/api/documents/presenters/document-template-list-view-item.presenter.ts new file mode 100644 index 000000000..066690060 --- /dev/null +++ b/backend/src/api/documents/presenters/document-template-list-view-item.presenter.ts @@ -0,0 +1,64 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IDocumentTemplateListViewModel } from 'src/modules/documents/models/document-template-list-view.model'; + +export class DocumentTemplateListViewItemPresenter { + constructor(item: IDocumentTemplateListViewModel) { + this.id = item.id; + this.name = item.name; + this.usageCount = item.usageCount; + this.lastUsage = item.lastUsage; + this.createdById = item.createdById; + this.createdByName = item.createdByName; + this.createdOn = item.createdOn; + } + + @Expose() + @ApiProperty({ + description: 'The unique identifier of the document template', + example: '525dcdf9-4117-443e-a0c3-bf652cdc5c1b', + }) + id: string; + + @Expose() + @ApiProperty({ + description: 'The name of the document template', + example: 'Volunteer Agreement', + }) + name: string; + + @Expose() + @ApiProperty({ + description: 'The number of times this template has been used', + example: 42, + }) + usageCount: number; + + @Expose() + @ApiProperty({ + description: 'The date when this template was last used', + example: '2023-05-15T14:30:00Z', + }) + lastUsage: Date | null; + + @Expose() + @ApiProperty({ + description: 'The ID of the user who created this template', + example: '8f7e9d3c-1a2b-3c4d-5e6f-7g8h9i0j1k2l', + }) + createdById: string; + + @Expose() + @ApiProperty({ + description: 'The name of the user who created this template', + example: 'John Doe', + }) + createdByName: string; + + @Expose() + @ApiProperty({ + description: 'The date when this template was created', + example: '2023-01-01T10:00:00Z', + }) + createdOn: Date; +} diff --git a/backend/src/api/documents/presenters/document-template.presenter.ts b/backend/src/api/documents/presenters/document-template.presenter.ts new file mode 100644 index 000000000..07f95181c --- /dev/null +++ b/backend/src/api/documents/presenters/document-template.presenter.ts @@ -0,0 +1,52 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { AdminUserPresenter } from 'src/api/auth/presenters/admin-user.presenter'; +import { + IDocumentTemplateModel, + IDocumentTemplateOrganizationData, +} from 'src/modules/documents/models/document-template.model'; + +export class DocumentTemplatePresenter { + constructor(template: IDocumentTemplateModel) { + this.id = template.id; + this.name = template.name; + this.organizationData = template.organizationData; + this.documentTerms = template.documentTerms; + this.createdByAdmin = template.createdByAdmin + ? new AdminUserPresenter(template.createdByAdmin) + : null; + } + + @Expose() + @ApiProperty({ + description: 'The uuid of the template', + example: '525dcdf9-4117-443e-a0c3-bf652cdc5c1b', + }) + id: string; + + @Expose() + @ApiProperty({ + description: 'The name of the template', + example: 'Template nou', + }) + name: string; + + @Expose() + @ApiProperty({ + description: 'The organization data of the template', + example: + '{ "officialName": "Official name", "registeredOffice": "Registered office (👀 should it be composed?)", "CUI": "RO42211332", "legalRepresentativeName": "Legal representative\'s full name", "legalRepresentativeRole": "Legal representative\'s role"}', + }) + organizationData: IDocumentTemplateOrganizationData; + + @Expose() + @ApiProperty({ + description: 'The document terms of the template', + example: '

Some HTML content

', + }) + documentTerms: string; + + @Expose() + @ApiProperty({ description: 'Admin who created the template' }) + createdByAdmin: AdminUserPresenter; +} diff --git a/backend/src/api/organization/organization.controller.ts b/backend/src/api/organization/organization.controller.ts index b69db9cc8..c0e0d0180 100644 --- a/backend/src/api/organization/organization.controller.ts +++ b/backend/src/api/organization/organization.controller.ts @@ -1,5 +1,14 @@ -import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiBody } from '@nestjs/swagger'; +import { Request } from 'express'; import { ExtractUser } from 'src/common/decorators/extract-user.decorator'; import { WebJwtAuthGuard } from 'src/modules/auth/guards/jwt-web.guard'; import { IAdminUserModel } from 'src/modules/user/models/admin-user.model'; @@ -7,6 +16,7 @@ import { GetOrganizationUseCaseService } from 'src/usecases/organization/get-org import { UpdateOrganizationDescriptionUseCaseService } from 'src/usecases/organization/update-organization-description.usecase'; import { UpdateOrganizationDescriptionDto } from './dto/update-organization-description.dto'; import { OrganizationPresenter } from './presenters/organization-presenter.interface'; +import { SyncWithOngHubUseCaseService } from 'src/usecases/organization/sync-with-ngohub.usecase'; // @Roles(Role.ADMIN) @ApiBearerAuth() @@ -16,24 +26,42 @@ export class OrganizationController { constructor( private readonly getOrganizationUseCase: GetOrganizationUseCaseService, private readonly updateOrganizationDescriptionUseCase: UpdateOrganizationDescriptionUseCaseService, + private readonly syncWithOngHubUseCase: SyncWithOngHubUseCaseService, ) {} @Get() - getOrganization( + async getOrganization( @ExtractUser() { organizationId }: IAdminUserModel, ): Promise { - return this.getOrganizationUseCase.execute(organizationId); + const organization = + await this.getOrganizationUseCase.execute(organizationId); + return new OrganizationPresenter(organization); } @ApiBody({ type: UpdateOrganizationDescriptionDto }) @Patch() - patchOrganization( + async patchOrganization( @ExtractUser() admin: IAdminUserModel, @Body() { description }: UpdateOrganizationDescriptionDto, ): Promise { - return this.updateOrganizationDescriptionUseCase.execute( - description, - admin, + const organization = + await this.updateOrganizationDescriptionUseCase.execute( + description, + admin, + ); + return new OrganizationPresenter(organization); + } + + @Post('onghub/sync') + async resyncWithOngHub( + @ExtractUser() { organizationId }: IAdminUserModel, + @Req() req: Request, + ): Promise { + const organization = await this.syncWithOngHubUseCase.execute( + organizationId, + req.headers.authorization.split(' ')[1], ); + + return new OrganizationPresenter(organization); } } diff --git a/backend/src/api/organization/presenters/organization-presenter.interface.ts b/backend/src/api/organization/presenters/organization-presenter.interface.ts index 01d6ece80..69ec3acaf 100644 --- a/backend/src/api/organization/presenters/organization-presenter.interface.ts +++ b/backend/src/api/organization/presenters/organization-presenter.interface.ts @@ -12,6 +12,9 @@ export class OrganizationPresenter { this.activityArea = organization.activityArea; this.logo = organization.logo; this.description = organization.description; + this.cui = organization.cui; + this.legalReprezentativeFullName = organization.legalReprezentativeFullName; + this.legalReprezentativeRole = organization.legalReprezentativeRole; } @Expose() @@ -63,4 +66,22 @@ export class OrganizationPresenter { description: 'The description phone of the Organization', }) description: string; + + @Expose() + @ApiProperty({ + description: 'CUI Organization', + }) + cui: string; + + @Expose() + @ApiProperty({ + description: 'The legal representative full name of the Organization', + }) + legalReprezentativeFullName: string; + + @Expose() + @ApiProperty({ + description: 'The legal representative role of the Organization', + }) + legalReprezentativeRole: string; } diff --git a/backend/src/api/public/public.controller.ts b/backend/src/api/public/public.controller.ts index 47220c57d..cb4f58571 100644 --- a/backend/src/api/public/public.controller.ts +++ b/backend/src/api/public/public.controller.ts @@ -1,9 +1,12 @@ import { Controller, Get } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; import { APP_VERSION } from 'src/common/constants/version'; +import { GeneratePDFsUseCase } from 'src/usecases/documents/new_contracts/generate-pdfs.usecase'; @Controller('public') export class PublicController { + constructor(private readonly generatePDFsUseCase: GeneratePDFsUseCase) {} + @SkipThrottle() @Get('health') healthCheck(): 'OK' { @@ -15,4 +18,9 @@ export class PublicController { version(): unknown { return APP_VERSION; } + + @Get('pdf') + async pdf(): Promise { + return this.generatePDFsUseCase.execute(); + } } diff --git a/backend/src/common/helpers/pdf-from-html.ts b/backend/src/common/helpers/pdf-from-html.ts new file mode 100644 index 000000000..d8013bad6 --- /dev/null +++ b/backend/src/common/helpers/pdf-from-html.ts @@ -0,0 +1,15 @@ +import puppeteer from 'puppeteer'; + +export const HTMLtoPDF = async (html: string): Promise => { + // TODO: https://www.flightcontrol.dev/docs/tips/deployment/puppeteer + const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + const page = await browser.newPage(); + await page.setContent(html); + const buffer = await page.pdf({ format: 'A4' }); + await browser.close(); + + return buffer; +}; diff --git a/backend/src/common/helpers/utils.ts b/backend/src/common/helpers/utils.ts index 98bf388e5..ebdc6fc93 100644 --- a/backend/src/common/helpers/utils.ts +++ b/backend/src/common/helpers/utils.ts @@ -1,3 +1,4 @@ +import { differenceInYears } from 'date-fns'; import * as XLSX from 'xlsx'; export function JSONStringifyError(value: Error): string { @@ -25,3 +26,19 @@ export function jsonToExcelBuffer( return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); } + +export const isOver16FromCNP = (cnp: string): boolean => { + // we don't need to perform the calculation before the user has entered all the necessary digits to calculate + if (cnp.length !== 13) { + throw new Error('CNP must be 13 digits long'); + } + // if first digit is above 5, then the birth year is 2000+ + const yearPrefix = parseInt(cnp[0], 10) < 5 ? '19' : '20'; + const year = (yearPrefix + cnp.substring(1, 3)).toString(); + const month = cnp.substring(3, 5); + const day = cnp.substring(5, 7); + const birthday = new Date(`${year}-${month}-${day}`); + + const age = differenceInYears(new Date(), birthday); + return age >= 16; +}; diff --git a/backend/src/common/validators/is-date-gte.validator.ts b/backend/src/common/validators/is-date-gte.validator.ts new file mode 100644 index 000000000..ca46705e6 --- /dev/null +++ b/backend/src/common/validators/is-date-gte.validator.ts @@ -0,0 +1,33 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; + +export function IsDateGreaterThanOrEqualTo( + property: string, + validationOptions?: ValidationOptions, +) { + return function (object: unknown, propertyName: string): void { + registerDecorator({ + name: 'isGreaterThanOrEqualTo', + target: object.constructor, + propertyName: propertyName, + constraints: [property], + options: validationOptions, + validator: { + validate(value: Date, args: ValidationArguments): boolean { + const [relatedPropertyName] = args.constraints; + const relatedValue = (args.object as Record)[ + relatedPropertyName + ] as Date; + return value >= relatedValue; + }, + defaultMessage(args: ValidationArguments): string { + const [relatedPropertyName] = args.constraints; + return `${propertyName} must be greater than or equal to ${relatedPropertyName}`; + }, + }, + }); + }; +} diff --git a/backend/src/infrastructure/base/base-pagination-filter.dto.ts b/backend/src/infrastructure/base/base-pagination-filter.dto.ts index 0c635d0c5..fc28f4dd0 100644 --- a/backend/src/infrastructure/base/base-pagination-filter.dto.ts +++ b/backend/src/infrastructure/base/base-pagination-filter.dto.ts @@ -1,24 +1,30 @@ import { Type } from 'class-transformer'; import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; import { OrderDirection } from 'src/common/enums/order-direction.enum'; +import { ApiProperty } from '@nestjs/swagger'; export class BasePaginationFilterDto { + @ApiProperty({ default: 10 }) @IsNumber() @Type(() => Number) - limit = 10; + limit: number = 10; + @ApiProperty({ default: 1 }) @IsNumber() @Type(() => Number) - page = 1; + page: number = 1; + @ApiProperty({ required: false }) @IsString() @IsOptional() search?: string; + @ApiProperty({ required: false }) @IsOptional() @IsString() orderBy?: string; + @ApiProperty({ enum: OrderDirection, required: false }) @IsOptional() @IsEnum(OrderDirection) orderDirection?: OrderDirection; diff --git a/backend/src/migrations/1725349961532-AddOrganizationNewFields.ts b/backend/src/migrations/1725349961532-AddOrganizationNewFields.ts new file mode 100644 index 000000000..d678f36f7 --- /dev/null +++ b/backend/src/migrations/1725349961532-AddOrganizationNewFields.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddOrganizationNewFields1725349961532 + implements MigrationInterface +{ + name = 'AddOrganizationNewFields1725349961532'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "organization" ADD "cui" text`); + await queryRunner.query( + `ALTER TABLE "organization" ADD "legal_representative_full_name" text`, + ); + await queryRunner.query( + `ALTER TABLE "organization" ADD "legal_representative_role" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "organization" DROP COLUMN "legal_representative_role"`, + ); + await queryRunner.query( + `ALTER TABLE "organization" DROP COLUMN "legal_representative_full_name"`, + ); + await queryRunner.query(`ALTER TABLE "organization" DROP COLUMN "cui"`); + } +} diff --git a/backend/src/migrations/1725881754175-DDLDocumentContractsSignature.ts b/backend/src/migrations/1725881754175-DDLDocumentContractsSignature.ts new file mode 100644 index 000000000..be52ede5e --- /dev/null +++ b/backend/src/migrations/1725881754175-DDLDocumentContractsSignature.ts @@ -0,0 +1,141 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DDLDocumentContractsSignature1725881754175 + implements MigrationInterface +{ + name = 'DDLDocumentContractsSignature1725881754175'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "document_signature" ("deleted_on" TIMESTAMP WITH TIME ZONE, "created_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "signature" text NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_36de69dee9bd822839c659c60a5" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8c0295ec75967828329ef52ac3" ON "document_signature" ("created_on") `, + ); + await queryRunner.query( + `CREATE TABLE "document_template" ("deleted_on" TIMESTAMP WITH TIME ZONE, "created_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" text NOT NULL, "organization_data" jsonb NOT NULL, "document_terms" text NOT NULL, "organization_id" uuid NOT NULL, "created_by_admin_id" uuid, CONSTRAINT "PK_0e9c5bda0dd75f3bde7ae176c62" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6dfe1cd0474df5d7d716bf59f0" ON "document_template" ("created_on") `, + ); + await queryRunner.query( + `CREATE TYPE "public"."document_contract_status_enum" AS ENUM('CREATED', 'SCHEDULED', 'PENDING_VOLUNTEER_SIGNATURE', 'PENDING_APPROVAL_NGO', 'PENDING_NGO_REPRESENTATIVE_SIGNATURE', 'APPROVED', 'REJECTED_VOLUNTEER', 'REJECTED_NGO', 'ACTION_EXPIRED')`, + ); + await queryRunner.query( + `CREATE TABLE "document_contract" ("deleted_on" TIMESTAMP WITH TIME ZONE, "created_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "status" "public"."document_contract_status_enum" NOT NULL DEFAULT 'CREATED', "document_number" text NOT NULL, "document_date" date NOT NULL, "document_start_date" date NOT NULL, "document_end_date" date NOT NULL, "file_path" text, "volunteer_data" jsonb NOT NULL, "volunteer_tutor_data" jsonb, "volunteer_id" uuid NOT NULL, "organization_id" uuid NOT NULL, "document_template_id" uuid, "created_by_admin_id" uuid NOT NULL, "ngo_legal_representative_signature_id" uuid, "volunteer_signature_id" uuid, "tutor_signature_id" uuid, CONSTRAINT "PK_bc0002326db7d928c061fc90953" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0bc738c91555e8a0ef9836de02" ON "document_contract" ("created_on") `, + ); + await queryRunner.query( + `ALTER TABLE "document_signature" ADD CONSTRAINT "FK_6e59057f55354293bf9f5e0a8df" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_template" ADD CONSTRAINT "FK_5b878af38db8ff501cbba07d97b" FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_template" ADD CONSTRAINT "FK_efd8efceb4027c6e48af499e005" FOREIGN KEY ("created_by_admin_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_7617b71c917deb25a66df28843d" FOREIGN KEY ("volunteer_id") REFERENCES "volunteer"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_8a7ea2aea4dc1cf32f367850602" FOREIGN KEY ("organization_id") REFERENCES "organization"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_8167fbb2efdbc38871cf081515a" FOREIGN KEY ("document_template_id") REFERENCES "document_template"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_8de249c663986ee044dbef54fac" FOREIGN KEY ("created_by_admin_id") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_24ce6d12fac4a65325e44396e47" FOREIGN KEY ("ngo_legal_representative_signature_id") REFERENCES "document_signature"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_e82ad9df7db32f7c2d9034bba5f" FOREIGN KEY ("volunteer_signature_id") REFERENCES "document_signature"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_8586b2b6c023b4a93d301363004" FOREIGN KEY ("tutor_signature_id") REFERENCES "document_signature"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query(`CREATE VIEW "DocumentContractListView" AS + SELECT + document_contract.id as document_id, + document_contract.document_number as document_number, + document_contract.status as status, + document_contract.document_start_date as document_start_date, + document_contract.document_end_date as document_end_date, + document_contract.file_path AS document_file_path, + organization.id AS organization_id, + organization.name AS organization_name, + volunteer.id AS volunteer_id, + "user"."name" AS volunteer_name + FROM + document_contract + JOIN volunteer ON document_contract.volunteer_id = volunteer.id + JOIN "user" ON "user".id = volunteer.user_id + JOIN organization ON document_contract.organization_id = organization.id + `); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'DocumentContractListView', + 'SELECT\n document_contract.id as document_id,\n document_contract.document_number as document_number,\n document_contract.status as status,\n document_contract.document_start_date as document_start_date,\n document_contract.document_end_date as document_end_date,\n document_contract.file_path AS document_file_path,\n organization.id AS organization_id,\n organization.name AS organization_name,\n volunteer.id AS volunteer_id,\n "user"."name" AS volunteer_name\n FROM\n document_contract\n JOIN volunteer ON document_contract.volunteer_id = volunteer.id\n JOIN "user" ON "user".id = volunteer.user_id\n JOIN organization ON document_contract.organization_id = organization.id', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'DocumentContractListView', 'public'], + ); + await queryRunner.query(`DROP VIEW "DocumentContractListView"`); + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_8586b2b6c023b4a93d301363004"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_e82ad9df7db32f7c2d9034bba5f"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_24ce6d12fac4a65325e44396e47"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_8de249c663986ee044dbef54fac"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_8167fbb2efdbc38871cf081515a"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_8a7ea2aea4dc1cf32f367850602"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_7617b71c917deb25a66df28843d"`, + ); + await queryRunner.query( + `ALTER TABLE "document_template" DROP CONSTRAINT "FK_efd8efceb4027c6e48af499e005"`, + ); + await queryRunner.query( + `ALTER TABLE "document_template" DROP CONSTRAINT "FK_5b878af38db8ff501cbba07d97b"`, + ); + await queryRunner.query( + `ALTER TABLE "document_signature" DROP CONSTRAINT "FK_6e59057f55354293bf9f5e0a8df"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_0bc738c91555e8a0ef9836de02"`, + ); + await queryRunner.query(`DROP TABLE "document_contract"`); + await queryRunner.query( + `DROP TYPE "public"."document_contract_status_enum"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_6dfe1cd0474df5d7d716bf59f0"`, + ); + await queryRunner.query(`DROP TABLE "document_template"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_8c0295ec75967828329ef52ac3"`, + ); + await queryRunner.query(`DROP TABLE "document_signature"`); + } +} diff --git a/backend/src/migrations/1725889826591-ChangeTimestampToDateForUser.ts b/backend/src/migrations/1725889826591-ChangeTimestampToDateForUser.ts new file mode 100644 index 000000000..2b20cb1d4 --- /dev/null +++ b/backend/src/migrations/1725889826591-ChangeTimestampToDateForUser.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ChangeTimestampToDateForUser1725889826591 + implements MigrationInterface +{ + name = 'ChangeTimestampToDateForUser1725889826591'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_personal_data" ALTER COLUMN "identity_document_issue_date" TYPE date USING ("identity_document_issue_date"::timestamp AT TIME ZONE 'GMT+3')::date`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" ALTER COLUMN "identity_document_expiration_date" TYPE date USING ("identity_document_expiration_date"::timestamp AT TIME ZONE 'GMT+3')::date`, + ); + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "birthday" TYPE date USING ("birthday"::timestamp AT TIME ZONE 'GMT+3')::date`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "birthday"`); + await queryRunner.query( + `ALTER TABLE "user" ADD "birthday" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" DROP COLUMN "identity_document_expiration_date"`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" ADD "identity_document_expiration_date" TIMESTAMP WITH TIME ZONE NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" DROP COLUMN "identity_document_issue_date"`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" ADD "identity_document_issue_date" TIMESTAMP WITH TIME ZONE NOT NULL`, + ); + } +} diff --git a/backend/src/migrations/1725953984933-AddUserPersonalDataAndTutor.ts b/backend/src/migrations/1725953984933-AddUserPersonalDataAndTutor.ts new file mode 100644 index 000000000..1ef63c5c5 --- /dev/null +++ b/backend/src/migrations/1725953984933-AddUserPersonalDataAndTutor.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserPersonalDataAndTutor1725953984933 + implements MigrationInterface +{ + name = 'AddUserPersonalDataAndTutor1725953984933'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_personal_data" ADD "cnp" text`); + await queryRunner.query( + `ALTER TABLE "user_personal_data" ADD "identity_document_issued_by" text`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" ADD "legal_guardian" jsonb`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" DROP CONSTRAINT "UQ_a43393c324223214daef1914850"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_personal_data" ADD CONSTRAINT "UQ_a43393c324223214daef1914850" UNIQUE ("identity_document_number")`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" DROP COLUMN "legal_guardian"`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" DROP COLUMN "issued_by"`, + ); + await queryRunner.query( + `ALTER TABLE "user_personal_data" DROP COLUMN "cnp"`, + ); + } +} diff --git a/backend/src/migrations/1726041710983-RemoveVolunteerTutorData.ts b/backend/src/migrations/1726041710983-RemoveVolunteerTutorData.ts new file mode 100644 index 000000000..efebc5874 --- /dev/null +++ b/backend/src/migrations/1726041710983-RemoveVolunteerTutorData.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveVolunteerTutorData1726041710983 + implements MigrationInterface +{ + name = 'RemoveVolunteerTutorData1726041710983'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "document_contract" DROP COLUMN "volunteer_tutor_data"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "document_contract" ADD "volunteer_tutor_data" jsonb`, + ); + } +} diff --git a/backend/src/migrations/1726046690797-DDLDocumentTemplateListView.ts b/backend/src/migrations/1726046690797-DDLDocumentTemplateListView.ts new file mode 100644 index 000000000..85ed12f3f --- /dev/null +++ b/backend/src/migrations/1726046690797-DDLDocumentTemplateListView.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DDLDocumentTemplateListView1726046690797 + implements MigrationInterface +{ + name = 'DDLDocumentTemplateListView1726046690797'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE VIEW "DocumentTemplateListView" AS + SELECT + document_template.id, + document_template."name", + document_template.created_on, + document_template.organization_id, + "user"."id" as created_by_id, + "user"."name" as created_by_name, + count(document_contract.id) as usage_count, + max(document_contract.created_on) as last_usage + FROM + document_template + LEFT JOIN document_contract ON document_template.id = document_contract.document_template_id + LEFT JOIN "user" on document_template.created_by_admin_id = "user".id + GROUP BY + document_template.id, + "user"."name", + "user"."id" + `); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'DocumentTemplateListView', + 'SELECT\n document_template.id,\n document_template."name",\n document_template.created_on,\n document_template.organization_id,\n "user"."id" as created_by_id,\n "user"."name" as created_by_name,\n count(document_contract.id) as usage_count,\n max(document_contract.created_on) as last_usage\n FROM\n document_template\n LEFT JOIN document_contract ON document_template.id = document_contract.document_template_id\n LEFT JOIN "user" on document_template.created_by_admin_id = "user".id\n GROUP BY\n document_template.id,\n "user"."name",\n "user"."id"', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'DocumentTemplateListView', 'public'], + ); + await queryRunner.query(`DROP VIEW "DocumentTemplateListView"`); + } +} diff --git a/backend/src/migrations/1726053758155-RenameTutorToGuardian.ts b/backend/src/migrations/1726053758155-RenameTutorToGuardian.ts new file mode 100644 index 000000000..821c7f803 --- /dev/null +++ b/backend/src/migrations/1726053758155-RenameTutorToGuardian.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameTutorToGuardian1726053758155 implements MigrationInterface { + name = 'RenameTutorToGuardian1726053758155'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_8586b2b6c023b4a93d301363004"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" RENAME COLUMN "tutor_signature_id" TO "legal_guardian_signature_id"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_219866394581cf64cb2b7194b88" FOREIGN KEY ("legal_guardian_signature_id") REFERENCES "document_signature"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "document_contract" DROP CONSTRAINT "FK_219866394581cf64cb2b7194b88"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" RENAME COLUMN "legal_guardian_signature_id" TO "tutor_signature_id"`, + ); + await queryRunner.query( + `ALTER TABLE "document_contract" ADD CONSTRAINT "FK_8586b2b6c023b4a93d301363004" FOREIGN KEY ("tutor_signature_id") REFERENCES "document_signature"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/backend/src/modules/documents/documents.module.ts b/backend/src/modules/documents/documents.module.ts index 472a9c852..d9ca336aa 100644 --- a/backend/src/modules/documents/documents.module.ts +++ b/backend/src/modules/documents/documents.module.ts @@ -6,21 +6,59 @@ import { TemplateFacade } from './services/template.facade'; import { ContractEntity } from './entities/contract.entity'; import { ContractRepositoryService } from './repositories/contract.repository'; import { ContractFacade } from './services/contract.facade'; +import { PDFGenerator } from './services/pdf-generator'; +import { DocumentTemplateRepositoryService } from './repositories/document-template.repository'; +import { DocumentTemplateFacade } from './services/document-template.facade'; +import { DocumentTemplateEntity } from './entities/document-template.entity'; +import { DocumentContractEntity } from './entities/document-contract.entity'; +import { DocumentContractRepositoryService } from './repositories/document-contract.repository'; +import { DocumentContractFacade } from './services/document-contract.facade'; +import { DocumentContractListViewEntity } from './entities/document-contract-list-view.entity'; +import { DocumentContractListViewRepository } from './repositories/document-contract-list-view.repository'; +import { DocumentSignatureEntity } from './entities/document-signature.entity'; +import { DocumentTemplateListViewEntity } from './entities/document-template-list-view.entity'; +import { DocumentTemplateListViewRepository } from './repositories/document-template-list-view.repository'; +import { DocumentSignatureRepository } from './repositories/document-signature.repository'; +import { DocumentSignatureFacade } from './services/document-signature.facade'; @Module({ - imports: [TypeOrmModule.forFeature([TemplateEntity, ContractEntity])], + imports: [ + TypeOrmModule.forFeature([ + DocumentContractListViewEntity, + DocumentTemplateListViewEntity, + TemplateEntity, + ContractEntity, + DocumentTemplateEntity, + DocumentContractEntity, + DocumentSignatureEntity, + ]), + ], providers: [ // Repositories TemplateRepositoryService, ContractRepositoryService, + DocumentTemplateRepositoryService, + DocumentContractRepositoryService, + DocumentSignatureRepository, + DocumentContractListViewRepository, + DocumentTemplateListViewRepository, // Facades TemplateFacade, ContractFacade, + DocumentTemplateFacade, + DocumentContractFacade, + DocumentSignatureFacade, + // Services + PDFGenerator, ], exports: [ // Export only facades! TemplateFacade, ContractFacade, + PDFGenerator, + DocumentTemplateFacade, + DocumentContractFacade, + DocumentSignatureFacade, ], }) export class DocumentsModule {} diff --git a/backend/src/modules/documents/entities/document-contract-list-view.entity.ts b/backend/src/modules/documents/entities/document-contract-list-view.entity.ts new file mode 100644 index 000000000..4a7fbad2d --- /dev/null +++ b/backend/src/modules/documents/entities/document-contract-list-view.entity.ts @@ -0,0 +1,56 @@ +import { Column, ViewColumn, ViewEntity } from 'typeorm'; +import { DocumentContractStatus } from '../enums/contract-status.enum'; + +@ViewEntity('DocumentContractListView', { + expression: ` + SELECT + document_contract.id as document_id, + document_contract.document_number as document_number, + document_contract.status as status, + document_contract.document_start_date as document_start_date, + document_contract.document_end_date as document_end_date, + document_contract.file_path AS document_file_path, + organization.id AS organization_id, + organization.name AS organization_name, + volunteer.id AS volunteer_id, + "user"."name" AS volunteer_name + FROM + document_contract + JOIN volunteer ON document_contract.volunteer_id = volunteer.id + JOIN "user" ON "user".id = volunteer.user_id + JOIN organization ON document_contract.organization_id = organization.id + `, +}) +export class DocumentContractListViewEntity { + @ViewColumn({ name: 'document_id' }) + documentId: string; + + @Column({ name: 'document_number' }) + documentNumber: string; + + // Use Column instead of ViewColumn because of https://github.com/typeorm/typeorm/issues/4320. Date was returned as Timestamp + @Column({ name: 'document_start_date', type: 'date' }) + documentStartDate: Date; + + // Use Column instead of ViewColumn because of https://github.com/typeorm/typeorm/issues/4320. Date was returned as Timestamp + @Column({ name: 'document_end_date', type: 'date' }) + documentEndDate: Date; + + @ViewColumn({ name: 'document_file_path' }) + documentFilePath: string; + + @ViewColumn({ name: 'status' }) + status: DocumentContractStatus; + + @ViewColumn({ name: 'volunteer_id' }) + volunteerId: string; + + @ViewColumn({ name: 'volunteer_name' }) + volunteerName: string; + + @ViewColumn({ name: 'organization_id' }) + organizationId: string; + + @ViewColumn({ name: 'organization_name' }) + organizationName: string; +} diff --git a/backend/src/modules/documents/entities/document-contract.entity.ts b/backend/src/modules/documents/entities/document-contract.entity.ts new file mode 100644 index 000000000..e80c701d5 --- /dev/null +++ b/backend/src/modules/documents/entities/document-contract.entity.ts @@ -0,0 +1,158 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { DocumentContractStatus } from '../enums/contract-status.enum'; +import { OrganizationEntity } from 'src/modules/organization/entities/organization.entity'; +import { DocumentTemplateEntity } from './document-template.entity'; +import { AdminUserEntity } from 'src/modules/user/entities/user.entity'; +import { VolunteerEntity } from 'src/modules/volunteer/entities/volunteer.entity'; +import { BaseEntity } from 'src/infrastructure/base/base-entity'; +import { DocumentSignatureEntity } from './document-signature.entity'; +import { VolunteerContractIdentityData } from '../models/document-contract.model'; + +@Entity({ name: 'document_contract' }) +export class DocumentContractEntity extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ + type: 'enum', + enum: DocumentContractStatus, + name: 'status', + default: DocumentContractStatus.CREATED, + }) + status: DocumentContractStatus; + + @Column({ type: 'text', name: 'document_number' }) + documentNumber: string; + + @Column({ type: 'date', name: 'document_date' }) + documentDate: Date; + + @Column({ type: 'date', name: 'document_start_date' }) + documentStartDate: Date; + + @Column({ type: 'date', name: 'document_end_date' }) + documentEndDate: Date; + + @Column({ type: 'text', name: 'file_path', nullable: true }) + filePath: string; + + // ==================== VOLUNTEER RELATION ================================= + + @Column({ type: 'jsonb', name: 'volunteer_data', nullable: false }) + volunteerData: VolunteerContractIdentityData; + + @Column({ + type: 'varchar', + name: 'volunteer_id', + }) + volunteerId: string; + + @ManyToOne(() => VolunteerEntity) + @JoinColumn({ name: 'volunteer_id' }) + volunteer: VolunteerEntity; + + // ==================== ORGANIZATION RELATION ================================= + @Column({ + type: 'varchar', + name: 'organization_id', + }) + organizationId: string; + + @ManyToOne(() => OrganizationEntity) + @JoinColumn({ name: 'organization_id' }) + organization: OrganizationEntity; + + // ==================== TEMPLATE RELATION ================================= + + @Column({ + type: 'varchar', + nullable: true, + name: 'document_template_id', + }) + documentTemplateId: string; + + @ManyToOne(() => DocumentTemplateEntity) + @JoinColumn({ name: 'document_template_id' }) + documentTemplate: DocumentTemplateEntity; + + // ==================== CONTRACT CREATED BY ================================= + + @Column({ + type: 'varchar', + name: 'created_by_admin_id', + }) + createdByAdminId: string; + + @ManyToOne(() => AdminUserEntity) + @JoinColumn({ name: 'created_by_admin_id' }) + createdByAdmin: AdminUserEntity; + + // ======================== SIGNATURES ================================= + @Column({ + type: 'varchar', + name: 'ngo_legal_representative_signature_id', + nullable: true, + }) + ngoLegalRepresentativeSignatureId: string; + + @ManyToOne(() => DocumentSignatureEntity) + @JoinColumn({ name: 'ngo_legal_representative_signature_id' }) + ngoLegalRepresentativeSignature: DocumentSignatureEntity; + + @Column({ + type: 'varchar', + name: 'volunteer_signature_id', + nullable: true, + }) + volunteerSignatureId: string; + + @ManyToOne(() => DocumentSignatureEntity) + @JoinColumn({ name: 'volunteer_signature_id' }) + volunteerSignature: DocumentSignatureEntity; + + @Column({ + type: 'varchar', + name: 'legal_guardian_signature_id', + nullable: true, + }) + legalGuardianSignatureId: string; + + @ManyToOne(() => DocumentSignatureEntity) + @JoinColumn({ name: 'legal_guardian_signature_id' }) + legalGuardianSignature: DocumentSignatureEntity; + + // // ==================== APPROVAL ================================= + + // TODO: instead of keeping here the approval/rejection/signatures we can keep them in ActionsArchive + + // @Column({ type: 'timestamptz', name: 'approved_on', nullable: true }) + // approvedOn: Date; + + // @Column({ type: 'string', name: 'approved_by', nullable: true }) + // approvedById: string; + + // @ManyToOne(() => AdminUserEntity) + // @JoinColumn({ name: 'approved_by' }) + // approvedBy: AdminUserEntity; + + // // ==================== REJECTION ================================= + + // @Column({ type: 'text', name: 'rejection_reason', nullable: true }) + // rejectionReason: string; + + // @Column({ type: 'timestamptz', name: 'rejected_on', nullable: true }) + // rejectedOn: Date; + + // @Column({ type: 'string', name: 'rejected_by', nullable: true }) + // rejectedById: string; + + // @ManyToOne(() => AdminUserEntity) + // @JoinColumn({ name: 'rejected_by' }) + // rejectedBy: AdminUserEntity; +} diff --git a/backend/src/modules/documents/entities/document-signature.entity.ts b/backend/src/modules/documents/entities/document-signature.entity.ts new file mode 100644 index 000000000..c1bc05aec --- /dev/null +++ b/backend/src/modules/documents/entities/document-signature.entity.ts @@ -0,0 +1,25 @@ +import { BaseEntity } from 'src/infrastructure/base/base-entity'; +import { UserEntity } from 'src/modules/user/entities/user.entity'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity({ name: 'document_signature' }) +export class DocumentSignatureEntity extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'text', name: 'signature' }) + signature: string; + + @Column({ type: 'string', name: 'user_id' }) + userId: string; + + @ManyToOne(() => UserEntity) + @JoinColumn({ name: 'user_id' }) + user: UserEntity; +} diff --git a/backend/src/modules/documents/entities/document-template-list-view.entity.ts b/backend/src/modules/documents/entities/document-template-list-view.entity.ts new file mode 100644 index 000000000..6319769bf --- /dev/null +++ b/backend/src/modules/documents/entities/document-template-list-view.entity.ts @@ -0,0 +1,48 @@ +import { ViewColumn, ViewEntity } from 'typeorm'; + +@ViewEntity('DocumentTemplateListView', { + expression: ` + SELECT + document_template.id, + document_template."name", + document_template.created_on, + document_template.organization_id, + "user"."id" as created_by_id, + "user"."name" as created_by_name, + count(document_contract.id) as usage_count, + max(document_contract.created_on) as last_usage + FROM + document_template + LEFT JOIN document_contract ON document_template.id = document_contract.document_template_id + LEFT JOIN "user" on document_template.created_by_admin_id = "user".id + GROUP BY + document_template.id, + "user"."name", + "user"."id" + `, +}) +export class DocumentTemplateListViewEntity { + @ViewColumn({ name: 'id' }) + id: string; + + @ViewColumn({ name: 'name' }) + name: string; + + @ViewColumn({ name: 'created_on' }) + createdOn: Date; + + @ViewColumn({ name: 'created_by_id' }) + createdById: string; + + @ViewColumn({ name: 'created_by_name' }) + createdByName: string; + + @ViewColumn({ name: 'usage_count' }) + usageCount: number; + + @ViewColumn({ name: 'last_usage' }) + lastUsage: Date; + + @ViewColumn({ name: 'organization_id' }) + organizationId: string; +} diff --git a/backend/src/modules/documents/entities/document-template.entity.ts b/backend/src/modules/documents/entities/document-template.entity.ts new file mode 100644 index 000000000..2f7ff1f3f --- /dev/null +++ b/backend/src/modules/documents/entities/document-template.entity.ts @@ -0,0 +1,57 @@ +import { BaseEntity } from 'src/infrastructure/base/base-entity'; +import { OrganizationEntity } from 'src/modules/organization/entities/organization.entity'; +import { AdminUserEntity } from 'src/modules/user/entities/user.entity'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { DocumentContractEntity } from './document-contract.entity'; + +@Entity({ name: 'document_template' }) +export class DocumentTemplateEntity extends BaseEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'text', name: 'name' }) + name: string; + + // { + // "officialName": "Official name", + // "registeredOffice": "Registered office (👀 should it be composed?)", + // "CUI": "RO42211332", + // "legalRepresentativeName": "Legal representative's full name", + // "legalRepresentativeRole": "Legal representative's role" + // } + @Column({ type: 'jsonb', name: 'organization_data' }) + organizationData: object; + + @Column({ type: 'text', name: 'document_terms' }) + documentTerms: string; // HTML string from WYSIWYG + + @Column({ type: 'text', name: 'organization_id' }) + organizationId: string; + + @ManyToOne(() => OrganizationEntity) + @JoinColumn({ name: 'organization_id' }) + organization: OrganizationEntity; + + @Column({ type: 'string', name: 'created_by_admin_id', nullable: true }) + createdByAdminId: string; + + @ManyToOne(() => AdminUserEntity) + @JoinColumn({ name: 'created_by_admin_id' }) + createdByAdmin: AdminUserEntity; + + @OneToMany( + () => DocumentContractEntity, + (contract) => contract.documentTemplate, + { + onDelete: 'SET NULL', + }, + ) + contracts: DocumentContractEntity[]; +} diff --git a/backend/src/modules/documents/enums/contract-status.enum.ts b/backend/src/modules/documents/enums/contract-status.enum.ts index a29af5fe9..04ac3452d 100644 --- a/backend/src/modules/documents/enums/contract-status.enum.ts +++ b/backend/src/modules/documents/enums/contract-status.enum.ts @@ -4,3 +4,15 @@ export enum ContractStatus { APPROVED = 'APPROVED', REJECTED = 'REJECTED', } + +export enum DocumentContractStatus { + CREATED = 'CREATED', // just created, not sent to volunteer + SCHEDULED = 'SCHEDULED', // se va trimite la o data setata intr-un CRON si isi va schimba statusul in PENDING_VOLUNTEER + PENDING_VOLUNTEER_SIGNATURE = 'PENDING_VOLUNTEER_SIGNATURE', // fost creat de Admin si trimis catre Voluntar + PENDING_APPROVAL_NGO = 'PENDING_APPROVAL_NGO', // a fost semnat de voluntar si trimis la ONG pentru verificare + PENDING_NGO_REPRESENTATIVE_SIGNATURE = 'PENDING_NGO_REPRESENTATIVE_SIGNATURE', // a fost aprobat de catre ONG si trimis catre reprezentantul legal al ONG-ului pentru semnare + APPROVED = 'APPROVED', // a fost aprobat de ambele parti + REJECTED_VOLUNTEER = 'REJECTED_VOLUNTEER', // a fost rejected de catre Voluntar + REJECTED_NGO = 'REJECTED_NGO', // a fost rejected de catre NGO + ACTION_EXPIRED = 'ACTION_EXPIRED', // a expirat dupa 30 zile de la generare daca nu a primit raspuns de la voluntar +} diff --git a/backend/src/modules/documents/exceptions/documente-template.exceptions.ts b/backend/src/modules/documents/exceptions/documente-template.exceptions.ts new file mode 100644 index 000000000..28346f6d2 --- /dev/null +++ b/backend/src/modules/documents/exceptions/documente-template.exceptions.ts @@ -0,0 +1,23 @@ +import { BusinessException } from 'src/common/interfaces/business-exception.interface'; + +export enum DocumentTemplateExceptionCodes { + TEMPLATE_001 = 'TEMPLATE_001', + TEMPLATE_002 = 'TEMPLATE_002', +} + +type DocumentTemplateExceptionCodeType = + keyof typeof DocumentTemplateExceptionCodes; + +export const DocumentTemplateExceptionMessages: Record< + DocumentTemplateExceptionCodes, + BusinessException +> = { + [DocumentTemplateExceptionCodes.TEMPLATE_001]: { + code_error: DocumentTemplateExceptionCodes.TEMPLATE_001, + message: 'Not found', + }, + [DocumentTemplateExceptionCodes.TEMPLATE_002]: { + code_error: DocumentTemplateExceptionCodes.TEMPLATE_002, + message: 'Error while creating the document template', + }, +}; diff --git a/backend/src/modules/documents/interfaces/document-template-repository.interface.ts b/backend/src/modules/documents/interfaces/document-template-repository.interface.ts new file mode 100644 index 000000000..81e8f8411 --- /dev/null +++ b/backend/src/modules/documents/interfaces/document-template-repository.interface.ts @@ -0,0 +1,17 @@ +import { IRepositoryWithPagination } from 'src/common/interfaces/repository-with-pagination.interface'; +import { + CreateDocumentTemplateOptions, + FindOneDocumentTemplateOptions, + IDocumentTemplateModel, +} from '../models/document-template.model'; +import { DocumentTemplateEntity } from '../entities/document-template.entity'; + +export interface IDocumentTemplateRepository + extends IRepositoryWithPagination { + create( + newDocumentTemplate: CreateDocumentTemplateOptions, + ): Promise; + findOne( + findOptions: FindOneDocumentTemplateOptions, + ): Promise; +} diff --git a/backend/src/modules/documents/models/document-contract-list-view.model.ts b/backend/src/modules/documents/models/document-contract-list-view.model.ts new file mode 100644 index 000000000..b32cc46df --- /dev/null +++ b/backend/src/modules/documents/models/document-contract-list-view.model.ts @@ -0,0 +1,38 @@ +import { IBasePaginationFilterModel } from 'src/infrastructure/base/base-pagination-filter.model'; +import { DocumentContractStatus } from '../enums/contract-status.enum'; +import { DocumentContractListViewEntity } from '../entities/document-contract-list-view.entity'; + +export interface IDocumentContractListViewModel { + documentId: string; + documentNumber: string; + documentStartDate: Date; + documentEndDate: Date; + documentFilePath: string; + status: DocumentContractStatus; + volunteerId: string; + volunteerName: string; + organizationId: string; + organizationName: string; +} + +export type FindOneDocumentContractListViewOptions = { + documentId: string; + volunteerId: string; +}; + +export type FindManyDocumentContractListViewOptions = + IBasePaginationFilterModel & { + organizationId: string; + volunteerId?: string; + status?: DocumentContractStatus; + }; + +export class DocumentContractListViewTransformer { + static fromEntity( + entity: DocumentContractListViewEntity, + ): IDocumentContractListViewModel { + return { + ...entity, + }; + } +} diff --git a/backend/src/modules/documents/models/document-contract.model.ts b/backend/src/modules/documents/models/document-contract.model.ts new file mode 100644 index 000000000..64e810b8d --- /dev/null +++ b/backend/src/modules/documents/models/document-contract.model.ts @@ -0,0 +1,136 @@ +import { IBaseModel } from 'src/common/interfaces/base.model'; +import { DocumentContractStatus } from '../enums/contract-status.enum'; +import { DocumentContractEntity } from '../entities/document-contract.entity'; +import { + DocumentTemplateTransformer, + IDocumentTemplateModel, +} from './document-template.model'; +import { + AdminUserTransformer, + IAdminUserModel, +} from 'src/modules/user/models/admin-user.model'; +import { + IVolunteerModel, + VolunteerModelTransformer, +} from 'src/modules/volunteer/model/volunteer.model'; +import { IUserPersonalDataModel } from 'src/modules/user/models/user-personal-data.model'; + +export type VolunteerContractIdentityData = IUserPersonalDataModel & { + name: string; +}; + +export interface IDocumentContractModel extends IBaseModel { + id: string; + status: DocumentContractStatus; + + documentNumber: string; + documentDate: Date; + documentStartDate: Date; + documentEndDate: Date; + + organizationId: string; + + // Template + documentTemplateId: string; + documentTemplate: IDocumentTemplateModel; // TODO: we don't always need all the data here... but id and name, hmm... + + // Created By + createdByAdminId: string; + createdByAdmin: IAdminUserModel; + + // Volunteer + volunteerId: string; + volunteer: IVolunteerModel; + + volunteerData: VolunteerContractIdentityData; // TODO: le tragem din user in usecase-ul de creare contract si raman ca snapshoot + + filePath?: string; + + ngoLegalRepresentativeSignatureId?: string; + volunteerSignatureId?: string; + legalGuardianSignatureId?: string; +} + +export type CreateDocumentContractOptions = { + status: DocumentContractStatus; + + documentNumber: string; + documentDate: Date; + documentStartDate: Date; + documentEndDate: Date; + + volunteerData: VolunteerContractIdentityData; + + volunteerId: string; + organizationId: string; + documentTemplateId: string; + createdByAdminId: string; +}; + +export type UpdateDocumentContractOptions = { + status?: DocumentContractStatus; + filePath?: string; + ngoLegalRepresentativeSignatureId?: string; + volunteerSignatureId?: string; + legalGuardianSignatureId?: string; +}; + +export type FindOneDocumentContractOptions = Partial< + Pick< + IDocumentContractModel, + 'id' | 'volunteerId' | 'organizationId' | 'status' | 'documentTemplateId' + > +>; + +export class DocumentContractTransformer { + static fromEntity(entity: DocumentContractEntity): IDocumentContractModel { + if (!entity) { + return null; + } + + return { + id: entity.id, + status: entity.status, + documentNumber: entity.documentNumber, + documentDate: entity.documentDate, + documentStartDate: entity.documentStartDate, + documentEndDate: entity.documentEndDate, + + organizationId: entity.organizationId, + + // Volunteer + volunteerId: entity.volunteerId, + volunteer: VolunteerModelTransformer.fromEntity(entity.volunteer), + volunteerData: entity.volunteerData, + // Template + documentTemplateId: entity.documentTemplateId, + documentTemplate: DocumentTemplateTransformer.fromEntity( + entity.documentTemplate, + ), + // CreatedBy + createdByAdminId: entity.createdByAdminId, + createdByAdmin: AdminUserTransformer.fromEntity(entity.createdByAdmin), + + createdOn: entity.createdOn, + updatedOn: entity.updatedOn, + }; + } + + static createDocumentContractToEntity( + model: CreateDocumentContractOptions, + ): DocumentContractEntity { + const entity = new DocumentContractEntity(); + entity.status = model.status; + entity.documentNumber = model.documentNumber; + entity.documentDate = model.documentDate; + entity.documentStartDate = model.documentStartDate; + entity.documentEndDate = model.documentEndDate; + entity.volunteerId = model.volunteerId; + entity.volunteerData = model.volunteerData; + entity.organizationId = model.organizationId; + entity.documentTemplateId = model.documentTemplateId; + entity.createdByAdminId = model.createdByAdminId; + + return entity; + } +} diff --git a/backend/src/modules/documents/models/document-signature.model.ts b/backend/src/modules/documents/models/document-signature.model.ts new file mode 100644 index 000000000..92807927a --- /dev/null +++ b/backend/src/modules/documents/models/document-signature.model.ts @@ -0,0 +1,21 @@ +import { IBaseModel } from 'src/common/interfaces/base.model'; + +export interface SignatureModel extends IBaseModel { + id: string; + + signature: string; + + userId: string; + + createdOn: Date; + updatedOn: Date; +} + +export type CreateSignatureOptions = Pick< + SignatureModel, + 'signature' | 'userId' +>; + +export type FindOneSignatureOptions = Partial< + Pick +>; diff --git a/backend/src/modules/documents/models/document-template-list-view.model.ts b/backend/src/modules/documents/models/document-template-list-view.model.ts new file mode 100644 index 000000000..79e50ef91 --- /dev/null +++ b/backend/src/modules/documents/models/document-template-list-view.model.ts @@ -0,0 +1,30 @@ +import { IBasePaginationFilterModel } from 'src/infrastructure/base/base-pagination-filter.model'; +import { DocumentTemplateListViewEntity } from '../entities/document-template-list-view.entity'; + +export interface IDocumentTemplateListViewModel { + id: string; + name: string; + + usageCount: number; + lastUsage: Date | null; + + createdById: string; + createdByName: string; + + createdOn: Date; +} + +export type FindManyDocumentTemplateListViewOptions = + IBasePaginationFilterModel & { + organizationId: string; + }; + +export class DocumentTemplateListViewTransformer { + static fromEntity( + entity: DocumentTemplateListViewEntity, + ): IDocumentTemplateListViewModel { + return { + ...entity, + }; + } +} diff --git a/backend/src/modules/documents/models/document-template.model.ts b/backend/src/modules/documents/models/document-template.model.ts new file mode 100644 index 000000000..c0cd153de --- /dev/null +++ b/backend/src/modules/documents/models/document-template.model.ts @@ -0,0 +1,73 @@ +import { IBaseModel } from 'src/common/interfaces/base.model'; +import { DocumentTemplateEntity } from '../entities/document-template.entity'; +import { + AdminUserTransformer, + IAdminUserModel, +} from 'src/modules/user/models/admin-user.model'; + +export interface IDocumentTemplateOrganizationData { + officialName: string; + registeredOffice: string; // Sediu social + CUI: string; + legalRepresentativeName: string; + legalRepresentativeRole: string; +} + +export interface IDocumentTemplateModel extends IBaseModel { + id: string; + name: string; + organizationData: IDocumentTemplateOrganizationData; + documentTerms: string; + organizationId: string; + + // Relations + createdByAdmin: IAdminUserModel; +} + +export type CreateDocumentTemplateOptions = Omit< + IDocumentTemplateModel, + 'id' | 'createdOn' | 'updatedOn' | 'createdByAdmin' +> & { createdByAdminId: string }; + +export type FindOneDocumentTemplateOptions = Partial< + Pick +>; + +export type DeleteOneDocumentTemplateOptions = { + id: string; + organizationId: string; +}; + +export class DocumentTemplateTransformer { + static fromEntity(entity: DocumentTemplateEntity): IDocumentTemplateModel { + if (!entity) { + return null; + } + + return { + id: entity.id, + name: entity.name, + organizationData: + entity.organizationData as IDocumentTemplateOrganizationData, + documentTerms: entity.documentTerms, + organizationId: entity.organizationId, + createdByAdmin: AdminUserTransformer.fromEntity(entity.createdByAdmin), + createdOn: entity.createdOn, + updatedOn: entity.updatedOn, + }; + } + + static toEntity( + model: CreateDocumentTemplateOptions, + ): DocumentTemplateEntity { + const entity = new DocumentTemplateEntity(); + + entity.name = model.name; + entity.organizationData = model.organizationData; + entity.documentTerms = model.documentTerms; + entity.organizationId = model.organizationId; + entity.createdByAdminId = model.createdByAdminId; + + return entity; + } +} diff --git a/backend/src/modules/documents/repositories/document-contract-list-view.repository.ts b/backend/src/modules/documents/repositories/document-contract-list-view.repository.ts new file mode 100644 index 000000000..211f73d7d --- /dev/null +++ b/backend/src/modules/documents/repositories/document-contract-list-view.repository.ts @@ -0,0 +1,88 @@ +import { + Pagination, + RepositoryWithPagination, +} from 'src/infrastructure/base/repository-with-pagination.class'; +import { DocumentContractListViewEntity } from '../entities/document-contract-list-view.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { + DocumentContractListViewTransformer, + FindManyDocumentContractListViewOptions, + FindOneDocumentContractListViewOptions, + IDocumentContractListViewModel, +} from '../models/document-contract-list-view.model'; +import { OrderDirection } from 'src/common/enums/order-direction.enum'; + +@Injectable() +export class DocumentContractListViewRepository extends RepositoryWithPagination { + constructor( + @InjectRepository(DocumentContractListViewEntity) + private readonly documentContractListViewRepository: Repository, + ) { + super(documentContractListViewRepository); + } + + async findMany( + findOptions: FindManyDocumentContractListViewOptions, + ): Promise> { + const { + orderBy, + orderDirection, + search, + limit, + page, + + organizationId, + volunteerId, + status, + } = findOptions; + + const query = this.documentContractListViewRepository + .createQueryBuilder('documentContractListView') + .where('documentContractListView.organizationId = :organizationId', { + organizationId, + }) + .orderBy( + this.buildOrderByQuery( + orderBy || 'documentNumber', + 'documentContractListView', + ), + orderDirection || OrderDirection.ASC, + ); + + if (volunteerId) { + query.andWhere('documentContractListView.volunteerId = :volunteerId', { + volunteerId, + }); + } + + if (status) { + query.andWhere('documentContractListView.status = :status', { status }); + } + + if (search) { + query.andWhere( + this.buildBracketSearchQuery( + ['documentContractListView.documentNumber', 'user.name'], + search, + ), + ); + } + + return this.paginateQuery( + query, + limit, + page, + DocumentContractListViewTransformer.fromEntity, + ); + } + + async findOne( + options: FindOneDocumentContractListViewOptions, + ): Promise { + return this.documentContractListViewRepository.findOne({ + where: options, + }); + } +} diff --git a/backend/src/modules/documents/repositories/document-contract.repository.ts b/backend/src/modules/documents/repositories/document-contract.repository.ts new file mode 100644 index 000000000..2202a189b --- /dev/null +++ b/backend/src/modules/documents/repositories/document-contract.repository.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { DocumentContractEntity } from '../entities/document-contract.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RepositoryWithPagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { + CreateDocumentContractOptions, + DocumentContractTransformer, + FindOneDocumentContractOptions, + IDocumentContractModel, + UpdateDocumentContractOptions, +} from '../models/document-contract.model'; + +@Injectable() +export class DocumentContractRepositoryService extends RepositoryWithPagination { + // TODO: implement IDocumentContractRepository + constructor( + @InjectRepository(DocumentContractEntity) + private readonly documentContractRepository: Repository, + ) { + super(documentContractRepository); + } + + async create( + newDocumentContract: CreateDocumentContractOptions, + ): Promise { + const documentContract = await this.documentContractRepository.save( + DocumentContractTransformer.createDocumentContractToEntity( + newDocumentContract, + ), + ); + + return documentContract.id; + } + + async findOne( + options: FindOneDocumentContractOptions, + ): Promise { + const documentContract = await this.documentContractRepository.findOne({ + where: options, + }); + + return DocumentContractTransformer.fromEntity(documentContract); + } + + async exists(options: FindOneDocumentContractOptions): Promise { + return this.documentContractRepository.exists({ where: options }); + } + + async update( + id: string, + updates: UpdateDocumentContractOptions, + ): Promise { + const documentContract = await this.documentContractRepository.preload({ + id, + ...updates, + }); + + await this.documentContractRepository.save(documentContract); + + return this.findOne({ id }); + } + + async delete(id: string): Promise { + const documentContract = await this.documentContractRepository.find({ + where: { id }, + }); + + if (documentContract) { + await this.documentContractRepository.remove(documentContract); + return id; + } + + return null; + } +} diff --git a/backend/src/modules/documents/repositories/document-signature.repository.ts b/backend/src/modules/documents/repositories/document-signature.repository.ts new file mode 100644 index 000000000..99996333e --- /dev/null +++ b/backend/src/modules/documents/repositories/document-signature.repository.ts @@ -0,0 +1,30 @@ +import { DocumentSignatureEntity } from '../entities/document-signature.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + CreateSignatureOptions, + FindOneSignatureOptions, +} from '../models/document-signature.model'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class DocumentSignatureRepository { + constructor( + @InjectRepository(DocumentSignatureEntity) + private readonly signatureRepository: Repository, + ) {} + + async create(newSignature: CreateSignatureOptions): Promise { + const signature = await this.signatureRepository.save(newSignature); + return signature.id; + } + + async findOne( + options: FindOneSignatureOptions, + ): Promise { + const signature = await this.signatureRepository.findOne({ + where: options, + }); + return signature; + } +} diff --git a/backend/src/modules/documents/repositories/document-template-list-view.repository.ts b/backend/src/modules/documents/repositories/document-template-list-view.repository.ts new file mode 100644 index 000000000..bc657ec80 --- /dev/null +++ b/backend/src/modules/documents/repositories/document-template-list-view.repository.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { DocumentTemplateListViewEntity } from '../entities/document-template-list-view.entity'; +import { + Pagination, + RepositoryWithPagination, +} from 'src/infrastructure/base/repository-with-pagination.class'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + DocumentTemplateListViewTransformer, + FindManyDocumentTemplateListViewOptions, + IDocumentTemplateListViewModel, +} from '../models/document-template-list-view.model'; +import { OrderDirection } from 'src/common/enums/order-direction.enum'; + +@Injectable() +export class DocumentTemplateListViewRepository extends RepositoryWithPagination { + constructor( + @InjectRepository(DocumentTemplateListViewEntity) + private readonly documentTemplateListViewRepository: Repository, + ) { + super(documentTemplateListViewRepository); + } + + async findMany( + findOptions: FindManyDocumentTemplateListViewOptions, + ): Promise> { + const { + orderBy, + orderDirection, + search, + limit, + page, + + organizationId, + } = findOptions; + + const query = this.documentTemplateListViewRepository + .createQueryBuilder('documentTemplateListView') + .where('documentTemplateListView.organizationId = :organizationId', { + organizationId, + }) + .orderBy( + this.buildOrderByQuery( + orderBy || 'createdOn', + 'documentTemplateListView', + ), + orderDirection || OrderDirection.ASC, + ); + + if (search) { + query.andWhere( + this.buildBracketSearchQuery(['documentContractListView.name'], search), + ); + } + + return this.paginateQuery( + query, + limit, + page, + DocumentTemplateListViewTransformer.fromEntity, + ); + } +} diff --git a/backend/src/modules/documents/repositories/document-template.repository.ts b/backend/src/modules/documents/repositories/document-template.repository.ts new file mode 100644 index 000000000..7d32eb6d5 --- /dev/null +++ b/backend/src/modules/documents/repositories/document-template.repository.ts @@ -0,0 +1,58 @@ +import { RepositoryWithPagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DocumentTemplateEntity } from '../entities/document-template.entity'; +import { IDocumentTemplateRepository } from '../interfaces/document-template-repository.interface'; +import { + CreateDocumentTemplateOptions, + DeleteOneDocumentTemplateOptions, + DocumentTemplateTransformer, + FindOneDocumentTemplateOptions, + IDocumentTemplateModel, +} from '../models/document-template.model'; + +export class DocumentTemplateRepositoryService + extends RepositoryWithPagination + implements IDocumentTemplateRepository +{ + constructor( + @InjectRepository(DocumentTemplateEntity) + private readonly documentTemplateRepository: Repository, + ) { + super(documentTemplateRepository); + } + + async create( + newDocumentTemplate: CreateDocumentTemplateOptions, + ): Promise { + const documentTemplate = await this.documentTemplateRepository.save( + DocumentTemplateTransformer.toEntity(newDocumentTemplate), + ); + + return this.findOne({ id: documentTemplate.id }); + } + + async findOne( + findOptions: FindOneDocumentTemplateOptions, + ): Promise { + const documentTemplate = await this.documentTemplateRepository.findOne({ + where: findOptions, + relations: { + createdByAdmin: true, + }, + }); + + return DocumentTemplateTransformer.fromEntity(documentTemplate); + } + + async delete(options: DeleteOneDocumentTemplateOptions): Promise { + const template = await this.documentTemplateRepository.findOneBy(options); + + if (template) { + await this.documentTemplateRepository.remove(template); + return options.id; + } + + return null; + } +} diff --git a/backend/src/modules/documents/services/document-contract.facade.ts b/backend/src/modules/documents/services/document-contract.facade.ts new file mode 100644 index 000000000..e20bebcd6 --- /dev/null +++ b/backend/src/modules/documents/services/document-contract.facade.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { DocumentContractRepositoryService } from '../repositories/document-contract.repository'; +import { + CreateDocumentContractOptions, + IDocumentContractModel, + FindOneDocumentContractOptions, + UpdateDocumentContractOptions, +} from '../models/document-contract.model'; +import { DocumentContractListViewRepository } from '../repositories/document-contract-list-view.repository'; +import { + FindManyDocumentContractListViewOptions, + FindOneDocumentContractListViewOptions, + IDocumentContractListViewModel, +} from '../models/document-contract-list-view.model'; +import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { DocumentContractStatus } from '../enums/contract-status.enum'; + +@Injectable() +export class DocumentContractFacade { + constructor( + private readonly documentContractRepository: DocumentContractRepositoryService, + private readonly documentContractListViewRepository: DocumentContractListViewRepository, + ) {} + + async approveDocumentContractByNGO( + documentContractId: string, + ): Promise { + return this.documentContractRepository.update(documentContractId, { + status: DocumentContractStatus.PENDING_NGO_REPRESENTATIVE_SIGNATURE, + }); + } + + async signDocumentContractByNGO( + documentContractId: string, + ): Promise { + return this.documentContractRepository.update(documentContractId, { + status: DocumentContractStatus.APPROVED, + }); + } + async rejectDocumentContractByNGO( + documentContractId: string, + ): Promise { + return this.documentContractRepository.update(documentContractId, { + status: DocumentContractStatus.REJECTED_NGO, + }); + } + + async create( + newDocumentContract: CreateDocumentContractOptions, + ): Promise { + return this.documentContractRepository.create(newDocumentContract); + } + + async findOne( + options: FindOneDocumentContractOptions, + ): Promise { + return this.documentContractRepository.findOne(options); + } + + async exists(options: FindOneDocumentContractOptions): Promise { + return this.documentContractRepository.exists(options); + } + + async findMany( + options: FindManyDocumentContractListViewOptions, + ): Promise> { + return this.documentContractListViewRepository.findMany(options); + } + + async findOneForVolunteer( + options: FindOneDocumentContractListViewOptions, + ): Promise { + return this.documentContractListViewRepository.findOne(options); + } + + async update( + id: string, + updates: UpdateDocumentContractOptions, + ): Promise { + return this.documentContractRepository.update(id, updates); + } + + async delete(id: string): Promise { + return this.documentContractRepository.delete(id); + } +} diff --git a/backend/src/modules/documents/services/document-signature.facade.ts b/backend/src/modules/documents/services/document-signature.facade.ts new file mode 100644 index 000000000..bd9a88214 --- /dev/null +++ b/backend/src/modules/documents/services/document-signature.facade.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { DocumentSignatureRepository } from '../repositories/document-signature.repository'; +import { CreateSignatureOptions } from '../models/document-signature.model'; + +@Injectable() +export class DocumentSignatureFacade { + constructor( + private readonly documentSignatureRepository: DocumentSignatureRepository, + ) {} + + async create(newSignature: CreateSignatureOptions): Promise { + return this.documentSignatureRepository.create(newSignature); + } +} diff --git a/backend/src/modules/documents/services/document-template.facade.ts b/backend/src/modules/documents/services/document-template.facade.ts new file mode 100644 index 000000000..d85d9477a --- /dev/null +++ b/backend/src/modules/documents/services/document-template.facade.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { DocumentTemplateRepositoryService } from '../repositories/document-template.repository'; +import { + CreateDocumentTemplateOptions, + DeleteOneDocumentTemplateOptions, + FindOneDocumentTemplateOptions, + IDocumentTemplateModel, +} from '../models/document-template.model'; +import { DocumentTemplateListViewRepository } from '../repositories/document-template-list-view.repository'; +import { + FindManyDocumentTemplateListViewOptions, + IDocumentTemplateListViewModel, +} from '../models/document-template-list-view.model'; +import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; + +@Injectable() +export class DocumentTemplateFacade { + constructor( + private readonly documentTemplateRepository: DocumentTemplateRepositoryService, + private readonly documentTemplateListViewRepository: DocumentTemplateListViewRepository, + ) {} + + async create( + newDocumentTemplate: CreateDocumentTemplateOptions, + ): Promise { + return this.documentTemplateRepository.create(newDocumentTemplate); + } + + async findOne( + findOptions: FindOneDocumentTemplateOptions, + ): Promise { + return this.documentTemplateRepository.findOne(findOptions); + } + + async findMany( + findOptions: FindManyDocumentTemplateListViewOptions, + ): Promise> { + return this.documentTemplateListViewRepository.findMany(findOptions); + } + + async delete(options: DeleteOneDocumentTemplateOptions): Promise { + return this.documentTemplateRepository.delete(options); + } +} diff --git a/backend/src/modules/documents/services/pdf-generator.ts b/backend/src/modules/documents/services/pdf-generator.ts new file mode 100644 index 000000000..9987b5214 --- /dev/null +++ b/backend/src/modules/documents/services/pdf-generator.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { resolve } from 'path'; +import * as fs from 'fs'; +import Handlebars from 'handlebars'; +import axios from 'axios'; + +@Injectable() +export class PDFGenerator { + constructor() { + // TODO: This should be handled using a HandlebarsAdapter, similar to MailerModule to register partials/helpers/etc + const templateDir = resolve( + __dirname, + '..', + 'templates/partials', + 'header.hbs', + ); + const file = fs.readFileSync(templateDir, 'utf-8'); + const template = Handlebars.compile(file); + Handlebars.registerPartial('header', template); + } + + public async generatePDF(): Promise { + const templateDir = resolve(__dirname, '..', 'templates', 'contract.hbs'); + const file = fs.readFileSync(templateDir, 'utf-8'); + const template = Handlebars.compile(file); + const fileHTML = template({ + title: 'dadsa', + subtitle: `
Text
`, + }); + + // return HTMLtoPDF(fileHTML); + return axios + .post( + 'https://iywe2rp7u1.execute-api.us-east-1.amazonaws.com/test', + fileHTML, + ) + .then((res) => res.data); + } +} diff --git a/backend/src/modules/documents/templates/contract.hbs b/backend/src/modules/documents/templates/contract.hbs new file mode 100644 index 000000000..6a083d9f0 --- /dev/null +++ b/backend/src/modules/documents/templates/contract.hbs @@ -0,0 +1,131 @@ + + + + + Contract de Voluntariat + + + +
+

CONTRACT DE VOLUNTARIAT

+

{{title}}

+ +

Nr. + [Numar contract] + din data de + [Data contract]

+ +
+

Între + Asociația ZEN, cu sediul în Târgu-Jiu, + jud. Gorj, str. Sediului oficial, nr 1, bl 1, ap 19, identificată cu + CUI + RO100001, reprezentată prin + Tudorache Tudor, în calitate de + Director executiv numită în continuare + Organizația

+ +

și

+ +

[Nume si prenume voluntar], + domiciliat(ă) în + [Adresa domiciliu voluntar], C.N.P + [Numar CNP], legitimat cu BI/CI seria + [Serie] + nr. + [Numar], eliberat de + [Unitate eliberare], la data de + [Data eliberarii], numit în continuare + Voluntar,

+
+ +
+

s-a convenit încheierea prezentului contract în baza Legii nr. + 78/2014 privind reglementarea activității de voluntariat din România.

+ +

DURATA CONTRACTULUI

+ +

Prezentul contract se încheie pe o perioadă determinată, între data + de + [Data inceput] + și + [Data final]

+
+ +

Program de Activități

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ZiuaOraActivitateLocațiePriorități
Luni09:00 - 12:00Asistență administrativăBiroul centralMedie
14:00 - 17:00Organizare evenimenteSala de conferințeÎnaltă
Marți10:00 - 15:00Activități cu beneficiariiCentrul comunitarÎnaltă
Miercuri09:00 - 11:00Întâlnire echipăBiroul centralMedie
12:00 - 16:00Strângere de fonduriDiverse locațiiÎnaltă
Joi13:00 - 18:00Mentorat tineriȘcoala localăMedie
Vineri10:00 - 14:00Activități ecologiceParcul orașuluiScăzută
+
+ + \ No newline at end of file diff --git a/backend/src/modules/documents/templates/partials/faq-tc.hbs b/backend/src/modules/documents/templates/partials/faq-tc.hbs new file mode 100644 index 000000000..e14c18cea --- /dev/null +++ b/backend/src/modules/documents/templates/partials/faq-tc.hbs @@ -0,0 +1,10 @@ + +

If you have any questions, reach out to us at + hello@onghub.com. Need help? Check out + our + FAQs + and + Terms and conditions. +

\ No newline at end of file diff --git a/backend/src/modules/documents/templates/partials/footer.hbs b/backend/src/modules/documents/templates/partials/footer.hbs new file mode 100644 index 000000000..cdcef4a06 --- /dev/null +++ b/backend/src/modules/documents/templates/partials/footer.hbs @@ -0,0 +1,84 @@ +
+ + + + + +
+

+ Soluție proiectată, dezvoltată și administrată pro-bono de +

+
+ +
+ +
+ +

+ Dacă vrei să iei legătura cu noi o poți face pe e-mail la adresa: + {{contactEmail}} +

+ + + + + + + + +
+ + + + + + + + + + + + + + + +
+
+` \ No newline at end of file diff --git a/backend/src/modules/documents/templates/partials/header.hbs b/backend/src/modules/documents/templates/partials/header.hbs new file mode 100644 index 000000000..38a190f10 --- /dev/null +++ b/backend/src/modules/documents/templates/partials/header.hbs @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/backend/src/modules/onghub/exceptions/exceptions.ts b/backend/src/modules/onghub/exceptions/exceptions.ts index fdaa82cec..d2da1c4f0 100644 --- a/backend/src/modules/onghub/exceptions/exceptions.ts +++ b/backend/src/modules/onghub/exceptions/exceptions.ts @@ -4,6 +4,7 @@ export enum OngHubExceptionCodes { ONG_001 = 'ONG_001', ONG_002 = 'ONG_002', ONG_003 = 'ONG_003', + ONG_004 = 'ONG_004', } type OngHubExceptionCodeType = keyof typeof OngHubExceptionCodes; @@ -25,4 +26,8 @@ export const OngHubExceptionMessages: Record< message: 'There was unexpected issue while requesting data from ONG Hub', code_error: OngHubExceptionCodes.ONG_003, }, + [OngHubExceptionCodes.ONG_004]: { + message: 'Could not update organization with the data from ONG Hub', + code_error: OngHubExceptionCodes.ONG_004, + }, }; diff --git a/backend/src/modules/organization/entities/organization.entity.ts b/backend/src/modules/organization/entities/organization.entity.ts index 6a012f1c1..9e80ccc7e 100644 --- a/backend/src/modules/organization/entities/organization.entity.ts +++ b/backend/src/modules/organization/entities/organization.entity.ts @@ -29,6 +29,19 @@ export class OrganizationEntity extends BaseEntity { @Column({ type: 'text', name: 'logo', nullable: true }) logo: string; + @Column({ type: 'text', name: 'cui', nullable: true }) + cui: string; + + @Column({ + type: 'text', + name: 'legal_representative_full_name', + nullable: true, + }) + legalReprezentativeFullName: string; + + @Column({ type: 'text', name: 'legal_representative_role', nullable: true }) + legalReprezentativeRole: string; + @OneToMany(() => VolunteerEntity, (volunteer) => volunteer.organization) volunteers: VolunteerEntity[]; diff --git a/backend/src/modules/organization/interfaces/organization-repository.interface.ts b/backend/src/modules/organization/interfaces/organization-repository.interface.ts index a895fd9ad..b5d6f9622 100644 --- a/backend/src/modules/organization/interfaces/organization-repository.interface.ts +++ b/backend/src/modules/organization/interfaces/organization-repository.interface.ts @@ -4,6 +4,7 @@ import { ICreateOrganizationModel, IFindOrganizationModel, IOrganizationModel, + IUpdateOrganizationModel, } from '../models/organization.model'; import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; import { OrganizationEntity } from '../entities/organization.entity'; @@ -15,7 +16,10 @@ import { IOrganizationVolunteerModel } from '../models/organization-volunteer.mo export interface IOrganizationRepository extends IRepositoryWithPagination { create(organization: ICreateOrganizationModel): Promise; - update(id: string, description: string): Promise; + update( + id: string, + updates: IUpdateOrganizationModel, + ): Promise; find( options: | Partial diff --git a/backend/src/modules/organization/models/organization.model.ts b/backend/src/modules/organization/models/organization.model.ts index cd43a2e72..d12d1f4fa 100644 --- a/backend/src/modules/organization/models/organization.model.ts +++ b/backend/src/modules/organization/models/organization.model.ts @@ -10,9 +10,13 @@ export interface IOrganizationModel { activityArea: string; logo: string; description: string; + cui?: string; + legalReprezentativeFullName?: string; + legalReprezentativeRole?: string; } export type ICreateOrganizationModel = Omit; +export type IUpdateOrganizationModel = Partial; export type IFindOrganizationModel = Pick< IOrganizationModel, @@ -37,6 +41,10 @@ export class OrganizationTransformer { activityArea: organizationEntity.activityArea, logo: organizationEntity.logo, description: organizationEntity.description, + cui: organizationEntity.cui, + legalReprezentativeFullName: + organizationEntity.legalReprezentativeFullName, + legalReprezentativeRole: organizationEntity.legalReprezentativeRole, }; } @@ -51,6 +59,11 @@ export class OrganizationTransformer { organizationEntity.activityArea = organizationModel.activityArea; organizationEntity.logo = organizationModel.logo; organizationEntity.description = organizationModel.description; + organizationEntity.cui = organizationModel.cui; + organizationEntity.legalReprezentativeFullName = + organizationModel.legalReprezentativeFullName; + organizationEntity.legalReprezentativeRole = + organizationModel.legalReprezentativeRole; return organizationEntity; } } diff --git a/backend/src/modules/organization/repositories/organization.repository.ts b/backend/src/modules/organization/repositories/organization.repository.ts index bbe7b7532..1ffdc3382 100644 --- a/backend/src/modules/organization/repositories/organization.repository.ts +++ b/backend/src/modules/organization/repositories/organization.repository.ts @@ -9,6 +9,7 @@ import { ICreateOrganizationModel, IFindOrganizationModel, IOrganizationModel, + IUpdateOrganizationModel, OrganizationTransformer, } from '../models/organization.model'; import { @@ -62,10 +63,10 @@ export class OrganizationRepositoryService public async update( id: string, - description: string, + updates: IUpdateOrganizationModel, ): Promise { // update organization entity - await this.organizationRepository.update({ id }, { description }); + await this.organizationRepository.update({ id }, { ...updates }); // return organization model return this.find({ id }); diff --git a/backend/src/modules/organization/services/organization.facade.ts b/backend/src/modules/organization/services/organization.facade.ts index 30d8e079b..ee9bbe638 100644 --- a/backend/src/modules/organization/services/organization.facade.ts +++ b/backend/src/modules/organization/services/organization.facade.ts @@ -5,6 +5,7 @@ import { ICreateOrganizationModel, IFindOrganizationModel, IOrganizationModel, + IUpdateOrganizationModel, } from '../models/organization.model'; import { OrganizationRepositoryService } from '../repositories/organization.repository'; import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; @@ -31,7 +32,14 @@ export class OrganizationFacadeService { organizationId: string, description: string, ): Promise { - return this.organizationRepository.update(organizationId, description); + return this.organizationRepository.update(organizationId, { description }); + } + + public async updateOrganization( + organizationId: string, + updates: IUpdateOrganizationModel, + ): Promise { + return this.organizationRepository.update(organizationId, updates); } public async createOrganization( diff --git a/backend/src/modules/user/entities/user-personal-data.entity.ts b/backend/src/modules/user/entities/user-personal-data.entity.ts index 3c310ed48..31fd38077 100644 --- a/backend/src/modules/user/entities/user-personal-data.entity.ts +++ b/backend/src/modules/user/entities/user-personal-data.entity.ts @@ -1,25 +1,35 @@ import { BaseEntity } from 'src/infrastructure/base/base-entity'; import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { LegalGuardianIdentityData } from '../models/user-personal-data.model'; @Entity({ name: 'user_personal_data' }) export class UserPersonalDataEntity extends BaseEntity { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'text', name: 'cnp', nullable: true }) + cnp: string; + @Column({ type: 'text', name: 'identity_document_series' }) identityDocumentSeries: string; - @Column({ type: 'text', name: 'identity_document_number', unique: true }) + @Column({ type: 'text', name: 'identity_document_number' }) identityDocumentNumber: string; @Column({ type: 'text', name: 'address' }) address: string; - @Column({ type: 'timestamptz', name: 'identity_document_issue_date' }) + @Column({ type: 'date', name: 'identity_document_issue_date' }) identityDocumentIssueDate: Date; @Column({ - type: 'timestamptz', + type: 'date', name: 'identity_document_expiration_date', }) identityDocumentExpirationDate: Date; + + @Column({ type: 'text', name: 'identity_document_issued_by', nullable: true }) + identityDocumentIssuedBy: string; + + @Column({ type: 'jsonb', name: 'legal_guardian', nullable: true }) + legalGuardian: LegalGuardianIdentityData; } diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index d5837c624..5450ad6f7 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -68,7 +68,7 @@ export class RegularUserEntity extends UserEntity { @Column({ type: 'text', name: 'last_name' }) lastName: string; - @Column({ type: 'timestamptz', name: 'birthday', nullable: true }) + @Column({ type: 'date', name: 'birthday', nullable: true }) birthday: Date; @Column({ type: 'varchar', name: 'sex', enum: SEX, nullable: true }) diff --git a/backend/src/modules/user/models/user-personal-data.model.ts b/backend/src/modules/user/models/user-personal-data.model.ts index 0cd7b55b5..eac225cdf 100644 --- a/backend/src/modules/user/models/user-personal-data.model.ts +++ b/backend/src/modules/user/models/user-personal-data.model.ts @@ -2,11 +2,24 @@ import { UserPersonalDataEntity } from '../entities/user-personal-data.entity'; export interface IUserPersonalDataModel { id: string; + cnp: string; + address: string; identityDocumentSeries: string; identityDocumentNumber: string; - address: string; identityDocumentIssueDate: Date; identityDocumentExpirationDate: Date; + identityDocumentIssuedBy: string; + legalGuardian?: LegalGuardianIdentityData; +} + +export interface LegalGuardianIdentityData { + name: string; + cnp: string; + address: string; + identityDocumentSeries: string; + identityDocumentNumber: string; + email: string; + phone: string; } export type CreateUserPersonalDataOptions = Omit; @@ -20,10 +33,13 @@ export class UserPersonalDataTransformer { if (!entity) return null; return { id: entity.id, + cnp: entity.cnp, identityDocumentSeries: entity.identityDocumentSeries, identityDocumentNumber: entity.identityDocumentNumber, identityDocumentIssueDate: entity.identityDocumentIssueDate, identityDocumentExpirationDate: entity.identityDocumentExpirationDate, + identityDocumentIssuedBy: entity.identityDocumentIssuedBy, + legalGuardian: entity.legalGuardian, address: entity.address, }; } @@ -38,6 +54,9 @@ export class UserPersonalDataTransformer { entity.identityDocumentIssueDate = model.identityDocumentIssueDate; entity.identityDocumentNumber = model.identityDocumentNumber; entity.identityDocumentSeries = model.identityDocumentSeries; + entity.identityDocumentIssuedBy = model.identityDocumentIssuedBy; + entity.legalGuardian = model.legalGuardian; + entity.cnp = model.cnp; return entity; } } diff --git a/backend/src/modules/volunteer/repositories/volunteer.repository.ts b/backend/src/modules/volunteer/repositories/volunteer.repository.ts index 093478b6c..8ce237c08 100644 --- a/backend/src/modules/volunteer/repositories/volunteer.repository.ts +++ b/backend/src/modules/volunteer/repositories/volunteer.repository.ts @@ -92,6 +92,11 @@ export class VolunteerRepositoryService 'department', ) .leftJoinAndMapOne('volunteer.user', 'volunteer.user', 'user') + .leftJoinAndMapOne( + 'user.userPersonalData', + 'user.userPersonalData', + 'userPersonalData', + ) .leftJoinAndMapOne('user.location', 'user.location', 'location') .leftJoinAndMapOne('location.county', 'location.county', 'county') .leftJoinAndMapOne( diff --git a/backend/src/usecases/documents/new_contracts/approve-document-contract-by-ngo.usecase.ts b/backend/src/usecases/documents/new_contracts/approve-document-contract-by-ngo.usecase.ts new file mode 100644 index 000000000..165b94fff --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/approve-document-contract-by-ngo.usecase.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; +import { ContractExceptionMessages } from 'src/modules/documents/exceptions/contract.exceptions'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; + +@Injectable() +export class ApproveDocumentContractByNgoUsecase { + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + async execute( + documentContractId: string, + organizationId: string, + ): Promise { + const exists = await this.documentContractFacade.exists({ + id: documentContractId, + organizationId, + status: DocumentContractStatus.PENDING_APPROVAL_NGO, + }); + + if (!exists) { + this.exceptionService.notFoundException( + ContractExceptionMessages.CONTRACT_002, + ); + } + try { + await this.documentContractFacade.approveDocumentContractByNGO( + documentContractId, + ); + } catch (error) { + // TODO: Update error + this.exceptionService.internalServerErrorException({ + message: `Error while approving the contract by NGO ${error?.message}`, + code_error: 'APPROVE_DOCUMENT_CONTRACT_BY_NGO_001', + }); + } + + // TODO: Track event + } +} diff --git a/backend/src/usecases/documents/new_contracts/create-document-contract.usecase.ts b/backend/src/usecases/documents/new_contracts/create-document-contract.usecase.ts new file mode 100644 index 000000000..4a5128156 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/create-document-contract.usecase.ts @@ -0,0 +1,222 @@ +import { Injectable } from '@nestjs/common'; +import { isOver16FromCNP } from 'src/common/helpers/utils'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; +import { CreateDocumentContractOptions } from 'src/modules/documents/models/document-contract.model'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; +import { DocumentTemplateFacade } from 'src/modules/documents/services/document-template.facade'; +import { + IUserPersonalDataModel, + LegalGuardianIdentityData, +} from 'src/modules/user/models/user-personal-data.model'; +import { VolunteerStatus } from 'src/modules/volunteer/enums/volunteer-status.enum'; +import { VolunteerExceptionMessages } from 'src/modules/volunteer/exceptions/volunteer.exceptions'; +import { IVolunteerModel } from 'src/modules/volunteer/model/volunteer.model'; +import { VolunteerFacade } from 'src/modules/volunteer/services/volunteer.facade'; +import { GetOrganizationUseCaseService } from 'src/usecases/organization/get-organization.usecase'; +import * as z from 'zod'; + +@Injectable() +export class CreateDocumentContractUsecase implements IUseCaseService { + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly documentTemplateFacade: DocumentTemplateFacade, + private readonly getOrganizationUsecase: GetOrganizationUseCaseService, + private readonly volunteerFacade: VolunteerFacade, + private readonly exceptionsService: ExceptionsService, + ) { + // this.execute({ + // documentDate: new Date(), + // documentStartDate: new Date(), + // documentEndDate: new Date(), + // volunteerId: '1a53406f-263b-41bc-b60c-cb30a1805f1e', + // organizationId: '7f005461-07c3-4693-a85d-40d31db43a4c', + // documentTemplateId: 'bc3b7d74-686e-47b4-850a-b1de69574e28', + // createdByAdminId: '4db075bd-4095-432e-98bd-dc68b4599337', + // status: DocumentContractStatus.CREATED, + // }); + } + + public async execute( + newContract: Omit< + CreateDocumentContractOptions, + 'volunteerData' | 'volunteerTutorData' | 'status' + >, + ): Promise { + // 1. check if the organization exists + await this.getOrganizationUsecase.execute(newContract.organizationId); + + // 2. check if the volunteer exists + const volunteer = await this.checkVolunteerExists( + newContract.volunteerId, + newContract.organizationId, + ); + + //3. check if template exists + await this.checkTemplateExists( + newContract.documentTemplateId, + newContract.organizationId, + ); + + //TODO: 4. check if the contract number already exists + + //TODO: 5. check if the volunteer has already a contract in that period + + // 6. Extract volunteerData and volunteerTutorData from the user + const volunteerPersonalData = volunteer.user.userPersonalData; + + await this.validateVolunteerPersonalData(volunteerPersonalData); + + if (!isOver16FromCNP(volunteerPersonalData.cnp)) { + if (!volunteerPersonalData.legalGuardian) { + this.exceptionsService.badRequestException({ + message: 'Legal guardian data is required for under 16 volunteers', + code_error: 'LEGAL_GUARDIAN_DATA_REQUIRED', + }); + } + + await this.validateLegalGuardianData(volunteerPersonalData.legalGuardian); + } + + const newContractOptions: CreateDocumentContractOptions = { + ...newContract, + status: DocumentContractStatus.CREATED, + volunteerData: { + name: volunteer.user.name, + ...volunteerPersonalData, + }, + }; + + // 7. Create the contract + let contractId: string; + try { + contractId = await this.documentContractFacade.create(newContractOptions); + } catch (error) { + this.exceptionsService.internalServerErrorException({ + message: 'Error creating contract', + code_error: 'ERROR_CREATING_CONTRACT', // TODO: create a new error code for this + }); + } + + // 8. Build the HTML with handlebars and set it to lambda to Create the PDF + + // 9. Send notification to the volunteer to sign the contract if the status is PENDING_VOLUNTEER_SIGNATURE + + // 10. Track event + + return contractId; + } + + private async validateVolunteerPersonalData( + volunteerPersonalData: IUserPersonalDataModel, + ): Promise { + const personalDataSchema = z.object({ + cnp: z.string().length(13, 'CNP must be 13 digits'), + address: z.string().min(1, 'Address is required'), + identityDocumentSeries: z + .string() + .min(2, 'Identity document series is required'), + identityDocumentNumber: z + .string() + .min(1, 'Identity document number is required'), + identityDocumentIssuedBy: z + .string() + .min(1, 'Identity document issuer is required'), + identityDocumentIssueDate: z.coerce + .date() + .max(new Date(), 'Issue date cannot be in the future'), + }); + + try { + personalDataSchema.parse(volunteerPersonalData); + } catch (error) { + if (error instanceof z.ZodError) { + const invalidFields = error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })); + + this.exceptionsService.badRequestException({ + message: `Invalid personal data ${JSON.stringify(invalidFields)}`, + code_error: 'INVALID_PERSONAL_DATA', // TODO: create a new error code for this + }); + } else { + throw error; // Re-throw unexpected errors + } + } + } + + private async validateLegalGuardianData( + legalGuardianData: LegalGuardianIdentityData, + ): Promise { + const legalGuardianSchema = z.object({ + name: z.string().min(1, 'Name is required'), + cnp: z.string().length(13, 'CNP must be 13 digits'), + address: z.string().min(1, 'Address is required'), + identityDocumentSeries: z + .string() + .min(2, 'Identity document series is required'), + identityDocumentNumber: z + .string() + .min(1, 'Identity document number is required'), + email: z.string().email('Invalid email address'), + phone: z.string().min(1, 'Phone number is required'), + }); + + try { + legalGuardianSchema.parse(legalGuardianData); + } catch (error) { + if (error instanceof z.ZodError) { + const invalidFields = error.issues.map((issue) => ({ + field: issue.path.join('.'), + message: issue.message, + })); + + this.exceptionsService.badRequestException({ + message: `Invalid legal guardian data ${JSON.stringify(invalidFields)}`, + code_error: 'INVALID_LEGAL_GUARDIAN_DATA', + }); + } else { + throw error; // Re-throw unexpected errors + } + } + } + + private async checkVolunteerExists( + volunteerId: string, + organizationId: string, + ): Promise { + const volunteer = await this.volunteerFacade.find({ + id: volunteerId, + organizationId: organizationId, + status: VolunteerStatus.ACTIVE, + }); + + if (!volunteer) { + this.exceptionsService.notFoundException( + VolunteerExceptionMessages.VOLUNTEER_001, + ); + } + + return volunteer; + } + + private async checkTemplateExists( + documentTemplateId: string, + organizationId: string, + ): Promise { + const template = await this.documentTemplateFacade.findOne({ + id: documentTemplateId, + organizationId: organizationId, + }); + + if (!template) { + this.exceptionsService.notFoundException({ + // TODO update this exception + message: 'Template not found', + code_error: 'TEMPLATE_NOT_FOUND', + }); + } + } +} diff --git a/backend/src/usecases/documents/new_contracts/create-document-template.usecase.ts b/backend/src/usecases/documents/new_contracts/create-document-template.usecase.ts new file mode 100644 index 000000000..f82a57630 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/create-document-template.usecase.ts @@ -0,0 +1,39 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { JSONStringifyError } from 'src/common/helpers/utils'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentTemplateExceptionMessages } from 'src/modules/documents/exceptions/documente-template.exceptions'; +import { + CreateDocumentTemplateOptions, + IDocumentTemplateModel, +} from 'src/modules/documents/models/document-template.model'; +import { DocumentTemplateFacade } from 'src/modules/documents/services/document-template.facade'; + +@Injectable() +export class CreateDocumentTemplateUsecase + implements IUseCaseService +{ + private readonly logger = new Logger(CreateDocumentTemplateUsecase.name); + constructor( + private readonly documentTemplateFacade: DocumentTemplateFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + public async execute( + options: CreateDocumentTemplateOptions, + ): Promise { + try { + const newTemplate = await this.documentTemplateFacade.create(options); + + return newTemplate; + } catch (error) { + this.logger.error({ + ...DocumentTemplateExceptionMessages.TEMPLATE_002, + error: JSONStringifyError(error), + }); + this.exceptionService.badRequestException( + DocumentTemplateExceptionMessages.TEMPLATE_002, + ); + } + } +} diff --git a/backend/src/usecases/documents/new_contracts/delete-document-template.usecase.ts b/backend/src/usecases/documents/new_contracts/delete-document-template.usecase.ts new file mode 100644 index 000000000..15bc1b60d --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/delete-document-template.usecase.ts @@ -0,0 +1,63 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { JSONStringifyError } from 'src/common/helpers/utils'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentTemplateExceptionMessages } from 'src/modules/documents/exceptions/documente-template.exceptions'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; +import { DocumentTemplateFacade } from 'src/modules/documents/services/document-template.facade'; + +@Injectable() +export class DeleteDocumentTemplateUsecase implements IUseCaseService { + private readonly logger = new Logger(DeleteDocumentTemplateUsecase.name); + constructor( + private readonly documentTemplateFacade: DocumentTemplateFacade, + private readonly documentContractFacade: DocumentContractFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + public async execute(id: string, organizationId: string): Promise { + try { + // 1. Templates can be deleted if are not linked with a contract + const isUsed = await this.documentContractFacade.exists({ + documentTemplateId: id, + organizationId: organizationId, + }); + + if (isUsed) { + this.exceptionService.badRequestException({ + message: 'Used templates cannot be deleted', + code_error: 'DELETE_DOCUMENT_TEMPLATE_USED', + }); + } + + // 2. Try to delete it + const deleted = await this.documentTemplateFacade.delete({ + id, + organizationId, + }); + + if (!deleted) { + this.exceptionService.badRequestException({ + message: + 'The template does not exist or is not part of your organization', + code_error: 'DELETE_TEMPLATE_ERR', + }); + } + + return deleted; + } catch (error) { + if (error.code_error) { + // Rethrow errors that we've thrown above, and catch the others + throw error; + } + + this.logger.error({ + ...DocumentTemplateExceptionMessages.TEMPLATE_002, + error: JSONStringifyError(error), + }); + this.exceptionService.internalServerErrorException({ + message: 'Could not delete the template', + }); + } + } +} diff --git a/backend/src/usecases/documents/new_contracts/generate-pdfs.usecase.ts b/backend/src/usecases/documents/new_contracts/generate-pdfs.usecase.ts new file mode 100644 index 000000000..e01421cd4 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/generate-pdfs.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { PDFGenerator } from 'src/modules/documents/services/pdf-generator'; + +@Injectable() +export class GeneratePDFsUseCase implements IUseCaseService { + constructor(private readonly pDFGenerator: PDFGenerator) {} + + public async execute(): Promise { + return this.pDFGenerator.generatePDF(); + } +} diff --git a/backend/src/usecases/documents/new_contracts/get-many-document-contracts-by-volunteer.usecase.ts b/backend/src/usecases/documents/new_contracts/get-many-document-contracts-by-volunteer.usecase.ts new file mode 100644 index 000000000..8a9e18eb9 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/get-many-document-contracts-by-volunteer.usecase.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { IBasePaginationFilterModel } from 'src/infrastructure/base/base-pagination-filter.model'; +import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { IDocumentContractListViewModel } from 'src/modules/documents/models/document-contract-list-view.model'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; +import { VolunteerExceptionMessages } from 'src/modules/volunteer/exceptions/volunteer.exceptions'; +import { VolunteerFacade } from 'src/modules/volunteer/services/volunteer.facade'; + +@Injectable() +export class GetManyDocumentContractsByVolunteerUsecase + implements IUseCaseService> +{ + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly volunteerFacade: VolunteerFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + public async execute({ + userId, + organizationId, + ...paginationOptions + }: { + userId: string; + organizationId: string; + } & IBasePaginationFilterModel): Promise< + Pagination + > { + // 1. Find the volunteerId based on userId and organizationId + const volunteer = await this.volunteerFacade.find({ + userId: userId, + organizationId, + }); + + if (!volunteer) { + this.exceptionService.notFoundException( + VolunteerExceptionMessages.VOLUNTEER_001, + ); + } + + // 2. Find the document contracts based on the volunteerId + return this.documentContractFacade.findMany({ + ...paginationOptions, + organizationId, + volunteerId: volunteer.id, + }); + } +} diff --git a/backend/src/usecases/documents/new_contracts/get-many-document-contracts.usecase.ts b/backend/src/usecases/documents/new_contracts/get-many-document-contracts.usecase.ts new file mode 100644 index 000000000..10e3d9312 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/get-many-document-contracts.usecase.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { + FindManyDocumentContractListViewOptions, + IDocumentContractListViewModel, +} from 'src/modules/documents/models/document-contract-list-view.model'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; + +@Injectable() +export class GetManyDocumentContractsUsecase + implements IUseCaseService> +{ + constructor( + private readonly documentContractFacade: DocumentContractFacade, + ) {} + + public async execute( + findOptions: FindManyDocumentContractListViewOptions, + ): Promise> { + return this.documentContractFacade.findMany(findOptions); + } +} diff --git a/backend/src/usecases/documents/new_contracts/get-many-document-templates.usecase.ts b/backend/src/usecases/documents/new_contracts/get-many-document-templates.usecase.ts new file mode 100644 index 000000000..4fc6a5cf8 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/get-many-document-templates.usecase.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { Pagination } from 'src/infrastructure/base/repository-with-pagination.class'; +import { + FindManyDocumentTemplateListViewOptions, + IDocumentTemplateListViewModel, +} from 'src/modules/documents/models/document-template-list-view.model'; +import { DocumentTemplateFacade } from 'src/modules/documents/services/document-template.facade'; + +@Injectable() +export class GetManyDocumentTemplatesUsecase + implements IUseCaseService> +{ + constructor( + private readonly documentTemplateFacade: DocumentTemplateFacade, + ) {} + + public async execute( + findOptions: FindManyDocumentTemplateListViewOptions, + ): Promise> { + return this.documentTemplateFacade.findMany(findOptions); + } +} diff --git a/backend/src/usecases/documents/new_contracts/get-one-document-contract-for-volunteer.usecase.ts b/backend/src/usecases/documents/new_contracts/get-one-document-contract-for-volunteer.usecase.ts new file mode 100644 index 000000000..c688f4a07 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/get-one-document-contract-for-volunteer.usecase.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentContractListViewEntity } from 'src/modules/documents/entities/document-contract-list-view.entity'; +import { ContractExceptionMessages } from 'src/modules/documents/exceptions/contract.exceptions'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; +import { VolunteerExceptionMessages } from 'src/modules/volunteer/exceptions/volunteer.exceptions'; +import { VolunteerFacade } from 'src/modules/volunteer/services/volunteer.facade'; + +@Injectable() +export class GetOneDocumentContractForVolunteerUsecase { + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly volunteerFacade: VolunteerFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + async execute({ + documentContractId, + userId, + organizationId, + }: { + documentContractId: string; + userId: string; + organizationId: string; + }): Promise { + const volunteer = await this.volunteerFacade.find({ + userId: userId, + organizationId, + }); + + if (!volunteer) { + this.exceptionService.notFoundException( + VolunteerExceptionMessages.VOLUNTEER_001, + ); + } + + const contract = await this.documentContractFacade.findOneForVolunteer({ + documentId: documentContractId, + volunteerId: volunteer.id, + }); + + if (!contract) { + this.exceptionService.notFoundException( + ContractExceptionMessages.CONTRACT_002, + ); + } + + return contract; + } +} diff --git a/backend/src/usecases/documents/new_contracts/get-one-document-template.usecase.ts b/backend/src/usecases/documents/new_contracts/get-one-document-template.usecase.ts new file mode 100644 index 000000000..20a5a8678 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/get-one-document-template.usecase.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentTemplateExceptionMessages } from 'src/modules/documents/exceptions/documente-template.exceptions'; +import { + FindOneDocumentTemplateOptions, + IDocumentTemplateModel, +} from 'src/modules/documents/models/document-template.model'; +import { DocumentTemplateFacade } from 'src/modules/documents/services/document-template.facade'; + +@Injectable() +export class GetOneDocumentTemplateUseCase + implements IUseCaseService +{ + constructor( + private readonly documentTemplateFacade: DocumentTemplateFacade, + private readonly exceptionsService: ExceptionsService, + ) {} + + public async execute( + findOptions: FindOneDocumentTemplateOptions, + organizationId: string, + ): Promise { + const template = await this.documentTemplateFacade.findOne({ + ...findOptions, + organizationId, + }); + + if (!template) { + this.exceptionsService.notFoundException( + DocumentTemplateExceptionMessages.TEMPLATE_001, + ); + } + + return template; + } +} diff --git a/backend/src/usecases/documents/new_contracts/reject-document-contact-by-volunteer.usecase.ts b/backend/src/usecases/documents/new_contracts/reject-document-contact-by-volunteer.usecase.ts new file mode 100644 index 000000000..3dfaf6b11 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/reject-document-contact-by-volunteer.usecase.ts @@ -0,0 +1,145 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; +import { ContractExceptionMessages } from 'src/modules/documents/exceptions/contract.exceptions'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; +import { VolunteerFacade } from 'src/modules/volunteer/services/volunteer.facade'; + +// ┌─────────────────────────────────────────────────────────────────────────┐ +// │ Business Rules for RejectDocumentContractByVolunteerUsecase: │ +// │ │ +// │ 1. Volunteer Authentication: │ +// │ - The volunteer must exist and be associated with the given │ +// │ organization. │ +// │ - If the volunteer is not found or not part of the organization, │ +// │ throw a not found exception. │ +// │ │ +// │ 2. Contract Validation: │ +// │ - The contract must exist and be in the PENDING_VOLUNTEER_SIGNATURE │ +// │ status. │ +// │ - The contract must be assigned to the current volunteer. │ +// │ - The contract must belong to the user's organization. │ +// │ - If any of these conditions are not met, throw a not found │ +// │ exception. │ +// │ │ +// │ 4. Contract Update: │ +// │ - Update the contract status to REJECTED_BY_VOLUNTEER. │ +// │ │ +// │ 5. Error Handling: │ +// │ - Any failures in the process should throw appropriate exceptions. │ +// │ - Use the ExceptionsService to handle and throw standardized │ +// │ exceptions. │ +// │ │ +// │ 6. Transactional Integrity: // TODO: Implement this │ +// │ - Ensure that all database operations are performed atomically. │ +// │ - If any part of the process fails, all changes should be rolled │ +// │ back. │ +// │ │ +// │ 7. Audit Trail: // TODO: Implement this │ +// │ - Track the rejection event in an Actions Archive for auditing │ +// │ purposes. │ +// │ - Store the rejection reason in the Actions Archive. │ +// │ │ +// │ 8. Authorization: │ +// │ - Ensure that only the assigned volunteer can reject their own │ +// │ contract. │ +// │ │ +// │ 9. Notification: │ +// │ - Notify relevant parties (e.g., NGO administrators) about the │ +// │ contract rejection. │ +// │ │ +// │ 10. Data Validation: │ +// │ - Validate the format and content of the rejection reason before │ +// │ processing. │ +// └─────────────────────────────────────────────────────────────────────────┘ + +@Injectable() +export class RejectDocumentContractByVolunteerUsecase + implements IUseCaseService +{ + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly volunteerFacade: VolunteerFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + public async execute({ + contractId, + userId, + organizationId, + rejectionReason, + }: { + contractId: string; + userId: string; + organizationId: string; + rejectionReason: string; + }): Promise { + /* ┌─────────────────────────────────────────────────────────────────────┐ + * │ Verify volunteer existence: │ + * │ │ + * │ 1. Volunteer must exist and be part of the organization │ + * │ │ + * │ This ensures that the volunteer is valid and authorized to reject. │ + * └─────────────────────────────────────────────────────────────────────┘ + */ + const volunteer = await this.volunteerFacade.find({ + userId: userId, + organizationId, + }); + if (!volunteer) { + this.exceptionService.notFoundException({ + message: 'Volunteer is not part of the organization', + code_error: 'VOLUNTEER_NOT_PART_OF_ORGANIZATION', + }); + } + + /* ┌─────────────────────────────────────────────────────────────────────┐ + * │ Verify contract existence and eligibility: │ + * │ │ + * │ 1. Status must be PENDING_VOLUNTEER_SIGNATURE │ + * │ 2. Contract is assigned to the current volunteer │ + * │ 3. Contract belongs to the user's organization │ + * │ │ + * │ This ensures that the contract is valid and authorized to be │ + * │ rejected. │ + * └─────────────────────────────────────────────────────────────────────┘ + */ + const contractExists = await this.documentContractFacade.exists({ + id: contractId, + volunteerId: volunteer.id, + organizationId, + status: DocumentContractStatus.PENDING_VOLUNTEER_SIGNATURE, + }); + + if (!contractExists) { + this.exceptionService.notFoundException( + ContractExceptionMessages.CONTRACT_002, + ); + } + + /* ┌─────────────────────────────────────────────────────────────────────┐ + * │ Update contract status: │ + * │ │ + * │ 1. Update the contract status to REJECTED_VOLUNTEER. │ + * └─────────────────────────────────────────────────────────────────────┘ + */ + await this.documentContractFacade.update(contractId, { + status: DocumentContractStatus.REJECTED_VOLUNTEER, + }); + + /* ┌─────────────────────────────────────────────────────────────────────┐ + * │ Audit trail logging: │ + * │ │ + * │ 1. Log the contract rejection event in the Actions Archive together │ + * │ with the rejection reason. │ + * └─────────────────────────────────────────────────────────────────────┘ + */ + // TODO: Implement audit trail logging + console.log('rejectionReason', rejectionReason); + + // TODO: Implement notification to relevant parties + + return; + } +} diff --git a/backend/src/usecases/documents/new_contracts/reject-document-contract-by-ngo.usecase.ts b/backend/src/usecases/documents/new_contracts/reject-document-contract-by-ngo.usecase.ts new file mode 100644 index 000000000..ba6baa3b3 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/reject-document-contract-by-ngo.usecase.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; +import { ContractExceptionMessages } from 'src/modules/documents/exceptions/contract.exceptions'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; + +@Injectable() +export class RejectDocumentContractByNgoUsecase + implements IUseCaseService +{ + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + public async execute({ + documentContractId, + organizationId, + rejectionReason, + }: { + documentContractId: string; + organizationId: string; + rejectionReason?: string; + }): Promise { + const contract = await this.documentContractFacade.findOne({ + id: documentContractId, + organizationId, + }); + + if (!contract) { + this.exceptionService.notFoundException( + ContractExceptionMessages.CONTRACT_002, + ); + } + + if ( + [ + DocumentContractStatus.PENDING_APPROVAL_NGO, + DocumentContractStatus.PENDING_NGO_REPRESENTATIVE_SIGNATURE, + ].includes(contract.status) !== true + ) { + // TODO: update error + this.exceptionService.notFoundException({ + message: 'Only Pending Contracts can be rejected', + code_error: 'REJECT_DOCUMENT_CONTRACT_BY_NGO_02', + }); + } + + try { + await this.documentContractFacade.rejectDocumentContractByNGO( + documentContractId, + ); + } catch (error) { + // TODO: Update error + this.exceptionService.internalServerErrorException({ + message: `Error while rejecting the contract by NGO ${error?.message}`, + code_error: 'REJECT_DOCUMENT_CONTRACT_BY_NGO_002', + }); + } + + // TODO: Send notification to Volunteer including Rejection Reason if exists (Contract was rejected) + // TODO: Track Rejection Event including Rejection Reason + console.log(rejectionReason); + } +} diff --git a/backend/src/usecases/documents/new_contracts/sign-document-contract-by-ngo.usecase.ts b/backend/src/usecases/documents/new_contracts/sign-document-contract-by-ngo.usecase.ts new file mode 100644 index 000000000..e5bb3d0aa --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/sign-document-contract-by-ngo.usecase.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; +import { ContractExceptionMessages } from 'src/modules/documents/exceptions/contract.exceptions'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; + +@Injectable() +export class SignDocumentContractByNgoUsecase implements IUseCaseService { + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + public async execute( + documentContractId: string, + organizationId: string, + ): Promise { + const exists = await this.documentContractFacade.findOne({ + id: documentContractId, + organizationId, + status: DocumentContractStatus.PENDING_NGO_REPRESENTATIVE_SIGNATURE, + }); + + if (!exists) { + this.exceptionService.notFoundException( + ContractExceptionMessages.CONTRACT_002, + ); + } + + try { + await this.documentContractFacade.signDocumentContractByNGO( + documentContractId, + ); + } catch (error) { + // TODO: Update error + this.exceptionService.internalServerErrorException({ + message: `Error while sigining the contract by NGO ${error?.message}`, + code_error: 'SIGN_DOCUMENT_CONTRACT_BY_NGO_003', + }); + } + + // TODO: Send notification to Volunteer (Contract is now active) + // TODO: Track Event + } +} diff --git a/backend/src/usecases/documents/new_contracts/sign-document-contract-by-volunteer.usecase.ts b/backend/src/usecases/documents/new_contracts/sign-document-contract-by-volunteer.usecase.ts new file mode 100644 index 000000000..77de58371 --- /dev/null +++ b/backend/src/usecases/documents/new_contracts/sign-document-contract-by-volunteer.usecase.ts @@ -0,0 +1,170 @@ +import { Injectable } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { DocumentContractStatus } from 'src/modules/documents/enums/contract-status.enum'; +import { ContractExceptionMessages } from 'src/modules/documents/exceptions/contract.exceptions'; +import { DocumentContractFacade } from 'src/modules/documents/services/document-contract.facade'; +import { DocumentSignatureFacade } from 'src/modules/documents/services/document-signature.facade'; +import { VolunteerFacade } from 'src/modules/volunteer/services/volunteer.facade'; + +// ┌─────────────────────────────────────────────────────────────────────────┐ +// │ Business Rules for SignDocumentContractByVolunteerUsecase: │ +// │ │ +// │ 1. Volunteer Authentication: │ +// │ - The volunteer must exist and be associated with the given │ +// │ organization. │ +// │ - If the volunteer is not found or not part of the organization, │ +// │ throw a not found exception. │ +// │ │s +// │ 2. Contract Validation: │ +// │ - The contract must exist and be in the PENDING_VOLUNTEER_SIGNATURE │ +// │ status. │ +// │ - The contract must be assigned to the current volunteer. │ +// │ - The contract must belong to the user's organization. │ +// │ - If any of these conditions are not met, throw a not found │ +// │ exception. │ +// │ │ +// │ 3. Signature Requirements: │ +// │ - A volunteer signature (volunteerSignatureBase64) is mandatory. │ +// │ - A legal guardian signature (legalGuardianSignatureBase64) is │ +// │ optional. │ +// │ │ +// │ 4. Signature Creation: │ +// │ - Create a new signature record for the volunteer using the │ +// │ provided signature. │ +// │ - If a legal guardian signature is provided, create a separate │ +// │ signature record for it. │ +// │ │ +// │ 5. Contract Update: │ +// │ - Update the contract with the newly created signature IDs. │ +// │ - Change the contract status to PENDING_APPROVAL_NGO. │ +// │ │ +// │ 6. Error Handling: │ +// │ - Any failures in the process should throw appropriate exceptions. │ +// │ - Use the ExceptionsService to handle and throw standardized │ +// │ exceptions. │ +// │ │ +// │ 7. Transactional Integrity: │ +// │ - Ensure that all database operations (signature creation and │ +// │ contract update) are performed atomically. │ +// │ - If any part of the process fails, all changes should be rolled │ +// │ back. │ +// │ │ +// │ 8. Audit Trail: // TODO: Implement this │ +// │ - Track the event in an Actions Archive for auditing purposes. │ +// │ │ +// │ 9. Authorization: │ +// │ - Ensure that only the assigned volunteer can sign their own │ +// │ contract. │ +// │ │ +// │ 10. Data Validation: // TODO: Implement this │ +// │ - Validate the format and content of the signature data before │ +// │ processing. │ +// └─────────────────────────────────────────────────────────────────────────┘ + +@Injectable() +export class SignDocumentContractByVolunteerUsecase + implements IUseCaseService +{ + constructor( + private readonly documentContractFacade: DocumentContractFacade, + private readonly documentSignatureFacade: DocumentSignatureFacade, + private readonly volunteerFacade: VolunteerFacade, + private readonly exceptionService: ExceptionsService, + ) {} + + public async execute({ + contractId, + userId, + organizationId, + volunteerSignatureBase64, + legalGuardianSignatureBase64, + }: { + contractId: string; + userId: string; + organizationId: string; + volunteerSignatureBase64: string; + legalGuardianSignatureBase64?: string; + }): Promise { + // ┌─────────────────────────────────────────────────────────────────────┐ + // │ Verify volunteer existence: │ + // │ │ + // │ 1. Volunteer must exist and be part of the organization │ + // │ │ + // │ This ensures that the volunteer is valid and authorized to sign. │ + // └─────────────────────────────────────────────────────────────────────┘ + const volunteer = await this.volunteerFacade.find({ + userId: userId, + organizationId, + }); + if (!volunteer) { + this.exceptionService.notFoundException({ + message: 'Volunteer is not part of the organization', + code_error: 'VOLUNTEER_NOT_PART_OF_ORGANIZATION', + }); + } + + // ┌─────────────────────────────────────────────────────────────────────┐ + // │ Verify contract existence and eligibility: │ + // │ │ + // │ 1. Status must be PENDING_VOLUNTEER_SIGNATURE │ + // │ 2. Contract is assigned to the current volunteer │ + // │ 3. Contract belongs to the user's organization │ + // │ │ + // │ This ensures proper authorization and workflow compliance. │ + // └─────────────────────────────────────────────────────────────────────┘ + const contractExists = await this.documentContractFacade.exists({ + id: contractId, + volunteerId: volunteer.id, + organizationId, + status: DocumentContractStatus.PENDING_VOLUNTEER_SIGNATURE, + }); + + if (!contractExists) { + this.exceptionService.notFoundException( + ContractExceptionMessages.CONTRACT_002, + ); + } + + // ┌─────────────────────────────────────────────────────────────────────┐ + // │ Create signatures: │ + // │ │ + // │ 1. Volunteer signature │ + // │ 2. Legal guardian signature (optional) │ + // │ │ + // │ This ensures that both signatures are securely stored. │ + // └─────────────────────────────────────────────────────────────────────┘ + // Create the volunteer signature + const volunteerSignatureId = await this.documentSignatureFacade.create({ + userId: userId, + signature: volunteerSignatureBase64, + }); + + // Create the legal guardian signature + const legalGuardianSignatureId = legalGuardianSignatureBase64 + ? await this.documentSignatureFacade.create({ + userId: userId, + signature: legalGuardianSignatureBase64, + }) + : null; + + // ┌─────────────────────────────────────────────────────────────────────┐ + // │ Update contract: │ + // │ │ + // │ 1. Update the contract with the signatures │ + // │ 2. Set the status to PENDING_APPROVAL_NGO │ + // │ │ + // │ This ensures that the contract is updated with the signatures and │ + // │ ready for further processing. │ + // └─────────────────────────────────────────────────────────────────────┘ + await this.documentContractFacade.update(contractId, { + status: DocumentContractStatus.PENDING_APPROVAL_NGO, + volunteerSignatureId: volunteerSignatureId, + legalGuardianSignatureId: legalGuardianSignatureId, + }); + + // Track event in Actions Archive + + return; + } +} diff --git a/backend/src/usecases/organization/sync-with-ngohub.usecase.ts b/backend/src/usecases/organization/sync-with-ngohub.usecase.ts new file mode 100644 index 000000000..867129af7 --- /dev/null +++ b/backend/src/usecases/organization/sync-with-ngohub.usecase.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { IUseCaseService } from 'src/common/interfaces/use-case-service.interface'; +import { ExceptionsService } from 'src/infrastructure/exceptions/exceptions.service'; +import { OngHubExceptionMessages } from 'src/modules/onghub/exceptions/exceptions'; +import { OngHubService } from 'src/modules/onghub/services/ong-hub.service'; +import { IOrganizationModel } from 'src/modules/organization/models/organization.model'; +import { OrganizationFacadeService } from 'src/modules/organization/services/organization.facade'; + +@Injectable() +export class SyncWithOngHubUseCaseService + implements IUseCaseService +{ + private readonly logger = new Logger(SyncWithOngHubUseCaseService.name); + + constructor( + private readonly ongHubService: OngHubService, + private readonly organizationService: OrganizationFacadeService, + private readonly exceptionService: ExceptionsService, + ) {} + + /** + * Synchronizes the organization data with ONG Hub. + * + * @param organizationId - The ID of the organization to be synchronized. + * @param token - The authentication token for ONG Hub which is the same as the one from VIC because we share the same Cognito User Pool. + * @returns A Promise that resolves to the updated IOrganizationModel. + * @throws InternalServerErrorException if there's an error fetching data from ONG Hub or updating the organization. + */ + async execute( + organizationId: string, + token: string, + ): Promise { + const userWithOrganization = + await this.ongHubService.getUserAndOrganizationDataFromOngHub(token); + + if (!userWithOrganization || !userWithOrganization.organization) { + this.exceptionService.internalServerErrorException( + OngHubExceptionMessages.ONG_002, + ); + } + + try { + const organization = await this.organizationService.updateOrganization( + organizationId, + { + name: userWithOrganization.organization.name, + email: userWithOrganization.organization.email, + phone: userWithOrganization.organization.phone, + address: userWithOrganization.organization.address, + activityArea: userWithOrganization.organization.activityArea, + logo: userWithOrganization.organization.logo, + description: userWithOrganization.organization.description, + cui: userWithOrganization.organization.cui, + legalReprezentativeFullName: + userWithOrganization.organization.legalReprezentativeFullName, + legalReprezentativeRole: + userWithOrganization.organization.legalReprezentativeRole, + }, + ); + + return organization; + } catch (error) { + console.log('[ONGHub Sync] Error updating organization:', error); + this.exceptionService.internalServerErrorException( + OngHubExceptionMessages.ONG_004, + ); + } + } +} diff --git a/backend/src/usecases/use-case.module.ts b/backend/src/usecases/use-case.module.ts index 3caad7105..5ca4d3c0c 100644 --- a/backend/src/usecases/use-case.module.ts +++ b/backend/src/usecases/use-case.module.ts @@ -136,6 +136,183 @@ import { GetOneRegularUserProfileUseCase } from './user/get-regule-user-profile. import { SyncUserOrganizationsUsecase } from './user/sync-user-organizations.usecase'; import { GetRejectedAccessRequestUsecase } from './access-request/get-rejected-access-request.usecase'; import { DeleteAccountRegularUserUsecase } from './user/delete-account.usecase'; +import { SyncWithOngHubUseCaseService } from './organization/sync-with-ngohub.usecase'; +import { CreateDocumentTemplateUsecase } from './documents/new_contracts/create-document-template.usecase'; +import { GetOneDocumentTemplateUseCase } from './documents/new_contracts/get-one-document-template.usecase'; +import { CreateDocumentContractUsecase } from './documents/new_contracts/create-document-contract.usecase'; +import { GetManyDocumentContractsUsecase } from './documents/new_contracts/get-many-document-contracts.usecase'; +import { GetManyDocumentTemplatesUsecase } from './documents/new_contracts/get-many-document-templates.usecase'; +import { SignDocumentContractByVolunteerUsecase } from './documents/new_contracts/sign-document-contract-by-volunteer.usecase'; +import { RejectDocumentContractByVolunteerUsecase } from './documents/new_contracts/reject-document-contact-by-volunteer.usecase'; +import { GeneratePDFsUseCase } from './documents/new_contracts/generate-pdfs.usecase'; +import { GetManyDocumentContractsByVolunteerUsecase } from './documents/new_contracts/get-many-document-contracts-by-volunteer.usecase'; +import { GetOneDocumentContractForVolunteerUsecase } from './documents/new_contracts/get-one-document-contract-for-volunteer.usecase'; +import { ApproveDocumentContractByNgoUsecase } from './documents/new_contracts/approve-document-contract-by-ngo.usecase'; +import { SignDocumentContractByNgoUsecase } from './documents/new_contracts/sign-document-contract-by-ngo.usecase'; +import { DeleteDocumentTemplateUsecase } from './documents/new_contracts/delete-document-template.usecase'; +import { RejectDocumentContractByNgoUsecase } from './documents/new_contracts/reject-document-contract-by-ngo.usecase'; + +const providers = [ + // Organization + GetOrganizationUseCaseService, + UpdateOrganizationDescriptionUseCaseService, + GetOrganizationsUseCase, + GetOrganizationWithEventsUseCase, + SwitchOrganizationUsecase, + LeaveOrganizationUsecase, + RejoinOrganizationUsecase, + SyncWithOngHubUseCaseService, + // Access Codes + CreateAccessCodeUseCase, + UpdateAccessCodeUseCase, + GetAccessCodeUseCase, + GetAllAccessCodeUseCase, + DeleteAccessCodeUseCase, + // Organization structure + CreateOrganizationStructureUseCase, + GetAllOrganizationStructureUseCase, + GetOneOrganizationStructureUseCase, + DeleteOrganizationStructureUseCase, + UpdateOrganizationStructureUseCase, + GetAllOrganizationStructureByTypeUseCase, + // User + GetUserProfileUseCaseService, + CreateRegularUsereUseCaseService, + GetOneRegularUserUseCase, + GetManyAdminUsersUseCase, + UpdateUserPersonalDataUsecase, + UpdateRegularUserUsecase, + GetOneRegularUserProfileUseCase, + SyncUserOrganizationsUsecase, + DeleteAccountRegularUserUsecase, + // Access Requests + GetManyNewAccessRequestsUseCase, + GetManyRejectedAccessRequestsUseCase, + CreateAccessRequestUseCase, + GetAccessRequestUseCase, + DeleteAccessRequestUseCase, + ApproveAccessRequestUseCase, + RejectAccessRequestUseCase, + GetAccessRequestsForDownloadUseCase, + JoinOrganizationByAccessCodeUsecase, + CancelAccessRequestUsecase, + GetRejectedAccessRequestUsecase, + // Location + GetCitiesUseCase, + GetCountiesUseCase, + GetCitiesByCountyIdUseCase, + // Activity Types + CreateActivityTypeUseCase, + UpdateActivityTypeUseCase, + ActivateActivityTypeUseCase, + ArchiveActivityTypeUseCase, + GetOneActivityTypeUseCase, + GetManyActivityTypeUseCase, + // Volunteers + GetOneVolunteerUsecase, + CreateVolunteerUseCase, + ArchiveVolunteerUsecase, + BlockVolunteerUsecase, + ActivateVolunteerUsecase, + CreateVolunteerProfileUseCase, + GetManyVolunteersUseCase, + UpdateVolunteerProfileUsecase, + GetVolunteersForDownloadUseCase, + GetVolunteerProfileUsecase, + GetVolunteersUserDataForNotificationsUsecase, + GetVolunteerOrganizationStatusUsecase, + // Announcement + GetOneAnnouncementUseCase, + GetManyAnnouncementUseCase, + CreateAnnouncementUseCase, + UpdateAnnouncementUseCase, + DeleteAnnouncementUseCase, + GetManyAnouncementsByUserAsUsecase, + // Events + CreateEventUseCase, + GetOneEventUseCase, + UpdateEventUseCase, + DeleteEventUseCase, + PublishEventUseCase, + ArchiveEventUseCase, + GetManyForDownloadEventUseCase, + GetManyEventUseCase, + GetMyEventsUsecase, + GetOneEventWithVolunteerStatusUsecase, + GetEventsByOrganizationUsecase, + // Events RSVP + CreateEventRSVPUseCase, + GetOneEventRSVPUseCase, + DeleteEventRSVPUseCase, + GetManyEventRSVPUseCase, + GetManyForDownloadEventRSVPUseCase, + // Activity Log + CreateActivityLogByAdmin, + GetOneActivityLogUsecase, + UpdateActivityLogUsecase, + ApproveActivityLogUsecase, + RejectActivityLogUsecase, + GetManyActivityLogsUsecase, + GetActivityLogCountersUsecase, + GetManyForDownloadActivityLogUseCase, + GetActivityLogCountUsecase, + CreateActivityLogByRegularUser, + CancelActivityLogUsecase, + // Actions Archive + GetManyActionsArchiveUseCase, + GetManyNewsUsecase, + // Dashboard + GetDashboardVolunteerStatusTimeseriesUsecase, + GetDashboardVolunteerGroupedUsecase, + GetDashboardVolunteersHoursUseCase, + GetDashboardVolunteersStatusUseCase, + // Push Notifications + RegisterDevicePushTokenUseCase, + UnregisterDevicePushTokenUseCase, + GetVolunteerMonthlyNewsStatisticsUsecase, + GetVicStatisticsUsecase, + // Templates + CreateTemplateUsecase, + GetTemplatesUsecase, + GetOneTemplateUseCase, + UpdateTemplateUsecase, + DeleteTemplateUseCase, + GetAllTemplatesUsecase, + GetTemplatesForDownloadUsecase, + // NEW Templates + CreateDocumentTemplateUsecase, + GetOneDocumentTemplateUseCase, + GetManyDocumentTemplatesUsecase, + DeleteDocumentTemplateUsecase, + // Contracts + CreateContractUsecase, + GetManyContractsUsecase, + CountPendingContractsUsecase, + GetOneContractUsecase, + SignContractByVolunteer, + SignAndConfirmContractUsecase, + SignAndConfirmContractUsecase, + RejectContractUsecase, + GetContractsForDownloadUsecase, + DeleteContractUsecase, + GetVolunteerContractHistoryUsecase, + GetVolunteerPendingContractsUsecase, + CancelContractUsecase, + // NEW Contracts + CreateDocumentContractUsecase, + GetManyDocumentContractsUsecase, + SignDocumentContractByVolunteerUsecase, + RejectDocumentContractByVolunteerUsecase, + GetManyDocumentContractsByVolunteerUsecase, + GetOneDocumentContractForVolunteerUsecase, + ApproveDocumentContractByNgoUsecase, + SignDocumentContractByNgoUsecase, + RejectDocumentContractByNgoUsecase, + // Notifications + UpdateSettingsUsecase, + // Testing PDFs + GeneratePDFsUseCase, +]; @Module({ imports: [ @@ -155,292 +332,7 @@ import { DeleteAccountRegularUserUsecase } from './user/delete-account.usecase'; NotificationsModule, DocumentsModule, ], - providers: [ - // Organization - GetOrganizationUseCaseService, - UpdateOrganizationDescriptionUseCaseService, - GetOrganizationsUseCase, - GetOrganizationWithEventsUseCase, - SwitchOrganizationUsecase, - LeaveOrganizationUsecase, - RejoinOrganizationUsecase, - // Access Codes - CreateAccessCodeUseCase, - UpdateAccessCodeUseCase, - GetAccessCodeUseCase, - GetAllAccessCodeUseCase, - DeleteAccessCodeUseCase, - // Organization structure - CreateOrganizationStructureUseCase, - GetAllOrganizationStructureUseCase, - GetOneOrganizationStructureUseCase, - DeleteOrganizationStructureUseCase, - UpdateOrganizationStructureUseCase, - GetAllOrganizationStructureByTypeUseCase, - // User - GetUserProfileUseCaseService, - CreateRegularUsereUseCaseService, - GetOneRegularUserUseCase, - GetManyAdminUsersUseCase, - UpdateUserPersonalDataUsecase, - UpdateRegularUserUsecase, - GetOneRegularUserProfileUseCase, - SyncUserOrganizationsUsecase, - DeleteAccountRegularUserUsecase, - // Access Requests - GetManyNewAccessRequestsUseCase, - GetManyRejectedAccessRequestsUseCase, - CreateAccessRequestUseCase, - GetAccessRequestUseCase, - DeleteAccessRequestUseCase, - ApproveAccessRequestUseCase, - RejectAccessRequestUseCase, - GetAccessRequestsForDownloadUseCase, - JoinOrganizationByAccessCodeUsecase, - CancelAccessRequestUsecase, - GetRejectedAccessRequestUsecase, - // Location - GetCitiesUseCase, - GetCountiesUseCase, - GetCitiesByCountyIdUseCase, - // Activity Types - CreateActivityTypeUseCase, - UpdateActivityTypeUseCase, - ActivateActivityTypeUseCase, - ArchiveActivityTypeUseCase, - GetOneActivityTypeUseCase, - GetManyActivityTypeUseCase, - // Volunteers - GetOneVolunteerUsecase, - CreateVolunteerUseCase, - ArchiveVolunteerUsecase, - BlockVolunteerUsecase, - ActivateVolunteerUsecase, - CreateVolunteerProfileUseCase, - GetManyVolunteersUseCase, - UpdateVolunteerProfileUsecase, - GetVolunteersForDownloadUseCase, - GetVolunteerProfileUsecase, - GetVolunteersUserDataForNotificationsUsecase, - GetVolunteerOrganizationStatusUsecase, - // Announcement - GetOneAnnouncementUseCase, - GetManyAnnouncementUseCase, - CreateAnnouncementUseCase, - UpdateAnnouncementUseCase, - DeleteAnnouncementUseCase, - GetManyAnouncementsByUserAsUsecase, - // Events - CreateEventUseCase, - GetOneEventUseCase, - UpdateEventUseCase, - DeleteEventUseCase, - PublishEventUseCase, - ArchiveEventUseCase, - GetManyForDownloadEventUseCase, - GetManyEventUseCase, - GetMyEventsUsecase, - GetOneEventWithVolunteerStatusUsecase, - GetEventsByOrganizationUsecase, - // Events RSVP - CreateEventRSVPUseCase, - GetOneEventRSVPUseCase, - DeleteEventRSVPUseCase, - GetManyEventRSVPUseCase, - GetManyForDownloadEventRSVPUseCase, - // Activity Log - CreateActivityLogByAdmin, - GetOneActivityLogUsecase, - UpdateActivityLogUsecase, - ApproveActivityLogUsecase, - RejectActivityLogUsecase, - GetManyActivityLogsUsecase, - GetActivityLogCountersUsecase, - GetManyForDownloadActivityLogUseCase, - GetActivityLogCountUsecase, - CreateActivityLogByRegularUser, - CancelActivityLogUsecase, - // Actions Archive - GetManyActionsArchiveUseCase, - GetManyNewsUsecase, - // Dashboard - GetDashboardVolunteerStatusTimeseriesUsecase, - GetDashboardVolunteerGroupedUsecase, - GetDashboardVolunteersHoursUseCase, - GetDashboardVolunteersStatusUseCase, - // Push Notifications - RegisterDevicePushTokenUseCase, - UnregisterDevicePushTokenUseCase, - GetVolunteerMonthlyNewsStatisticsUsecase, - GetVicStatisticsUsecase, - // Templates - CreateTemplateUsecase, - GetTemplatesUsecase, - GetOneTemplateUseCase, - UpdateTemplateUsecase, - DeleteTemplateUseCase, - GetAllTemplatesUsecase, - GetTemplatesForDownloadUsecase, - // Contracts - CreateContractUsecase, - GetManyContractsUsecase, - CountPendingContractsUsecase, - GetOneContractUsecase, - SignContractByVolunteer, - SignAndConfirmContractUsecase, - SignAndConfirmContractUsecase, - RejectContractUsecase, - GetContractsForDownloadUsecase, - DeleteContractUsecase, - GetVolunteerContractHistoryUsecase, - GetVolunteerPendingContractsUsecase, - CancelContractUsecase, - // Notifications - UpdateSettingsUsecase, - ], - exports: [ - // Organization - GetOrganizationUseCaseService, - UpdateOrganizationDescriptionUseCaseService, - GetOrganizationsUseCase, - GetOrganizationWithEventsUseCase, - SwitchOrganizationUsecase, - LeaveOrganizationUsecase, - RejoinOrganizationUsecase, - // Access Codes - CreateAccessCodeUseCase, - UpdateAccessCodeUseCase, - GetAccessCodeUseCase, - GetAllAccessCodeUseCase, - DeleteAccessCodeUseCase, - // Organization Structure - CreateOrganizationStructureUseCase, - GetAllOrganizationStructureUseCase, - GetOneOrganizationStructureUseCase, - DeleteOrganizationStructureUseCase, - UpdateOrganizationStructureUseCase, - GetAllOrganizationStructureByTypeUseCase, - // User - GetUserProfileUseCaseService, - CreateRegularUsereUseCaseService, - GetOneRegularUserUseCase, - GetManyAdminUsersUseCase, - UpdateUserPersonalDataUsecase, - UpdateRegularUserUsecase, - GetOneRegularUserProfileUseCase, - SyncUserOrganizationsUsecase, - DeleteAccountRegularUserUsecase, - // Access Requests - GetManyNewAccessRequestsUseCase, - GetManyRejectedAccessRequestsUseCase, - CreateAccessRequestUseCase, - GetAccessRequestUseCase, - DeleteAccessRequestUseCase, - ApproveAccessRequestUseCase, - RejectAccessRequestUseCase, - GetAccessRequestsForDownloadUseCase, - JoinOrganizationByAccessCodeUsecase, - CancelAccessRequestUsecase, - GetRejectedAccessRequestUsecase, - // Location - GetCitiesUseCase, - GetCountiesUseCase, - GetCitiesByCountyIdUseCase, - // Activity Types - CreateActivityTypeUseCase, - UpdateActivityTypeUseCase, - ActivateActivityTypeUseCase, - ArchiveActivityTypeUseCase, - GetOneActivityTypeUseCase, - GetManyActivityTypeUseCase, - // Volunteers - GetOneVolunteerUsecase, - CreateVolunteerUseCase, - ArchiveVolunteerUsecase, - BlockVolunteerUsecase, - ActivateVolunteerUsecase, - CreateVolunteerProfileUseCase, - GetManyVolunteersUseCase, - UpdateVolunteerProfileUsecase, - GetVolunteersForDownloadUseCase, - GetVolunteerProfileUsecase, - GetVolunteersUserDataForNotificationsUsecase, - GetVolunteerOrganizationStatusUsecase, - // Announcement - GetOneAnnouncementUseCase, - GetManyAnnouncementUseCase, - CreateAnnouncementUseCase, - UpdateAnnouncementUseCase, - DeleteAnnouncementUseCase, - CreateEventUseCase, - GetManyAnouncementsByUserAsUsecase, - // Events - CreateEventUseCase, - GetOneEventUseCase, - UpdateEventUseCase, - DeleteEventUseCase, - PublishEventUseCase, - ArchiveEventUseCase, - GetManyEventUseCase, - GetManyForDownloadEventUseCase, - GetMyEventsUsecase, - GetOneEventWithVolunteerStatusUsecase, - GetEventsByOrganizationUsecase, - // Events RSVP - CreateEventRSVPUseCase, - GetOneEventRSVPUseCase, - DeleteEventRSVPUseCase, - GetManyEventRSVPUseCase, - GetManyForDownloadEventRSVPUseCase, - // Activity Log - CreateActivityLogByAdmin, - GetOneActivityLogUsecase, - UpdateActivityLogUsecase, - ApproveActivityLogUsecase, - RejectActivityLogUsecase, - GetManyActivityLogsUsecase, - GetActivityLogCountersUsecase, - GetManyForDownloadActivityLogUseCase, - GetActivityLogCountUsecase, - CreateActivityLogByRegularUser, - CancelActivityLogUsecase, - // Actions Archive - GetManyActionsArchiveUseCase, - GetManyNewsUsecase, - // Dashboard - GetDashboardVolunteerStatusTimeseriesUsecase, - GetDashboardVolunteerGroupedUsecase, - GetDashboardVolunteersHoursUseCase, - GetDashboardVolunteersStatusUseCase, - // Push Notifications - RegisterDevicePushTokenUseCase, - UnregisterDevicePushTokenUseCase, - GetVolunteerMonthlyNewsStatisticsUsecase, - // Templates - CreateTemplateUsecase, - GetTemplatesUsecase, - GetOneTemplateUseCase, - UpdateTemplateUsecase, - DeleteTemplateUseCase, - GetAllTemplatesUsecase, - GetTemplatesForDownloadUsecase, - // Contracts - CreateContractUsecase, - GetManyContractsUsecase, - CountPendingContractsUsecase, - GetOneContractUsecase, - SignContractByVolunteer, - SignAndConfirmContractUsecase, - SignAndConfirmContractUsecase, - RejectContractUsecase, - GetContractsForDownloadUsecase, - DeleteContractUsecase, - GetVolunteerContractHistoryUsecase, - GetVolunteerPendingContractsUsecase, - CancelContractUsecase, - GetVicStatisticsUsecase, - // Notifications - UpdateSettingsUsecase, - ], + providers: providers, + exports: providers, }) export class UseCaseModule {} diff --git a/backend/src/usecases/user/update-user-personal-data.usecase.ts b/backend/src/usecases/user/update-user-personal-data.usecase.ts index d1c528a58..944e0f835 100644 --- a/backend/src/usecases/user/update-user-personal-data.usecase.ts +++ b/backend/src/usecases/user/update-user-personal-data.usecase.ts @@ -45,9 +45,8 @@ export class UpdateUserPersonalDataUsecase // 4. check if the user has personal data if (!user.userPersonalData) { // 4.1 if not create new personal data entity for the user - userIdentityData = await this.userService.createUserPersonalData( - personalData, - ); + userIdentityData = + await this.userService.createUserPersonalData(personalData); // 4.2 save the data to the user await this.userService.updateRegularUser(id, { diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json index 0515424ac..68e609f44 100644 --- a/frontend/.prettierrc.json +++ b/frontend/.prettierrc.json @@ -2,6 +2,7 @@ "printWidth": 100, "singleQuote": true, "trailingComma": "all", + "tabWidth": 2, "semi": true, "bracketSpacing": true, "endOfLine": "auto", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c60588113..1c0c41e04 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "aws-amplify": "^6.4.0", "axios": "^1.7.2", "date-fns": "^3.6.0", + "dompurify": "^3.1.6", "i18next": "23.11.5", "lodash.debounce": "^4.0.8", "react": "18.3.1", @@ -26,10 +27,12 @@ "react-hook-form": "^7.52.1", "react-i18next": "14.1.2", "react-query": "^3.39.3", + "react-quill": "^2.0.0", "react-router-dom": "6.24.1", "react-select": "^5.8.0", "react-select-async-paginate": "0.7.4", "react-toastify": "^10.0.5", + "react-tooltip": "^5.28.0", "recharts": "^2.13.0-alpha.4", "use-query-params": "^2.2.1", "yup": "^1.4.0" @@ -37,6 +40,7 @@ "devDependencies": { "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.7", + "@types/dompurify": "^3.0.5", "@types/feather-icons": "^4.29.4", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20.14.10", @@ -4439,6 +4443,15 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", @@ -4507,6 +4520,14 @@ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, + "node_modules/@types/quill": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz", + "integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==", + "dependencies": { + "parchment": "^1.1.2" + } + }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", @@ -4550,6 +4571,12 @@ "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==", "peer": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, "node_modules/@types/uuid": { "version": "9.0.8", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", @@ -5390,7 +5417,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -5552,6 +5578,11 @@ "node": ">= 6" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -6064,6 +6095,25 @@ "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", "dev": true }, + "node_modules/deep-equal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", + "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", + "dependencies": { + "is-arguments": "^1.1.1", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "regexp.prototype.flags": "^1.5.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6094,7 +6144,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -6111,7 +6160,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", @@ -6210,6 +6258,11 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.6.tgz", + "integrity": "sha512-cTOAhc36AalkjtBpfG6O8JimdTMWNXjiePT2xQH/ppBGi/4uIpmj8eKyIkMJErXWARyINV/sB38yf8JCLF5pbQ==" + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -6335,7 +6388,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.4" }, @@ -6347,7 +6399,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -7132,6 +7183,11 @@ "node": ">=0.10.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/external-editor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", @@ -7152,6 +7208,11 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", + "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==" + }, "node_modules/fast-equals": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", @@ -7490,7 +7551,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7516,7 +7576,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dev": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2", @@ -7732,7 +7791,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -7780,7 +7838,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -7792,7 +7849,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -7804,7 +7860,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -7816,7 +7871,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "dependencies": { "has-symbols": "^1.0.3" }, @@ -8160,6 +8214,21 @@ "loose-envify": "^1.0.0" } }, + "node_modules/is-arguments": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", + "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -8281,7 +8350,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dev": true, "dependencies": { "has-tostringtag": "^1.0.0" }, @@ -8418,7 +8486,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dev": true, "dependencies": { "call-bind": "^1.0.2", "has-tostringtag": "^1.0.0" @@ -9183,11 +9250,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, "engines": { "node": ">= 0.4" } @@ -9489,6 +9570,11 @@ "tslib": "^2.0.3" } }, + "node_modules/parchment": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", + "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9891,6 +9977,45 @@ } ] }, + "node_modules/quill": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", + "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "dependencies": { + "clone": "^2.1.1", + "deep-equal": "^1.0.1", + "eventemitter3": "^2.0.3", + "extend": "^3.0.2", + "parchment": "^1.1.4", + "quill-delta": "^3.6.2" + } + }, + "node_modules/quill-delta": { + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", + "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "dependencies": { + "deep-equal": "^1.0.1", + "extend": "^3.0.2", + "fast-diff": "1.1.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/quill/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/quill/node_modules/eventemitter3": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", + "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -10053,6 +10178,20 @@ } } }, + "node_modules/react-quill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz", + "integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==", + "dependencies": { + "@types/quill": "^1.3.10", + "lodash": "^4.17.4", + "quill": "^1.3.7" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18", + "react-dom": "^16 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -10222,6 +10361,19 @@ "react-dom": ">=18" } }, + "node_modules/react-tooltip": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.0.tgz", + "integrity": "sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==", + "dependencies": { + "@floating-ui/dom": "^1.6.1", + "classnames": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -10337,7 +10489,6 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", - "dev": true, "dependencies": { "call-bind": "^1.0.6", "define-properties": "^1.2.1", @@ -10663,7 +10814,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", @@ -10680,7 +10830,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", diff --git a/frontend/package.json b/frontend/package.json index 4535b977c..718d2be72 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "aws-amplify": "^6.4.0", "axios": "^1.7.2", "date-fns": "^3.6.0", + "dompurify": "^3.1.6", "i18next": "23.11.5", "lodash.debounce": "^4.0.8", "react": "18.3.1", @@ -29,10 +30,12 @@ "react-hook-form": "^7.52.1", "react-i18next": "14.1.2", "react-query": "^3.39.3", + "react-quill": "^2.0.0", "react-router-dom": "6.24.1", "react-select": "^5.8.0", "react-select-async-paginate": "0.7.4", "react-toastify": "^10.0.5", + "react-tooltip": "^5.28.0", "recharts": "^2.13.0-alpha.4", "use-query-params": "^2.2.1", "yup": "^1.4.0" @@ -58,6 +61,7 @@ "devDependencies": { "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.7", + "@types/dompurify": "^3.0.5", "@types/feather-icons": "^4.29.4", "@types/lodash.debounce": "^4.0.9", "@types/node": "^20.14.10", diff --git a/frontend/src/assets/images/signature.svg b/frontend/src/assets/images/signature.svg new file mode 100644 index 000000000..57d46283e --- /dev/null +++ b/frontend/src/assets/images/signature.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/assets/locales/en/translation.json b/frontend/src/assets/locales/en/translation.json index 679c4ebcb..0e0343506 100644 --- a/frontend/src/assets/locales/en/translation.json +++ b/frontend/src/assets/locales/en/translation.json @@ -87,7 +87,10 @@ }, "confirm": "Confirm", "no_options": "No options", - "type_for_options": "Type for options..." + "type_for_options": "Type for options...", + "hide": "Show less", + "show": "Show more", + "loading": "Loading..." }, "header": { "login": "Login", @@ -285,6 +288,9 @@ "subtitle": "Data taken from ONGHub.", "logo": "Organization logo", "name": "Organization name", + "cui": "CUI", + "legal_representative_full_name": "Legal representative name", + "legal_representative_role": "Legal representative role", "description": "Organization description", "vic_description": "Description of the organization in VIC", "description_placeholder": "The organization description is displayed in the organization profile in the VIC application. You can choose to formulate a specific description for the VIC app, which will attract as many volunteers as possible, or you can use the organization description as it is in ONGHub.", @@ -971,5 +977,189 @@ "remove_from_list": "Remove from list", "confirm": "Confirm and sign" } + }, + "volunteering_contracts": { + "title": "Volunteering contracts", + "templates": "Contract templates", + "description": "View the list of all your volunteer contracts in VIC. The volunteer contract has a predefined structure. You can create multiple contract templates (e.g. contract for volunteers >16 years old, contract for volunteers <16 years old).", + "tabs": { + "contracts": "Contract list", + "templates": "Templates" + }, + "statistics": { + "active_contracts": "Active contracts", + "in_signing_contracts": "Contracts in signing process", + "saved_contracts": "Saved contracts (unsent)", + "to_expire_soon": "Contracts expiring soon" + }, + "table_header": { + "title": "Contract templates", + "download_all": "Download all", + "create_template": "Create template" + }, + "generate": { + "title": "Generate contract" + }, + "modal": { + "loading": { + "title": "Generating contracts...", + "description": "Successfully sent {{value1}}/{{value2}} contracts" + }, + "success": { + "title": "Contracts generated" + }, + "error": { + "title": "Contract Generation", + "description": "Close this window to see the contracts that could not be generated and require your attention" + } + } + }, + "doc_templates": { + "title": "Create template", + "subheading": { + "p1": "This template was created respecting the rules in the field.", + "p1_link": "Learn more", + "p2": "The contract model is not conform to the organization's needs?", + "p2_link": "Send your feedback" + }, + "table_header": { + "title": "Contract template", + "download_uncompleted": "Download uncompleted", + "save": "Save" + }, + "tooltip": "The contract data (Number, contract period, and establishment date) is automatically completed when the contract is assigned to one or more volunteers.", + "template_name": "Template name", + "organization": { + "data": "Organization data", + "description": "The data is automatically retrieved from the organization's profile in NGO Hub", + "view_profile": "View organization profile", + "edit": "Edit the organization's information for this contract template, or move on.", + "name": "Official organization name **", + "address": "Headquarters address **", + "cui": "CUI/CIF **", + "legal_representative": "Legal representative name **", + "legal_representative_role": "Role **", + "volunteer_data": { + "title": "Volunteer data", + "description": "The volunteer's data will be automatically retrieved from the VIC application when a contract is assigned." + }, + "legal_representative_data": { + "title": "Legal representative's data", + "description": "Volunteers under the age of 16 will need to complete the section containing the legal representative's data in the mobile application when signing the contract." + }, + "organization_data_form": { + "loading_error": "We encountered a problem while loading the organization data", + "synced": { + "is_syncing": "Updating...", + "p1": "Have you updated the data in NGO Hub? Press", + "p2": "here", + "p3": "to synchronise" + }, + "retry_button": "Retry", + "missing_name": "Organization name is missing", + "missing_address": "Organization address is missing", + "missing_cui": "Organization CUI is missing", + "missing_legalReprezentativeFullName": "Organization legal representative's name is missing", + "missing_legalReprezentativeRole": "Organization legal representative's role is missing" + } + }, + "volunteer": { + "volunteer": "Volunteer", + "name": "[Volunteer full name]", + "address": "[Volunteer home address]", + "cnp": "[CNP number]", + "series": "[Series]", + "no": "[Number]", + "institution": "[Issuing institution]", + "eliberation_date": "[Issuance date]", + "missing_data": "There is missing data for generating the contract for this volunteer." + }, + "template_preview": { + "title": "VOLUNTEERING CONTRACT", + "p1": { + "no": "No.", + "contract_no": "[Contract number]", + "date": "dated", + "contract_date": "[Contract date]" + }, + "p2": { + "between": "Between", + "address": "headquartered at", + "identified": "identified with CUI", + "represented_by": "represented by", + "as": "as", + "named": "hereinafter referred to as", + "organization": "Organization" + }, + "and": "and", + "p3": { + "lives": "residing at", + "cnp": "C.N.P", + "legitimate": "legitimated with ID series", + "no": "no.", + "by": "issued by", + "at_date": "on", + "named": "hereinafter referred to as", + "volunteer": "Volunteer," + }, + "p4": "it was agreed to conclude this contract under Law no. 78/2014 regarding the regulation of volunteering activity in Romania." + }, + "contract_data": "Contract data", + "contract_no": "Contract number", + "contract_date": "Contract date", + "contract_period": "Contract period", + "contract_duration": { + "title": "CONTRACT DURATION", + "description": "This contract is concluded for a fixed period, between", + "start": "[Start date]", + "end": "[End date]" + }, + "contract_terms": { + "title": "Contract terms", + "describe": "Describe the contract terms", + "add_text": "Add text", + "first_contract": "First contract? Read more about what's included in a", + "volunteering_contract": "volunteering contract", + "cancel": "Cancel", + "save": "Save changes" + }, + "text_editor": { + "placeholder": "Add contract terms...", + "title": "Edit text" + }, + "organization_name": "Organization name", + "organization_address": "Organization address", + "organization_cui": "CUI", + "represented_by": "Represented by", + "legal_representative": "Legal representative of the minor", + "identification": "ID series and number", + "series": "Series", + "tel_no": "Phone number", + "volunteer_name": "Volunteer name", + "legal_rep_name": "Legal representative's name", + "legal_rep_role": "Legal representative's role", + "number": "Number", + "telephone": "Phone", + "error": { + "title": "You cannot generate this contract yet" + } + }, + "stepper": { + "choose_template": "Choose template", + "choose_volunteers": "Choose volunteers", + "fill": "Fill", + "attachments": "Attachments", + "complete": "Complete" + }, + "fast_contract_fill": { + "title": "Quick Fill", + "description": "Pre-fill the data below with a single click! You can modify the data individually later.", + "form": { + "starting_number": "Consecutive contract numbers starting with", + "contract_date": "Contract date", + "contract_period": "Contract period" + }, + "clear": "Clear", + "apply_all": "Apply to all" } } \ No newline at end of file diff --git a/frontend/src/assets/locales/ro/translation.json b/frontend/src/assets/locales/ro/translation.json index 9dfe0f15f..0e3b49e46 100644 --- a/frontend/src/assets/locales/ro/translation.json +++ b/frontend/src/assets/locales/ro/translation.json @@ -87,7 +87,10 @@ }, "confirm": "Confirmă", "no_options": "Fără opțiuni", - "type_for_options": "Începeți să scrieți pentru opțiuni..." + "type_for_options": "Începeți să scrieți pentru opțiuni...", + "hide": "Vezi mai puțin", + "show": "Vezi tot", + "loading": "Se încarcă..." }, "header": { "login": "Login", @@ -285,6 +288,9 @@ "subtitle": "Datele sunt preluate din ONGHub.", "logo": "Logo organizație", "name": "Denumire organizație", + "cui": "CUI", + "legal_representative_full_name": "Nume reprezentant legal", + "legal_representative_role": "Rol reprezentant legal", "description": "Descriere organizație", "vic_description": "Descrierea organizației în VIC", "description_placeholder": "Descrierea organizației este afișată în profilul organizației din aplicația VIC. Poți alege să formulezi o descriere specifica pentru aplicația VIC, prin care să atragi cat mai multi voluntari, sau să folosești descrierea organizației asa cum se regăsește în ONGHub.", @@ -971,5 +977,203 @@ "remove_from_list": "Elimină din listă", "confirm": "Confirmă și semnează" } + }, + "volunteering_contracts": { + "title": "Contracte de voluntariat", + "templates": "Template-uri de contracte", + "description": "Vizualizează lista tuturor contractelor voluntarilor tăi din VIC. Contractul de voluntar are o structura prestabilită. Poți crea mai multe template-uri de contracte (ex. contract pentru voluntari >16 ani, contract pentru voluntari <16 ani).", + "tabs": { + "contracts": "Listă contracte", + "templates": "Template-uri" + }, + "statistics": { + "active_contracts": "Contracte active", + "in_signing_contracts": "Contracte în curs de semnare", + "saved_contracts": "Contracte salvate (netrimise)", + "to_expire_soon": "Contracte care expiră curând" + }, + "table_header": { + "title": "Template-uri de contracte", + "download_all": "Descarcă toate", + "create_template": "Creează template" + }, + "generate": { + "title": "Generează contract" + }, + "modal": { + "loading": { + "title": "Se generează contractele...", + "description": "S-au trimis cu succes {{value1}}/{{value2}} contracte" + }, + "success": { + "title": "Contracte generate" + }, + "error": { + "title": "Generare Contract", + "description": " Închide această fereastră pentru a vedea contractele care nu au putut fi generate și necesită atenție" + } + } + }, + "doc_templates": { + "title": "Creează template", + "subheading": { + "p1": "Acest template a fost realizat respectând normele din domeniu.", + "p1_link": "Află mai multe", + "p2": "Modelul de contract nu este conform nevoilor organizației tale?", + "p2_link": "Trimite feedback-ul tău" + }, + "table_header": { + "title": "Template contract", + "download_uncompleted": "Descarcă necompletat", + "save": "Salvează" + }, + "tooltip": "Datele contractului (Numărul, perioada contractului și data întemeierii) se completează în momentul atribuirii contractului către unul sau mai mulți voluntari.", + "template_name": "Numele template-ului", + "organization": { + "data": "Datele organizației", + "description": "Datele sunt preluate automat din profilul organizației în NGO Hub", + "view_profile": "Vezi profilul organizației", + "edit": "Editează informațiile organizației pentru acest template de contract, sau treci mai departe.", + "name": "Denumirea oficială a organizației **", + "address": "Sediul social **", + "cui": "CUI/CIF **", + "legal_representative": "Nume prenume reprezentant legal **", + "legal_representative_role": "În calitate de **", + "volunteer_data": { + "title": "Datele voluntarului", + "description": "Datele voluntarului vor fi preluate automat din aplicația VIC, în momentul atribuirii unui contract." + }, + "legal_representative_data": { + "title": "Datele reprezentantului legal", + "description": " Voluntarii sub 16 ani vor trebui să completeze sectiunea ce conține datele reprezentantului, din aplicația mobilă, în momentul semnării contractului." + }, + "organization_data_form": { + "loading_error": "Am întâmpinat o problemă la încărcarea datelor despre organizație", + "synced": { + "is_syncing": "Se actualizează...", + "p1": "Ai actualizat datele în NGO Hub? Apasă", + "p2": "aici", + "p3": "pentru sincronizare" + }, + "retry_button": "Reîncearcă", + "missing_name": "Numele organizației lipsește", + "missing_address": "Adresa organizației lipsește", + "missing_cui": "CUI-ul organizației lipsește", + "missing_legalReprezentativeFullName": "Numele reprezentantului legal al organizației lipsește", + "missing_legalReprezentativeRole": "Rolul reprezentantului legal al organizației lipsește" + } + }, + "volunteer": { + "volunteer": "Voluntar", + "name": "[Nume și prenume voluntar]", + "address": "[Adresă domiciliu voluntar]", + "cnp": "[Număr CNP]", + "series": "[Serie]", + "no": "[Număr]", + "institution": "[Unitate eliberare]", + "eliberation_date": "[Data eliberării]", + "missing_data": "Există date lipsă la generarea contractului pentru acest voluntar." + }, + "template_preview": { + "title": "CONTRACT DE VOLUNTARIAT", + "p1": { + "no": "Nr.", + "contract_no": "[Numar contract]", + "date": "din data de", + "contract_date": "[Data contract]" + }, + "p2": { + "between": "Între", + "address": "cu sediul în", + "identified": "identificată cu CUI", + "represented_by": "reprezentată prin", + "as": "în calitate de", + "named": "numită în continuare", + "organization": "Organizația" + }, + "and": "și", + "p3": { + "lives": "domiciliat(ă) în", + "cnp": "C.N.P", + "legitimate": "legitimat cu BI/CI seria", + "no": "nr.", + "by": "eliberat de", + "at_date": "la data de", + "named": "numit în continuare", + "volunteer": "Voluntar," + }, + "p4": "s-a convenit încheierea prezentului contract în baza Legii nr. 78/2014 privind reglementarea activității de voluntariat din România." + }, + "contract_data": "Datele contractului", + "contract_no": "Număr contract", + "contract_date": "Data contractlui", + "contract_period": "Perioadă contract", + "contract_duration": { + "title": "DURATA CONTRACTULUI", + "description": "Prezentul contract se încheie pe o perioadă determinată, între data de", + "start": "[Data început]", + "end": "[Data final]" + }, + "contract_card_form": { + "document_number": { + "required": "Numărul contractului este obligatoriu", + "unique": "Numărul contractului trebuie să fie unic", + "invalid": "Numărul contractului trebuie să fie un număr întreg pozitiv" + }, + "document_period": { + "required": "Perioada contractului este obligatorie", + "must_be_after": "Perioada contractului trebuie să înceapă după data contractului" + }, + "document_date": { + "required": "Data contractului este obligatorie" + } + }, + "contract_terms": { + "title": "Termenii contractului", + "describe": "Descrie termenii contractului", + "add_text": "Adaugă text", + "first_contract": "Primul contract? Citește mai multe despre ce conține un", + "volunteering_contract": "contract de voluntariat", + "cancel": "Anulează", + "save": "Salvează modificări" + }, + "text_editor": { + "placeholder": "Adaugă termenii contractului...", + "title": "Editează text" + }, + "organization_name": "Nume organizație", + "organization_address": "Sediu organizație", + "organization_cui": "CUI", + "represented_by": "Reprezentată de", + "legal_representative": "Reprezentantul legal al minorului", + "identification": "Serie si nr. CI", + "series": "Serie", + "tel_no": "Nr. Tel.", + "volunteer_name": "Nume voluntar", + "legal_rep_name": "Nume reprezentant legal", + "legal_rep_role": "Rolul reprezentantului legal", + "number": "Număr", + "telephone": "Telefon", + "error": { + "title": "Nu poți genera încă acest contract" + } + }, + "stepper": { + "choose_template": "Alege template", + "choose_volunteers": "Alege voluntari", + "fill": "Completează", + "attachments": "Anexe", + "complete": "Finalizează" + }, + "fast_contract_fill": { + "title": "Completare rapidă", + "description": "Precompletează datele de mai jos dintr-un singur click! Poți modifica datele individual ulterior.", + "form": { + "starting_number": "Numere contract consecutive începând cu", + "contract_date": "Data contractului", + "contract_period": "Perioadă contract" + }, + "clear": "Șterge", + "apply_all": "Aplică pentru toate" } } \ No newline at end of file diff --git a/frontend/src/common/constants/routes.ts b/frontend/src/common/constants/routes.ts index 08592e968..0c516b8ed 100644 --- a/frontend/src/common/constants/routes.ts +++ b/frontend/src/common/constants/routes.ts @@ -63,9 +63,24 @@ export const ROUTES: IRoute[] = [ childRoutes: [ { id: 61, - name: i18n.t('general:contracts'), + name: i18n.t('general:contracts') + ' - OLD', href: 'documents/contracts', }, + { + id: 62, + name: 'Contracte', + href: 'documents/templates', + }, + { + id: 63, + name: 'Creează template', + href: 'documents/templates/create', + }, + { + id: 64, + name: 'Generează contract', + href: 'documents/templates/contracts/generate', + }, ], }, { diff --git a/frontend/src/common/interfaces/document-contract.interface.ts b/frontend/src/common/interfaces/document-contract.interface.ts new file mode 100644 index 000000000..dd41497d0 --- /dev/null +++ b/frontend/src/common/interfaces/document-contract.interface.ts @@ -0,0 +1,9 @@ +export interface IAddDocumentContractDTO { + documentNumber: string; + documentDate: Date; + documentStartDate: Date; + documentEndDate: Date; + volunteerId: string; + documentTemplateId: string; + status: 'CREATED'; +} diff --git a/frontend/src/common/interfaces/organization.interface.ts b/frontend/src/common/interfaces/organization.interface.ts index 72e4d4979..e4f24e8d5 100644 --- a/frontend/src/common/interfaces/organization.interface.ts +++ b/frontend/src/common/interfaces/organization.interface.ts @@ -7,4 +7,7 @@ export interface IOrganization { activityArea: string; logo: string; description: string; + cui: string; + legalReprezentativeFullName: string; + legalReprezentativeRole: string; } diff --git a/frontend/src/common/interfaces/template.interface.ts b/frontend/src/common/interfaces/template.interface.ts index b00cfde6f..adc2f6f8e 100644 --- a/frontend/src/common/interfaces/template.interface.ts +++ b/frontend/src/common/interfaces/template.interface.ts @@ -1,5 +1,34 @@ +import { IOrganization } from './organization.interface'; +import { IUser } from './user.interface'; + export interface ITemplate { id: string; name: string; path: string; } + +export interface IDocumentTemplateListItem { + id: string; + name: string; + usageCount: string; + lastUsage: string; + createdById: string; + createdByName: string; + createdOn: string; +} + +export interface IOrganizationData { + officialName: string; + registeredOffice: string; + CUI: string; + legalRepresentativeName: string; + legalRepresentativeRole: string; +} + +export interface IDocumentTemplate { + id: string; + name: string; + documentTerms: string; // HTML + createdByAdmin: Pick; + organizationData: IOrganizationData; +} diff --git a/frontend/src/common/interfaces/user.interface.ts b/frontend/src/common/interfaces/user.interface.ts index 88491f028..9ab599e0a 100644 --- a/frontend/src/common/interfaces/user.interface.ts +++ b/frontend/src/common/interfaces/user.interface.ts @@ -1,6 +1,28 @@ import { Sex } from '../enums/sex.enum'; import { ICity } from './city.interface'; +export interface IUserPersonalDataModel { + id: string; + cnp: string; + address: string; + identityDocumentSeries: string; + identityDocumentNumber: string; + identityDocumentIssueDate: Date; + identityDocumentExpirationDate: Date; + identityDocumentIssuedBy: string; + legalGuardian?: LegalGuardianIdentityData; +} + +export interface LegalGuardianIdentityData { + name: string; + cnp: string; + address: string; + identityDocumentSeries: string; + identityDocumentNumber: string; + email: string; + phone: string; +} + export interface IUser { id: string; name: string; @@ -12,4 +34,6 @@ export interface IUser { sex: Sex; createdOn: Date; updatedOn: Date; + + userPersonalData?: IUserPersonalDataModel; } diff --git a/frontend/src/common/utils/volunteer-data.util.ts b/frontend/src/common/utils/volunteer-data.util.ts new file mode 100644 index 000000000..5d91ca5db --- /dev/null +++ b/frontend/src/common/utils/volunteer-data.util.ts @@ -0,0 +1,102 @@ +import { differenceInYears } from 'date-fns'; +import { IVolunteer } from '../interfaces/volunteer.interface'; + +export interface VolunteerDataCheck { + isIncomplete: boolean; + missingInfo: string; +} + +const fieldTranslations: { [key: string]: string } = { + guardian: 'Tutore legal', + cnp: 'CNP', + identityDocumentSeries: 'Serie buletin', + identityDocumentNumber: 'Număr buletin', + address: 'Adresă', + email: 'Email', + phone: 'Telefon', + name: 'Nume', + identityDocumentIssueDate: 'Data emiterii buletinului', + identityDocumentExpirationDate: 'Data expirării buletinului', + identityDocumentIssuedBy: 'Emitentul documentului', +}; + +export const checkIsVolunteerDataIncomplete = (volunteer: IVolunteer): VolunteerDataCheck => { + const missingVolunteerFields: string[] = []; + const missingGuardianFields: string[] = []; + + if (volunteer?.user?.age < 18) { + if (!volunteer?.user?.userPersonalData?.legalGuardian) { + missingGuardianFields.push('Necompletat'); + } else { + const legalGuardian = volunteer.user.userPersonalData.legalGuardian; + const guardianFields = [ + 'cnp', + 'identityDocumentSeries', + 'identityDocumentNumber', + 'address', + 'email', + 'phone', + 'name', + ]; + guardianFields.forEach((field) => { + if (!legalGuardian[field as keyof typeof legalGuardian]) { + missingGuardianFields.push(`${fieldTranslations[field]}`); + } + }); + } + } + + const personalDataFields = [ + 'identityDocumentSeries', + 'identityDocumentNumber', + 'identityDocumentIssueDate', + 'identityDocumentExpirationDate', + 'identityDocumentIssuedBy', + 'cnp', + 'address', + ]; + + personalDataFields.forEach((field) => { + if ( + !volunteer?.user?.userPersonalData?.[field as keyof typeof volunteer.user.userPersonalData] + ) { + missingVolunteerFields.push(fieldTranslations[field]); + } + }); + + const volunteerInfo = + missingVolunteerFields.length > 0 + ? `Informatii necompletate Voluntar: ${missingVolunteerFields.join(', ')}` + : ''; + const guardianInfo = + missingGuardianFields.length > 0 + ? `Informatii necompletate Tutore: ${missingGuardianFields.join(', ')}` + : ''; + + const missingInfo = [volunteerInfo, guardianInfo].filter(Boolean).join('\n\n'); + + return { + isIncomplete: missingVolunteerFields.length > 0 || missingGuardianFields.length > 0, + missingInfo, + }; +}; + +export const isOver16FromCNP = (cnp: string) => { + // we don't need to perform the calculation before the user has entered all the necessary digits to calculate + if (cnp.length < 7) { + return true; + } + + // CNP example: 2980825... -> 1998-08-25 + // 6000825... -> 2000-08-25 + + // if first digit is above 5, then the birth year is 2000+ + const yearPrefix = parseInt(cnp[0], 10) < 5 ? '19' : '20'; + const year = (yearPrefix + cnp.substring(1, 3)).toString(); + const month = cnp.substring(3, 5); + const day = cnp.substring(5, 7); + const birthday = new Date(`${year}-${month}-${day}`); + + const age = differenceInYears(new Date(), birthday); + return age >= 16; +}; diff --git a/frontend/src/components/AccessRequestTable.tsx b/frontend/src/components/AccessRequestTable.tsx index 729f1e880..2b1c30a5a 100644 --- a/frontend/src/components/AccessRequestTable.tsx +++ b/frontend/src/components/AccessRequestTable.tsx @@ -352,21 +352,24 @@ const AccessRequestTable = ({ ); }; - const onCreatedOnRangeChange = ([createdOnStart, createdOnEnd]: Date[]) => { + const onCreatedOnRangeChange = ([createdOnStart, createdOnEnd]: [Date | null, Date | null]) => { setQuery( { - createdOnStart, - createdOnEnd, + createdOnStart: createdOnStart ?? undefined, + createdOnEnd: createdOnEnd ?? undefined, }, 'replaceIn', ); }; - const onRejectedOnRangeChange = ([rejectedOnStart, rejectedOnEnd]: Date[]) => { + const onRejectedOnRangeChange = ([rejectedOnStart, rejectedOnEnd]: [ + Date | null, + Date | null, + ]) => { setQuery( { - rejectedOnStart, - rejectedOnEnd, + rejectedOnStart: rejectedOnStart ?? undefined, + rejectedOnEnd: rejectedOnEnd ?? undefined, }, 'replaceIn', ); diff --git a/frontend/src/components/ActionsArchiveTable.tsx b/frontend/src/components/ActionsArchiveTable.tsx index 424da50ca..b80a83d3b 100644 --- a/frontend/src/components/ActionsArchiveTable.tsx +++ b/frontend/src/components/ActionsArchiveTable.tsx @@ -117,10 +117,13 @@ const ActionsArchiveTable = ({ query, setQuery, volunteerId }: ActionsArchiveTab }); }; - const onActionDateRangeChange = ([actionStartDate, actionEndDate]: Date[]) => { + const onActionDateRangeChange = ([actionStartDate, actionEndDate]: [ + Date | null, + Date | null, + ]) => { setQuery({ - actionStartDate, - actionEndDate, + actionStartDate: actionStartDate ?? undefined, + actionEndDate: actionEndDate ?? undefined, }); }; diff --git a/frontend/src/components/ActivityLogTable.tsx b/frontend/src/components/ActivityLogTable.tsx index d066eadaa..a93ab1f82 100644 --- a/frontend/src/components/ActivityLogTable.tsx +++ b/frontend/src/components/ActivityLogTable.tsx @@ -375,17 +375,23 @@ const ActivityLogTable = ({ setQuery({ status: status?.key }); }; - const onExecutionOnRangeChange = ([executionDateStart, executionDateEnd]: Date[]) => { + const onExecutionOnRangeChange = ([executionDateStart, executionDateEnd]: [ + Date | null, + Date | null, + ]) => { setQuery({ - executionDateStart, - executionDateEnd, + executionDateStart: executionDateStart ?? undefined, + executionDateEnd: executionDateEnd ?? undefined, }); }; - const onRegistrationOnRangeChange = ([registrationDateStart, registrationDateEnd]: Date[]) => { + const onRegistrationOnRangeChange = ([registrationDateStart, registrationDateEnd]: [ + Date | null, + Date | null, + ]) => { setQuery({ - registrationDateStart, - registrationDateEnd, + registrationDateStart: registrationDateStart ?? undefined, + registrationDateEnd: registrationDateEnd ?? undefined, }); }; diff --git a/frontend/src/components/AutoFillContractCard.tsx b/frontend/src/components/AutoFillContractCard.tsx new file mode 100644 index 000000000..8960ad61d --- /dev/null +++ b/frontend/src/components/AutoFillContractCard.tsx @@ -0,0 +1,93 @@ +import React, { useEffect } from 'react'; +import { Controller, FieldValues, useForm } from 'react-hook-form'; +import FormInput from './FormInput'; +import FormDatePicker from './FormDatePicker'; +import DateRangePicker from './DateRangePicker'; +import { useTranslation } from 'react-i18next'; +import Button from './Button'; + +interface AutoFillContractCardProps { + onSubmit: ({ startingNumber, contractDate, contractPeriod }: FieldValues) => void; +} + +export const AutoFillContractCard = ({ + onSubmit, +}: AutoFillContractCardProps) => { + const { control, reset, handleSubmit, watch, setValue } = useForm(); + + const handleReset = () => { + reset({}); + }; + const { t } = useTranslation('fast_contract_fill'); + + const contractDate = watch('contractDate'); + + useEffect(() => { + setValue('contractPeriod', [null, null]); + }, [contractDate]); + + return ( + <> +
+

{t('title')}

+

{t('description')}

+
+ ( + + )} + /> + ( + + )} + /> + + ( + + )} + /> +
+
+
+
+ + ); +}; diff --git a/frontend/src/components/ContentExpander.tsx b/frontend/src/components/ContentExpander.tsx new file mode 100644 index 000000000..f8e4a20d7 --- /dev/null +++ b/frontend/src/components/ContentExpander.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Button from './Button'; +import DOMPurify from 'dompurify'; +import { useTranslation } from 'react-i18next'; + +interface ContentExpanderProps { + fullContent: string; + maxHeight?: number; +} + +/** + * This component renders expandable content with a "Show More/Less" functionality. + * + * @param {string} fullContent - The full HTML content to be displayed/expanded + * @param {number} maxHeight - The maximum height (in pixels) before content is truncated + * + * + * Features: + * - Automatically detects if content needs expansion (height > 144px by default) + * - Truncates content with a gradient overlay when collapsed + * - Toggles between expanded and collapsed states + * - Sanitizes HTML content for safe rendering + * + * @returns {JSX.Element} Rendered ContentExpander component + */ + +export const ContentExpander = ({ fullContent, maxHeight = 144 }: ContentExpanderProps) => { + const { t } = useTranslation('general'); + const [isExpanded, setIsExpanded] = useState(false); + const [needsExpansion, setNeedsExpansion] = useState(false); + const contentRef = useRef(null); + + // see if the content inside has a bigger height than 144px + // we use this to decide if we should display the see more/less button + useEffect(() => { + if (contentRef.current) { + setNeedsExpansion(contentRef.current.scrollHeight > maxHeight); // h-36 = 36 * 4 = 144px + } + }, [fullContent]); + + const toggleExpand = () => { + setIsExpanded(!isExpanded); + }; + + return ( +
+
+
+ + {/* fade to white overlay*/} + {needsExpansion && !isExpanded && ( +
+ )} +
+ {needsExpansion && ( + + )} +
+ ); +}; diff --git a/frontend/src/components/ContractCard.tsx b/frontend/src/components/ContractCard.tsx new file mode 100644 index 000000000..790fb2cc0 --- /dev/null +++ b/frontend/src/components/ContractCard.tsx @@ -0,0 +1,351 @@ +import React, { useEffect, useState } from 'react'; +import FormInput from './FormInput'; +import Button from './Button'; +import { Controller, FieldValues, useForm } from 'react-hook-form'; +import FormDatePicker from './FormDatePicker'; +import DateRangePicker from './DateRangePicker'; +import { ContractCardHeader } from './ContractCardHeader'; +import { useTranslation } from 'react-i18next'; +import { Signatures } from './Signatures'; +import { ContentExpander } from './ContentExpander'; +import { IVolunteer } from '../common/interfaces/volunteer.interface'; +import { IDocumentTemplate } from '../common/interfaces/template.interface'; +import { format } from 'date-fns'; +import * as yup from 'yup'; +import i18n from '../common/config/i18n'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { IDocumentVolunteerData } from '../pages/GenerateContract'; +import { XCircleIcon } from '@heroicons/react/24/solid'; +import { AxiosError } from 'axios'; + +const dotsString = '.........................'; + +interface ContractCardProps { + volunteer: IVolunteer; + template: IDocumentTemplate; + initialNumber?: number; + initialDate?: Date | undefined; + initialPeriod?: [Date | undefined, Date | undefined]; + isOpen?: boolean; + onDelete: (id: string) => void; + saveVolunteerData: (voluneerId: string, volunteerData: IDocumentVolunteerData) => void; + volunteersData: Record | undefined; + error?: AxiosError; +} + +export const fillCardValidationSchema = yup.object({ + documentNumber: yup + .number() + .positive(`${i18n.t('doc_templates:contract_card_form.document_number.invalid')}`) + .typeError(`${i18n.t('doc_templates:contract_card_form.document_number.invalid')}`) + .required(`${i18n.t('doc_templates:contract_card_form.document_number:required')}`), + documentDate: yup + .date() + .required(`${i18n.t('doc_templates:contract_card_form.document_date.required')}`), + documentPeriod: yup + .array() + .of( + yup.date().required(`${i18n.t('doc_templates:contract_card_form.document_period.required')}`), + ) + .required(`${i18n.t('doc_templates:contract_card_form.document_period.required')}`), +}); + +export const ContractCard = ({ + volunteer, + template, + initialNumber, + initialDate, + initialPeriod, + onDelete, + saveVolunteerData, + volunteersData, + isOpen = false, + error, +}: ContractCardProps) => { + const { t } = useTranslation(['doc_templates', 'general']); + // contract card states + const [open, setOpen] = useState(isOpen); + const [edit, setEdit] = useState(false); + const isVolunteerDataIncomplete: boolean = !(volunteersData && volunteersData[volunteer.id]); + + const { + control, + handleSubmit, + setValue, + watch, + formState: { errors }, + setError, + } = useForm({ + resolver: yupResolver(fillCardValidationSchema), + defaultValues: { + documentNumber: initialNumber || undefined, + documentDate: initialDate || undefined, + documentPeriod: initialPeriod || [undefined, undefined], + }, + }); + + const documentDateValue = watch('documentDate'); + + // update values for the contract data, as well as for the contract preview, whenever the initial values coming from the parent change (the fast contract completion feature) + useEffect(() => { + // update contract number + setValue('documentNumber', initialNumber as number); + + // update contract date + setValue('documentDate', initialDate as Date); + + // update contract period + setValue( + 'documentPeriod', + initialPeriod && initialPeriod[0] && initialPeriod[1] + ? [initialPeriod[0] as Date, initialPeriod[1] as Date] + : [undefined as unknown as Date, undefined as unknown as Date], + ); + }, [initialNumber, initialDate, initialPeriod]); + + const onSubmit = (data: FieldValues) => { + if ( + data.documentPeriod && + data.documentPeriod[0] && + data.documentPeriod[1] && + (data.documentPeriod[0] < data.documentDate || data.documentPeriod[1] < data.documentDate) + ) { + setError('documentPeriod', { + type: 'manual', + message: t('doc_templates:contract_card_form.document_period.must_be_after'), + }); + return; + } + + if (volunteersData) { + const existingNumbers = Object.entries(volunteersData) + .filter(([key, v]: [string, IDocumentVolunteerData]) => { + return ( + v.documentDate.getFullYear() === data.documentDate.getFullYear() && key !== volunteer.id + ); + }) + .map(([, v]: [string, IDocumentVolunteerData]) => v.documentNumber); + + if (existingNumbers.includes(data.documentNumber)) { + setError('documentNumber', { + type: 'manual', + message: t('doc_templates:contract_card_form.document_number.unique'), + }); + return; + } + } + + saveVolunteerData(volunteer.id, { + documentNumber: data.documentNumber, + documentDate: data.documentDate, + documentPeriod: data.documentPeriod, + }); + + setEdit(false); + }; + + const onCancel = () => { + // Reset Errors + setError('documentNumber', {}); + setError('documentDate', {}); + setError('documentPeriod', {}); + + // Reintilize with initial values Document Number + setValue('documentNumber', initialNumber as number); + + // Reintilize with initial values Document Date + setValue('documentDate', initialDate as Date); + + // Reintilize with initial values Document Period + setValue('documentPeriod', initialPeriod as [Date, Date]); + + setEdit(false); + }; + + return ( +
+ + + {open && ( +
+ {/* datele contractului */} +
+ {!!error && ( +
+ +
+

{t('error.title')}

+
    +
  • +

    {error.message}

    +
  • +
+
+
+ )} +
+

{t('contract_data')}

+ ( + + )} + /> + ( + + )} + /> + + ( + + )} + /> + +
+ {edit && ( +
+
+
+ + {/* contract preview */} +
+

{t('template_preview.title')}

+

+ {t('template_preview.p1.no')} {initialNumber} {t('template_preview.p1.date')}{' '} + {initialDate ? format(initialDate, 'dd.MM.yyyy') : dotsString} +

+ +

+ {t('template_preview.p2.between')}{' '} + + {template?.organizationData?.officialName || `[${t('organization_name')}]`} + {' '} + {t('template_preview.p2.address')}{' '} + {template?.organizationData?.registeredOffice || `[${t('organization_address')}]`}{' '} + {t('template_preview.p2.identified')} + + {' '} + {template?.organizationData?.CUI || `[${t('organization_cui')}]`} + + {', '} + {t('template_preview.p2.represented_by')}{' '} + + {' '} + {template?.organizationData?.legalRepresentativeName || `[${t('legal_rep_name')}]`} + + {', '} + {t('template_preview.p2.as')}{' '} + {template?.organizationData?.legalRepresentativeRole || `[${t('legal_rep_role')}]`}{' '} + {t('template_preview.p2.named')}{' '} + {t('template_preview.p2.organization')}{' '} +

+ +

{t('template_preview.and')}

+ +

+ {volunteer.user.name},{' '} + {t('template_preview.p3.lives')} {volunteer.user.userPersonalData?.address},{' '} + {t('template_preview.p3.cnp')}{' '} + {volunteer.user.userPersonalData?.cnp},{' '} + {t('template_preview.p3.legitimate')}{' '} + {volunteer.user.userPersonalData?.identityDocumentSeries}{' '} + {t('template_preview.p3.no')}{' '} + {volunteer.user.userPersonalData?.identityDocumentNumber},{' '} + {t('template_preview.p3.by')}{' '} + {volunteer.user.userPersonalData?.identityDocumentIssuedBy},{' '} + {t('template_preview.p3.at_date')}{' '} + {volunteer.user.userPersonalData?.identityDocumentExpirationDate && + format( + volunteer.user.userPersonalData?.identityDocumentExpirationDate, + 'dd/MM/yyyy', + )} + ,{', '} + {t('template_preview.p3.named')}{' '} + {t('template_preview.p3.volunteer')}{' '} +

+ +

{t('template_preview.p4')}

+ + {/* P5: DURATA CONTRACTULUI */} +

{t('contract_duration.title')}

+

+ {t('contract_duration.description')}{' '} + {initialPeriod && initialPeriod[0] + ? format(initialPeriod[0], 'dd.MM.yyyy') + : dotsString}{' '} + {t('template_preview.and')}{' '} + {initialPeriod && initialPeriod[1] + ? format(initialPeriod[1], 'dd.MM.yyyy') + : dotsString} + . +

+ +
+

{t('contract_terms.title')}

+ + {template.documentTerms && } +
+ +
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/ContractCardHeader.tsx b/frontend/src/components/ContractCardHeader.tsx new file mode 100644 index 000000000..e87b3f33c --- /dev/null +++ b/frontend/src/components/ContractCardHeader.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { TrashIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid'; +import LoadingContent from './LoadingContent'; +import { IVolunteer } from '../common/interfaces/volunteer.interface'; +import { Pill } from './Pill'; + +interface ContractCardHeaderProps { + open: boolean; + setOpen: React.Dispatch>; + volunteer: IVolunteer; + onDelete: (id: string) => void; + isLoading?: boolean; + isVolunteerDataIncomplete?: boolean; + error?: unknown; +} + +export const ContractCardHeader = ({ + open, + setOpen, + volunteer, + onDelete, + isLoading, + isVolunteerDataIncomplete, + error, +}: ContractCardHeaderProps) => { + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
onDelete(volunteer.id)} + > + +
+ +
setOpen(!open)} + > +
+ +
+ {volunteer.user.name} +
+ {isVolunteerDataIncomplete && } + {!!error && } + + {open ? ( + + ) : ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/components/ContractsStatistics.tsx b/frontend/src/components/ContractsStatistics.tsx new file mode 100644 index 000000000..7e930e2ac --- /dev/null +++ b/frontend/src/components/ContractsStatistics.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import StatisticsCard from './StatisticsCard'; +import { useTranslation } from 'react-i18next'; + +export const ContractsStatistics = () => { + const { t } = useTranslation('volunteering_contracts'); + + return ( +
+ {}, + }} + /> + {}, + }} + /> + {}, + }} + /> + {}, + }} + /> +
+ ); +}; diff --git a/frontend/src/components/DataTableComponent.tsx b/frontend/src/components/DataTableComponent.tsx index 6d2cde4d2..336552d4a 100644 --- a/frontend/src/components/DataTableComponent.tsx +++ b/frontend/src/components/DataTableComponent.tsx @@ -18,9 +18,15 @@ interface DataTableProps { paginationDefaultPage?: number; onChangePage?: (page: number) => void; onChangeRowsPerPage?: (rowsPerPage: number) => void; + selectableRows?: boolean; + selectableRowsSingle?: boolean; + onSelectedRowsChange?: (selectedRows: T[]) => void; + selectableRowSelected?: (row: T) => boolean; + selectableRowDisabled?: (row: T) => boolean; onSort?: (selectedColumn: TableColumn, sortDirection: SortOrder, sortedRows: T[]) => void; defaultSortFieldId?: string | number; defaultSortAsc?: boolean; + } const DataTableComponent = ({ @@ -35,6 +41,11 @@ const DataTableComponent = ({ onSort, onChangePage, onChangeRowsPerPage, + selectableRows, + selectableRowsSingle, + onSelectedRowsChange, + selectableRowSelected, + selectableRowDisabled, defaultSortFieldId, defaultSortAsc, }: DataTableProps) => { @@ -68,6 +79,13 @@ const DataTableComponent = ({ progressComponent={} defaultSortFieldId={defaultSortFieldId} defaultSortAsc={defaultSortAsc} + selectableRows={selectableRows} + selectableRowsSingle={selectableRowsSingle} + selectableRowSelected={selectableRowSelected} + selectableRowDisabled={selectableRowDisabled} + onSelectedRowsChange={(selected) => + onSelectedRowsChange && onSelectedRowsChange(selected.selectedRows) + } /> ); }; diff --git a/frontend/src/components/DateRangePicker.tsx b/frontend/src/components/DateRangePicker.tsx index caa9046e0..39ce8a56c 100644 --- a/frontend/src/components/DateRangePicker.tsx +++ b/frontend/src/components/DateRangePicker.tsx @@ -8,35 +8,46 @@ import i18n from '../common/config/i18n'; interface DateRangePickerProps { id?: string; label: string; - value?: Date[]; - onChange?: (range: Date[]) => void; + value?: [Date | null, Date | null]; + minDate?: Date | undefined; + onChange?: (range: [Date | null, Date | null]) => void; + disabled?: boolean; + className?: string; + errorMessage?: string; } -const DateRangePicker = ({ label, value, onChange, id }: DateRangePickerProps) => { - const [dateRange, setDateRange] = useState([]); - const [startDate, endDate] = dateRange; +const DateRangePicker = ({ + label, + value, + minDate, + onChange, + id, + disabled, + className, + errorMessage, +}: DateRangePickerProps) => { + const [dateRange, setDateRange] = useState<[Date | null, Date | null]>(value || [null, null]); useEffect(() => { - if (value) { + // Update internal state when value prop changes, including when it's reset to undefined + if (value === undefined) { + setDateRange([null, null]); + } else if (value && (value[0] !== dateRange[0] || value[1] !== dateRange[1])) { setDateRange(value); - } else [setDateRange([])]; - }, [value]); - - useEffect(() => { - if (dateRange[0] && dateRange[1]) { - onChange && onChange(dateRange); } - }, [dateRange]); + }, [value]); - const onChangeDate = (update: Date[] | unknown) => { - setDateRange(update as Date[]); + const onChangeDate = (update: [Date | null, Date | null] | unknown) => { + const newDateRange = update as [Date | null, Date | null]; + setDateRange(newDateRange); + onChange && onChange(newDateRange); }; return ( -
+
{label && } -
+
+

{errorMessage}

); }; diff --git a/frontend/src/components/DocumentContractFillCards.tsx b/frontend/src/components/DocumentContractFillCards.tsx new file mode 100644 index 000000000..03f0e6044 --- /dev/null +++ b/frontend/src/components/DocumentContractFillCards.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useState } from 'react'; +import { ContractCard } from './ContractCard'; +import { AutoFillContractCard } from './AutoFillContractCard'; +import { + IDocumentTemplate, + IDocumentTemplateListItem, +} from '../common/interfaces/template.interface'; +import { IVolunteer } from '../common/interfaces/volunteer.interface'; +import { useDocumentTemplateByIdQuery } from '../services/documents-templates/documents-templates.service'; +import LoadingContent from './LoadingContent'; +import ConfirmationModal from './ConfirmationModal'; +import { IDocumentVolunteerData } from '../pages/GenerateContract'; +import { FieldValues } from 'react-hook-form'; +import { AxiosError } from 'axios'; + +interface DocumentContractFillCardsProps { + volunteers: IVolunteer[]; + template: IDocumentTemplateListItem; + setSelectedVolunteers: (volunteers: IVolunteer[]) => void; + setVolunteerData: (volunteerData: Record) => void; + volunteersData: Record | undefined; + contractsWithErrors?: Record; +} + +export const DocumentContractFillCards = ({ + volunteers, + template, + setSelectedVolunteers, + setVolunteerData, + volunteersData, + contractsWithErrors, +}: DocumentContractFillCardsProps) => { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [volunteerToDelete, setVolunteerToDelete] = useState(null); + + const { data: templateData, isLoading: isLoadingTemplate } = useDocumentTemplateByIdQuery( + template.id, + ); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + const onSubmitFillForm = ({ startingNumber, contractDate, contractPeriod }: FieldValues) => { + volunteers.forEach((volunteer, index) => { + const volunteerData: IDocumentVolunteerData = { + documentNumber: startingNumber ? +startingNumber + index : 0, + documentDate: contractDate ? contractDate : undefined, + documentPeriod: contractPeriod ? contractPeriod : undefined, + }; + setVolunteerData({ [volunteer.id]: volunteerData }); + }); + }; + + if (isLoadingTemplate) { + return ; + } + + const onDelete = (id: string) => { + setVolunteerToDelete(id); + setIsDeleteModalOpen(true); + }; + + const handleConfirmDelete = () => { + if (volunteerToDelete) { + setSelectedVolunteers(volunteers.filter((volunteer) => volunteer.id !== volunteerToDelete)); + setIsDeleteModalOpen(false); + setVolunteerToDelete(null); + } + }; + + const handleCancelDelete = () => { + setIsDeleteModalOpen(false); + setVolunteerToDelete(null); + }; + + const handleAddVolunteerData = (volunteerId: string, volunteerData: IDocumentVolunteerData) => { + setVolunteerData({ [volunteerId]: volunteerData }); + }; + + return ( + <> + + +
+ {volunteers.map((item, index) => ( + + ))} +
+ + {isDeleteModalOpen && ( + + )} + + ); +}; diff --git a/frontend/src/components/DocumentTemplateTable.tsx b/frontend/src/components/DocumentTemplateTable.tsx new file mode 100644 index 000000000..8f68b3ecc --- /dev/null +++ b/frontend/src/components/DocumentTemplateTable.tsx @@ -0,0 +1,154 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import CardBody from './CardBody'; +import DataTableComponent from './DataTableComponent'; +import Card from '../layouts/CardLayout'; +import CardHeader from './CardHeader'; +import { IDocumentTemplateListItem } from '../common/interfaces/template.interface'; +import { useDocumentTemplatesQuery } from '../services/documents-templates/documents-templates.service'; +import { OrderDirection } from '../common/enums/order-direction.enum'; +import { format } from 'date-fns'; +import { DocumentTemplatesProps } from '../containers/query/DocumentTemplatesTableWithQueryParams'; +import { SortOrder, TableColumn } from 'react-data-table-component'; + +const DocumentTemplatesTableHeader = [ + { + id: 'name', + name: 'Nume', + sortable: true, + grow: 4, + minWidth: '9rem', + selector: (row: IDocumentTemplateListItem) => row.name, + }, + { + id: 'uses', + name: 'Utilizări', + sortable: true, + grow: 1, + minWidth: '5rem', + selector: (row: IDocumentTemplateListItem) => row.usageCount, + }, + { + id: 'last_used', + name: 'Ultima utilizare', + sortable: true, + grow: 1, + minWidth: '5rem', + selector: (row: IDocumentTemplateListItem) => row.lastUsage ? format(row.lastUsage, 'dd/MM/yyyy') : '-', + }, + { + id: 'created_by', + name: 'Creat de', + sortable: true, + grow: 1, + minWidth: '5rem', + selector: (row: IDocumentTemplateListItem) => row.createdByName, + }, + { + id: 'created_at', + name: 'Data creării', + sortable: true, + grow: 1, + minWidth: '5rem', + selector: (row: IDocumentTemplateListItem) => format(row.createdOn, 'dd/MM/yyyy'), + }, +]; + + +export const DocumentTemplateTable = ({ query, setQuery, selectedTemplate, onSelectTemplate }: DocumentTemplatesProps) => { + const { t } = useTranslation(['volunteering_contracts', 'stepper']); + const firstRender = useRef(true); + + useEffect(() => { firstRender.current = false }, []); + + const { data: templates, isLoading: isLoadingDocumentTemplates } = useDocumentTemplatesQuery({ + limit: 10, + page: 1, + orderBy: 'name', + orderDirection: OrderDirection.ASC, + }); + + const handleOnSelectTemplate = (templates: IDocumentTemplateListItem[]) => { + if (templates.length === 0) { + onSelectTemplate(templates[0]); + } else if (templates[0].id !== selectedTemplate?.id) { + onSelectTemplate(templates[0]); + } + }; + + const defaultSelectedRows = useCallback((row: IDocumentTemplateListItem) => { + return row.id === selectedTemplate?.id; + }, [selectedTemplate]); + + // We're doing this because of a bug in DataTableComponent + // https://github.com/jbetancur/react-data-table-component/issues/930 + // https://github.com/jbetancur/react-data-table-component/issues/955 + + // While some fixes exist we should be able to also unselect the row, thus removing props after the first render is the only way that works. + const selectProps = { + ...(firstRender.current && { + selectableRowSelected: defaultSelectedRows + }), + ...(!firstRender.current && { + onSelectedRowsChange: handleOnSelectTemplate, + }), + }; + + // pagination + const onRowsPerPageChange = (limit: number) => { + setQuery( + { + limit, + page: 1, + }, + 'replaceIn', + ); + }; + + const onChangePage = (page: number) => { + setQuery( + { + page, + }, + 'replaceIn', + ); + }; + + const onSort = (column: TableColumn, direction: SortOrder) => { + setQuery( + { + orderBy: column.id as string, + orderDirection: + direction.toLocaleUpperCase() === OrderDirection.ASC + ? OrderDirection.ASC + : OrderDirection.DESC, + }, + 'replaceIn', + ); + }; + + return ( + + +

{t('templates')}

+
+ + + +
+ ); +}; diff --git a/frontend/src/components/DocumentVolunteersTable.tsx b/frontend/src/components/DocumentVolunteersTable.tsx new file mode 100644 index 000000000..5b1276751 --- /dev/null +++ b/frontend/src/components/DocumentVolunteersTable.tsx @@ -0,0 +1,399 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import PageLayout from '../layouts/PageLayout'; +import Card from '../layouts/CardLayout'; +import CardBody from './CardBody'; +import DataTableComponent from './DataTableComponent'; +import i18n from '../common/config/i18n'; +import { SortOrder, TableColumn } from 'react-data-table-component'; +import { OrderDirection } from '../common/enums/order-direction.enum'; +import { SelectItem } from './Select'; +import { AgeRangeOptions, formatLocation } from '../common/utils/utils'; +import { useErrorToast } from '../hooks/useToast'; +import { InternalErrors } from '../common/errors/internal-errors.class'; +import MediaCell from './MediaCell'; +import { + useVolunteersQuery, +} from '../services/volunteer/volunteer.service'; +import { IVolunteer } from '../common/interfaces/volunteer.interface'; +import { VolunteerStatus } from '../common/enums/volunteer-status.enum'; +import DataTableFilters from './DataTableFilters'; +import DateRangePicker from './DateRangePicker'; +import LocationSelect from '../containers/LocationSelect'; +import { ListItem } from '../common/interfaces/list-item.interface'; +import OrganizationStructureSelect from '../containers/OrganizationStructureSelect'; +import { DivisionType } from '../common/enums/division-type.enum'; +import { AgeRangeEnum } from '../common/enums/age-range.enum'; +import SelectFilter from '../containers/SelectFilter'; +import CardHeader from './CardHeader'; +import { DocumentVolunteersProps } from '../containers/query/DocumentVolunteersTableWithQueryParams'; +import { ExclamationCircleIcon } from '@heroicons/react/24/outline'; +import { Tooltip } from 'react-tooltip'; +import { checkIsVolunteerDataIncomplete, VolunteerDataCheck } from '../common/utils/volunteer-data.util'; + + +const ActiveVolunteersTableHeader = [ + { + id: 'user.firstName', + name: i18n.t('general:name'), + sortable: true, + grow: 1, + minWidth: '5rem', + cell: (row: IVolunteer & { completionStatus: VolunteerDataCheck }) => ( +
+
+ + +
+
+ + {row.completionStatus?.isIncomplete && } + {row.completionStatus?.isIncomplete && } +
+
+ ) + }, + { + id: 'department.name', + name: i18n.t('volunteers:department_and_role'), + sortable: true, + grow: 1, + minWidth: '9rem', + selector: (row: IVolunteer) => + row.profile?.department || row?.profile?.role + ? `${row.profile?.role?.name || ''}${row.profile?.role && row.profile?.department ? '\n' : '' + }${row.profile?.department?.name || ''}` + : '-', + }, + { + id: 'location.name', + name: i18n.t('volunteers:location'), + sortable: true, + grow: 1, + minWidth: '5rem', + selector: (row: IVolunteer) => formatLocation(row.user.location), + }, + { + id: 'volunteerProfile.email', + name: i18n.t('general:contact'), + sortable: true, + grow: 1, + minWidth: '14rem', + selector: (row: IVolunteer) => + row.profile ? `${row.profile?.email}\n${row.user?.phone}` : '-', + }, +]; + + +const DocumentVolunteersTable = ({ query, setQuery, selectedVolunteers, setSelectedVolunteers }: DocumentVolunteersProps) => { + // filters + const [location, setLocation] = useState(); + const [branch, setBranch] = useState>(); + const [department, setDepartment] = useState>(); + const [role, setRole] = useState>(); + + const firstRender = useRef(true); + + useEffect(() => { firstRender.current = false }, []); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, []); + + const { + data: volunteers, + isLoading: isVolunteersLoading, + error: volunteersError, + } = useVolunteersQuery( + query?.volunteerStatus as VolunteerStatus, + query?.limit as number, + query?.page as number, + query?.orderBy as string, + query?.orderDirection as OrderDirection, + query?.search as string, + query?.age as AgeRangeEnum, + query?.branch, + query?.department, + query?.role, + query?.location && query?.location[0], + query?.location && query?.location[1], + query?.createdOnStart as Date, + query?.createdOnEnd as Date, + ); + + const volunteersWithChecks = useMemo(() => { + return volunteers?.items?.map((volunteer: IVolunteer) => { + const completionStatus = checkIsVolunteerDataIncomplete(volunteer); + return { + ...volunteer, + completionStatus, + }; + }); + }, [volunteers]); + + useEffect(() => { + if (volunteersError) + useErrorToast( + InternalErrors.VOLUNTEER_ERRORS.getError(volunteersError.response?.data.code_error), + ); + }, [volunteersError]); + + const handleOnSelectVolunteers = (volunteers: IVolunteer[]) => { + if (volunteers.length === 0) { + setSelectedVolunteers(volunteers); + } else if (volunteers.length !== selectedVolunteers?.length) { + setSelectedVolunteers(volunteers); + } + }; + + const defaultSelectedRows = useCallback((row: IVolunteer) => { + return selectedVolunteers?.some((volunteer: IVolunteer) => volunteer.id === row.id); + }, [selectedVolunteers]); + + // We're doing this because of a bug in DataTableComponent + // https://github.com/jbetancur/react-data-table-component/issues/930 + // https://github.com/jbetancur/react-data-table-component/issues/955 + + // While some fixes exist we should be able to also unselect the row, thus removing props after the first render is the only way that works. + const selectProps = { + ...(firstRender.current && { + selectableRowSelected: defaultSelectedRows + }), + ...(!firstRender.current && { + onSelectedRowsChange: handleOnSelectVolunteers, + }), + }; + + + + // pagination + const onRowsPerPageChange = (limit: number) => { + setQuery( + { + limit, + page: 1, + }, + 'replaceIn', + ); + }; + + const onChangePage = (page: number) => { + setQuery( + { + page, + }, + 'replaceIn', + ); + }; + + const onSort = (column: TableColumn, direction: SortOrder) => { + setQuery( + { + orderBy: column.id as string, + orderDirection: + direction.toLocaleUpperCase() === OrderDirection.ASC + ? OrderDirection.ASC + : OrderDirection.DESC, + }, + 'replaceIn', + ); + }; + + const onSearch = (search: string) => { + setQuery( + { + search, + }, + 'replaceIn', + ); + }; + + const onSetBranchFilter = (branch: SelectItem | undefined) => { + setBranch(branch); + setQuery( + { + branch: branch?.value, + }, + 'replaceIn', + ); + }; + + const onSetDepartmentFilter = (department: SelectItem | undefined) => { + setDepartment(department); + setQuery( + { + department: department?.value, + }, + 'replaceIn', + ); + }; + + const onSetRoleFilter = (role: SelectItem | undefined) => { + setRole(role); + setQuery( + { + role: role?.value, + }, + 'replaceIn', + ); + }; + + const onCreatedOnRangeChange = ([createdOnStart, createdOnEnd]: Date[]) => { + setQuery( + { + createdOnStart, + createdOnEnd, + }, + 'replaceIn', + ); + }; + + const onLocationChange = (location: ListItem) => { + setLocation(location); + setQuery( + { + location: location.label.split(', '), + }, + 'replaceIn', + ); + }; + + const onAgeRangeChange = (selectedRange: SelectItem | undefined) => { + setQuery( + { + age: selectedRange?.key, + }, + 'replaceIn', + ); + }; + + const onResetFilters = () => { + setLocation(undefined); + setBranch(undefined); + setDepartment(undefined); + setRole(undefined); + setQuery( + { + volunteerStatus: query.volunteerStatus, + location: undefined, + branch: undefined, + department: undefined, + role: undefined, + age: undefined, + createdOnEnd: undefined, + createdOnStart: undefined, + }, + 'replaceIn', + ); + }; + + + + return ( + + + { + const [createdOnStart, createdOnEnd] = range; + onCreatedOnRangeChange([createdOnStart as Date, createdOnEnd as Date]); + }} + value={ + query?.createdOnStart && query?.createdOnEnd + ? [query?.createdOnStart, query?.createdOnEnd] + : undefined + } + id="created-on-range__picker" + /> + + + + + + + +

{i18n.t('side_menu:options.volunteers_list')}

+ + {query?.volunteerStatus === VolunteerStatus.ACTIVE && ( + row.completionStatus.isIncomplete} + selectableRows + paginationPerPage={query.limit as number} + paginationTotalRows={volunteers?.meta?.totalItems} + paginationDefaultPage={query.page as number} + onChangeRowsPerPage={onRowsPerPageChange} + onChangePage={onChangePage} + onSort={onSort} + /> + )} + +
+
+ ); +}; + +export default DocumentVolunteersTable; diff --git a/frontend/src/components/FormInput.tsx b/frontend/src/components/FormInput.tsx index f3b06b22b..5f850aae2 100644 --- a/frontend/src/components/FormInput.tsx +++ b/frontend/src/components/FormInput.tsx @@ -5,6 +5,7 @@ import FormReadOnlyElement from './FormReadOnlyElement'; interface FormInputProps extends InputProps { errorMessage?: string; + wrapperClassname?: string; } const FormInput = ({ @@ -13,6 +14,7 @@ const FormInput = ({ value, label, className, + wrapperClassname, helper, ...props }: FormInputProps) => { @@ -31,6 +33,7 @@ const FormInput = ({ : '', className || '', )} + wrapperClassname={wrapperClassname} aria-invalid={errorMessage ? 'true' : 'false'} {...props} helper={errorMessage ?

{errorMessage}

: helper} diff --git a/frontend/src/components/InfoParagraph.tsx b/frontend/src/components/InfoParagraph.tsx new file mode 100644 index 000000000..5ab75a400 --- /dev/null +++ b/frontend/src/components/InfoParagraph.tsx @@ -0,0 +1,66 @@ +import React, { DetailedHTMLProps, HTMLAttributes } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from 'react-tooltip'; + +interface InfoParagraphProps + extends DetailedHTMLProps, HTMLParagraphElement> { + text: string; + tooltip?: boolean; + tooltipTheme?: 'info' | 'error'; + tooltipContent?: string; + highlighted?: boolean; +} + +export const InfoParagraph = ({ + text, + tooltip, + tooltipTheme = 'info', + tooltipContent, + highlighted, + className, + ...rest +}: InfoParagraphProps) => { + const { t } = useTranslation('doc_templates'); + + return ( + <> + + {text} + + {tooltip && + (tooltipTheme === 'info' ? ( + + ) : ( + + ))} + + ); +}; diff --git a/frontend/src/components/Input.tsx b/frontend/src/components/Input.tsx index 8c9132d6c..b952fd18f 100644 --- a/frontend/src/components/Input.tsx +++ b/frontend/src/components/Input.tsx @@ -3,11 +3,12 @@ import React, { ComponentPropsWithoutRef, ReactNode } from 'react'; export interface InputProps extends ComponentPropsWithoutRef<'input'> { label?: string; helper?: ReactNode; + wrapperClassname?: string; } -const Input = ({ label, helper, ...props }: InputProps) => { +const Input = ({ label, helper, wrapperClassname, ...props }: InputProps) => { return ( -
+
{label && } {helper} diff --git a/frontend/src/components/LoadingContract.tsx b/frontend/src/components/LoadingContract.tsx new file mode 100644 index 000000000..7e90e9fbc --- /dev/null +++ b/frontend/src/components/LoadingContract.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import Spinner from './Spinner'; +import { DocumentTextIcon } from '@heroicons/react/24/outline'; + +export const LoadingContract = () => { + return ( +
+
+ + +
+
+ ); +}; diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index e439f2642..64db1ab19 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -4,11 +4,12 @@ import { XMarkIcon } from '@heroicons/react/24/outline'; interface ModalProps { title: string; + titleClassName?: string; onClose: () => void; children: React.ReactNode; } -const Modal = ({ children, title, onClose }: ModalProps) => { +const Modal = ({ children, title, titleClassName, onClose }: ModalProps) => { return ( @@ -39,7 +40,7 @@ const Modal = ({ children, title, onClose }: ModalProps) => {
{title} diff --git a/frontend/src/components/NewContractsTable.tsx b/frontend/src/components/NewContractsTable.tsx new file mode 100644 index 000000000..1f7c8181c --- /dev/null +++ b/frontend/src/components/NewContractsTable.tsx @@ -0,0 +1,565 @@ +import React, { useEffect, useState } from 'react'; +import DataTableComponent from './DataTableComponent'; +import CardHeader from './CardHeader'; +import CardBody from './CardBody'; +import Card from '../layouts/CardLayout'; +import { IContractListItem } from '../common/interfaces/contract.interface'; +import i18n from '../common/config/i18n'; +import { + ArrowDownTrayIcon, + CheckIcon, + EyeIcon, + PlusIcon, + TrashIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import { SortOrder, TableColumn } from 'react-data-table-component'; +import { + useApproveContractMutation, + useContractsQuery, + useDeleteContractMutation, + useRejectContractMutation, +} from '../services/contracts/contracts.service'; +import { OrderDirection } from '../common/enums/order-direction.enum'; +import { ContractsTableBasicProps } from '../containers/query/ContractsTableWithQueryParams'; +import Popover from './Popover'; +import { useErrorToast, useSuccessToast } from '../hooks/useToast'; +import { InternalErrors } from '../common/errors/internal-errors.class'; +import Button from './Button'; +import { + ContractStatusMarkerColorMapper, + downloadExcel, + downloadFile, + formatDate, +} from '../common/utils/utils'; +import LinkCell from './LinkCell'; +import CellLayout from '../layouts/CellLayout'; +import StatusWithMarker from './StatusWithMarker'; +import DataTableFilters from './DataTableFilters'; +import VolunteerSelect from '../containers/VolunteerSelect'; +import FormDatePicker from './FormDatePicker'; +import { ListItem } from '../common/interfaces/list-item.interface'; +import Select, { SelectItem } from './Select'; +import { ContractStatus } from '../common/enums/contract-status.enum'; +import { useNavigate } from 'react-router-dom'; +import ContractSidePanel from './ContractSidePanel'; +import ConfirmationModal from './ConfirmationModal'; +import RejectTextareaModal from './RejectTextareaModal'; +import { VolunteerTabsOptions } from '../pages/Volunteer'; +import { useTranslation } from 'react-i18next'; +import { getContractsForDownload } from '../services/contracts/contracts.api'; +import UploadFileModal from './UploadFileModal'; + +const StatusOptions: SelectItem[] = [ + { + key: ContractStatus.ACTIVE, + value: `${i18n.t(`documents:contract.status.${ContractStatus.ACTIVE}`)}`, + }, + { + key: ContractStatus.CLOSED, + value: `${i18n.t(`documents:contract.status.${ContractStatus.CLOSED}`)}`, + }, + { + key: ContractStatus.NOT_STARTED, + value: `${i18n.t(`documents:contract.status.${ContractStatus.NOT_STARTED}`)}`, + }, + { + key: ContractStatus.REJECTED, + value: `${i18n.t(`documents:contract.status.${ContractStatus.REJECTED}`)}`, + }, + { + key: ContractStatus.PENDING_ADMIN, + value: `${i18n.t(`documents:contract.status.${ContractStatus.PENDING_ADMIN}`)}`, + }, + { + key: ContractStatus.PENDING_VOLUNTEER, + value: `${i18n.t(`documents:contract.status.${ContractStatus.PENDING_VOLUNTEER}`)}`, + }, +]; + +const ContractsTableHeader = [ + { + id: 'contractNumber', + name: i18n.t('documents:contracts.headers.contract_number'), + sortable: true, + selector: (row: IContractListItem) => row.contractNumber, + }, + { + id: 'volunteer', + name: i18n.t('documents:contracts.headers.volunteer'), + grow: 2, + sortable: true, + cell: (row: IContractListItem) => ( + {row.volunteer.name} + ), + }, + { + id: 'startDate', + name: i18n.t('documents:contracts.headers.start_date'), + sortable: true, + selector: (row: IContractListItem) => formatDate(row.startDate), + }, + { + id: 'endDate', + name: i18n.t('documents:contracts.headers.end_date'), + sortable: true, + selector: (row: IContractListItem) => formatDate(row.endDate), + }, + { + id: 'status', + name: i18n.t('documents:contracts.headers.status'), + minWidth: '11rem', + sortable: true, + cell: (row: IContractListItem) => ( + + + {i18n.t(`documents:contract.status.${row.status}`)} + + + ), + }, +]; + +const VolunteerContractsTableHeader = [ + { + id: 'contractNumber', + name: i18n.t('documents:contracts.headers.contract_number'), + sortable: true, + grow: 3, + selector: (row: IContractListItem) => row.contractNumber, + }, + { + id: 'status', + name: i18n.t('documents:contracts.headers.status'), + minWidth: '11rem', + sortable: true, + cell: (row: IContractListItem) => ( + + + {i18n.t(`documents:contract.status.${row.status}`)} + + + ), + }, + { + id: 'startDate', + name: i18n.t('documents:contracts.headers.start_date'), + sortable: true, + selector: (row: IContractListItem) => formatDate(row.startDate), + }, + { + id: 'endDate', + name: i18n.t('documents:contracts.headers.end_date'), + sortable: true, + selector: (row: IContractListItem) => formatDate(row.endDate), + }, +]; + +interface ContractsTableProps extends ContractsTableBasicProps { + volunteerName?: string; + volunteerId?: string; +} + +const ContractsTable = ({ query, setQuery, volunteerName, volunteerId }: ContractsTableProps) => { + // selected contract id + const [selectedContract, setSelectedContract] = useState(); + // side panel state + const [isViewContractSidePanelOpen, setIsViewContractSidePanelOpen] = useState(false); + // confirmation modals + const [showRejectContract, setShowRejectContract] = useState(null); + const [showDeleteContract, setShowDeleteContract] = useState(null); + const [showApproveContract, setShowApproveContract] = useState(null); + // translation + const { t } = useTranslation('documents'); + // navigation + const navigate = useNavigate(); + + //Actions + const { mutateAsync: deleteContract, isLoading: isDeletingContract } = + useDeleteContractMutation(); + + const { mutateAsync: approveContract, isLoading: isApprovingContract } = + useApproveContractMutation(); + + const { mutateAsync: rejectContract, isLoading: isRejectingContract } = + useRejectContractMutation(); + + const { + data: contracts, + isLoading: isContractsLoading, + error, + refetch, + } = useContractsQuery({ + limit: query?.limit as number, + page: query?.page as number, + orderBy: query?.orderBy as string, + orderDirection: query?.orderDirection as OrderDirection, + search: query?.search, + volunteerName: query?.volunteer, + startDate: query?.startDate, + endDate: query?.endDate, + status: query?.status as ContractStatus, + volunteerId, + }); + + // query error handling + useEffect(() => { + if (error) + useErrorToast(InternalErrors.CONTRACT_ERRORS.getError(error.response?.data.code_error)); + }, [error]); + + const onView = (row: IContractListItem) => { + setSelectedContract(row.id); + setIsViewContractSidePanelOpen(true); + }; + + const onExport = async () => { + const { data } = await getContractsForDownload({ + orderBy: query?.orderBy as string, + orderDirection: query?.orderDirection as OrderDirection, + search: query?.search, + volunteerName: query?.volunteer, + startDate: query?.startDate, + endDate: query?.endDate, + status: query?.status as ContractStatus, + volunteerId, + }); + + downloadExcel(data as BlobPart, t('contracts.download')); + }; + + const onRejectContract = (row: IContractListItem) => { + setShowRejectContract(row); + }; + + const onRemove = (row: IContractListItem) => { + setShowDeleteContract(row); + }; + + const buildContractActionColumn = (): TableColumn => { + const contractsMenuItems = [ + { + label: t('events:popover.view'), + icon: , + onClick: onView, + }, + { + label: t('general:download', { item: i18n.t('general:contract').toLowerCase() }), + icon: , + onClick: onDownloadContract, + }, + ]; + + const contractsValidateOngMenuItems = [ + ...contractsMenuItems, + { + label: t('popover.confirm'), + icon: , + onClick: setShowApproveContract, + }, + { + label: t('popover.reject'), + icon: , + onClick: onRejectContract, + }, + { + label: t('popover.remove'), + icon: , + alert: true, + onClick: onRemove, + }, + ]; + + const contractsValidateVolunteerMenuItems = [ + ...contractsMenuItems, + { + label: t('popover.remove'), + icon: , + alert: true, + onClick: onRemove, + }, + ]; + + const contractsRejectedMenuItems = [ + { + label: t('events:popover.view'), + icon: , + onClick: onView, + }, + { + label: t('popover.remove_from_list'), + icon: , + alert: true, + onClick: onRemove, + }, + ]; + + const mapContractStatusToPopoverItems = (status: ContractStatus) => { + switch (status) { + case ContractStatus.ACTIVE: + case ContractStatus.CLOSED: + case ContractStatus.NOT_STARTED: + return contractsMenuItems; + case ContractStatus.PENDING_ADMIN: + return contractsValidateOngMenuItems; + case ContractStatus.PENDING_VOLUNTEER: + return contractsValidateVolunteerMenuItems; + case ContractStatus.REJECTED: + return contractsRejectedMenuItems; + default: + return []; + } + }; + + return { + name: '', + cell: (row: IContractListItem) => ( + row={row} items={mapContractStatusToPopoverItems(row.status)} /> + ), + width: '50px', + allowOverflow: true, + }; + }; + + const buildContractTableHeader = (): TableColumn[] => { + return volunteerName ? VolunteerContractsTableHeader : ContractsTableHeader; + }; + + // pagination + const onRowsPerPageChange = (rows: number) => { + setQuery({ + limit: rows, + page: 1, + }); + }; + + const onChangePage = (newPage: number) => { + setQuery({ + page: newPage, + }); + }; + + const onSort = (column: TableColumn, direction: SortOrder) => { + setQuery({ + orderBy: column.id as string, + orderDirection: + direction.toLocaleUpperCase() === OrderDirection.ASC + ? OrderDirection.ASC + : OrderDirection.DESC, + }); + }; + + const onDownloadContract = (row: IContractListItem) => { + downloadFile(row.uri, row.fileName); + }; + + const onAddContract = () => { + navigate(`/documents/templates/contracts/generate`); + }; + + const onStartDateChange = (startDate: Date | null) => { + setQuery({ startDate: startDate as Date }); + }; + + const onEndDateChange = (endDate: Date | null) => { + setQuery({ endDate: endDate as Date }); + }; + + const onVolunteerChange = (volunteer: ListItem) => { + setQuery({ volunteer: volunteer.label }); + }; + + const onResetFilters = () => { + if (volunteerName) { + setQuery({ activeTab: VolunteerTabsOptions.DOCUMENTS }, 'push'); + } else { + setQuery({}, 'push'); + } + }; + + const onSearch = (search: string) => { + setQuery({ + search, + }); + }; + + const onStatusChange = (item: SelectItem | undefined) => { + setQuery({ status: item?.key }); + }; + + const onCloseSidePanel = (shouldRefetch?: boolean) => { + setIsViewContractSidePanelOpen(false); + setSelectedContract(undefined); + if (shouldRefetch) refetch(); + }; + + const confirmReject = (rejectMessage?: string) => { + if (showRejectContract) + rejectContract( + { + id: showRejectContract.id, + rejectMessage, + }, + { + onSuccess: () => { + useSuccessToast(t('contract.submit.reject')); + refetch(); + }, + onError: (error) => { + useErrorToast(InternalErrors.CONTRACT_ERRORS.getError(error.response?.data.code_error)); + }, + onSettled: () => { + setShowRejectContract(null); + }, + }, + ); + }; + + const confirmDelete = () => { + if (showDeleteContract) { + const contractId = showDeleteContract.id; + setShowDeleteContract(null); + deleteContract(contractId, { + onSuccess: () => { + useSuccessToast(t('contract.submit.delete')); + refetch(); + }, + onError: (error) => { + useErrorToast(InternalErrors.CONTRACT_ERRORS.getError(error.response?.data.code_error)); + }, + }); + } + }; + + const onConfirmSign = (contract?: File) => { + if (!contract) return; + + // store id and close modal + const contractId = showApproveContract?.id; + setShowApproveContract(null); + + // approval process + approveContract( + { + id: contractId as string, + contract, + }, + { + onSuccess: () => { + useSuccessToast(t('contract.submit.confirm')); + }, + onError: (error) => { + useErrorToast(InternalErrors.CONTRACT_ERRORS.getError(error.response?.data.code_error)); + }, + }, + ); + }; + + return ( + <> + + {!volunteerName && ( + + )} + + +