From 92d5547733ec20744585eb3edad855ec25cd2299 Mon Sep 17 00:00:00 2001 From: Chavda Bhavik Date: Wed, 16 Nov 2022 13:10:34 +0530 Subject: [PATCH] Development V1 (#63) * feat: basic setup * feat: Setup NestJS API * fix: preinstall script not working * feat: Added nx support * fix: start script not running * feat: Added API to create project * fix: swagger response * feat: Added Update API and made DTO reusable * feat: Added Delete project API * fix: Updated module imports * feat: Added Template repository * fix: updated response to return _id * feat: Added setup for templates API with get-templates route * feat: Added create-template API * feat: Added Update template API * feat: Added valid mongodb id validation to params * feat: Added delete API and added error-handling for not-found documents * feat: Added authHeaderName to project * feat: Added shared lib * feat: Added tranformer to tranform code * feat: Added column repository * feat: Added template columns add & fetch routes * feat: Switched create-template API to use projectId from param * feat: Added valid callbackUrl validation * feat: setup localstack and saved sample file to localstack while updating schema * feat: Added Validation for env variables and Added env variable types * feat: Added authentication support * feat: Added file repository * feat: Upload repository * feat: mapping repository * feat: Added file upload API * feat: Completed CSV File Upload API * feat: removed json2csv dependency and build csv by logic * feat: Updated file information on upload * feat: Updated file names to match others * fix: removed comment * fix: Added preinstall scripts to run after instalation to build packages * feat: Removed columnKeys from column and added key and alternateKeys * feat: temp commit * feat: Finalized column mapping * feat: Added Get Headings API * feat: Moved template from body to param for upload * feat: Deleted old validator and updated invalid message for new one * feat: Moved mapping APIs to mapping controller * feat: Finalize Mapping API * feat: Added all-data file to upload and added api to retrive file while review * fix: Updated column data templateId to _templateId * fix: Fix columns not deleting issue * feat: Added Review API * feat: Removed additional properties from schema while processing * feat: wrapped errors in list * fix: Regex pattern validation * fix: Updated fixes and Added validations * feat: refactored and separated file mimetypes from supported types * feat: Added facility to store reviewed data * feat: saved invalid & valid data in files and proceed accordingly * feat: Added stages validation to mapping API * feat: Added field to whether proceed for invalid data or not * feat: Reused get-upload usecase * feat: Added not-found upload validation in mapping * feat: Added confirm review API * Create CODE_OF_CONDUCT.md * feat: Added documentation * Fixed documentation errors Fixed documentation errors * feat: Setup RabbitMQ * feat: Added Storage service to @impler/shared * feat: Implemented Processing functionality for upload file * feat: Put types in seperate file for file-processing * feat: Added webhook-log dal * feat: Added facility to finalize upload * Update README.md "Setup" means system whereas "Set up" is used in the context of setting up a system. Fixed some other documentation errors too. @chavda-bhavik Please review. * feat: Updated recursion flow for processing file * feat: Updated end-review flow to start the process after review is finalized * feat: Added validation error messages * feat: Implemented review API to return data in pagination * feat: Added embed App * feat: Added widget App * feat: Added react package to help import * feat: Added packages * feat: Applied modifications to embed lib * feat: Renamed import-helper package to react * fix: updated embed lint scripts * feat: Organized widget structure * feat: Added widget types and util functions in impler/shared * feat: removed commnt and updated packages * fix: Added missing types for styled-components in @impler/react * feat: Updated docker script and removed unused backendUrl from embed * feat: Updated @impler/react to to be used in packages * feat: Added demo to @impler/react package * feat: Removed default background color and added filter for projectId not defined * feat: Added scripts * feat: Added ReadME for @impler/react demo app * fix: Removed unused dependencies for @impler/embed * feat: Reorganized widget components * feat: Get started with mantine and ModalContainer component * fix: Removed styled-components dependency and used @emotion/react instead * feat: Added Modal and Stepper Design System components with Storybook * feat: Renamed ModalContainer to Modal * feat: Added Button Component * feat: Added gitignore * feat: Added Dropzone component to Design System * feat: Added File component to design system * feat: Added Dropzone component * feat: Added mapping Item component * feat: Added Table component to design system * feat: Added Pagination component to design-system * feat: Added Empty Data Story for Table * feat: Updated Modal and Stepper styles to meet the design needs * feat: Added Select component * feat: Updated select component to meet the design needs * feat: Updated Dropzone, Select and Stepper component styles to meet the design needs * feat: Added required prop and Responsivness to MappingItem * feat: Updated Dropzone and File components to meed design needs: * feat: Removed total data showing from Pagination * feat: Added warning to Table * feat: Added Warning icon and updated config * feat: Added left-icon to Button and added Download icon * feat: Implemented Phases static design * feat: Updated confirmModal flow * feat: Made Modal overlay transparent * feat: Updated dropzone styles and showed file size properly * feat: Updated Phases Modal design and made height to remain same * feat: Updated scrollbar design * feat: Added Complete Step * feat: removed unwanted embed script and streamlined widget open flow * feat: Made ParentCommand Utils reusable * feat: Created separate package @impler/client for API Communication * feat: Added Common API to check if User can access API or not * feat: Updated valid check API to consider ProjectID and Template * feat: Reused access-key names and wrapped axios calls for error handling * feat: Updated layout architecture and Added authentication to Widget * fix: Widget Build issue * feat: Refactored code to reuse EventNames and functionality * feat: Disabled widget logging warning for widget * feat: made start script to start widget and api together * feat: Added Prompt modal * feat: Showed Prompt Modal before Closing and Reseting import * feat: Added error to Dropzone component * feat: Made Widget to load only when show is clicked * feat: Updated select component to be used with ReactHookForm * feat: Added headers support to HttpClient * feat: Added Loading Overlay component * feat: Added Reusable Upload and Template interfaces * feat: Added APIs for UploadFile and GetTemplates * feat: Added Loading Indicators to Footer Buttons * feat: Added Phase1 basic implementation * feat: Implemented Phase1 functionality * fix: Fix issue of first value is always selected in select * fix: Fix isssue of wrong URL creation * feat: Added download sample functionality to widget * feat: Added NoMagicNumber linting * feat: Added commad to build packages before commit * feat: Added notification functionality for download error * fix: Fix issue of Template error not reseting after value changes * feat: Removed building from pre-commit * feat: Made consistency of using PhasesEnum in layout and heading * Implementation Phase 2 (#41) * fix: Issue of Footer actions not comming properly * feat: Added properties to MappingItem to meet development needs * feat: Made updates to App Context, fix warning in svg icon and Added Mapping APIs * feat: Created new component for Phase2 columns heading * feat: Implemented Mapping * feat: Added loading to Phase 2 (#43) * Fix in Upload Again and Reset * feat: Made ReactQuery cache clear on upload reset * feat: Made app to reset progress on close and Upload Again * feat: Updated footer actions * Implementation of Phase 3 & 4 (#46) * feat: Added Review Interface and API * feat: Updated error message formatting * feat: Updated error variable configuration * feat: Showed review data * feat: Implemented Phase3 * feat: Implemented Phase 4 * feat: Added loading indicator in Review * Export Review data (#50) * feat: Added path to shared folder and made invalid data file public * fix: Removed unwanted json2csv types * feat: Added invalidCSVData file parameters to entity * feat: Stored invalid data to csv file while reviewing * feat: Added default page limit and page to review API * feat: Moved Get-Template-Uploads to template controller * feat: Added Get Upload API * feat: Added Export data functionality * feat: fix typo in confirmation with wrong data * fix: Fix storybook run error * fix: Consistency issue among Footer, Title and Stepper * Fix Impler not defined & No Such Bucket errors (#51) * feat: Updated embed import way * fix: Formatted 'impler not defined' error * feat: Made table headings sticky (#53) * Readme Updates & Build workflow (#54) * feat: Added Test Build workflow * feat: Made nestjs available * feat: Optimized and renamed workflow file * feat: Updated version to 0.1.0 * fix: scripts in @impler/client (#55) * Lerna Setup (#56) * fix: Removed unwanted variables from queue-manager env files * feat: Added setup-project-env script * feat: Added learna * v0.1.0 * feat: Added nvmrc * feat: Fix clean & prebuild scripts * Setup widget demo app (#57) * feat: Moved widget-demo to app * feat: Updated env setup script to setup env for widget-demo app * Fixs & Updates (#58) * feat: Made widget to not open window and renamed .env.example to .example.env in widget-demo * refactor: removed unused demo folder from @impler/react * feat: Made widget error more specified * refactor: Organized components hierarchy for better management of state * fix: Removed http-server from embed and added script for starting * fix: Added guards for valid/invalid data not found * feat: Added missing environment variables * feat: Updated number formattion in Confirm and Complete modal * Review phase Updates (#59) * feat: Added missing environment variables ad env-validator to queue-manager * feat: Handled edge case for invalid data not found * Env Updates (#60) * fix: Removed unused env variables * feat: Removed unused start:widget-dev script * feat: Added setup for widget env file * Validation Tests (#61) * test: Added AJVService test cases for isRequired, isEmail and Email types * feat: Added RegExp test cases * test: Added Number test cases * test: Added test cases for Date * test: Added test cases for Select type * test: Added test cases for Any type * fix: Removed unused test scripts and Added test script to test everything * Packages publish updates (#62) * feat: Updated Shared, client, react packages package.json to publish * feat: Updated package.json and added env script for widget * feat: Removed workspace prefix from packages usage Co-authored-by: Rutam Prita Mishra Co-authored-by: Akash Kundu <112017800+Akash190104@users.noreply.github.com> --- .commitlintrc.json | 28 + .editorconfig | 11 + .eslintignore | 12 + .eslintrc.js | 95 + .github/workflows/test-build.yml | 50 + .gitignore | 5 + .husky/commit-msg | 4 + .husky/pre-commit | 4 + .npmrc | 2 + .nvmrc | 1 + .prettierignore | 2 + .prettierrc | 11 + .vscode/settings.json | 11 + CODE_OF_CONDUCT.md | 128 + README.md | 172 + apps/api/.eslintrc.js | 4 + apps/api/nest-cli.json | 49 + apps/api/nodemon.json | 8 + apps/api/package.json | 62 + apps/api/src/.env.development | 15 + apps/api/src/.env.production | 15 + apps/api/src/.env.test | 5 + apps/api/src/.example.env | 15 + apps/api/src/app.module.ts | 31 + apps/api/src/app/column/column.controller.ts | 55 + apps/api/src/app/column/column.module.ts | 11 + .../app/column/dtos/column-response.dto.ts | 57 + .../column/dtos/update-column-request.dto.ts | 99 + .../get-columns/get-columns.usecase.ts | 11 + apps/api/src/app/column/usecases/index.ts | 8 + .../update-columns/update-columns.command.ts | 51 + .../update-columns/update-columns.usecase.ts | 37 + apps/api/src/app/common/common.controller.ts | 28 + apps/api/src/app/common/common.module.ts | 11 + apps/api/src/app/common/dtos/valid.dto.ts | 18 + apps/api/src/app/common/usecases/index.ts | 6 + .../valid-request/valid-request.command.ts | 12 + .../valid-request/valid-request.usecase.ts | 37 + .../app/mapping/dtos/update-columns.dto.ts | 18 + .../api/src/app/mapping/mapping.controller.ts | 111 + apps/api/src/app/mapping/mapping.module.ts | 11 + .../usecases/do-mapping/do-mapping.command.ts | 16 + .../usecases/do-mapping/do-mapping.usecase.ts | 73 + .../finalize-upload.usecase.ts | 12 + .../get-mappings/get-mappings.usecase.ts | 11 + apps/api/src/app/mapping/usecases/index.ts | 16 + .../update-mappings.command.ts | 16 + .../update-mappings.usecase.ts | 14 + .../validate-mapping.command.ts | 12 + .../validate-mapping.usecase.ts | 29 + .../dtos/create-project-request.dto.ts | 32 + .../app/project/dtos/project-response.dto.ts | 32 + .../dtos/update-project-request.dto.ts | 18 + .../api/src/app/project/project.controller.ts | 94 + apps/api/src/app/project/project.module.ts | 12 + .../create-project/create-project.command.ts | 16 + .../create-project/create-project.usecase.ts | 12 + .../delete-project/delete-project.usecase.ts | 11 + .../get-projects/get-projects.usecase.ts | 21 + apps/api/src/app/project/usecases/index.ts | 12 + .../update-project/update-project.command.ts | 12 + .../update-project/update-project.usecase.ts | 12 + .../review/dtos/confirm-review-request.dto.ts | 13 + apps/api/src/app/review/review.controller.ts | 114 + apps/api/src/app/review/review.module.ts | 13 + .../app/review/service/AJV.service.spec.ts | 192 + .../api/src/app/review/service/AJV.service.ts | 216 + .../confirm-review/confirm-review.command.ts | 12 + .../confirm-review/confirm-review.usecase.ts | 16 + .../usecases/do-review/do-review.usecase.ts | 52 + .../get-file-invalid-data.usecase.ts | 14 + .../get-upload-invalid-data.usecase.ts | 11 + apps/api/src/app/review/usecases/index.ts | 18 + .../save-review-data.usecase.ts | 79 + .../start-process/start-process.command.ts | 12 + .../start-process/start-process.usecase.ts | 21 + .../src/app/shared/commands/base.command.ts | 21 + apps/api/src/app/shared/constants.ts | 23 + .../shared/dtos/pagination-response.dto.ts | 34 + .../app/shared/errors/file-not-exist.error.ts | 6 + .../document-not-found.exception.ts | 7 + .../shared/exceptions/empty-file.exception.ts | 8 + .../exceptions/file-not-valid.exception.ts | 8 + apps/api/src/app/shared/file/file.service.ts | 75 + apps/api/src/app/shared/file/name.service.ts | 48 + .../src/app/shared/framework/auth.gaurd.ts | 19 + .../shared/framework/is-unique.validator.ts | 21 + .../framework/is-valid-regex.validator.ts | 18 + .../src/app/shared/helpers/common.helper.ts | 39 + .../api/src/app/shared/helpers/file.helper.ts | 13 + .../src/app/shared/helpers/upload.helpers.ts | 22 + apps/api/src/app/shared/shared.module.ts | 55 + .../src/app/shared/storage/queue.service.ts | 29 + .../src/app/shared/storage/storage.service.ts | 79 + .../usecases/get-upload/get-upload.command.ts | 12 + .../usecases/get-upload/get-upload.usecase.ts | 12 + .../valid-import-file.validation.ts | 16 + .../validations/valid-mongo-id.validation.ts | 17 + .../validations/valid-template.validation.ts | 16 + .../dtos/create-template-request.dto.ts | 39 + .../template/dtos/template-response.dto.ts | 53 + .../dtos/update-template-request.dto.ts | 39 + .../src/app/template/template.controller.ts | 121 + apps/api/src/app/template/template.module.ts | 11 + .../create-template.command.ts | 24 + .../create-template.usecase.ts | 12 + .../delete-template.usecase.ts | 11 + .../get-templates/get-templates.usecase.ts | 22 + .../get-uploads/get-uploads.command.ts | 12 + .../get-uploads/get-uploads.usecase.ts | 12 + apps/api/src/app/template/usecases/index.ts | 14 + .../update-template.command.ts | 26 + .../update-template.usecase.ts | 12 + .../src/app/upload/dtos/upload-request.dto.ts | 26 + apps/api/src/app/upload/upload.controller.ts | 74 + apps/api/src/app/upload/upload.module.ts | 11 + apps/api/src/app/upload/usecases/index.ts | 8 + .../add-upload-entry.command.ts | 36 + .../make-upload-entry.command.ts | 19 + .../make-upload-entry.usecase.ts | 91 + apps/api/src/bootstrap.ts | 82 + apps/api/src/config/env-validator.ts | 23 + apps/api/src/config/index.ts | 17 + apps/api/src/main.ts | 3 + apps/api/src/types/env.d.ts | 14 + apps/api/tsconfig.build.json | 23 + apps/api/tsconfig.json | 17 + apps/queue-manager/.dockerignore | 1 + apps/queue-manager/.eslintrc.js | 3 + apps/queue-manager/nodemon.json | 6 + apps/queue-manager/package.json | 38 + apps/queue-manager/src/.env.development | 8 + apps/queue-manager/src/.env.production | 8 + apps/queue-manager/src/.env.test | 8 + apps/queue-manager/src/.example.env | 8 + apps/queue-manager/src/bootstrap.ts | 44 + apps/queue-manager/src/config/env-config.ts | 17 + .../queue-manager/src/config/env-validator.ts | 21 + .../src/consumers/base.consumer.ts | 3 + apps/queue-manager/src/consumers/index.ts | 1 + .../src/consumers/process-file.ts | 229 + .../src/helpers/storage.helper.ts | 11 + apps/queue-manager/src/index.ts | 3 + apps/queue-manager/src/types/env.d.ts | 10 + .../src/types/file-processing.types.ts | 37 + apps/queue-manager/tsconfig.build.json | 13 + apps/queue-manager/tsconfig.json | 6 + apps/widget-demo/.example.env | 3 + apps/widget-demo/.gitignore | 24 + apps/widget-demo/index.html | 14 + apps/widget-demo/package.json | 28 + apps/widget-demo/public/vite.svg | 1 + apps/widget-demo/src/App.tsx | 13 + apps/widget-demo/src/main.tsx | 9 + apps/widget-demo/src/vite-env.d.ts | 13 + apps/widget-demo/tsconfig.json | 21 + apps/widget-demo/tsconfig.node.json | 9 + apps/widget-demo/vite.config.ts | 7 + apps/widget/.eslintrc.js | 38 + apps/widget/.example.env | 1 + apps/widget/.gitignore | 27 + apps/widget/.storybook/main.js | 8 + apps/widget/.storybook/preview-head.html | 3 + apps/widget/craco.config.js | 14 + apps/widget/env.sh | 29 + apps/widget/package.json | 71 + apps/widget/public/arrow.svg | 4 + apps/widget/public/favicon.ico | Bin 0 -> 3870 bytes apps/widget/public/index.html | 45 + apps/widget/public/logo192.png | Bin 0 -> 5347 bytes apps/widget/public/logo512.png | Bin 0 -> 9664 bytes apps/widget/public/manifest.json | 25 + apps/widget/public/robots.txt | 3 + apps/widget/src/components/App.tsx | 44 + .../src/components/ApplicationShell.tsx | 52 + .../components/Common/Container/Container.tsx | 107 + .../src/components/Common/Container/index.ts | 1 + .../src/components/Common/Footer/Footer.tsx | 60 + .../src/components/Common/Footer/index.ts | 1 + .../src/components/Common/Heading/Heading.tsx | 41 + .../src/components/Common/Heading/index.ts | 1 + .../src/components/Common/Layout/Layout.tsx | 21 + .../src/components/Common/Layout/Styles.tsx | 28 + .../src/components/Common/Layout/index.ts | 1 + .../components/Common/Provider/Provider.tsx | 34 + .../src/components/Common/Provider/index.ts | 1 + .../Phases/ConfirmModal/ConfirmModal.tsx | 36 + .../widget/Phases/ConfirmModal/index.ts | 1 + .../widget/Phases/Phase1/Phase1.tsx | 97 + .../widget/Phases/Phase1/Styles.tsx | 38 + .../components/widget/Phases/Phase1/index.ts | 1 + .../Phase2/MappingHeading/MappingHeading.tsx | 21 + .../Phases/Phase2/MappingHeading/Styles.tsx | 17 + .../Phases/Phase2/MappingHeading/index.ts | 1 + .../widget/Phases/Phase2/Phase2.tsx | 76 + .../widget/Phases/Phase2/Styles.tsx | 16 + .../components/widget/Phases/Phase2/index.ts | 1 + .../widget/Phases/Phase3/Phase3.tsx | 93 + .../widget/Phases/Phase3/Styles.tsx | 27 + .../components/widget/Phases/Phase3/index.ts | 1 + .../widget/Phases/Phase4/Phase4.tsx | 34 + .../widget/Phases/Phase4/Styles.tsx | 30 + .../components/widget/Phases/Phase4/index.ts | 1 + .../widget/Phases/PromptModal/PromptModal.tsx | 43 + .../widget/Phases/PromptModal/index.ts | 1 + apps/widget/src/components/widget/Widget.tsx | 71 + apps/widget/src/components/widget/index.ts | 1 + apps/widget/src/config/app.config.ts | 9 + apps/widget/src/config/colors.config.ts | 14 + apps/widget/src/config/index.ts | 5 + apps/widget/src/config/texts.config.ts | 70 + apps/widget/src/config/theme.config.ts | 10 + apps/widget/src/config/variable.config.ts | 9 + .../design-system/Button/Button.stories.tsx | 35 + .../src/design-system/Button/Button.tsx | 30 + apps/widget/src/design-system/Button/index.ts | 1 + .../Dropzone/Dropzone.stories.tsx | 41 + .../design-system/Dropzone/Dropzone.style.ts | 55 + .../src/design-system/Dropzone/Dropzone.tsx | 96 + .../src/design-system/Dropzone/index.ts | 1 + .../src/design-system/File/File.stories.tsx | 20 + .../src/design-system/File/File.style.ts | 45 + apps/widget/src/design-system/File/File.tsx | 40 + apps/widget/src/design-system/File/index.ts | 1 + .../InvalidWarning/InvalidWarning.tsx | 24 + .../src/design-system/InvalidWarning/index.ts | 1 + .../LoadingOverlay/LoadingOverlay.tsx | 11 + .../src/design-system/LoadingOverlay/index.ts | 1 + .../MappingItem/MappingItem.stories.tsx | 48 + .../MappingItem/MappingItem.style.ts | 85 + .../design-system/MappingItem/MappingItem.tsx | 73 + .../src/design-system/MappingItem/index.ts | 1 + .../src/design-system/Modal/Modal.stories.tsx | 36 + .../src/design-system/Modal/Modal.style.ts | 29 + apps/widget/src/design-system/Modal/Modal.tsx | 34 + apps/widget/src/design-system/Modal/index.ts | 1 + .../Pagination/Pagination.stories.tsx | 20 + .../design-system/Pagination/Pagination.tsx | 19 + .../src/design-system/Pagination/index.ts | 1 + .../design-system/Select/Select.stories.tsx | 16 + .../src/design-system/Select/Select.styles.ts | 27 + .../src/design-system/Select/Select.tsx | 46 + apps/widget/src/design-system/Select/index.ts | 1 + .../design-system/Stepper/Stepper.stories.tsx | 25 + .../design-system/Stepper/Stepper.styles.ts | 29 + .../src/design-system/Stepper/Stepper.tsx | 37 + .../widget/src/design-system/Stepper/index.ts | 1 + .../src/design-system/Table/Table.stories.tsx | 42 + .../src/design-system/Table/Table.style.ts | 32 + apps/widget/src/design-system/Table/Table.tsx | 90 + apps/widget/src/design-system/Table/index.ts | 1 + apps/widget/src/global.d.ts | 11 + apps/widget/src/hooks/Phase1/usePhase1.ts | 98 + apps/widget/src/hooks/Phase2/usePhase2.ts | 60 + apps/widget/src/hooks/Phase3/usePhase3.ts | 87 + apps/widget/src/hooks/useAuthentication.ts | 44 + apps/widget/src/icons/check.icon.tsx | 16 + apps/widget/src/icons/chevron-down.icon.tsx | 24 + apps/widget/src/icons/cross.icon.tsx | 17 + apps/widget/src/icons/download.icon.tsx | 17 + apps/widget/src/icons/file.icon.tsx | 20 + apps/widget/src/icons/green-check.icon.tsx | 23 + apps/widget/src/icons/index.ts | 7 + apps/widget/src/icons/warning.icon.tsx | 19 + apps/widget/src/index.tsx | 20 + apps/widget/src/react-app-env.d.ts | 1 + apps/widget/src/reportWebVitals.ts | 17 + apps/widget/src/setupTests.ts | 7 + apps/widget/src/store/api.context.tsx | 31 + apps/widget/src/store/app.context.tsx | 26 + apps/widget/src/store/impler.context.tsx | 39 + apps/widget/src/types/component.types.ts | 35 + apps/widget/src/types/event.types.ts | 8 + apps/widget/src/types/icon.types.ts | 6 + apps/widget/src/types/index.ts | 4 + apps/widget/src/types/store.types.ts | 20 + apps/widget/src/util/helpers.ts | 45 + apps/widget/src/util/index.ts | 4 + apps/widget/src/util/logger.ts | 7 + apps/widget/src/util/notifier/Notifier.tsx | 23 + apps/widget/src/util/notifier/index.ts | 1 + apps/widget/src/util/parent-window.ts | 14 + apps/widget/tsconfig.json | 37 + lerna.json | 8 + libs/dal/.eslintrc.js | 3 + libs/dal/package.json | 32 + libs/dal/src/dal.service.ts | 28 + libs/dal/src/index.ts | 9 + libs/dal/src/repositories/base-repository.ts | 125 + .../src/repositories/column/column.entity.ts | 25 + .../repositories/column/column.repository.ts | 9 + .../src/repositories/column/column.schema.ts | 29 + libs/dal/src/repositories/column/index.ts | 3 + .../repositories/common/common.repository.ts | 22 + libs/dal/src/repositories/common/index.ts | 1 + libs/dal/src/repositories/file/file.entity.ts | 11 + .../src/repositories/file/file.repository.ts | 9 + libs/dal/src/repositories/file/file.schema.ts | 19 + libs/dal/src/repositories/file/index.ts | 3 + libs/dal/src/repositories/mapping/index.ts | 3 + .../repositories/mapping/mapping.entity.ts | 9 + .../mapping/mapping.repository.ts | 26 + .../repositories/mapping/mapping.schema.ts | 24 + libs/dal/src/repositories/project/index.ts | 3 + .../repositories/project/project.entity.ts | 9 + .../project/project.repository.ts | 9 + .../repositories/project/project.schema.ts | 24 + .../repositories/schema-default.options.ts | 7 + libs/dal/src/repositories/template/index.ts | 3 + .../repositories/template/template.entity.ts | 15 + .../template/template.repository.ts | 9 + .../repositories/template/template.schema.ts | 35 + libs/dal/src/repositories/upload/index.ts | 3 + .../src/repositories/upload/upload.entity.ts | 35 + .../repositories/upload/upload.repository.ts | 21 + .../src/repositories/upload/upload.schema.ts | 60 + .../dal/src/repositories/webhook-log/index.ts | 3 + .../webhook-log/webhook-log.entity.ts | 15 + .../webhook-log/webhook-log.repository.ts | 9 + .../webhook-log/webhook-log.schema.ts | 24 + libs/dal/src/types/env.d.ts | 8 + libs/dal/tsconfig.build.json | 13 + libs/dal/tsconfig.json | 7 + libs/embed/.editorconfig | 13 + libs/embed/.eslintrc.js | 8 + libs/embed/.gitignore | 12 + libs/embed/index.js | 23 + libs/embed/package.json | 59 + libs/embed/rollup.config.js | 54 + libs/embed/src/embed.ts | 191 + libs/embed/src/global.d.ts | 5 + libs/embed/src/shared/errors.ts | 34 + libs/embed/src/shared/eventTypes.ts | 8 + libs/embed/src/shared/helpers.ts | 37 + libs/embed/src/shared/resources.ts | 39 + libs/embed/test/index.html | 275 + libs/embed/tsconfig.json | 18 + libs/shared/.dockerignore | 1 + libs/shared/.eslintrc.js | 3 + libs/shared/nodemon.json | 6 + libs/shared/package.json | 42 + libs/shared/src/config/api.config.ts | 1 + libs/shared/src/config/contextPath.ts | 33 + libs/shared/src/config/index.ts | 2 + .../src/entities/Mapping/Mapping.interface.ts | 14 + libs/shared/src/entities/Mapping/index.ts | 1 + .../src/entities/Review/Review.interface.ts | 7 + libs/shared/src/entities/Review/index.ts | 1 + .../entities/Template/Template.interface.ts | 9 + libs/shared/src/entities/Template/index.ts | 1 + .../src/entities/Upload/Upload.interface.ts | 20 + libs/shared/src/entities/Upload/index.ts | 1 + libs/shared/src/entities/index.ts | 4 + .../shared/src/errors/file-not-exist.error.ts | 6 + libs/shared/src/errors/index.ts | 1 + libs/shared/src/index.ts | 6 + .../src/services/http-client/api.client.ts | 60 + libs/shared/src/services/http-client/index.ts | 1 + libs/shared/src/services/index.ts | 2 + libs/shared/src/services/storage.service.ts | 79 + libs/shared/src/types/column/column.types.ts | 9 + libs/shared/src/types/common/common.types.ts | 7 + libs/shared/src/types/index.ts | 5 + libs/shared/src/types/upload/upload.types.ts | 59 + .../types/webhook-log/webhook-log.types.ts | 4 + libs/shared/src/types/widget/widget.types.ts | 12 + libs/shared/src/utils/helpers.ts | 39 + libs/shared/tsconfig.build.json | 12 + libs/shared/tsconfig.json | 6 + nx.json | 37 + package.json | 103 + packages/client/.editorconfig | 15 + packages/client/.eslintrc.js | 3 + packages/client/.gitignore | 25 + packages/client/.prettierignore | 2 + packages/client/package.json | 54 + packages/client/src/api/api.service.ts | 101 + packages/client/src/index.ts | 1 + packages/client/tsconfig.build.json | 12 + packages/client/tsconfig.json | 6 + packages/react/.eslintrc.js | 23 + packages/react/.gitignore | 23 + packages/react/package.json | 56 + packages/react/rollup.config.js | 37 + .../react/src/components/button/Button.tsx | 76 + .../src/components/button/Button.types.ts | 9 + packages/react/src/components/button/index.ts | 2 + packages/react/src/components/index.ts | 1 + packages/react/src/config/index.ts | 1 + packages/react/src/config/texts.config.ts | 4 + packages/react/src/global.d.ts | 11 + packages/react/src/index.ts | 1 + packages/react/src/utils/index.ts | 1 + packages/react/src/utils/logger.ts | 6 + packages/react/tsconfig.json | 16 + pnpm-lock.yaml | 23229 ++++++++++++++++ pnpm-workspace.yaml | 4 + scripts/setup-env-files.js | 31 + tsconfig.base.json | 22 + tsconfig.json | 12 + 400 files changed, 33786 insertions(+) create mode 100644 .commitlintrc.json create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc.js create mode 100644 .github/workflows/test-build.yml create mode 100644 .gitignore create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 .vscode/settings.json create mode 100644 CODE_OF_CONDUCT.md create mode 100644 README.md create mode 100644 apps/api/.eslintrc.js create mode 100644 apps/api/nest-cli.json create mode 100644 apps/api/nodemon.json create mode 100644 apps/api/package.json create mode 100644 apps/api/src/.env.development create mode 100644 apps/api/src/.env.production create mode 100644 apps/api/src/.env.test create mode 100644 apps/api/src/.example.env create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/app/column/column.controller.ts create mode 100644 apps/api/src/app/column/column.module.ts create mode 100644 apps/api/src/app/column/dtos/column-response.dto.ts create mode 100644 apps/api/src/app/column/dtos/update-column-request.dto.ts create mode 100644 apps/api/src/app/column/usecases/get-columns/get-columns.usecase.ts create mode 100644 apps/api/src/app/column/usecases/index.ts create mode 100644 apps/api/src/app/column/usecases/update-columns/update-columns.command.ts create mode 100644 apps/api/src/app/column/usecases/update-columns/update-columns.usecase.ts create mode 100644 apps/api/src/app/common/common.controller.ts create mode 100644 apps/api/src/app/common/common.module.ts create mode 100644 apps/api/src/app/common/dtos/valid.dto.ts create mode 100644 apps/api/src/app/common/usecases/index.ts create mode 100644 apps/api/src/app/common/usecases/valid-request/valid-request.command.ts create mode 100644 apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts create mode 100644 apps/api/src/app/mapping/dtos/update-columns.dto.ts create mode 100644 apps/api/src/app/mapping/mapping.controller.ts create mode 100644 apps/api/src/app/mapping/mapping.module.ts create mode 100644 apps/api/src/app/mapping/usecases/do-mapping/do-mapping.command.ts create mode 100644 apps/api/src/app/mapping/usecases/do-mapping/do-mapping.usecase.ts create mode 100644 apps/api/src/app/mapping/usecases/finalize-upload/finalize-upload.usecase.ts create mode 100644 apps/api/src/app/mapping/usecases/get-mappings/get-mappings.usecase.ts create mode 100644 apps/api/src/app/mapping/usecases/index.ts create mode 100644 apps/api/src/app/mapping/usecases/update-mappings/update-mappings.command.ts create mode 100644 apps/api/src/app/mapping/usecases/update-mappings/update-mappings.usecase.ts create mode 100644 apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.command.ts create mode 100644 apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.usecase.ts create mode 100644 apps/api/src/app/project/dtos/create-project-request.dto.ts create mode 100644 apps/api/src/app/project/dtos/project-response.dto.ts create mode 100644 apps/api/src/app/project/dtos/update-project-request.dto.ts create mode 100644 apps/api/src/app/project/project.controller.ts create mode 100644 apps/api/src/app/project/project.module.ts create mode 100644 apps/api/src/app/project/usecases/create-project/create-project.command.ts create mode 100644 apps/api/src/app/project/usecases/create-project/create-project.usecase.ts create mode 100644 apps/api/src/app/project/usecases/delete-project/delete-project.usecase.ts create mode 100644 apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts create mode 100644 apps/api/src/app/project/usecases/index.ts create mode 100644 apps/api/src/app/project/usecases/update-project/update-project.command.ts create mode 100644 apps/api/src/app/project/usecases/update-project/update-project.usecase.ts create mode 100644 apps/api/src/app/review/dtos/confirm-review-request.dto.ts create mode 100644 apps/api/src/app/review/review.controller.ts create mode 100644 apps/api/src/app/review/review.module.ts create mode 100644 apps/api/src/app/review/service/AJV.service.spec.ts create mode 100644 apps/api/src/app/review/service/AJV.service.ts create mode 100644 apps/api/src/app/review/usecases/confirm-review/confirm-review.command.ts create mode 100644 apps/api/src/app/review/usecases/confirm-review/confirm-review.usecase.ts create mode 100644 apps/api/src/app/review/usecases/do-review/do-review.usecase.ts create mode 100644 apps/api/src/app/review/usecases/get-file-invalid-data/get-file-invalid-data.usecase.ts create mode 100644 apps/api/src/app/review/usecases/get-upload-invalid-data/get-upload-invalid-data.usecase.ts create mode 100644 apps/api/src/app/review/usecases/index.ts create mode 100644 apps/api/src/app/review/usecases/save-review-data/save-review-data.usecase.ts create mode 100644 apps/api/src/app/review/usecases/start-process/start-process.command.ts create mode 100644 apps/api/src/app/review/usecases/start-process/start-process.usecase.ts create mode 100644 apps/api/src/app/shared/commands/base.command.ts create mode 100644 apps/api/src/app/shared/constants.ts create mode 100644 apps/api/src/app/shared/dtos/pagination-response.dto.ts create mode 100644 apps/api/src/app/shared/errors/file-not-exist.error.ts create mode 100644 apps/api/src/app/shared/exceptions/document-not-found.exception.ts create mode 100644 apps/api/src/app/shared/exceptions/empty-file.exception.ts create mode 100644 apps/api/src/app/shared/exceptions/file-not-valid.exception.ts create mode 100644 apps/api/src/app/shared/file/file.service.ts create mode 100644 apps/api/src/app/shared/file/name.service.ts create mode 100644 apps/api/src/app/shared/framework/auth.gaurd.ts create mode 100644 apps/api/src/app/shared/framework/is-unique.validator.ts create mode 100644 apps/api/src/app/shared/framework/is-valid-regex.validator.ts create mode 100644 apps/api/src/app/shared/helpers/common.helper.ts create mode 100644 apps/api/src/app/shared/helpers/file.helper.ts create mode 100644 apps/api/src/app/shared/helpers/upload.helpers.ts create mode 100644 apps/api/src/app/shared/shared.module.ts create mode 100644 apps/api/src/app/shared/storage/queue.service.ts create mode 100644 apps/api/src/app/shared/storage/storage.service.ts create mode 100644 apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts create mode 100644 apps/api/src/app/shared/usecases/get-upload/get-upload.usecase.ts create mode 100644 apps/api/src/app/shared/validations/valid-import-file.validation.ts create mode 100644 apps/api/src/app/shared/validations/valid-mongo-id.validation.ts create mode 100644 apps/api/src/app/shared/validations/valid-template.validation.ts create mode 100644 apps/api/src/app/template/dtos/create-template-request.dto.ts create mode 100644 apps/api/src/app/template/dtos/template-response.dto.ts create mode 100644 apps/api/src/app/template/dtos/update-template-request.dto.ts create mode 100644 apps/api/src/app/template/template.controller.ts create mode 100644 apps/api/src/app/template/template.module.ts create mode 100644 apps/api/src/app/template/usecases/create-template/create-template.command.ts create mode 100644 apps/api/src/app/template/usecases/create-template/create-template.usecase.ts create mode 100644 apps/api/src/app/template/usecases/delete-template/delete-template.usecase.ts create mode 100644 apps/api/src/app/template/usecases/get-templates/get-templates.usecase.ts create mode 100644 apps/api/src/app/template/usecases/get-uploads/get-uploads.command.ts create mode 100644 apps/api/src/app/template/usecases/get-uploads/get-uploads.usecase.ts create mode 100644 apps/api/src/app/template/usecases/index.ts create mode 100644 apps/api/src/app/template/usecases/update-template/update-template.command.ts create mode 100644 apps/api/src/app/template/usecases/update-template/update-template.usecase.ts create mode 100644 apps/api/src/app/upload/dtos/upload-request.dto.ts create mode 100644 apps/api/src/app/upload/upload.controller.ts create mode 100644 apps/api/src/app/upload/upload.module.ts create mode 100644 apps/api/src/app/upload/usecases/index.ts create mode 100644 apps/api/src/app/upload/usecases/make-upload-entry/add-upload-entry.command.ts create mode 100644 apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts create mode 100644 apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts create mode 100644 apps/api/src/bootstrap.ts create mode 100644 apps/api/src/config/env-validator.ts create mode 100644 apps/api/src/config/index.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/types/env.d.ts create mode 100644 apps/api/tsconfig.build.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/queue-manager/.dockerignore create mode 100644 apps/queue-manager/.eslintrc.js create mode 100644 apps/queue-manager/nodemon.json create mode 100644 apps/queue-manager/package.json create mode 100644 apps/queue-manager/src/.env.development create mode 100644 apps/queue-manager/src/.env.production create mode 100644 apps/queue-manager/src/.env.test create mode 100644 apps/queue-manager/src/.example.env create mode 100644 apps/queue-manager/src/bootstrap.ts create mode 100644 apps/queue-manager/src/config/env-config.ts create mode 100644 apps/queue-manager/src/config/env-validator.ts create mode 100644 apps/queue-manager/src/consumers/base.consumer.ts create mode 100644 apps/queue-manager/src/consumers/index.ts create mode 100644 apps/queue-manager/src/consumers/process-file.ts create mode 100644 apps/queue-manager/src/helpers/storage.helper.ts create mode 100644 apps/queue-manager/src/index.ts create mode 100644 apps/queue-manager/src/types/env.d.ts create mode 100644 apps/queue-manager/src/types/file-processing.types.ts create mode 100644 apps/queue-manager/tsconfig.build.json create mode 100644 apps/queue-manager/tsconfig.json create mode 100644 apps/widget-demo/.example.env create mode 100644 apps/widget-demo/.gitignore create mode 100644 apps/widget-demo/index.html create mode 100644 apps/widget-demo/package.json create mode 100644 apps/widget-demo/public/vite.svg create mode 100644 apps/widget-demo/src/App.tsx create mode 100644 apps/widget-demo/src/main.tsx create mode 100644 apps/widget-demo/src/vite-env.d.ts create mode 100644 apps/widget-demo/tsconfig.json create mode 100644 apps/widget-demo/tsconfig.node.json create mode 100644 apps/widget-demo/vite.config.ts create mode 100644 apps/widget/.eslintrc.js create mode 100644 apps/widget/.example.env create mode 100644 apps/widget/.gitignore create mode 100644 apps/widget/.storybook/main.js create mode 100644 apps/widget/.storybook/preview-head.html create mode 100644 apps/widget/craco.config.js create mode 100755 apps/widget/env.sh create mode 100644 apps/widget/package.json create mode 100644 apps/widget/public/arrow.svg create mode 100644 apps/widget/public/favicon.ico create mode 100644 apps/widget/public/index.html create mode 100644 apps/widget/public/logo192.png create mode 100644 apps/widget/public/logo512.png create mode 100644 apps/widget/public/manifest.json create mode 100644 apps/widget/public/robots.txt create mode 100644 apps/widget/src/components/App.tsx create mode 100644 apps/widget/src/components/ApplicationShell.tsx create mode 100644 apps/widget/src/components/Common/Container/Container.tsx create mode 100644 apps/widget/src/components/Common/Container/index.ts create mode 100644 apps/widget/src/components/Common/Footer/Footer.tsx create mode 100644 apps/widget/src/components/Common/Footer/index.ts create mode 100644 apps/widget/src/components/Common/Heading/Heading.tsx create mode 100644 apps/widget/src/components/Common/Heading/index.ts create mode 100644 apps/widget/src/components/Common/Layout/Layout.tsx create mode 100644 apps/widget/src/components/Common/Layout/Styles.tsx create mode 100644 apps/widget/src/components/Common/Layout/index.ts create mode 100644 apps/widget/src/components/Common/Provider/Provider.tsx create mode 100644 apps/widget/src/components/Common/Provider/index.ts create mode 100644 apps/widget/src/components/widget/Phases/ConfirmModal/ConfirmModal.tsx create mode 100644 apps/widget/src/components/widget/Phases/ConfirmModal/index.ts create mode 100644 apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase1/Styles.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase1/index.ts create mode 100644 apps/widget/src/components/widget/Phases/Phase2/MappingHeading/MappingHeading.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase2/MappingHeading/Styles.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase2/MappingHeading/index.ts create mode 100644 apps/widget/src/components/widget/Phases/Phase2/Phase2.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase2/Styles.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase2/index.ts create mode 100644 apps/widget/src/components/widget/Phases/Phase3/Phase3.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase3/Styles.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase3/index.ts create mode 100644 apps/widget/src/components/widget/Phases/Phase4/Phase4.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase4/Styles.tsx create mode 100644 apps/widget/src/components/widget/Phases/Phase4/index.ts create mode 100644 apps/widget/src/components/widget/Phases/PromptModal/PromptModal.tsx create mode 100644 apps/widget/src/components/widget/Phases/PromptModal/index.ts create mode 100644 apps/widget/src/components/widget/Widget.tsx create mode 100644 apps/widget/src/components/widget/index.ts create mode 100644 apps/widget/src/config/app.config.ts create mode 100644 apps/widget/src/config/colors.config.ts create mode 100644 apps/widget/src/config/index.ts create mode 100644 apps/widget/src/config/texts.config.ts create mode 100644 apps/widget/src/config/theme.config.ts create mode 100644 apps/widget/src/config/variable.config.ts create mode 100644 apps/widget/src/design-system/Button/Button.stories.tsx create mode 100644 apps/widget/src/design-system/Button/Button.tsx create mode 100644 apps/widget/src/design-system/Button/index.ts create mode 100644 apps/widget/src/design-system/Dropzone/Dropzone.stories.tsx create mode 100644 apps/widget/src/design-system/Dropzone/Dropzone.style.ts create mode 100644 apps/widget/src/design-system/Dropzone/Dropzone.tsx create mode 100644 apps/widget/src/design-system/Dropzone/index.ts create mode 100644 apps/widget/src/design-system/File/File.stories.tsx create mode 100644 apps/widget/src/design-system/File/File.style.ts create mode 100644 apps/widget/src/design-system/File/File.tsx create mode 100644 apps/widget/src/design-system/File/index.ts create mode 100644 apps/widget/src/design-system/InvalidWarning/InvalidWarning.tsx create mode 100644 apps/widget/src/design-system/InvalidWarning/index.ts create mode 100644 apps/widget/src/design-system/LoadingOverlay/LoadingOverlay.tsx create mode 100644 apps/widget/src/design-system/LoadingOverlay/index.ts create mode 100644 apps/widget/src/design-system/MappingItem/MappingItem.stories.tsx create mode 100644 apps/widget/src/design-system/MappingItem/MappingItem.style.ts create mode 100644 apps/widget/src/design-system/MappingItem/MappingItem.tsx create mode 100644 apps/widget/src/design-system/MappingItem/index.ts create mode 100644 apps/widget/src/design-system/Modal/Modal.stories.tsx create mode 100644 apps/widget/src/design-system/Modal/Modal.style.ts create mode 100644 apps/widget/src/design-system/Modal/Modal.tsx create mode 100644 apps/widget/src/design-system/Modal/index.ts create mode 100644 apps/widget/src/design-system/Pagination/Pagination.stories.tsx create mode 100644 apps/widget/src/design-system/Pagination/Pagination.tsx create mode 100644 apps/widget/src/design-system/Pagination/index.ts create mode 100644 apps/widget/src/design-system/Select/Select.stories.tsx create mode 100644 apps/widget/src/design-system/Select/Select.styles.ts create mode 100644 apps/widget/src/design-system/Select/Select.tsx create mode 100644 apps/widget/src/design-system/Select/index.ts create mode 100644 apps/widget/src/design-system/Stepper/Stepper.stories.tsx create mode 100644 apps/widget/src/design-system/Stepper/Stepper.styles.ts create mode 100644 apps/widget/src/design-system/Stepper/Stepper.tsx create mode 100644 apps/widget/src/design-system/Stepper/index.ts create mode 100644 apps/widget/src/design-system/Table/Table.stories.tsx create mode 100644 apps/widget/src/design-system/Table/Table.style.ts create mode 100644 apps/widget/src/design-system/Table/Table.tsx create mode 100644 apps/widget/src/design-system/Table/index.ts create mode 100644 apps/widget/src/global.d.ts create mode 100644 apps/widget/src/hooks/Phase1/usePhase1.ts create mode 100644 apps/widget/src/hooks/Phase2/usePhase2.ts create mode 100644 apps/widget/src/hooks/Phase3/usePhase3.ts create mode 100644 apps/widget/src/hooks/useAuthentication.ts create mode 100644 apps/widget/src/icons/check.icon.tsx create mode 100644 apps/widget/src/icons/chevron-down.icon.tsx create mode 100644 apps/widget/src/icons/cross.icon.tsx create mode 100644 apps/widget/src/icons/download.icon.tsx create mode 100644 apps/widget/src/icons/file.icon.tsx create mode 100644 apps/widget/src/icons/green-check.icon.tsx create mode 100644 apps/widget/src/icons/index.ts create mode 100644 apps/widget/src/icons/warning.icon.tsx create mode 100644 apps/widget/src/index.tsx create mode 100644 apps/widget/src/react-app-env.d.ts create mode 100644 apps/widget/src/reportWebVitals.ts create mode 100644 apps/widget/src/setupTests.ts create mode 100644 apps/widget/src/store/api.context.tsx create mode 100644 apps/widget/src/store/app.context.tsx create mode 100644 apps/widget/src/store/impler.context.tsx create mode 100644 apps/widget/src/types/component.types.ts create mode 100644 apps/widget/src/types/event.types.ts create mode 100644 apps/widget/src/types/icon.types.ts create mode 100644 apps/widget/src/types/index.ts create mode 100644 apps/widget/src/types/store.types.ts create mode 100644 apps/widget/src/util/helpers.ts create mode 100644 apps/widget/src/util/index.ts create mode 100644 apps/widget/src/util/logger.ts create mode 100644 apps/widget/src/util/notifier/Notifier.tsx create mode 100644 apps/widget/src/util/notifier/index.ts create mode 100644 apps/widget/src/util/parent-window.ts create mode 100644 apps/widget/tsconfig.json create mode 100644 lerna.json create mode 100644 libs/dal/.eslintrc.js create mode 100644 libs/dal/package.json create mode 100644 libs/dal/src/dal.service.ts create mode 100644 libs/dal/src/index.ts create mode 100644 libs/dal/src/repositories/base-repository.ts create mode 100644 libs/dal/src/repositories/column/column.entity.ts create mode 100644 libs/dal/src/repositories/column/column.repository.ts create mode 100644 libs/dal/src/repositories/column/column.schema.ts create mode 100644 libs/dal/src/repositories/column/index.ts create mode 100644 libs/dal/src/repositories/common/common.repository.ts create mode 100644 libs/dal/src/repositories/common/index.ts create mode 100644 libs/dal/src/repositories/file/file.entity.ts create mode 100644 libs/dal/src/repositories/file/file.repository.ts create mode 100644 libs/dal/src/repositories/file/file.schema.ts create mode 100644 libs/dal/src/repositories/file/index.ts create mode 100644 libs/dal/src/repositories/mapping/index.ts create mode 100644 libs/dal/src/repositories/mapping/mapping.entity.ts create mode 100644 libs/dal/src/repositories/mapping/mapping.repository.ts create mode 100644 libs/dal/src/repositories/mapping/mapping.schema.ts create mode 100644 libs/dal/src/repositories/project/index.ts create mode 100644 libs/dal/src/repositories/project/project.entity.ts create mode 100644 libs/dal/src/repositories/project/project.repository.ts create mode 100644 libs/dal/src/repositories/project/project.schema.ts create mode 100644 libs/dal/src/repositories/schema-default.options.ts create mode 100644 libs/dal/src/repositories/template/index.ts create mode 100644 libs/dal/src/repositories/template/template.entity.ts create mode 100644 libs/dal/src/repositories/template/template.repository.ts create mode 100644 libs/dal/src/repositories/template/template.schema.ts create mode 100644 libs/dal/src/repositories/upload/index.ts create mode 100644 libs/dal/src/repositories/upload/upload.entity.ts create mode 100644 libs/dal/src/repositories/upload/upload.repository.ts create mode 100644 libs/dal/src/repositories/upload/upload.schema.ts create mode 100644 libs/dal/src/repositories/webhook-log/index.ts create mode 100644 libs/dal/src/repositories/webhook-log/webhook-log.entity.ts create mode 100644 libs/dal/src/repositories/webhook-log/webhook-log.repository.ts create mode 100644 libs/dal/src/repositories/webhook-log/webhook-log.schema.ts create mode 100644 libs/dal/src/types/env.d.ts create mode 100644 libs/dal/tsconfig.build.json create mode 100644 libs/dal/tsconfig.json create mode 100644 libs/embed/.editorconfig create mode 100644 libs/embed/.eslintrc.js create mode 100644 libs/embed/.gitignore create mode 100644 libs/embed/index.js create mode 100644 libs/embed/package.json create mode 100644 libs/embed/rollup.config.js create mode 100644 libs/embed/src/embed.ts create mode 100644 libs/embed/src/global.d.ts create mode 100644 libs/embed/src/shared/errors.ts create mode 100644 libs/embed/src/shared/eventTypes.ts create mode 100644 libs/embed/src/shared/helpers.ts create mode 100644 libs/embed/src/shared/resources.ts create mode 100644 libs/embed/test/index.html create mode 100644 libs/embed/tsconfig.json create mode 100644 libs/shared/.dockerignore create mode 100644 libs/shared/.eslintrc.js create mode 100644 libs/shared/nodemon.json create mode 100644 libs/shared/package.json create mode 100644 libs/shared/src/config/api.config.ts create mode 100644 libs/shared/src/config/contextPath.ts create mode 100644 libs/shared/src/config/index.ts create mode 100644 libs/shared/src/entities/Mapping/Mapping.interface.ts create mode 100644 libs/shared/src/entities/Mapping/index.ts create mode 100644 libs/shared/src/entities/Review/Review.interface.ts create mode 100644 libs/shared/src/entities/Review/index.ts create mode 100644 libs/shared/src/entities/Template/Template.interface.ts create mode 100644 libs/shared/src/entities/Template/index.ts create mode 100644 libs/shared/src/entities/Upload/Upload.interface.ts create mode 100644 libs/shared/src/entities/Upload/index.ts create mode 100644 libs/shared/src/entities/index.ts create mode 100644 libs/shared/src/errors/file-not-exist.error.ts create mode 100644 libs/shared/src/errors/index.ts create mode 100644 libs/shared/src/index.ts create mode 100644 libs/shared/src/services/http-client/api.client.ts create mode 100644 libs/shared/src/services/http-client/index.ts create mode 100644 libs/shared/src/services/index.ts create mode 100644 libs/shared/src/services/storage.service.ts create mode 100644 libs/shared/src/types/column/column.types.ts create mode 100644 libs/shared/src/types/common/common.types.ts create mode 100644 libs/shared/src/types/index.ts create mode 100644 libs/shared/src/types/upload/upload.types.ts create mode 100644 libs/shared/src/types/webhook-log/webhook-log.types.ts create mode 100644 libs/shared/src/types/widget/widget.types.ts create mode 100644 libs/shared/src/utils/helpers.ts create mode 100644 libs/shared/tsconfig.build.json create mode 100644 libs/shared/tsconfig.json create mode 100644 nx.json create mode 100644 package.json create mode 100644 packages/client/.editorconfig create mode 100644 packages/client/.eslintrc.js create mode 100644 packages/client/.gitignore create mode 100644 packages/client/.prettierignore create mode 100644 packages/client/package.json create mode 100644 packages/client/src/api/api.service.ts create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/tsconfig.build.json create mode 100644 packages/client/tsconfig.json create mode 100644 packages/react/.eslintrc.js create mode 100644 packages/react/.gitignore create mode 100644 packages/react/package.json create mode 100644 packages/react/rollup.config.js create mode 100644 packages/react/src/components/button/Button.tsx create mode 100644 packages/react/src/components/button/Button.types.ts create mode 100644 packages/react/src/components/button/index.ts create mode 100644 packages/react/src/components/index.ts create mode 100644 packages/react/src/config/index.ts create mode 100644 packages/react/src/config/texts.config.ts create mode 100644 packages/react/src/global.d.ts create mode 100644 packages/react/src/index.ts create mode 100644 packages/react/src/utils/index.ts create mode 100644 packages/react/src/utils/logger.ts create mode 100644 packages/react/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/setup-env-files.js create mode 100644 tsconfig.base.json create mode 100644 tsconfig.json diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 000000000..7293d5af9 --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,28 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "subject-case": [ + 2, + "always", + ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case"] + ], + "type-enum": [ + 2, + "always", + [ + "build", + "chore", + "ci", + "docs", + "feat", + "fix", + "perf", + "refactor", + "revert", + "style", + "test", + "sample" + ] + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..4ac4973fb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..df95e3b5c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,12 @@ +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# OSX +.DS_Store diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..3408a06eb --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,95 @@ +module.exports = { + root: true, + extends: [ + 'airbnb-typescript', + 'plugin:import/typescript', + 'plugin:@typescript-eslint/recommended', + 'prettier', + 'plugin:prettier/recommended', + 'plugin:promise/recommended', + ], + ignorePatterns: ['.eslintrc.js', '*.json', 'jest.config.js'], + plugins: ['import', 'promise', '@typescript-eslint', 'prettier'], + parser: '@typescript-eslint/parser', + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + }, + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + }, + rules: { + '@typescript-eslint/space-before-blocks': 'off', + '@typescript-eslint/lines-between-class-members': 'off', + 'react/jsx-wrap-multilines': 'off', + 'react/jsx-filename-extension': 'off', + 'multiline-comment-style': ['error', 'starred-block'], + 'promise/catch-or-return': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + 'react/jsx-closing-bracket-location': 'off', + '@typescript-eslint/no-var-requires': 'off', + 'no-unused-vars': 'error', + '@typescript-eslint/no-unused-vars': ['error'], + 'mocha/no-mocha-arrows': 'off', + '@typescript-eslint/default-param-last': 'off', + 'no-return-await': 'off', + 'no-await-in-loop': 'off', + 'no-continue': 'off', + 'no-console': 'warn', + 'no-magic-numbers': 'error', + 'no-prototype-builtins': 'off', + 'import/no-cycle': 'off', + 'class-methods-use-this': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-explicit-any': 1, + 'no-restricted-syntax': 'off', + '@typescript-eslint/interface-name-prefix': 'off', + 'no-underscore-dangle': 'off', + 'import/prefer-default-export': 'off', + // A temporary hack related to IDE not resolving correct package.json + 'import/no-extraneous-dependencies': 'off', + 'react/jsx-one-expression-per-line': 'off', + 'react/jsx-no-bind': 'off', + 'lines-between-class-members': 'off', + 'max-classes-per-file': 'off', + 'react/react-in-jsx-scope': 'off', + 'max-len': ['warn', { code: 140 }], + '@typescript-eslint/return-await': 'off', + 'no-restricted-imports': [ + 'error', + { + patterns: ['@impler/shared/*', '@impler/dal/*', '!import2/good'], + }, + ], + 'padding-line-between-statements': [ + 'error', + { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['if', 'for'] }, + { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, + { blankLine: 'always', prev: '*', next: 'return' }, + ], + 'id-length': ['error', { min: 2, exceptions: ['i', 'e', 'a', 'b', '_', 't'], properties: 'never' }], + '@typescript-eslint/naming-convention': [ + 'error', + + { selector: 'enumMember', format: ['UPPER_CASE'] }, + { selector: 'enum', format: ['PascalCase'], suffix: ['Enum'] }, + { selector: 'class', format: ['PascalCase'] }, + { selector: 'variableLike', format: ['camelCase', 'UPPER_CASE', 'PascalCase'], leadingUnderscore: 'allow' }, + { + selector: 'interface', + format: ['PascalCase'], + prefix: ['I'], + }, + { + selector: ['function'], + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + ], + }, +}; diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml new file mode 100644 index 000000000..364671649 --- /dev/null +++ b/.github/workflows/test-build.yml @@ -0,0 +1,50 @@ +# This is a basic workflow to help you get started with Actions + +name: Test Build is happening + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main", "next", "development" ] + schedule: + - cron: '25 2 * * 4' + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + environment: Development + if: "!contains(github.event.head_commit.message, 'build skip')" + # The type of runner that the job will run on + runs-on: ubuntu-latest + timeout-minutes: 80 + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + - name: Setup kernel for react native, increase watchers + run: echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p + - uses: actions/setup-node@v2 + with: + node-version: '16.15.1' + + - name: Cache pnpm modules + uses: actions/cache@v2 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}- + + - name: Nestjs + run: npm i -g @nestjs/cli + + - uses: pnpm/action-setup@v2.0.1 + with: + version: 7.9.4 + run_install: true \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..0b52eccd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +build + +.env \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..42a70e424 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit "$1" \ No newline at end of file diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..a5a29d9f7 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +pnpm lint-staged diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..4c2f52b3b --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..d9289897d --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +16.15.1 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..0e80a3c86 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# package.json is formatted by package managers, so we ignore it here +package.json \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..6718863f9 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "printWidth": 120, + "trailingComma": "es5", + "singleQuote": true, + "semi": true, + "tabWidth": 2, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..d7f3ce132 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "npm.packageManager": "pnpm", + "editor.defaultFormatter": "dbaeumer.vscode-eslint", + "editor.formatOnSave": true, + "eslint.format.enable": true, + "editor.codeActionsOnSave": { + "source.fixAll": true + }, +} + \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..0b42ea814 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +hello@knovator.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/README.md b/README.md new file mode 100644 index 000000000..f4eda6504 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ + + +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![MIT License][license-shield]][license-url] + + + +
+
+ +

impler.io

+ +

+ Open source infrastructure for data import +
+ Explore the docs » +
+
+ View Demo + · + Report Bug + · + Request Feature +

+
+ + + + +
+ Table of Contents +
    +
  1. + About The Project + +
  2. +
  3. Setup
  4. +
  5. Usage
  6. +
  7. Roadmap
  8. +
  9. Contributing
  10. +
  11. License
  12. +
  13. Contact
  14. +
+
+ + + + +## About The Project + +All projects need to give some kind of data import facility, so that their users can import data in application through files like `.csv`, `.xls`, `.xlsx`, etc. + +At first it looks like just importing file and inserting in database, but as the app grows facilities like validating data, data mapping, becomes must. `impler` provides infrastructure to applications, so that they don't have to write code for data import. + +> impler.io is under development. + +

(back to top)

+ +### Built With + +* [Nestjs](https://nestjs.com/) +* [Typescript](https://www.typescriptlang.org/) +* [Nx](https://nx.dev/) +* [Pnpm](https://pnpm.io/) + +

(back to top)

+ +## Setup +To set up `impler.io` locally, you need the following things installed in your computer. +1. `pnpm` +2. `localstack` +3. `mongodb` + +Follow these steps to setup the project locally, +1. Clone the repo, `git clone https://github.com/knovator/impler.io`. +2. Install the dependencies, `pnpm install`. +3. Copy `.env.development` file from `apps/api/src` to `apps/api/src/.env` and do changes to variables if needed. +4. Start the application, `pnpm start:dev`. +5. Start interacting with API by visiting `http://localhost:3000/api`. + + +## Usage + +`impler` need to be communicated through **REST API**, you can easily make call through **Swagger UI** provided at `http://localhost:3000/api`, +1. You create `project`. +2. You add `template` to `project`, Template refers to set of data you want to import i.e. users data. +3. Add `columns` to `template`, Column refers to individual fields template can have, for example users Template can have firstname, lastname, address, email, phonenumber, etc. +4. Upload `file` to `template`, After columns being set well, we're ready to import `.csv`, `.xls`, `.xlsx` file to aplication. + * `Upload` response returns data with `headings` specified in uploaded file. + * Keep note of uploaded file `id`, it will be used later. + * Uploaded file headings will be mapped automatically with `columns` provided for `template`. +5. Check `mapping` done for uploaded file, and finalize mappings. +6. Get `review` data for uploaded file and confirm reivew with option whether you want ot excempt invalid data or want to keep them. + +

(back to top)

+ + +## Roadmap + +- [x] API + - [x] Project + - [x] Template + - [x] Column + - [x] Upload + - [x] Mapping + - [x] Review + - [x] Processing data +- [x] Web + - [x] Upload Phase + - [x] Mapping Phase + - [x] Review Phase + - [x] Confirm Phase +- [] Infra + - [] Docker + +

(back to top)

+ + + +## Contributing + +Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. + +If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". +Don't forget to give the project a star! Thanks again! + +1. Fork the Project +2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) +3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the Branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +

(back to top)

+ + + + +## License + +Distributed under the MIT License. See `LICENSE.txt` for more information. + +

(back to top)

+ + + + +## Contact + +Knovator - [@knovator](https://twitter.com/knovator) + +Project Link: [https://github.com/knovator/impler.io](https://github.com/knovator/impler.io) + +

(back to top)

+ + + + +[contributors-shield]: https://img.shields.io/github/contributors/knovator/impler.io.svg?style=for-the-badge +[contributors-url]: https://github.com/knovator/impler.io/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/knovator/impler.io.svg?style=for-the-badge +[forks-url]: https://github.com/knovator/impler.io/network/members +[stars-shield]: https://img.shields.io/github/stars/knovator/impler.io.svg?style=for-the-badge +[stars-url]: https://github.com/knovator/impler.io/stargazers +[issues-shield]: https://img.shields.io/github/issues/knovator/impler.io.svg?style=for-the-badge +[issues-url]: https://github.com/knovator/impler.io/issues +[license-shield]: https://img.shields.io/github/license/knovator/impler.io.svg?style=for-the-badge +[license-url]: https://github.com/knovator/impler.io/blob/master/LICENSE.txt diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js new file mode 100644 index 000000000..2ebdf17bd --- /dev/null +++ b/apps/api/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: ['../../.eslintrc.js'], + ignorePatterns: '*.spec.ts', +}; diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 000000000..e4e078182 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,49 @@ +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "assets": [ + { + "include": ".env", + "outDir": "dist/src" + }, + { + "include": ".env.development", + "outDir": "dist/src" + }, + { + "include": ".env.test", + "outDir": "dist/src" + }, + { + "include": ".env.production", + "outDir": "dist/src" + }, + { + "include": "app/content-templates/usecases/compile-template/templates/basic.handlebars", + "outDir": "dist/src" + } + ], + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { + "classValidatorShim": true, + "introspectComments": true + } + } + ], + "webpack": true + }, + "projects": { + "dal": { + "type": "library", + "root": "../../../libs/dal", + "entryFile": "index", + "sourceRoot": "../../../libs/dal/src", + "compilerOptions": { + "tsConfigPath": "../../../libs/dal/tsconfig.build.json" + } + } + } +} diff --git a/apps/api/nodemon.json b/apps/api/nodemon.json new file mode 100644 index 000000000..4e2bc9177 --- /dev/null +++ b/apps/api/nodemon.json @@ -0,0 +1,8 @@ +{ + "watch": ["src", "../core/dist"], + "ext": "ts", + "delay": 2, + "ignoreRoot": [".git"], + "ignore": ["src/**/*.spec.ts"], + "exec": "ts-node -r tsconfig-paths/register src/main.ts" +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 000000000..861121332 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,62 @@ +{ + "name": "@impler/api", + "version": "0.1.0", + "author": "knovator", + "license": "MIT", + "private": true, + "scripts": { + "prebuild": "rimraf dist", + "preinstall": "pnpm build", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "pnpm start:dev", + "start:dev": "cross-env TZ=UTC nest start --watch", + "start:test": "cross-env NODE_ENV=test PORT=1336 TZ=UTC nest start --watch", + "start:debug": "TZ=UTC nodemon --config nodemon-debug.json", + "start:prod": "TZ=UTC node dist/main.js", + "lint": "eslint src", + "lint:fix": "pnpm lint -- --fix", + "test": "cross-env TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --timeout 10000 --require ts-node/register --exit src/**/**/*.spec.ts" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.185.0", + "@impler/dal": "^0.1.0", + "@impler/shared": "^0.1.0", + "@nestjs/common": "^9.1.2", + "@nestjs/core": "^9.1.2", + "@nestjs/platform-express": "^9.1.2", + "@nestjs/swagger": "^6.1.2", + "ajv": "^8.11.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0", + "body-parser": "^1.20.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.13.2", + "compression": "^1.7.4", + "dotenv": "^16.0.2", + "envalid": "^7.3.1", + "fast-csv": "^4.3.6", + "rimraf": "^3.0.2", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@nestjs/cli": "^9.1.5", + "@types/chai": "^4.3.4", + "@types/express": "^4.17.14", + "@types/mocha": "^10.0.0", + "@types/multer": "^1.4.7", + "@types/node": "^18.7.18", + "chai": "^4.3.7", + "mocha": "^10.1.0", + "nodemon": "^2.0.20", + "ts-loader": "^9.4.1", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.1.0", + "typescript": "^4.8.3" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint --fix" + ] + } +} diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development new file mode 100644 index 000000000..76cf09286 --- /dev/null +++ b/apps/api/src/.env.development @@ -0,0 +1,15 @@ +NODE_ENV=local +PORT=3000 +FRONT_BASE_URL=http://localhost:4200 +RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 + +# Database +MONGO_URL=mongodb://localhost:27017/impler-db + +# Storage +S3_LOCAL_STACK=http://localhost:4566 +S3_REGION=us-east-1 +S3_BUCKET_NAME=impler + +# Security +ACCESS-KEY= diff --git a/apps/api/src/.env.production b/apps/api/src/.env.production new file mode 100644 index 000000000..76cf09286 --- /dev/null +++ b/apps/api/src/.env.production @@ -0,0 +1,15 @@ +NODE_ENV=local +PORT=3000 +FRONT_BASE_URL=http://localhost:4200 +RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 + +# Database +MONGO_URL=mongodb://localhost:27017/impler-db + +# Storage +S3_LOCAL_STACK=http://localhost:4566 +S3_REGION=us-east-1 +S3_BUCKET_NAME=impler + +# Security +ACCESS-KEY= diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test new file mode 100644 index 000000000..0e9640aef --- /dev/null +++ b/apps/api/src/.env.test @@ -0,0 +1,5 @@ +NODE_ENV=local +PORT=3000 +FRONT_BASE_URL=http://localhost:4200 + +MONGO_URL=mongodb://localhost:27017/impler-db \ No newline at end of file diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env new file mode 100644 index 000000000..76cf09286 --- /dev/null +++ b/apps/api/src/.example.env @@ -0,0 +1,15 @@ +NODE_ENV=local +PORT=3000 +FRONT_BASE_URL=http://localhost:4200 +RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 + +# Database +MONGO_URL=mongodb://localhost:27017/impler-db + +# Storage +S3_LOCAL_STACK=http://localhost:4566 +S3_REGION=us-east-1 +S3_BUCKET_NAME=impler + +# Security +ACCESS-KEY= diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 000000000..3452e84e9 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,31 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { Type } from '@nestjs/common/interfaces/type.interface'; +import { ForwardReference } from '@nestjs/common/interfaces/modules/forward-reference.interface'; +import { SharedModule } from './app/shared/shared.module'; +import { ProjectModule } from './app/project/project.module'; +import { TemplateModule } from './app/template/template.module'; +import { ColumnModule } from './app/column/column.module'; +import { UploadModule } from './app/upload/upload.module'; +import { MappingModule } from './app/mapping/mapping.module'; +import { ReviewModule } from './app/review/review.module'; +import { CommonModule } from './app/common/common.module'; + +const modules: Array | ForwardReference> = [ + ProjectModule, + SharedModule, + TemplateModule, + ColumnModule, + UploadModule, + MappingModule, + ReviewModule, + CommonModule, +]; + +const providers = []; + +@Module({ + imports: modules, + controllers: [], + providers, +}) +export class AppModule {} diff --git a/apps/api/src/app/column/column.controller.ts b/apps/api/src/app/column/column.controller.ts new file mode 100644 index 000000000..6f563f0c1 --- /dev/null +++ b/apps/api/src/app/column/column.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Put, Param, Body, Get, ParseArrayPipe, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBody, ApiOperation, ApiSecurity } from '@nestjs/swagger'; +import { ValidateMongoId } from '../shared/validations/valid-mongo-id.validation'; +import { UpdateColumnRequestDto } from './dtos/update-column-request.dto'; +import { UpdateColumnCommand } from './usecases/update-columns/update-columns.command'; +import { UpdateColumns } from './usecases/update-columns/update-columns.usecase'; +import { ColumnResponseDto } from './dtos/column-response.dto'; +import { GetColumns } from './usecases/get-columns/get-columns.usecase'; +import { APIKeyGuard } from '../shared/framework/auth.gaurd'; +import { ACCESS_KEY_NAME } from '@impler/shared'; + +@Controller('/column') +@ApiTags('Column') +@ApiSecurity(ACCESS_KEY_NAME) +@UseGuards(APIKeyGuard) +export class ColumnController { + constructor(private updateColumns: UpdateColumns, private getColumns: GetColumns) {} + + @Put(':templateId') + @ApiOperation({ + summary: 'Update columns for Template', + }) + @ApiBody({ type: [UpdateColumnRequestDto] }) + async updateTemplateColumns( + @Param('templateId', ValidateMongoId) _templateId: string, + @Body(new ParseArrayPipe({ items: UpdateColumnRequestDto })) body: UpdateColumnRequestDto[] + ): Promise { + return this.updateColumns.execute( + body.map((columnData) => + UpdateColumnCommand.create({ + key: columnData.key, + alternateKeys: columnData.alternateKeys, + isRequired: columnData.isRequired, + isUnique: columnData.isUnique, + name: columnData.name, + regex: columnData.regex, + regexDescription: columnData.regexDescription, + selectValues: columnData.selectValues, + sequence: columnData.sequence, + _templateId, + type: columnData.type, + }) + ), + _templateId + ); + } + + @Get(':templateId') + @ApiOperation({ + summary: 'Get template columns', + }) + async getTemplateColumns(@Param('templateId') _templateId: string): Promise { + return this.getColumns.execute(_templateId); + } +} diff --git a/apps/api/src/app/column/column.module.ts b/apps/api/src/app/column/column.module.ts new file mode 100644 index 000000000..12faa3a6f --- /dev/null +++ b/apps/api/src/app/column/column.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { ColumnController } from './column.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [ColumnController], +}) +export class ColumnModule {} diff --git a/apps/api/src/app/column/dtos/column-response.dto.ts b/apps/api/src/app/column/dtos/column-response.dto.ts new file mode 100644 index 000000000..eac619751 --- /dev/null +++ b/apps/api/src/app/column/dtos/column-response.dto.ts @@ -0,0 +1,57 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ColumnTypesEnum } from '@impler/shared'; + +export class ColumnResponseDto { + @ApiProperty({ + description: 'Name of the column', + type: 'string', + }) + name: string; + + @ApiProperty({ + description: 'Key of the column', + }) + key: string; + + @ApiProperty({ + description: 'Alternative possible keys of the column', + type: Array, + }) + alternateKeys: string[]; + + @ApiPropertyOptional({ + description: 'While true, it Indicates column value should exists in data', + }) + isRequired = false; + + @ApiPropertyOptional({ + description: 'While true, it Indicates column value should be unique in data', + }) + isUnique = false; + + @ApiProperty({ + description: 'Specifies the type of column', + enum: ColumnTypesEnum, + }) + type: string; + + @ApiPropertyOptional({ + description: 'Regex if type is Regex', + }) + regex: string; + + @ApiPropertyOptional({ + description: 'Description of Regex', + }) + regexDescription: string; + + @ApiPropertyOptional({ + description: 'List of possible values for column if type is Select', + }) + selectValues: string[]; + + @ApiProperty({ + description: 'Sequence of column', + }) + sequence: number; +} diff --git a/apps/api/src/app/column/dtos/update-column-request.dto.ts b/apps/api/src/app/column/dtos/update-column-request.dto.ts new file mode 100644 index 000000000..076354b1e --- /dev/null +++ b/apps/api/src/app/column/dtos/update-column-request.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDefined, + IsOptional, + IsString, + IsEnum, + IsNumber, + ValidateIf, + IsNotEmpty, + Validate, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ColumnTypesEnum } from '@impler/shared'; +import { IsValidRegex } from '../../shared/framework/is-valid-regex.validator'; + +export class UpdateColumnRequestDto { + @ApiProperty({ + description: 'Name of the column', + type: 'string', + }) + @IsString() + @IsDefined() + name: string; + + @ApiProperty({ + description: 'Key of the column', + }) + @IsString() + @IsDefined() + key: string; + + @ApiProperty({ + description: 'Alternative possible keys of the column', + type: Array, + }) + @IsArray() + @IsOptional() + @Type(() => Array) + alternateKeys: string[]; + + @ApiPropertyOptional({ + description: 'While true, it Indicates column value should exists in data', + }) + @IsBoolean() + @IsOptional() + isRequired = false; + + @ApiPropertyOptional({ + description: 'While true, it Indicates column value should be unique in data', + }) + @IsBoolean() + @IsOptional() + isUnique = false; + + @ApiProperty({ + description: 'Specifies the type of column', + enum: ColumnTypesEnum, + }) + @IsEnum(ColumnTypesEnum, { + message: `type must be one of ${Object.values(ColumnTypesEnum).join(', ')}`, + }) + @IsDefined() + type: ColumnTypesEnum; + + @ApiPropertyOptional({ + description: 'Regex if type is Regex', + }) + @ValidateIf((object) => object.type === ColumnTypesEnum.REGEX) + @Validate(IsValidRegex) + @IsNotEmpty() + regex: string; + + @ApiPropertyOptional({ + description: 'Description of Regex', + }) + @ValidateIf((object) => object.type === ColumnTypesEnum.REGEX) + @IsString() + @IsOptional() + regexDescription: string; + + @ApiPropertyOptional({ + description: 'List of possible values for column if type is Select', + }) + @ValidateIf((object) => object.type === ColumnTypesEnum.SELECT) + @IsArray() + @ArrayMinSize(1) + @Type(() => Array) + selectValues: string[]; + + @ApiProperty({ + description: 'Sequence of column', + }) + @IsNumber() + @IsOptional() + sequence: number; +} diff --git a/apps/api/src/app/column/usecases/get-columns/get-columns.usecase.ts b/apps/api/src/app/column/usecases/get-columns/get-columns.usecase.ts new file mode 100644 index 000000000..7c6716a13 --- /dev/null +++ b/apps/api/src/app/column/usecases/get-columns/get-columns.usecase.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { ColumnRepository } from '@impler/dal'; + +@Injectable() +export class GetColumns { + constructor(private columnRepository: ColumnRepository) {} + + async execute(_templateId: string) { + return this.columnRepository.find({ _templateId }); + } +} diff --git a/apps/api/src/app/column/usecases/index.ts b/apps/api/src/app/column/usecases/index.ts new file mode 100644 index 000000000..4ec139175 --- /dev/null +++ b/apps/api/src/app/column/usecases/index.ts @@ -0,0 +1,8 @@ +import { UpdateColumns } from './update-columns/update-columns.usecase'; +import { GetColumns } from './get-columns/get-columns.usecase'; + +export const USE_CASES = [ + UpdateColumns, + GetColumns, + // +]; diff --git a/apps/api/src/app/column/usecases/update-columns/update-columns.command.ts b/apps/api/src/app/column/usecases/update-columns/update-columns.command.ts new file mode 100644 index 000000000..cd0133278 --- /dev/null +++ b/apps/api/src/app/column/usecases/update-columns/update-columns.command.ts @@ -0,0 +1,51 @@ +import { IsArray, IsBoolean, IsDefined, IsMongoId, IsNumber, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ColumnTypesEnum } from '@impler/shared'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class UpdateColumnCommand extends BaseCommand { + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsDefined() + key: string; + + @IsArray() + @IsOptional() + @Type(() => Array) + alternateKeys: string[]; + + @IsBoolean() + @IsOptional() + isRequired = false; + + @IsBoolean() + @IsOptional() + isUnique = false; + + @IsDefined() + type: ColumnTypesEnum; + + @IsString() + @IsOptional() + regex: string; + + @IsString() + @IsOptional() + regexDescription: string; + + @IsArray() + @IsOptional() + @Type(() => Array) + selectValues: string[]; + + @IsNumber() + @IsOptional() + sequence: number; + + @IsDefined() + @IsMongoId() + _templateId: string; +} diff --git a/apps/api/src/app/column/usecases/update-columns/update-columns.usecase.ts b/apps/api/src/app/column/usecases/update-columns/update-columns.usecase.ts new file mode 100644 index 000000000..79f67d524 --- /dev/null +++ b/apps/api/src/app/column/usecases/update-columns/update-columns.usecase.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { FileMimeTypesEnum } from '@impler/shared'; +import { ColumnRepository, TemplateRepository } from '@impler/dal'; +import { UpdateColumnCommand } from './update-columns.command'; +import { StorageService } from '../../../shared/storage/storage.service'; +import { FileNameService } from '../../../shared/file/name.service'; + +@Injectable() +export class UpdateColumns { + constructor( + private columnRepository: ColumnRepository, + private storageService: StorageService, + private fileNameService: FileNameService, + private templateRepository: TemplateRepository + ) {} + + async execute(command: UpdateColumnCommand[], _templateId: string) { + await this.columnRepository.deleteMany({ _templateId }); + this.saveSampleFile(command, _templateId); + + return this.columnRepository.createMany(command); + } + + async saveSampleFile(data: UpdateColumnCommand[], templateId: string) { + const csvContent = this.createCSVFileHeadingContent(data); + const fileName = this.fileNameService.getSampleFileName(templateId); + const sampleFileUrl = this.fileNameService.getSampleFileUrl(templateId); + await this.storageService.uploadFile(fileName, csvContent, FileMimeTypesEnum.CSV, true); + await this.templateRepository.update({ _id: templateId }, { sampleFileUrl }); + } + + createCSVFileHeadingContent(data: UpdateColumnCommand[]): string { + const headings = data.map((column) => column.key); + + return headings.join(','); + } +} diff --git a/apps/api/src/app/common/common.controller.ts b/apps/api/src/app/common/common.controller.ts new file mode 100644 index 000000000..08431566c --- /dev/null +++ b/apps/api/src/app/common/common.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiSecurity } from '@nestjs/swagger'; +import { ACCESS_KEY_NAME } from '@impler/shared'; +import { APIKeyGuard } from '../shared/framework/auth.gaurd'; +import { ValidRequestDto } from './dtos/valid.dto'; +import { ValidRequestCommand } from './usecases/valid-request/valid-request.command'; +import { ValidRequest } from './usecases/valid-request/valid-request.usecase'; + +@Controller('/common') +@ApiTags('Common') +@ApiSecurity(ACCESS_KEY_NAME) +@UseGuards(APIKeyGuard) +export class CommonController { + constructor(private validRequest: ValidRequest) {} + + @Post('/valid') + @ApiOperation({ + summary: 'Check if request is valid (Checks Auth)', + }) + async isRequestValid(@Body() body: ValidRequestDto): Promise { + return this.validRequest.execute( + ValidRequestCommand.create({ + projectId: body.projectId, + template: body.template, + }) + ); + } +} diff --git a/apps/api/src/app/common/common.module.ts b/apps/api/src/app/common/common.module.ts new file mode 100644 index 000000000..7f3fe1611 --- /dev/null +++ b/apps/api/src/app/common/common.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { CommonController } from './common.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [CommonController], +}) +export class CommonModule {} diff --git a/apps/api/src/app/common/dtos/valid.dto.ts b/apps/api/src/app/common/dtos/valid.dto.ts new file mode 100644 index 000000000..edc221b5b --- /dev/null +++ b/apps/api/src/app/common/dtos/valid.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsOptional, IsString, IsMongoId } from 'class-validator'; + +export class ValidRequestDto { + @ApiProperty({ + description: 'Id of the project', + }) + @IsMongoId() + @IsDefined() + projectId: string; + + @ApiProperty({ + description: 'ID or Code of the template', + }) + @IsString() + @IsOptional() + template?: string; +} diff --git a/apps/api/src/app/common/usecases/index.ts b/apps/api/src/app/common/usecases/index.ts new file mode 100644 index 000000000..f04a8fb27 --- /dev/null +++ b/apps/api/src/app/common/usecases/index.ts @@ -0,0 +1,6 @@ +import { ValidRequest } from './valid-request/valid-request.usecase'; + +export const USE_CASES = [ + ValidRequest, + // +]; diff --git a/apps/api/src/app/common/usecases/valid-request/valid-request.command.ts b/apps/api/src/app/common/usecases/valid-request/valid-request.command.ts new file mode 100644 index 000000000..b726a48ce --- /dev/null +++ b/apps/api/src/app/common/usecases/valid-request/valid-request.command.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class ValidRequestCommand extends BaseCommand { + @IsMongoId() + @IsDefined() + projectId: string; + + @IsString() + @IsOptional() + template: string; +} diff --git a/apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts b/apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts new file mode 100644 index 000000000..89dd30df3 --- /dev/null +++ b/apps/api/src/app/common/usecases/valid-request/valid-request.usecase.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectRepository, TemplateRepository, CommonRepository } from '@impler/dal'; +import { ValidRequestCommand } from './valid-request.command'; +import { DocumentNotFoundException } from '../../../shared/exceptions/document-not-found.exception'; +import { APIMessages } from '../../../shared/constants'; + +@Injectable() +export class ValidRequest { + constructor( + private projectRepository: ProjectRepository, + private templateRepository: TemplateRepository, + private commonRepository: CommonRepository + ) {} + + async execute(command: ValidRequestCommand) { + if (command.template) { + const isMongoId = this.commonRepository.validMongoId(command.template); + const templateCount = await this.templateRepository.count( + isMongoId + ? { _id: command.template, _projectId: command.projectId } + : { code: command.template, _projectId: command.projectId } + ); + if (!templateCount) { + throw new DocumentNotFoundException('Template', command.template, APIMessages.PROJECT_WITH_TEMPLATE_MISSING); + } + } else { + const projectCount = await this.projectRepository.count({ + _id: command.projectId, + }); + if (!projectCount) { + throw new DocumentNotFoundException('Project', command.projectId); + } + } + + return true; + } +} diff --git a/apps/api/src/app/mapping/dtos/update-columns.dto.ts b/apps/api/src/app/mapping/dtos/update-columns.dto.ts new file mode 100644 index 000000000..97a1cee32 --- /dev/null +++ b/apps/api/src/app/mapping/dtos/update-columns.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDefined, IsString, IsMongoId } from 'class-validator'; + +export class UpdateMappingDto { + @ApiProperty({ + description: 'Id of the column', + }) + @IsMongoId() + @IsDefined() + _columnId: string; + + @ApiProperty({ + description: 'Selected Heading for column', + }) + @IsDefined() + @IsString() + columnHeading: string; +} diff --git a/apps/api/src/app/mapping/mapping.controller.ts b/apps/api/src/app/mapping/mapping.controller.ts new file mode 100644 index 000000000..af7e7ada7 --- /dev/null +++ b/apps/api/src/app/mapping/mapping.controller.ts @@ -0,0 +1,111 @@ +import { Body, Controller, Get, Param, ParseArrayPipe, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiSecurity, ApiOperation, ApiBody } from '@nestjs/swagger'; +import { ACCESS_KEY_NAME, UploadStatusEnum } from '@impler/shared'; +import { MappingEntity } from '@impler/dal'; + +import { APIKeyGuard } from '../shared/framework/auth.gaurd'; +import { ValidateMongoId } from '../shared/validations/valid-mongo-id.validation'; +import { GetUploadCommand } from '../shared/usecases/get-upload/get-upload.command'; +import { DoMapping } from './usecases/do-mapping/do-mapping.usecase'; +import { DoMappingCommand } from './usecases/do-mapping/do-mapping.command'; +import { GetUpload } from '../shared/usecases/get-upload/get-upload.usecase'; +import { GetMappings } from './usecases/get-mappings/get-mappings.usecase'; +import { UpdateMappingCommand } from './usecases/update-mappings/update-mappings.command'; +import { UpdateMappings } from './usecases/update-mappings/update-mappings.usecase'; +import { FinalizeUpload } from './usecases/finalize-upload/finalize-upload.usecase'; +import { UpdateMappingDto } from './dtos/update-columns.dto'; +import { ValidateMapping } from './usecases/validate-mapping/validate-mapping.usecase'; +import { validateUploadStatus } from '../shared/helpers/upload.helpers'; +import { validateNotFound } from '../shared/helpers/common.helper'; + +@Controller('/mapping') +@ApiTags('Mappings') +@ApiSecurity(ACCESS_KEY_NAME) +@UseGuards(APIKeyGuard) +export class MappingController { + constructor( + private getUpload: GetUpload, + private doMapping: DoMapping, + private getMappings: GetMappings, + private updateMappings: UpdateMappings, + private finalizeUpload: FinalizeUpload, + private validateMapping: ValidateMapping + ) {} + + @Get(':uploadId') + @ApiOperation({ + summary: 'Get mapping information for uploaded file', + }) + async getMappingInformation(@Param('uploadId', ValidateMongoId) uploadId: string): Promise[]> { + const uploadInformation = await this.getUpload.execute( + GetUploadCommand.create({ + uploadId, + select: 'status headings _templateId', + }) + ); + + // throw error if upload information not found + validateNotFound(uploadInformation, 'upload'); + + // Get mappings can be called only when file is uploaded or it's mapping in progress + validateUploadStatus(uploadInformation.status as UploadStatusEnum, [ + UploadStatusEnum.UPLOADED, + UploadStatusEnum.MAPPING, + ]); + + if (uploadInformation.status === UploadStatusEnum.UPLOADED) { + await this.doMapping.execute( + DoMappingCommand.create({ + headings: uploadInformation.headings, + _templateId: uploadInformation._templateId, + _uploadId: uploadId, + }) + ); + } + + return this.getMappings.execute(uploadId); + } + + @Post(':uploadId/finalize') + @ApiOperation({ + summary: 'Finalize mappings for upload', + }) + @ApiBody({ type: [UpdateMappingDto] }) + async finalizeMappings( + @Param('uploadId', ValidateMongoId) _uploadId: string, + @Body(new ParseArrayPipe({ items: UpdateMappingDto, optional: true })) body: UpdateMappingDto[] + ) { + const uploadInformation = await this.getUpload.execute( + GetUploadCommand.create({ + uploadId: _uploadId, + select: 'status', + }) + ); + + // throw error if upload information not found + validateNotFound(uploadInformation, 'upload'); + + // Finalize mapping can only be called after the mapping has been completed + validateUploadStatus(uploadInformation.status as UploadStatusEnum, [UploadStatusEnum.MAPPING]); + + // validate mapping data + await this.validateMapping.execute(body, _uploadId); + + // save mapping + if (Array.isArray(body) && body.length > 0) { + this.updateMappings.execute( + body.map((updateColumnData) => + UpdateMappingCommand.create({ + _columnId: updateColumnData._columnId, + _uploadId, + columnHeading: updateColumnData.columnHeading, + }) + ), + _uploadId + ); + } + + // update mapping status + return this.finalizeUpload.execute(_uploadId); + } +} diff --git a/apps/api/src/app/mapping/mapping.module.ts b/apps/api/src/app/mapping/mapping.module.ts new file mode 100644 index 000000000..02443ad4a --- /dev/null +++ b/apps/api/src/app/mapping/mapping.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { MappingController } from './mapping.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [MappingController], +}) +export class MappingModule {} diff --git a/apps/api/src/app/mapping/usecases/do-mapping/do-mapping.command.ts b/apps/api/src/app/mapping/usecases/do-mapping/do-mapping.command.ts new file mode 100644 index 000000000..450a96448 --- /dev/null +++ b/apps/api/src/app/mapping/usecases/do-mapping/do-mapping.command.ts @@ -0,0 +1,16 @@ +import { IsArray, IsDefined, IsMongoId } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class DoMappingCommand extends BaseCommand { + @IsDefined() + @IsMongoId() + _uploadId: string; + + @IsDefined() + @IsMongoId() + _templateId: string; + + @IsDefined() + @IsArray() + headings: string[]; +} diff --git a/apps/api/src/app/mapping/usecases/do-mapping/do-mapping.usecase.ts b/apps/api/src/app/mapping/usecases/do-mapping/do-mapping.usecase.ts new file mode 100644 index 000000000..77c7cc3d6 --- /dev/null +++ b/apps/api/src/app/mapping/usecases/do-mapping/do-mapping.usecase.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { UploadStatusEnum } from '@impler/shared'; +import { ColumnEntity, ColumnRepository, MappingEntity, MappingRepository, UploadRepository } from '@impler/dal'; +import { DoMappingCommand } from './do-mapping.command'; + +@Injectable() +export class DoMapping { + constructor( + private columnRepository: ColumnRepository, + private mappingRepository: MappingRepository, + private uploadRepository: UploadRepository + ) {} + + async execute(command: DoMappingCommand) { + const columns = await this.columnRepository.find( + { + _templateId: command._templateId, + }, + 'key alternateKeys sequence', + { + sort: 'sequence', + } + ); + const mapping = this.buildMapping(columns, command.headings, command._uploadId); + const createdHeadings = await this.mappingRepository.createMany(mapping); + await this.uploadRepository.update({ _id: command._uploadId }, { status: UploadStatusEnum.MAPPING }); + + return createdHeadings; + } + + private buildMapping(columns: ColumnEntity[], headings: string[], _uploadId: string) { + const mappings: MappingEntity[] = []; + for (const column of columns) { + const heading = this.findBestMatchingHeading(headings, column.key, column.alternateKeys); + if (heading) { + mappings.push(this.buildMappingItem(column._id, _uploadId, heading)); + } else { + mappings.push(this.buildMappingItem(column._id, _uploadId)); + } + } + + return mappings; + } + + private findBestMatchingHeading(headings: string[], key: string, alternateKeys: string[]): string | null { + const mappedHeading = headings.find((heading: string) => this.checkStringEqual(heading, key)); + if (mappedHeading) { + // compare key + return mappedHeading; + } else if (Array.isArray(alternateKeys) && alternateKeys.length) { + // compare alternateKeys + const intersection = headings.find( + (heading: string) => !!alternateKeys.find((altKey) => this.checkStringEqual(altKey, heading)) + ); + + return intersection; + } + + return null; + } + + private checkStringEqual(a: string, b: string): boolean { + return String(a).localeCompare(String(b), undefined, { sensitivity: 'accent' }) === 0; + } + + private buildMappingItem(columnId: string, uploadId: string, heading?: string): MappingEntity { + return { + _columnId: columnId, + _uploadId: uploadId, + columnHeading: heading || null, + }; + } +} diff --git a/apps/api/src/app/mapping/usecases/finalize-upload/finalize-upload.usecase.ts b/apps/api/src/app/mapping/usecases/finalize-upload/finalize-upload.usecase.ts new file mode 100644 index 000000000..af74880e1 --- /dev/null +++ b/apps/api/src/app/mapping/usecases/finalize-upload/finalize-upload.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { UploadRepository } from '@impler/dal'; +import { UploadStatusEnum } from '@impler/shared'; + +@Injectable() +export class FinalizeUpload { + constructor(private uploadRepository: UploadRepository) {} + + async execute(_uploadId: string) { + return await this.uploadRepository.findOneAndUpdate({ _id: _uploadId }, { status: UploadStatusEnum.MAPPED }); + } +} diff --git a/apps/api/src/app/mapping/usecases/get-mappings/get-mappings.usecase.ts b/apps/api/src/app/mapping/usecases/get-mappings/get-mappings.usecase.ts new file mode 100644 index 000000000..5f0e79152 --- /dev/null +++ b/apps/api/src/app/mapping/usecases/get-mappings/get-mappings.usecase.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { MappingRepository } from '@impler/dal'; + +@Injectable() +export class GetMappings { + constructor(private mappingRepository: MappingRepository) {} + + async execute(_uploadId: string) { + return await this.mappingRepository.getMappingInfo(_uploadId); + } +} diff --git a/apps/api/src/app/mapping/usecases/index.ts b/apps/api/src/app/mapping/usecases/index.ts new file mode 100644 index 000000000..f1557f27e --- /dev/null +++ b/apps/api/src/app/mapping/usecases/index.ts @@ -0,0 +1,16 @@ +import { DoMapping } from './do-mapping/do-mapping.usecase'; +import { GetMappings } from './get-mappings/get-mappings.usecase'; +import { UpdateMappings } from './update-mappings/update-mappings.usecase'; +import { FinalizeUpload } from './finalize-upload/finalize-upload.usecase'; +import { ValidateMapping } from './validate-mapping/validate-mapping.usecase'; +import { GetUpload } from '../../shared/usecases/get-upload/get-upload.usecase'; + +export const USE_CASES = [ + DoMapping, + GetMappings, + UpdateMappings, + FinalizeUpload, + ValidateMapping, + GetUpload, + // +]; diff --git a/apps/api/src/app/mapping/usecases/update-mappings/update-mappings.command.ts b/apps/api/src/app/mapping/usecases/update-mappings/update-mappings.command.ts new file mode 100644 index 000000000..49b7c382f --- /dev/null +++ b/apps/api/src/app/mapping/usecases/update-mappings/update-mappings.command.ts @@ -0,0 +1,16 @@ +import { IsString, IsDefined, IsMongoId } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class UpdateMappingCommand extends BaseCommand { + @IsDefined() + @IsMongoId() + _columnId: string; + + @IsDefined() + @IsMongoId() + _uploadId: string; + + @IsDefined() + @IsString() + columnHeading: string; +} diff --git a/apps/api/src/app/mapping/usecases/update-mappings/update-mappings.usecase.ts b/apps/api/src/app/mapping/usecases/update-mappings/update-mappings.usecase.ts new file mode 100644 index 000000000..5a2bc0fb7 --- /dev/null +++ b/apps/api/src/app/mapping/usecases/update-mappings/update-mappings.usecase.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { MappingRepository } from '@impler/dal'; +import { UpdateMappingCommand } from './update-mappings.command'; + +@Injectable() +export class UpdateMappings { + constructor(private mappingRepository: MappingRepository) {} + + async execute(command: UpdateMappingCommand[], _uploadId: string) { + await this.mappingRepository.deleteMany({ _uploadId }); + + return this.mappingRepository.createMany(command); + } +} diff --git a/apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.command.ts b/apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.command.ts new file mode 100644 index 000000000..8e2815725 --- /dev/null +++ b/apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.command.ts @@ -0,0 +1,12 @@ +import { IsString, IsDefined, IsMongoId } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class ValidateMappingCommand extends BaseCommand { + @IsDefined() + @IsMongoId() + _columnId: string; + + @IsDefined() + @IsString() + columnHeading: string; +} diff --git a/apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.usecase.ts b/apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.usecase.ts new file mode 100644 index 000000000..c06301454 --- /dev/null +++ b/apps/api/src/app/mapping/usecases/validate-mapping/validate-mapping.usecase.ts @@ -0,0 +1,29 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ColumnRepository, UploadRepository } from '@impler/dal'; +import { ValidateMappingCommand } from './validate-mapping.command'; + +@Injectable() +export class ValidateMapping { + constructor(private columnRepository: ColumnRepository, private uploadRepository: UploadRepository) {} + + async execute(command: ValidateMappingCommand[], _uploadId: string) { + // check if mapping data contains duplicates + const mappings = [...new Map(command.map((item) => [item._columnId, item])).values()]; + if (mappings.length !== command.length) throw new BadRequestException('Mapping data contains duplicates'); + + // Check if mapping data _columnIds are valid + const columnIds = command.map((mapping) => ({ + _id: mapping._columnId, + })); + const count = await this.columnRepository.count({ + $or: [...columnIds], + }); + if (count !== command.length) throw new BadRequestException(`Mapping data contains invalid _columnId(s)`); + + // check if mapping data headings are valid + const columnHeadings = command.map((mapping) => mapping.columnHeading); + const uploadInfo = await this.uploadRepository.findById(_uploadId, 'headings'); + const isAllHeadingsAreValid = columnHeadings.every((heading) => uploadInfo.headings.includes(heading)); + if (!isAllHeadingsAreValid) throw new BadRequestException(`Mapping data contains invalid columnHeading values`); + } +} diff --git a/apps/api/src/app/project/dtos/create-project-request.dto.ts b/apps/api/src/app/project/dtos/create-project-request.dto.ts new file mode 100644 index 000000000..4cafa3ed0 --- /dev/null +++ b/apps/api/src/app/project/dtos/create-project-request.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDefined, IsOptional, IsString, Validate } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { changeToCode } from '@impler/shared'; +import { UniqueValidator } from '../../shared/framework/is-unique.validator'; + +export class CreateProjectRequestDto { + @ApiProperty({ + description: 'Name of the project', + }) + @IsString() + @IsDefined() + name: string; + + @ApiProperty({ + description: 'Code of the project', + }) + @IsString() + @IsDefined() + @Validate(UniqueValidator, ['Project', 'code'], { + message: 'Code is already taken', + }) + @Transform((value) => changeToCode(value.value)) + code: string; + + @ApiPropertyOptional({ + description: 'Name of authentication header to sent along the request', + }) + @IsString() + @IsOptional() + authHeaderName: string; +} diff --git a/apps/api/src/app/project/dtos/project-response.dto.ts b/apps/api/src/app/project/dtos/project-response.dto.ts new file mode 100644 index 000000000..f7848568b --- /dev/null +++ b/apps/api/src/app/project/dtos/project-response.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDefined, IsOptional, IsString } from 'class-validator'; + +export class ProjectResponseDto { + @ApiPropertyOptional({ + description: 'Id of the project', + }) + @IsString() + @IsDefined() + _id?: string; + + @ApiProperty({ + description: 'Name of the project', + }) + @IsString() + @IsDefined() + name: string; + + @ApiProperty({ + description: 'Code of the project', + }) + @IsString() + @IsDefined() + code: string; + + @ApiPropertyOptional({ + description: 'Name of authentication header to sent along the request', + }) + @IsString() + @IsOptional() + authHeaderName?: string; +} diff --git a/apps/api/src/app/project/dtos/update-project-request.dto.ts b/apps/api/src/app/project/dtos/update-project-request.dto.ts new file mode 100644 index 000000000..15c61f6d8 --- /dev/null +++ b/apps/api/src/app/project/dtos/update-project-request.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateProjectRequestDto { + @ApiPropertyOptional({ + description: 'Name of the project', + }) + @IsString() + @IsOptional() + name: string; + + @ApiPropertyOptional({ + description: 'Name of authentication header to sent along the request', + }) + @IsString() + @IsOptional() + authHeaderName: string; +} diff --git a/apps/api/src/app/project/project.controller.ts b/apps/api/src/app/project/project.controller.ts new file mode 100644 index 000000000..91d20ed82 --- /dev/null +++ b/apps/api/src/app/project/project.controller.ts @@ -0,0 +1,94 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags, ApiOkResponse, ApiSecurity } from '@nestjs/swagger'; +import { CreateProjectRequestDto } from './dtos/create-project-request.dto'; +import { ProjectResponseDto } from './dtos/project-response.dto'; +import { GetProjects } from './usecases/get-projects/get-projects.usecase'; +import { CreateProject } from './usecases/create-project/create-project.usecase'; +import { CreateProjectCommand } from './usecases/create-project/create-project.command'; +import { UpdateProjectRequestDto } from './dtos/update-project-request.dto'; +import { UpdateProject } from './usecases/update-project/update-project.usecase'; +import { UpdateProjectCommand } from './usecases/update-project/update-project.command'; +import { DeleteProject } from './usecases/delete-project/delete-project.usecase'; +import { DocumentNotFoundException } from '../shared/exceptions/document-not-found.exception'; +import { ValidateMongoId } from '../shared/validations/valid-mongo-id.validation'; +import { APIKeyGuard } from '../shared/framework/auth.gaurd'; +import { ACCESS_KEY_NAME } from '@impler/shared'; + +@Controller('/project') +@ApiTags('Project') +@ApiSecurity(ACCESS_KEY_NAME) +@UseGuards(APIKeyGuard) +export class ProjectController { + constructor( + private getProjectsUsecase: GetProjects, + private createProjectUsecase: CreateProject, + private updateProjectUsecase: UpdateProject, + private deleteProjectUsecase: DeleteProject + ) {} + + @Get('') + @ApiOperation({ + summary: 'Get projects', + }) + @ApiOkResponse({ + type: [ProjectResponseDto], + }) + getProjects(): Promise { + return this.getProjectsUsecase.execute(); + } + + @Post('') + @ApiOperation({ + summary: 'Create project', + }) + @ApiOkResponse({ + type: ProjectResponseDto, + }) + createProject(@Body() body: CreateProjectRequestDto): Promise { + return this.createProjectUsecase.execute( + CreateProjectCommand.create({ + code: body.code, + name: body.name, + authHeaderName: body.authHeaderName, + }) + ); + } + + @Put(':projectId') + @ApiOperation({ + summary: 'Update project', + }) + @ApiOkResponse({ + type: ProjectResponseDto, + }) + async updateProject( + @Body() body: UpdateProjectRequestDto, + @Param('projectId', ValidateMongoId) projectId: string + ): Promise { + const document = await this.updateProjectUsecase.execute( + UpdateProjectCommand.create({ name: body.name, authHeaderName: body.authHeaderName }), + projectId + ); + if (!document) { + throw new DocumentNotFoundException('Project', projectId); + } + + return document; + } + + @Delete(':projectId') + @ApiOperation({ + summary: 'Delete project', + }) + @ApiOkResponse({ + type: ProjectResponseDto, + }) + async deleteProject(@Param('projectId', ValidateMongoId) projectId: string): Promise { + const document = await this.deleteProjectUsecase.execute(projectId); + if (!document) { + throw new DocumentNotFoundException('Project', projectId); + } + + return document; + } +} diff --git a/apps/api/src/app/project/project.module.ts b/apps/api/src/app/project/project.module.ts new file mode 100644 index 000000000..6b404050f --- /dev/null +++ b/apps/api/src/app/project/project.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { SharedModule } from '../shared/shared.module'; +import { ProjectController } from './project.controller'; +import { UniqueValidator } from '../shared/framework/is-unique.validator'; + +@Module({ + imports: [SharedModule, UniqueValidator], + providers: [...USE_CASES], + controllers: [ProjectController], +}) +export class ProjectModule {} diff --git a/apps/api/src/app/project/usecases/create-project/create-project.command.ts b/apps/api/src/app/project/usecases/create-project/create-project.command.ts new file mode 100644 index 000000000..0bdca9e27 --- /dev/null +++ b/apps/api/src/app/project/usecases/create-project/create-project.command.ts @@ -0,0 +1,16 @@ +import { IsDefined, IsOptional, IsString } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class CreateProjectCommand extends BaseCommand { + @IsDefined() + @IsString() + name: string; + + @IsDefined() + @IsString() + code: string; + + @IsOptional() + @IsString() + authHeaderName: string; +} diff --git a/apps/api/src/app/project/usecases/create-project/create-project.usecase.ts b/apps/api/src/app/project/usecases/create-project/create-project.usecase.ts new file mode 100644 index 000000000..61ed4030a --- /dev/null +++ b/apps/api/src/app/project/usecases/create-project/create-project.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectRepository } from '@impler/dal'; +import { CreateProjectCommand } from './create-project.command'; + +@Injectable() +export class CreateProject { + constructor(private projectRepository: ProjectRepository) {} + + async execute(command: CreateProjectCommand) { + return this.projectRepository.create(command); + } +} diff --git a/apps/api/src/app/project/usecases/delete-project/delete-project.usecase.ts b/apps/api/src/app/project/usecases/delete-project/delete-project.usecase.ts new file mode 100644 index 000000000..9b43729f9 --- /dev/null +++ b/apps/api/src/app/project/usecases/delete-project/delete-project.usecase.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectRepository } from '@impler/dal'; + +@Injectable() +export class DeleteProject { + constructor(private projectRepository: ProjectRepository) {} + + async execute(id: string) { + return this.projectRepository.delete({ _id: id }); + } +} diff --git a/apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts b/apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts new file mode 100644 index 000000000..da5f7cdc5 --- /dev/null +++ b/apps/api/src/app/project/usecases/get-projects/get-projects.usecase.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectRepository } from '@impler/dal'; +import { ProjectResponseDto } from '../../dtos/project-response.dto'; + +@Injectable() +export class GetProjects { + constructor(private projectRepository: ProjectRepository) {} + + async execute(): Promise { + const projects = await this.projectRepository.find({}); + + return projects.map((project) => { + return { + _id: project._id, + name: project.name, + code: project.code, + authHeaderName: project.authHeaderName, + }; + }); + } +} diff --git a/apps/api/src/app/project/usecases/index.ts b/apps/api/src/app/project/usecases/index.ts new file mode 100644 index 000000000..6a77192e0 --- /dev/null +++ b/apps/api/src/app/project/usecases/index.ts @@ -0,0 +1,12 @@ +import { CreateProject } from './create-project/create-project.usecase'; +import { GetProjects } from './get-projects/get-projects.usecase'; +import { UpdateProject } from './update-project/update-project.usecase'; +import { DeleteProject } from './delete-project/delete-project.usecase'; + +export const USE_CASES = [ + GetProjects, + CreateProject, + UpdateProject, + DeleteProject, + // +]; diff --git a/apps/api/src/app/project/usecases/update-project/update-project.command.ts b/apps/api/src/app/project/usecases/update-project/update-project.command.ts new file mode 100644 index 000000000..befb7aa72 --- /dev/null +++ b/apps/api/src/app/project/usecases/update-project/update-project.command.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class UpdateProjectCommand extends BaseCommand { + @IsOptional() + @IsString() + name: string; + + @IsOptional() + @IsString() + authHeaderName: string; +} diff --git a/apps/api/src/app/project/usecases/update-project/update-project.usecase.ts b/apps/api/src/app/project/usecases/update-project/update-project.usecase.ts new file mode 100644 index 000000000..01eba6063 --- /dev/null +++ b/apps/api/src/app/project/usecases/update-project/update-project.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { ProjectRepository } from '@impler/dal'; +import { UpdateProjectCommand } from './update-project.command'; + +@Injectable() +export class UpdateProject { + constructor(private projectRepository: ProjectRepository) {} + + async execute(command: UpdateProjectCommand, id: string) { + return this.projectRepository.findOneAndUpdate({ _id: id }, command); + } +} diff --git a/apps/api/src/app/review/dtos/confirm-review-request.dto.ts b/apps/api/src/app/review/dtos/confirm-review-request.dto.ts new file mode 100644 index 000000000..040edc4ef --- /dev/null +++ b/apps/api/src/app/review/dtos/confirm-review-request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class ConfirmReviewRequestDto { + @ApiProperty({ + description: 'Boolean value indicating whether to process the invalid data or not.', + default: false, + required: false, + }) + @IsBoolean() + @IsOptional() + processInvalidRecords: boolean; +} diff --git a/apps/api/src/app/review/review.controller.ts b/apps/api/src/app/review/review.controller.ts new file mode 100644 index 000000000..593017c4d --- /dev/null +++ b/apps/api/src/app/review/review.controller.ts @@ -0,0 +1,114 @@ +import { BadRequestException, Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags, ApiSecurity, ApiQuery, ApiOkResponse } from '@nestjs/swagger'; +import { FileEntity, UploadEntity } from '@impler/dal'; +import { ACCESS_KEY_NAME, UploadStatusEnum } from '@impler/shared'; +import { APIMessages } from '@shared/constants'; +import { APIKeyGuard } from '@shared/framework/auth.gaurd'; +import { validateUploadStatus } from '@shared/helpers/upload.helpers'; +import { DoReview } from './usecases/do-review/do-review.usecase'; +import { GetUploadInvalidData } from './usecases/get-upload-invalid-data/get-upload-invalid-data.usecase'; +import { SaveReviewData } from './usecases/save-review-data/save-review-data.usecase'; +import { GetFileInvalidData } from './usecases/get-file-invalid-data/get-file-invalid-data.usecase'; +import { ValidateMongoId } from '@shared/validations/valid-mongo-id.validation'; +import { ConfirmReviewRequestDto } from './dtos/confirm-review-request.dto'; +import { GetUploadCommand } from '@shared/usecases/get-upload/get-upload.command'; +import { GetUpload } from '@shared/usecases/get-upload/get-upload.usecase'; +import { paginateRecords, validateNotFound } from '@shared/helpers/common.helper'; +import { StartProcess } from './usecases/start-process/start-process.usecase'; +import { StartProcessCommand } from './usecases/start-process/start-process.command'; +import { PaginationResponseDto } from '@shared/dtos/pagination-response.dto'; +import { Defaults } from '@shared/constants'; + +@Controller('/review') +@ApiTags('Review') +@ApiSecurity(ACCESS_KEY_NAME) +@UseGuards(APIKeyGuard) +export class ReviewController { + constructor( + private doReview: DoReview, + private getUpload: GetUpload, + private startProcess: StartProcess, + private saveReviewData: SaveReviewData, + private getFileInvalidData: GetFileInvalidData, + private getUploadInvalidData: GetUploadInvalidData + ) {} + + @Get(':uploadId') + @ApiOperation({ + summary: 'Get Review data for uploaded file', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + description: 'Page index of data to return', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + description: 'Size of data to return', + }) + @ApiOkResponse({ + description: 'Paginated reviewed data', + type: PaginationResponseDto, + }) + async getReview( + @Param('uploadId') _uploadId: string, + @Query('page') page = Defaults.PAGE, + @Query('limit') limit = Defaults.PAGE_LIMIT + ): Promise { + const uploadData = await this.getUploadInvalidData.execute(_uploadId); + if (!uploadData) throw new BadRequestException(APIMessages.UPLOAD_NOT_FOUND); + + // Only Mapped & Reviewing status are allowed + validateUploadStatus(uploadData.status as UploadStatusEnum, [UploadStatusEnum.MAPPED, UploadStatusEnum.REVIEWING]); + + // Get Invalid Data either from Validation or Validation Result + let invalidData = []; + if (uploadData.status === UploadStatusEnum.MAPPED) { + // uploaded file is mapped, do review + const reviewData = await this.doReview.execute(_uploadId); + // save invalid data to storage + this.saveReviewData.execute(_uploadId, reviewData.invalid, reviewData.valid); + + invalidData = reviewData.invalid; + } else { + // Uploaded file is already reviewed, return reviewed data + invalidData = await this.getFileInvalidData.execute( + (uploadData._invalidDataFileId as unknown as FileEntity).path + ); + } + + return paginateRecords(invalidData, page, limit); + } + + @Post(':uploadId/confirm') + @ApiOperation({ + summary: 'Confirm review data for uploaded file', + }) + async doConfirmReview( + @Param('uploadId', ValidateMongoId) _uploadId: string, + @Body() body: ConfirmReviewRequestDto + ): Promise { + const uploadInformation = await this.getUpload.execute( + GetUploadCommand.create({ + uploadId: _uploadId, + select: 'status', + }) + ); + + // throw error if upload information not found + validateNotFound(uploadInformation, 'upload'); + + // upload files with status reviewing can only be confirmed + validateUploadStatus(uploadInformation.status as UploadStatusEnum, [UploadStatusEnum.REVIEWING]); + + return this.startProcess.execute( + StartProcessCommand.create({ + _uploadId: _uploadId, + processInvalidRecords: body.processInvalidRecords, + }) + ); + } +} diff --git a/apps/api/src/app/review/review.module.ts b/apps/api/src/app/review/review.module.ts new file mode 100644 index 000000000..a1b78ba44 --- /dev/null +++ b/apps/api/src/app/review/review.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { ReviewController } from './review.controller'; +import { SharedModule } from '../shared/shared.module'; +import { AJVService } from './service/AJV.service'; +import { QueueService } from '../shared/storage/queue.service'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES, AJVService, QueueService], + controllers: [ReviewController], +}) +export class ReviewModule {} diff --git a/apps/api/src/app/review/service/AJV.service.spec.ts b/apps/api/src/app/review/service/AJV.service.spec.ts new file mode 100644 index 000000000..2c2a35550 --- /dev/null +++ b/apps/api/src/app/review/service/AJV.service.spec.ts @@ -0,0 +1,192 @@ +import { ColumnTypesEnum } from '@impler/shared'; +import { expect } from 'chai'; +import { AJVService } from './AJV.service'; + +describe('AJV Service', () => { + let ajvService = new AJVService(); + describe('isRequired', () => { + it('should mark data invalid if value is empty', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "username", isRequired: true, name: "Username", type: ColumnTypesEnum.STRING }], + [{ _columnId: "a", columnHeading: "username" }], + [{ username: "" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.equal("`username` must not be empty"); + expect(validationResult.valid.length).to.equal(0); + }); + it('should mark data invalid if value is empty for number type', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "id", isRequired: true, name: "ID", type: ColumnTypesEnum.NUMBER }], + [{ _columnId: "a", columnHeading: "id" }], + [{ id: "" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.include("`id` must not be empty"); + expect(validationResult.valid.length).to.equal(0); + }); + it('should mark data valid if value is not empty', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "username", isRequired: true, name: "Username", type: ColumnTypesEnum.STRING }], + [{ _columnId: "a", columnHeading: "username" }], + [{ username: "test" }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(1); + }); + }); + describe('isUnique', () => { + it('should mark data invalid if value is not unique', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "username", isUnique: true, name: "Username", type: ColumnTypesEnum.STRING }], + [{ _columnId: "a", columnHeading: "username" }], + [{ username: "test" }, { username: "test" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.equal("`username` must be unique"); + expect(validationResult.valid.length).to.equal(1); + }); + it('should mark data valid if value is unique', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "username", isUnique: true, name: "Username", type: ColumnTypesEnum.STRING }], + [{ _columnId: "a", columnHeading: "username" }], + [{ username: "test" }, { username: "test2" }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(2); + }); + }); + describe("Email", () => { + it('should mark data invalid if value is not a valid email', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "regemail", name: "Email", type: ColumnTypesEnum.EMAIL }], + [{ _columnId: "a", columnHeading: "regemail" }], + [{ regemail: "test" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.equal("`regemail` must be a valid email"); + expect(validationResult.valid.length).to.equal(0); + }); + it('should mark data valid if value is a valid email', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "regemail", name: "Email", type: ColumnTypesEnum.EMAIL }], + [{ _columnId: "a", columnHeading: "regemail" }], + [{ regemail: "test@gmail.com" }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(1); + }); + }) + describe("Regex", () => { + it('should mark data invalid if value does not match regex', () => { + const pattern = "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$"; + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "regemail", name: "Email", type: ColumnTypesEnum.REGEX, regex: "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" }], + [{ _columnId: "a", columnHeading: "regemail" }], + [{ regemail: "test" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.include('`regemail`' + ` must match the pattern /${pattern}/`); + expect(validationResult.valid.length).to.equal(0); + }); + it('should mark data valid if value matches regex', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "regemail", name: "Email", type: ColumnTypesEnum.REGEX, regex: "^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$" }], + [{ _columnId: "a", columnHeading: "regemail" }], + [{ regemail: "test@gmail.com" }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(1); + }); + }); + describe("Number", () => { + it('should mark data invalid if value is not a number', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "id", name: "ID", type: ColumnTypesEnum.NUMBER }], + [{ _columnId: "a", columnHeading: "id" }], + [{ id: "test" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.equal("`id` must be number"); + expect(validationResult.valid.length).to.equal(0); + }); + it('should mark data valid if value is a number', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "id", name: "ID", type: ColumnTypesEnum.NUMBER }], + [{ _columnId: "a", columnHeading: "id" }], + [{ id: 1 }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(1); + }); + }); + describe("Date", () => { + it('should mark data invalid if value is not a date', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "dob", name: "Date of Birth", type: ColumnTypesEnum.DATE }], + [{ _columnId: "a", columnHeading: "dob" }], + [{ dob: "test" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.equal("`dob` must be a valid date"); + expect(validationResult.valid.length).to.equal(0); + }); + it('should mark data valid if value is a date', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "dob", name: "Date of Birth", type: ColumnTypesEnum.DATE }], + [{ _columnId: "a", columnHeading: "dob" }], + [{ dob: "2020-01-01" }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(1); + }); + }); + describe('Select', () => { + it('should mark data invalid if value is not in select options', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "gender", name: "Gender", type: ColumnTypesEnum.SELECT, selectValues: ['Male', 'Female'] }], + [{ _columnId: "a", columnHeading: "gender" }], + [{ gender: "abcd" }] + ); + expect(validationResult.invalid.length).to.equal(1); + expect(validationResult.invalid[0].message).to.equal("`gender` must be from [Male,Female]"); + expect(validationResult.valid.length).to.equal(0); + }); + it('should mark data valid if value is in select options', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "gender", name: "Gender", type: ColumnTypesEnum.SELECT, selectValues: ['Male', 'Female'] }], + [{ _columnId: "a", columnHeading: "gender" }], + [{ gender: "Male" }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(1); + }); + }); + describe('Any', () => { + it('should mark data valid if value is any', () => { + let validationResult = ajvService.validate( + // @ts-ignore + [{ _id: "a", key: "any", name: "Any", type: ColumnTypesEnum.ANY }], + [{ _columnId: "a", columnHeading: "any" }], + [{ any: "test" }, { any: 1 }, { any: "2020-01-01" }] + ); + expect(validationResult.invalid.length).to.equal(0); + expect(validationResult.valid.length).to.equal(3); + }); + }) +}) diff --git a/apps/api/src/app/review/service/AJV.service.ts b/apps/api/src/app/review/service/AJV.service.ts new file mode 100644 index 000000000..09a063a54 --- /dev/null +++ b/apps/api/src/app/review/service/AJV.service.ts @@ -0,0 +1,216 @@ +import { Injectable } from '@nestjs/common'; +import { ColumnTypesEnum } from '@impler/shared'; +import { ColumnEntity, MappingEntity } from '@impler/dal'; +import Ajv, { ErrorObject, AnySchemaObject } from 'ajv'; +import addFormats from 'ajv-formats'; +import addKeywords from 'ajv-keywords'; + +const ajv = new Ajv({ + allErrors: true, + coerceTypes: true, + allowUnionTypes: true, + removeAdditional: true, + verbose: true, +}); +addFormats(ajv, ['email']); +addKeywords(ajv); +ajv.addFormat('custom-date-time', function (dateTimeString) { + if (typeof dateTimeString === 'object') { + dateTimeString = (dateTimeString as Date).toISOString(); + } + + return !isNaN(Date.parse(dateTimeString)); // any test that returns true/false +}); + +// Empty keyword +ajv.addKeyword({ + keyword: 'emptyCheck', + schema: false, + compile: () => { + return (data) => (data === undefined || data === null || data === '' ? false : true); + }, +}); + +// Unique keyword +let uniqueItems: Record> = {}; +ajv.addKeyword({ + keyword: 'uniqueCheck', + schema: false, // keyword value is not used, can be true + validate: function (data: any, dataPath: AnySchemaObject) { + if (uniqueItems[dataPath.parentDataProperty].has(data)) { + return false; + } + uniqueItems[dataPath.parentDataProperty].add(data); + + return true; + }, +}); + +@Injectable() +export class AJVService { + validate(columns: ColumnEntity[], mappings: MappingEntity[], data: any) { + const schema = this.buildAJVSchema(columns, mappings); + const validator = ajv.compile(schema); + + const valid = validator(data); + const returnData = { + invalid: [], + valid: [], + }; + if (!valid) { + const errors: Record = this.buildErrorRecords(validator.errors, data); + + returnData.invalid = Object.values(errors); + // eslint-disable-next-line no-magic-numbers + Object.keys(errors).forEach((index) => (data as any).splice(index as unknown as number, 1)); + } + returnData.valid = data as any; + // resetting uniqueItems + uniqueItems = {}; + + return returnData; + } + private buildAJVSchema(columns: ColumnEntity[], mappings: MappingEntity[]) { + const formattedColumns: Record = columns.reduce((acc, column) => { + acc[column._id] = { ...column }; + + return acc; + }, {}); + const properties: Record = mappings.reduce((acc, mapping) => { + acc[mapping.columnHeading] = this.getProperty(formattedColumns[mapping._columnId]); + + return acc; + }, {}); + const requiredProperties: string[] = mappings.reduce((acc, mapping) => { + if (formattedColumns[mapping._columnId].isRequired) acc.push(mapping.columnHeading); + + return acc; + }, []); + // setting uniqueItems to empty set to avoid error + mappings.forEach((mapping) => { + if (formattedColumns[mapping._columnId].isUnique) { + uniqueItems[mapping.columnHeading] = new Set(); + } + }); + const objectSchema = { + type: 'object', + properties, + required: requiredProperties, + additionalProperties: false, + }; + + return { + type: 'array', + items: objectSchema, + }; + } + private getProperty(column: ColumnEntity): Record { + let property: Record = {}; + + switch (column.type) { + case ColumnTypesEnum.STRING: + property = { + type: 'string', + }; + break; + case ColumnTypesEnum.NUMBER: + property = { + type: 'number', + }; + break; + case ColumnTypesEnum.SELECT: + property = { + type: 'string', + enum: column.selectValues || [], + }; + break; + case ColumnTypesEnum.REGEX: + const [full, pattern, flags] = column.regex.match(/\/(.*)\/(.*)|(.*)/); + + property = { type: 'string', regexp: { pattern: pattern || full, flags: flags || '' } }; + break; + case ColumnTypesEnum.EMAIL: + property = { type: 'string', format: 'email' }; + break; + case ColumnTypesEnum.DATE: + property = { type: 'string', format: 'custom-date-time' }; + break; + case ColumnTypesEnum.ANY: + property = { type: ['string', 'number', 'object'] }; + break; + } + + return { + ...property, + ...(column.isUnique && { uniqueCheck: true }), + ...(column.isRequired && { emptyCheck: true }), + }; + } + private buildErrorRecords(errors: ErrorObject[], data?: any[]) { + let index: string, field: string, message: string; + + return errors.reduce((acc, error) => { + [, index, field] = error.instancePath.split('/'); + // eslint-disable-next-line no-magic-numbers + message = this.getMessage(error, field || error.schema[0]); + + if (acc[index]) { + acc[index].message += `, ${message}`; + } else + acc[index] = { + index, + message, + ...data[index], + }; + + return acc; + }, {}); + } + private getMessage(error: ErrorObject, field: string): string { + let message = ''; + switch (true) { + // empty string case + case error.keyword === 'emptyCheck': + message = ` must not be empty`; + break; + // uniqueCheck + case error.keyword === 'uniqueCheck': + message = ` must be unique`; + break; + // custom date format + case error.keyword === 'format' && error.params.format === 'custom-date-time': + message = ` must be a valid date`; + break; + // common cases + case error.keyword === 'type': + message = ' ' + error.message; + break; + case error.keyword === 'enum': + message = ` must be from [${error.params.allowedValues}]`; + break; + case error.keyword === 'regexp': + message = ` must match the pattern ${new RegExp( + error.parentSchema?.regexp?.pattern, + error.parentSchema?.regexp?.flags || '' + ).toString()}`; + break; + case error.keyword === 'pattern': + message = ` must match the pattern ${error.params.pattern}`; + break; + case error.keyword === 'format': + message = ` must be a valid ${error.params.format}`; + break; + case error.keyword === 'required': + message = ` is required`; + break; + case error.keyword === 'uniqueItemProperties': + message = ` must be unique`; + break; + default: + message = ` contains invalid data`; + break; + } + + return '`' + field + '`' + message; + } +} diff --git a/apps/api/src/app/review/usecases/confirm-review/confirm-review.command.ts b/apps/api/src/app/review/usecases/confirm-review/confirm-review.command.ts new file mode 100644 index 000000000..80e0b97c5 --- /dev/null +++ b/apps/api/src/app/review/usecases/confirm-review/confirm-review.command.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsDefined, IsMongoId } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class ConfirmReviewCommand extends BaseCommand { + @IsDefined() + @IsMongoId() + _uploadId: string; + + @IsDefined() + @IsBoolean() + processInvalidRecords: boolean; +} diff --git a/apps/api/src/app/review/usecases/confirm-review/confirm-review.usecase.ts b/apps/api/src/app/review/usecases/confirm-review/confirm-review.usecase.ts new file mode 100644 index 000000000..681db332f --- /dev/null +++ b/apps/api/src/app/review/usecases/confirm-review/confirm-review.usecase.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { UploadEntity, UploadRepository } from '@impler/dal'; +import { ConfirmReviewCommand } from './confirm-review.command'; +import { UploadStatusEnum } from '@impler/shared'; + +@Injectable() +export class ConfirmReview { + constructor(private uploadRepository: UploadRepository) {} + + execute(command: ConfirmReviewCommand): Promise { + return this.uploadRepository.findOneAndUpdate( + { _id: command._uploadId }, + { status: UploadStatusEnum.CONFIRMED, processInvalidRecords: command.processInvalidRecords } + ); + } +} diff --git a/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts b/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts new file mode 100644 index 000000000..8faad0a82 --- /dev/null +++ b/apps/api/src/app/review/usecases/do-review/do-review.usecase.ts @@ -0,0 +1,52 @@ +import { FileEncodingsEnum, UploadStatusEnum } from '@impler/shared'; +import { Injectable, BadRequestException } from '@nestjs/common'; +import { ColumnRepository, UploadRepository, MappingRepository, FileEntity } from '@impler/dal'; +import { StorageService } from '../../../shared/storage/storage.service'; +import { AJVService } from '../../service/AJV.service'; +import { APIMessages } from '../../../shared/constants'; +import { FileNotExistError } from '../../../shared/errors/file-not-exist.error'; + +@Injectable() +export class DoReview { + constructor( + private uploadRepository: UploadRepository, + private storageService: StorageService, + private columnRepository: ColumnRepository, + private mappingRepository: MappingRepository, + private ajvService: AJVService + ) {} + + async execute(uploadId: string) { + const uploadInfo = await this.uploadRepository.getUploadInformation(uploadId); + if (!uploadInfo) { + throw new BadRequestException(APIMessages.UPLOAD_NOT_FOUND); + } + if (uploadInfo.status !== UploadStatusEnum.MAPPED) { + throw new BadRequestException(APIMessages.FILE_MAPPING_REMAINING); + } + const allDataFileInfo = uploadInfo._allDataFileId as unknown as FileEntity; + const dataContent = await this.getFileContent(allDataFileInfo.path); + const mappings = await this.mappingRepository.find({ _uploadId: uploadId }, '_columnId columnHeading'); + const columns = await this.columnRepository.find( + { _templateId: uploadInfo._templateId }, + 'isRequired isUnique selectValues type regex' + ); + const reviewData = this.ajvService.validate(columns, mappings, dataContent); + this.uploadRepository.update({ _id: uploadId }, { status: UploadStatusEnum.REVIEWING }); + + return reviewData; + } + + async getFileContent(path): Promise { + try { + const dataContent = await this.storageService.getFileContent(path, FileEncodingsEnum.JSON); + + return JSON.parse(dataContent); + } catch (error) { + if (error instanceof FileNotExistError) { + throw new BadRequestException(APIMessages.FILE_NOT_FOUND_IN_STORAGE); + } + throw error; + } + } +} diff --git a/apps/api/src/app/review/usecases/get-file-invalid-data/get-file-invalid-data.usecase.ts b/apps/api/src/app/review/usecases/get-file-invalid-data/get-file-invalid-data.usecase.ts new file mode 100644 index 000000000..bba552b15 --- /dev/null +++ b/apps/api/src/app/review/usecases/get-file-invalid-data/get-file-invalid-data.usecase.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { FileEncodingsEnum } from '@impler/shared'; +import { StorageService } from '../../../shared/storage/storage.service'; + +@Injectable() +export class GetFileInvalidData { + constructor(private storageService: StorageService) {} + + async execute(path: string) { + const stringContent = await this.storageService.getFileContent(path, FileEncodingsEnum.JSON); + + return JSON.parse(stringContent); + } +} diff --git a/apps/api/src/app/review/usecases/get-upload-invalid-data/get-upload-invalid-data.usecase.ts b/apps/api/src/app/review/usecases/get-upload-invalid-data/get-upload-invalid-data.usecase.ts new file mode 100644 index 000000000..e82990748 --- /dev/null +++ b/apps/api/src/app/review/usecases/get-upload-invalid-data/get-upload-invalid-data.usecase.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { UploadRepository } from '@impler/dal'; + +@Injectable() +export class GetUploadInvalidData { + constructor(private uploadRepository: UploadRepository) {} + + async execute(_uploadId: string) { + return this.uploadRepository.getUploadInvalidDataInformation(_uploadId); + } +} diff --git a/apps/api/src/app/review/usecases/index.ts b/apps/api/src/app/review/usecases/index.ts new file mode 100644 index 000000000..1d3ae1116 --- /dev/null +++ b/apps/api/src/app/review/usecases/index.ts @@ -0,0 +1,18 @@ +import { DoReview } from './do-review/do-review.usecase'; +import { SaveReviewData } from './save-review-data/save-review-data.usecase'; +import { GetUploadInvalidData } from './get-upload-invalid-data/get-upload-invalid-data.usecase'; +import { GetFileInvalidData } from './get-file-invalid-data/get-file-invalid-data.usecase'; +import { GetUpload } from '../../shared/usecases/get-upload/get-upload.usecase'; +import { ConfirmReview } from './confirm-review/confirm-review.usecase'; +import { StartProcess } from './start-process/start-process.usecase'; + +export const USE_CASES = [ + DoReview, + GetUpload, + StartProcess, + ConfirmReview, + SaveReviewData, + GetFileInvalidData, + GetUploadInvalidData, + // +]; diff --git a/apps/api/src/app/review/usecases/save-review-data/save-review-data.usecase.ts b/apps/api/src/app/review/usecases/save-review-data/save-review-data.usecase.ts new file mode 100644 index 000000000..288667f10 --- /dev/null +++ b/apps/api/src/app/review/usecases/save-review-data/save-review-data.usecase.ts @@ -0,0 +1,79 @@ +import * as XLSX from 'xlsx'; +import { Injectable } from '@nestjs/common'; +import { FileRepository, UploadRepository } from '@impler/dal'; +import { FileMimeTypesEnum } from '@impler/shared'; +import { FileNameService } from '@shared/file/name.service'; +import { StorageService } from '@shared/storage/storage.service'; +import { Defaults } from '@shared/constants'; + +@Injectable() +export class SaveReviewData { + constructor( + private fileNameService: FileNameService, + private storageService: StorageService, + private fileRepository: FileRepository, + private uploadRepository: UploadRepository + ) {} + + async execute(_uploadId: string, invalidData: any[], validData: any[]) { + const _invalidDataFileId = await this.storeInvalidFile(_uploadId, invalidData); + const _validDataFileId = await this.storeValidFile(_uploadId, validData); + const _invalidCSVDataFileId = await this.storeInvalidCSVFile(_uploadId, invalidData); + const invalidCSVDataFileUrl = this.fileNameService.getInvalidCSVDataFileUrl(_uploadId); + await this.uploadRepository.update( + { _id: _uploadId }, + { _invalidDataFileId, _validDataFileId, _invalidCSVDataFileId, invalidCSVDataFileUrl } + ); + } + + private async storeValidFile(_uploadId: string, validData: any[]): Promise { + if (validData.length < Defaults.DATA_LENGTH) return null; + + const strinValidData = JSON.stringify(validData); + const validFilePath = this.fileNameService.getValidDataFilePath(_uploadId); + await this.storeFile(validFilePath, strinValidData); + const validFileName = this.fileNameService.getValidDataFileName(); + const entry = await this.makeFileEntry(validFileName, validFilePath); + + return entry._id; + } + + private async storeInvalidFile(_uploadId: string, invalidData: any[]): Promise { + if (invalidData.length < Defaults.DATA_LENGTH) return null; + + const stringInvalidData = JSON.stringify(invalidData); + const invalidFilePath = this.fileNameService.getInvalidDataFilePath(_uploadId); + await this.storeFile(invalidFilePath, stringInvalidData); + const invalidFileName = this.fileNameService.getInvalidDataFileName(); + const entry = await this.makeFileEntry(invalidFileName, invalidFilePath); + + return entry._id; + } + + private async storeInvalidCSVFile(_uploadId: string, invalidData: any[]): Promise { + if (invalidData.length < Defaults.DATA_LENGTH) return null; + + const ws = XLSX.utils.json_to_sheet(invalidData); + const invalidCSVDataContent = XLSX.utils.sheet_to_csv(ws, { FS: ',' }); + + const invalidCSVFilePath = this.fileNameService.getInvalidCSVDataFilePath(_uploadId); + await this.storeFile(invalidCSVFilePath, invalidCSVDataContent, true); + const invalidCSVFileName = this.fileNameService.getInvalidCSVDataFileName(); + const entry = await this.makeFileEntry(invalidCSVFileName, invalidCSVFilePath); + + return entry._id; + } + + private async storeFile(invalidFilePath: string, data: string, isPublic = false) { + await this.storageService.uploadFile(invalidFilePath, data, FileMimeTypesEnum.JSON, isPublic); + } + + private async makeFileEntry(fileName: string, filePath: string) { + return await this.fileRepository.create({ + mimeType: FileMimeTypesEnum.JSON, + name: fileName, + originalName: fileName, + path: filePath, + }); + } +} diff --git a/apps/api/src/app/review/usecases/start-process/start-process.command.ts b/apps/api/src/app/review/usecases/start-process/start-process.command.ts new file mode 100644 index 000000000..c44f7c9d8 --- /dev/null +++ b/apps/api/src/app/review/usecases/start-process/start-process.command.ts @@ -0,0 +1,12 @@ +import { IsBoolean, IsDefined, IsMongoId } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class StartProcessCommand extends BaseCommand { + @IsDefined() + @IsMongoId() + _uploadId: string; + + @IsDefined() + @IsBoolean() + processInvalidRecords: boolean; +} diff --git a/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts new file mode 100644 index 000000000..d6e1cdf70 --- /dev/null +++ b/apps/api/src/app/review/usecases/start-process/start-process.usecase.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { UploadEntity, UploadRepository } from '@impler/dal'; +import { StartProcessCommand } from './start-process.command'; +import { QueuesEnum, UploadStatusEnum } from '@impler/shared'; +import { QueueService } from '../../../shared/storage/queue.service'; + +@Injectable() +export class StartProcess { + constructor(private uploadRepository: UploadRepository, private queueService: QueueService) {} + + async execute(command: StartProcessCommand): Promise { + const upload = await this.uploadRepository.findOneAndUpdate( + { _id: command._uploadId }, + { status: UploadStatusEnum.PROCESSING, processInvalidRecords: command.processInvalidRecords } + ); + + this.queueService.publishToQueue(QueuesEnum.PROCESS_FILE, { uploadId: command._uploadId }); + + return upload; + } +} diff --git a/apps/api/src/app/shared/commands/base.command.ts b/apps/api/src/app/shared/commands/base.command.ts new file mode 100644 index 000000000..0044a4b75 --- /dev/null +++ b/apps/api/src/app/shared/commands/base.command.ts @@ -0,0 +1,21 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { plainToClass } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { BadRequestException, flatten } from '@nestjs/common'; + +export abstract class BaseCommand { + static create(this: new (...args: any[]) => T, data: T): T { + const convertedObject = plainToClass(this, { + ...data, + }); + + const errors = validateSync(convertedObject as unknown as object); + if (errors?.length) { + const mappedErrors = flatten(errors.map((item) => Object.values(item.constraints))); + + throw new BadRequestException(mappedErrors); + } + + return convertedObject; + } +} diff --git a/apps/api/src/app/shared/constants.ts b/apps/api/src/app/shared/constants.ts new file mode 100644 index 000000000..cfb3ea917 --- /dev/null +++ b/apps/api/src/app/shared/constants.ts @@ -0,0 +1,23 @@ +export const APIMessages = { + FILE_TYPE_NOT_VALID: 'File type is not supported.', + FILE_IS_EMPTY: 'File is empty', + EMPTY_HEADING_PREFIX: 'Empty Heading', + INVALID_TEMPLATE_ID_CODE_SUFFIX: 'is not valid TemplateId or CODE.', + FILE_MAPPING_REMAINING: 'File mapping is not yet done, please finalize mapping before.', + UPLOAD_NOT_FOUND: 'Upload information not found with specified uploadId.', + FILE_NOT_FOUND_IN_STORAGE: + "File not found, make sure you're using the same storage provider, that you were using before.", + DO_MAPPING_FIRST: 'You may landed to wrong place, Please finalize mapping and proceed ahead.', + DO_REVIEW_FIRST: 'You may landed to wrong place, Please review data and proceed ahead.', + DO_CONFIRM_FIRST: 'You may landed to wrong place, Please confirm data and proceed ahead.', + ALREADY_CONFIRMED: '`You may landed to wrong place, This upload file is confirmed already.', + IN_PROGRESS: 'You may landed to wrong place, This uploaded file processing is started already.', + COMPLETED: 'You may landed to wrong place, This uploaded file is already completed, no more steps left to perform.', + PROJECT_WITH_TEMPLATE_MISSING: 'Template not found with provided ProjectId and Template', +}; + +export const Defaults = { + DATA_LENGTH: 1, + PAGE: 1, + PAGE_LIMIT: 100, +}; diff --git a/apps/api/src/app/shared/dtos/pagination-response.dto.ts b/apps/api/src/app/shared/dtos/pagination-response.dto.ts new file mode 100644 index 000000000..ebe43e437 --- /dev/null +++ b/apps/api/src/app/shared/dtos/pagination-response.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsNumber } from 'class-validator'; + +export class PaginationResponseDto { + @ApiProperty({ + description: 'Page index of the data', + }) + @IsNumber() + page: number; + + @ApiProperty({ + description: 'Array of data', + }) + @IsArray() + data: any[]; + + @ApiProperty({ + description: 'Size of the data', + }) + @IsNumber() + limit: number; + + @ApiProperty({ + description: 'Total number of pages', + }) + @IsNumber() + totalPages: number; + + @ApiProperty({ + description: 'Total number of records', + }) + @IsNumber() + totalRecords: number; +} diff --git a/apps/api/src/app/shared/errors/file-not-exist.error.ts b/apps/api/src/app/shared/errors/file-not-exist.error.ts new file mode 100644 index 000000000..05e1c431a --- /dev/null +++ b/apps/api/src/app/shared/errors/file-not-exist.error.ts @@ -0,0 +1,6 @@ +export class FileNotExistError extends Error { + constructor() { + super('File not found for the key provided'); + this.name = 'NonExistingFileError'; + } +} diff --git a/apps/api/src/app/shared/exceptions/document-not-found.exception.ts b/apps/api/src/app/shared/exceptions/document-not-found.exception.ts new file mode 100644 index 000000000..46ba57e57 --- /dev/null +++ b/apps/api/src/app/shared/exceptions/document-not-found.exception.ts @@ -0,0 +1,7 @@ +import { NotFoundException } from '@nestjs/common'; + +export class DocumentNotFoundException extends NotFoundException { + constructor(name: string, id: string, message?: string) { + super(message || `${name} with id ${id} does not exist`); + } +} diff --git a/apps/api/src/app/shared/exceptions/empty-file.exception.ts b/apps/api/src/app/shared/exceptions/empty-file.exception.ts new file mode 100644 index 000000000..82153630f --- /dev/null +++ b/apps/api/src/app/shared/exceptions/empty-file.exception.ts @@ -0,0 +1,8 @@ +import { BadRequestException } from '@nestjs/common'; +import { APIMessages } from '../constants'; + +export class EmptyFileException extends BadRequestException { + constructor() { + super(APIMessages.FILE_IS_EMPTY); + } +} diff --git a/apps/api/src/app/shared/exceptions/file-not-valid.exception.ts b/apps/api/src/app/shared/exceptions/file-not-valid.exception.ts new file mode 100644 index 000000000..3c826a3a8 --- /dev/null +++ b/apps/api/src/app/shared/exceptions/file-not-valid.exception.ts @@ -0,0 +1,8 @@ +import { UnprocessableEntityException } from '@nestjs/common'; +import { APIMessages } from '../constants'; + +export class FileNotValidError extends UnprocessableEntityException { + constructor() { + super(APIMessages.FILE_TYPE_NOT_VALID); + } +} diff --git a/apps/api/src/app/shared/file/file.service.ts b/apps/api/src/app/shared/file/file.service.ts new file mode 100644 index 000000000..934152876 --- /dev/null +++ b/apps/api/src/app/shared/file/file.service.ts @@ -0,0 +1,75 @@ +import * as XLSX from 'xlsx'; +import { FileEncodingsEnum, IFileInformation } from '@impler/shared'; +import { ParserOptionsArgs, parseString } from 'fast-csv'; +import { StorageService } from '../storage/storage.service'; +import { EmptyFileException } from '../exceptions/empty-file.exception'; +import { APIMessages } from '../constants'; + +export abstract class FileService { + abstract getFileInformation(storageService: StorageService, storageKey: string): Promise; +} + +export class CSVFileService extends FileService { + async getFileInformation(storageService: StorageService, storageKey: string): Promise { + const fileContent = await storageService.getFileContent(storageKey, FileEncodingsEnum.CSV); + + return await this.getCSVInformation(fileContent, { headers: true }); + } + private async getCSVInformation(fileContent: string, options?: ParserOptionsArgs): Promise { + return new Promise((resolve, reject) => { + const information: IFileInformation = { + data: [], + headings: [], + totalRecords: 0, + }; + + parseString(fileContent, options) + .on('error', reject) + .on('headers', (headers) => information.headings.push(...headers)) + .on('data', (row) => information.data.push(row)) + .on('end', () => { + information.totalRecords = information.data.length; + resolve(information); + }); + }); + } +} +export class ExcelFileService extends FileService { + async getFileInformation(storageService: StorageService, storageKey: string): Promise { + const fileContent = await storageService.getFileContent(storageKey, FileEncodingsEnum.EXCEL); + + return this.getExcelInformation(fileContent); + } + async getExcelInformation(fileContent: string): Promise { + const workbookHeaders = XLSX.read(fileContent); + // Throw empty error if file do not have any sheets + if (workbookHeaders.SheetNames.length < 1) throw new EmptyFileException(); + + // get file headings + const headings = XLSX.utils.sheet_to_json(workbookHeaders.Sheets[workbookHeaders.SheetNames[0]], { + header: 1, + })[0] as string[]; + // throw error if sheet is empty + if (!headings || headings.length < 1) throw new EmptyFileException(); + + // Refine headings by replacing empty heading + let emptyHeadingCount = 0; + const updatedHeading = []; + for (const headingItem of headings) { + if (!headingItem) { + emptyHeadingCount += 1; + updatedHeading.push(`${APIMessages.EMPTY_HEADING_PREFIX} ${emptyHeadingCount}`); + } else updatedHeading.push(headingItem); + } + + const data: Record[] = XLSX.utils.sheet_to_json( + workbookHeaders.Sheets[workbookHeaders.SheetNames[0]] + ); + + return { + data, + headings: updatedHeading, + totalRecords: data.length, + }; + } +} diff --git a/apps/api/src/app/shared/file/name.service.ts b/apps/api/src/app/shared/file/name.service.ts new file mode 100644 index 000000000..e8a6aca9d --- /dev/null +++ b/apps/api/src/app/shared/file/name.service.ts @@ -0,0 +1,48 @@ +export class FileNameService { + getSampleFileName(templateId: string): string { + return `${templateId}/sample.csv`; + } + getSampleFileUrl(templateId: string): string { + const fileName = this.getSampleFileName(templateId); + + return [process.env.S3_LOCAL_STACK, process.env.S3_BUCKET_NAME, fileName].join('/'); + } + getFileExtension(fileName: string): string { + return fileName.split('.').pop(); + } + getUploadedFilePath(uploadId: string, fileName: string): string { + return `${uploadId}/${this.getUploadedFileName(fileName)}`; + } + getUploadedFileName(fileName: string): string { + return `uploaded.${this.getFileExtension(fileName)}`; + } + getAllJsonDataFileName(): string { + return `all-data.json`; + } + getAllJsonDataFilePath(uploadId: string): string { + return `${uploadId}/${this.getAllJsonDataFileName()}`; + } + getInvalidDataFileName(): string { + return `invalid-data.json`; + } + getInvalidDataFilePath(uploadId: string): string { + return `${uploadId}/${this.getInvalidDataFileName()}`; + } + getInvalidCSVDataFileName(): string { + return 'invalid-data.csv'; + } + getInvalidCSVDataFilePath(uploadId: string): string { + return `${uploadId}/${this.getInvalidCSVDataFileName()}`; + } + getInvalidCSVDataFileUrl(uploadId: string): string { + const path = this.getInvalidCSVDataFilePath(uploadId); + + return [process.env.S3_LOCAL_STACK, process.env.S3_BUCKET_NAME, path].join('/'); + } + getValidDataFileName(): string { + return `valid-data.json`; + } + getValidDataFilePath(uploadId: string): string { + return `${uploadId}/${this.getValidDataFileName()}`; + } +} diff --git a/apps/api/src/app/shared/framework/auth.gaurd.ts b/apps/api/src/app/shared/framework/auth.gaurd.ts new file mode 100644 index 000000000..2e5238e2c --- /dev/null +++ b/apps/api/src/app/shared/framework/auth.gaurd.ts @@ -0,0 +1,19 @@ +import { ExecutionContext, Injectable, CanActivate, UnauthorizedException } from '@nestjs/common'; +import { ACCESS_KEY_NAME } from '@impler/shared'; + +@Injectable() +export class APIKeyGuard implements CanActivate { + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + + const authorizationHeader = request.headers[ACCESS_KEY_NAME]; + const API_KEY = + process.env[String(ACCESS_KEY_NAME).toLowerCase()] || process.env[String(ACCESS_KEY_NAME).toUpperCase()]; + + if (API_KEY && API_KEY !== authorizationHeader) { + throw new UnauthorizedException(); + } + + return true; + } +} diff --git a/apps/api/src/app/shared/framework/is-unique.validator.ts b/apps/api/src/app/shared/framework/is-unique.validator.ts new file mode 100644 index 000000000..448e0f657 --- /dev/null +++ b/apps/api/src/app/shared/framework/is-unique.validator.ts @@ -0,0 +1,21 @@ +import { ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator'; +import { CommonRepository, ProjectEntity } from '@impler/dal'; + +@ValidatorConstraint({ name: 'IsUnique', async: true }) +export class UniqueValidator implements ValidatorConstraintInterface { + private commonRepository: CommonRepository; + constructor() { + this.commonRepository = new CommonRepository(); + } + + async validate(value: any, args: ValidationArguments) { + const [modelName, field] = args.constraints; + const count = await this.commonRepository.count(modelName, { [field]: value }); + + return !count; + } + + defaultMessage(args: ValidationArguments) { + return `${args.value} is already taken`; + } +} diff --git a/apps/api/src/app/shared/framework/is-valid-regex.validator.ts b/apps/api/src/app/shared/framework/is-valid-regex.validator.ts new file mode 100644 index 000000000..ec50e59fb --- /dev/null +++ b/apps/api/src/app/shared/framework/is-valid-regex.validator.ts @@ -0,0 +1,18 @@ +import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from 'class-validator'; + +@ValidatorConstraint({ name: 'IsValidRegex' }) +export class IsValidRegex implements ValidatorConstraintInterface { + validate(text: string) { + try { + new RegExp(text); + + return true; + } catch (error) { + return false; + } + } + + defaultMessage(args: ValidationArguments) { + return `Text (${args.value}) is not valid Regular expression!`; + } +} diff --git a/apps/api/src/app/shared/helpers/common.helper.ts b/apps/api/src/app/shared/helpers/common.helper.ts new file mode 100644 index 000000000..966998007 --- /dev/null +++ b/apps/api/src/app/shared/helpers/common.helper.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import { APIMessages } from '../constants'; +import { PaginationResult } from '@impler/shared'; + +export function validateNotFound(data: any, entityName: 'upload'): boolean { + if (data) return true; + else { + switch (entityName) { + case 'upload': + throw new BadRequestException(APIMessages.UPLOAD_NOT_FOUND); + default: + throw new BadRequestException(); + } + } +} + +export function paginateRecords(data: any[], page: number, limit: number): PaginationResult { + if (!page || page < 1) page = 1; + if (!limit || limit < 1) limit = 1; + if (!Array.isArray(data)) + return { + data: [], + limit, + page, + totalPages: 0, + totalRecords: 0, + }; + + const sliceFrom = Math.max((page - 1) * limit, 0); + const sliceTo = Math.min(page * limit, data.length); + + return { + data: data.slice(sliceFrom, sliceTo), + limit, + page, + totalPages: Math.ceil(data.length / limit), + totalRecords: data.length, + }; +} diff --git a/apps/api/src/app/shared/helpers/file.helper.ts b/apps/api/src/app/shared/helpers/file.helper.ts new file mode 100644 index 000000000..d2ef48710 --- /dev/null +++ b/apps/api/src/app/shared/helpers/file.helper.ts @@ -0,0 +1,13 @@ +import { FileMimeTypesEnum } from '@impler/shared'; +import { APIMessages } from '../constants'; +import { FileService, CSVFileService, ExcelFileService } from '../file/file.service'; + +export const getFileService = (mimeType: string): FileService => { + if (mimeType === FileMimeTypesEnum.CSV) { + return new CSVFileService(); + } else if (mimeType === FileMimeTypesEnum.EXCEL || mimeType === FileMimeTypesEnum.EXCELX) { + return new ExcelFileService(); + } + + throw new Error(APIMessages.FILE_TYPE_NOT_VALID); +}; diff --git a/apps/api/src/app/shared/helpers/upload.helpers.ts b/apps/api/src/app/shared/helpers/upload.helpers.ts new file mode 100644 index 000000000..7f4e2a5b4 --- /dev/null +++ b/apps/api/src/app/shared/helpers/upload.helpers.ts @@ -0,0 +1,22 @@ +import { UploadStatusEnum } from '@impler/shared'; +import { BadRequestException } from '@nestjs/common'; +import { APIMessages } from '../constants'; + +export function validateUploadStatus(currentStatus: UploadStatusEnum, expectedStatus: UploadStatusEnum[]): boolean { + if (expectedStatus.includes(currentStatus)) return true; + else { + if (currentStatus === UploadStatusEnum.UPLOADED) { + throw new BadRequestException(APIMessages.DO_MAPPING_FIRST); + } else if (currentStatus === UploadStatusEnum.MAPPED) { + throw new BadRequestException(APIMessages.DO_REVIEW_FIRST); + } else if (currentStatus === UploadStatusEnum.REVIEWED) { + throw new BadRequestException(APIMessages.DO_CONFIRM_FIRST); + } else if (currentStatus === UploadStatusEnum.CONFIRMED) { + throw new BadRequestException(APIMessages.ALREADY_CONFIRMED); + } else if (currentStatus === UploadStatusEnum.PROCESSING) { + throw new BadRequestException(APIMessages.IN_PROGRESS); + } else if (currentStatus === UploadStatusEnum.COMPLETED) { + throw new BadRequestException(APIMessages.COMPLETED); + } + } +} diff --git a/apps/api/src/app/shared/shared.module.ts b/apps/api/src/app/shared/shared.module.ts new file mode 100644 index 000000000..2374284e5 --- /dev/null +++ b/apps/api/src/app/shared/shared.module.ts @@ -0,0 +1,55 @@ +import { Module } from '@nestjs/common'; +import { + ColumnRepository, + CommonRepository, + DalService, + FileRepository, + MappingRepository, + ProjectRepository, + TemplateRepository, + UploadRepository, +} from '@impler/dal'; +import { S3StorageService, StorageService } from './storage/storage.service'; +import { CSVFileService } from './file/file.service'; +import { FileNameService } from './file/name.service'; + +const DAL_MODELS = [ + ProjectRepository, + TemplateRepository, + ColumnRepository, + FileRepository, + UploadRepository, + MappingRepository, + CommonRepository, +]; +const FILE_SERVICES = [CSVFileService, FileNameService]; + +const dalService = new DalService(); + +function getStorageServiceClass() { + return S3StorageService; +} + +const PROVIDERS = [ + { + provide: DalService, + useFactory: async () => { + await dalService.connect(process.env.MONGO_URL); + + return dalService; + }, + }, + ...DAL_MODELS, + { + provide: StorageService, + useClass: getStorageServiceClass(), + }, + ...FILE_SERVICES, +]; + +@Module({ + imports: [], + providers: [...PROVIDERS], + exports: [...PROVIDERS], +}) +export class SharedModule {} diff --git a/apps/api/src/app/shared/storage/queue.service.ts b/apps/api/src/app/shared/storage/queue.service.ts new file mode 100644 index 000000000..210b5ded4 --- /dev/null +++ b/apps/api/src/app/shared/storage/queue.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import amqp from 'amqp-connection-manager'; +import { ProcessFileData, PublishToQueueData, QueuesEnum } from '@impler/shared'; + +@Injectable() +export class QueueService { + private connection: any; + private chanelWrapper: any; + + constructor() { + this.connection = amqp.connect([process.env.RABBITMQ_CONN_URL]); + this.connection.on('connect', () => console.log('QueueService RabbitMQ::Connected!')); + this.connection.on('disconnect', (err: Error) => console.log('RabbitMQ::Disconnected.', err)); + + this.chanelWrapper = this.connection.createChannel({ + json: true, + persistent: true, + }); + } + + publishToQueue(queueName: QueuesEnum.PROCESS_FILE, data: ProcessFileData): void; + publishToQueue(queueName: QueuesEnum, data: PublishToQueueData) { + if (this.connection.isConnected()) { + this.chanelWrapper.sendToQueue(queueName, data, { durable: false }); + } else { + throw new Error('RabbitMQ connection is not established'); + } + } +} diff --git a/apps/api/src/app/shared/storage/storage.service.ts b/apps/api/src/app/shared/storage/storage.service.ts new file mode 100644 index 000000000..50084b416 --- /dev/null +++ b/apps/api/src/app/shared/storage/storage.service.ts @@ -0,0 +1,79 @@ +import { Readable } from 'stream'; +import { + S3Client, + PutObjectCommand, + PutObjectCommandOutput, + GetObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; +import { FileNotExistError } from '../errors/file-not-exist.error'; + +export interface IFilePath { + path: string; + name: string; +} + +export abstract class StorageService { + abstract uploadFile( + key: string, + file: Buffer | string, + contentType: string, + isPublic?: boolean + ): Promise; + abstract getFileContent(key: string, encoding?: BufferEncoding): Promise; + abstract deleteFile(key: string): Promise; +} + +async function streamToString(stream: Readable, encoding: BufferEncoding): Promise { + return await new Promise((resolve, reject) => { + const chunks: Uint8Array[] = []; + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks).toString(encoding))); + }); +} + +export class S3StorageService implements StorageService { + private s3 = new S3Client({ + region: process.env.S3_REGION, + endpoint: process.env.S3_LOCAL_STACK || undefined, + forcePathStyle: true, + }); + + async uploadFile(key: string, file: Buffer, contentType: string, isPublic = false): Promise { + const command = new PutObjectCommand({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + Body: file, + ACL: isPublic ? 'public-read' : 'private', + ContentType: contentType, + }); + + return await this.s3.send(command); + } + + async getFileContent(key: string, encoding = 'utf8' as BufferEncoding): Promise { + try { + const command = new GetObjectCommand({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + }); + const data = await this.s3.send(command); + + return await streamToString(data.Body as Readable, encoding); + } catch (error) { + if (error.code === 404 || error.message === 'The specified key does not exist.') { + throw new FileNotExistError(); + } + throw error; + } + } + + async deleteFile(key: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET_NAME, + Key: key, + }); + await this.s3.send(command); + } +} diff --git a/apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts b/apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts new file mode 100644 index 000000000..9abd8f9b8 --- /dev/null +++ b/apps/api/src/app/shared/usecases/get-upload/get-upload.command.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { BaseCommand } from '../../commands/base.command'; + +export class GetUploadCommand extends BaseCommand { + @IsDefined() + @IsMongoId() + uploadId: string; + + @IsOptional() + @IsString() + select?: string; +} diff --git a/apps/api/src/app/shared/usecases/get-upload/get-upload.usecase.ts b/apps/api/src/app/shared/usecases/get-upload/get-upload.usecase.ts new file mode 100644 index 000000000..f8609aaef --- /dev/null +++ b/apps/api/src/app/shared/usecases/get-upload/get-upload.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { UploadRepository } from '@impler/dal'; +import { GetUploadCommand } from './get-upload.command'; + +@Injectable() +export class GetUpload { + constructor(private uploadRepository: UploadRepository) {} + + async execute(command: GetUploadCommand) { + return this.uploadRepository.findOne({ _id: command.uploadId }, command.select); + } +} diff --git a/apps/api/src/app/shared/validations/valid-import-file.validation.ts b/apps/api/src/app/shared/validations/valid-import-file.validation.ts new file mode 100644 index 000000000..05aed9323 --- /dev/null +++ b/apps/api/src/app/shared/validations/valid-import-file.validation.ts @@ -0,0 +1,16 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars +import _whatever from 'multer'; +import { SupportedFileMimeTypes } from '@impler/shared'; +import { Injectable, PipeTransform } from '@nestjs/common'; +import { FileNotValidError } from '../exceptions/file-not-valid.exception'; + +@Injectable() +export class ValidImportFile implements PipeTransform { + transform(value: Express.Multer.File) { + if (!SupportedFileMimeTypes.includes(value.mimetype)) { + throw new FileNotValidError(); + } + + return value; + } +} diff --git a/apps/api/src/app/shared/validations/valid-mongo-id.validation.ts b/apps/api/src/app/shared/validations/valid-mongo-id.validation.ts new file mode 100644 index 000000000..5b1339ad8 --- /dev/null +++ b/apps/api/src/app/shared/validations/valid-mongo-id.validation.ts @@ -0,0 +1,17 @@ +import { CommonRepository } from '@impler/dal'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; + +@Injectable() +export class ValidateMongoId implements PipeTransform { + private commonRepository: CommonRepository; + constructor() { + this.commonRepository = new CommonRepository(); + } + + transform(value: string): string { + if (value && this.commonRepository.validMongoId(value)) { + return value; + } + throw new BadRequestException(); + } +} diff --git a/apps/api/src/app/shared/validations/valid-template.validation.ts b/apps/api/src/app/shared/validations/valid-template.validation.ts new file mode 100644 index 000000000..7b854aa3b --- /dev/null +++ b/apps/api/src/app/shared/validations/valid-template.validation.ts @@ -0,0 +1,16 @@ +import { CommonRepository, TemplateRepository } from '@impler/dal'; +import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common'; +import { APIMessages } from '../constants'; + +@Injectable() +export class ValidateTemplate implements PipeTransform { + constructor(private commonRepository: CommonRepository, private templateRepository: TemplateRepository) {} + + async transform(value: string): Promise { + const isMongoId = this.commonRepository.validMongoId(value); + const template = await this.templateRepository.findOne(isMongoId ? { _id: value } : { code: value }); + if (!template) throw new BadRequestException(`${value} ${APIMessages.INVALID_TEMPLATE_ID_CODE_SUFFIX}`); + + return template._id; + } +} diff --git a/apps/api/src/app/template/dtos/create-template-request.dto.ts b/apps/api/src/app/template/dtos/create-template-request.dto.ts new file mode 100644 index 000000000..d4100c3ea --- /dev/null +++ b/apps/api/src/app/template/dtos/create-template-request.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { changeToCode } from '@impler/shared'; +import { IsDefined, IsString, Validate, IsNumber, IsUrl } from 'class-validator'; +import { UniqueValidator } from '../../shared/framework/is-unique.validator'; + +export class CreateTemplateRequestDto { + @ApiProperty({ + description: 'Name of the template', + }) + @IsString() + @IsDefined() + name: string; + + @ApiProperty({ + description: 'Code of the template', + }) + @IsString() + @IsDefined() + @Validate(UniqueValidator, ['Template', 'code'], { + message: 'Code is already taken', + }) + @Transform((value) => changeToCode(value.value)) + code: string; + + @ApiProperty({ + description: 'Callback URL of the template, gets called when sending data to the application', + }) + @IsUrl() + @IsDefined() + callbackUrl: string; + + @ApiProperty({ + description: 'Size of data in rows that gets sent to the application', + }) + @IsNumber() + @IsDefined() + chunkSize: number; +} diff --git a/apps/api/src/app/template/dtos/template-response.dto.ts b/apps/api/src/app/template/dtos/template-response.dto.ts new file mode 100644 index 000000000..99c3a155d --- /dev/null +++ b/apps/api/src/app/template/dtos/template-response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDefined, IsNumber, IsString } from 'class-validator'; + +export class TemplateResponseDto { + @ApiPropertyOptional({ + description: 'Id of the template', + }) + @IsString() + @IsDefined() + _id?: string; + + @ApiProperty({ + description: 'Name of the template', + }) + @IsString() + @IsDefined() + name: string; + + @ApiProperty({ + description: 'Code of the template', + }) + @IsString() + @IsDefined() + code: string; + + @ApiProperty({ + description: 'Callback URL of the template, gets called when sending data to the application', + }) + @IsString() + @IsDefined() + callbackUrl: string; + + @ApiProperty({ + description: 'Size of data in rows that gets sent to the application', + }) + @IsNumber() + @IsDefined() + chunkSize: number; + + @ApiProperty({ + description: 'URL to download samle csv file', + }) + @IsString() + @IsDefined() + sampleFileUrl: string; + + @ApiProperty({ + description: 'Id of project related to the template', + }) + @IsString() + @IsDefined() + _projectId: string; +} diff --git a/apps/api/src/app/template/dtos/update-template-request.dto.ts b/apps/api/src/app/template/dtos/update-template-request.dto.ts new file mode 100644 index 000000000..ef13a82c2 --- /dev/null +++ b/apps/api/src/app/template/dtos/update-template-request.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsNumber, IsNotEmpty, IsUrl } from 'class-validator'; + +export class UpdateTemplateRequestDto { + @ApiProperty({ + description: 'Name of the template', + nullable: false, + }) + @IsOptional() + name?: string; + + @ApiProperty({ + description: 'Callback URL of the template, gets called when sending data to the application', + nullable: false, + }) + @IsUrl() + @IsOptional() + callbackUrl?: string; + + @ApiProperty({ + description: 'Size of data in rows that gets sent to the application', + format: 'number', + nullable: false, + }) + @IsNumber({ + allowNaN: false, + }) + @IsOptional() + @IsNotEmpty() + chunkSize?: number; + + @ApiProperty({ + description: 'Id of project related to the template', + nullable: false, + }) + @IsString() + @IsOptional() + _projectId?: string; +} diff --git a/apps/api/src/app/template/template.controller.ts b/apps/api/src/app/template/template.controller.ts new file mode 100644 index 000000000..ec0371808 --- /dev/null +++ b/apps/api/src/app/template/template.controller.ts @@ -0,0 +1,121 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags, ApiOkResponse, ApiSecurity } from '@nestjs/swagger'; +import { UploadEntity } from '@impler/dal'; +import { ACCESS_KEY_NAME } from '@impler/shared'; +import { DocumentNotFoundException } from '@shared/exceptions/document-not-found.exception'; +import { APIKeyGuard } from '@shared/framework/auth.gaurd'; +import { ValidateMongoId } from '@shared/validations/valid-mongo-id.validation'; + +import { CreateTemplateRequestDto } from './dtos/create-template-request.dto'; +import { TemplateResponseDto } from './dtos/template-response.dto'; +import { UpdateTemplateRequestDto } from './dtos/update-template-request.dto'; +import { CreateTemplateCommand } from './usecases/create-template/create-template.command'; +import { CreateTemplate } from './usecases/create-template/create-template.usecase'; +import { DeleteTemplate } from './usecases/delete-template/delete-template.usecase'; +import { GetTemplates } from './usecases/get-templates/get-templates.usecase'; +import { UpdateTemplateCommand } from './usecases/update-template/update-template.command'; +import { UpdateTemplate } from './usecases/update-template/update-template.usecase'; +import { GetUploads } from './usecases/get-uploads/get-uploads.usecase'; +import { GetUploadsCommand } from './usecases/get-uploads/get-uploads.command'; + +@Controller('/template') +@ApiTags('Template') +@ApiSecurity(ACCESS_KEY_NAME) +@UseGuards(APIKeyGuard) +export class TemplateController { + constructor( + private getTemplatesUsecase: GetTemplates, + private createTemplateUsecase: CreateTemplate, + private updateTemplateUsecase: UpdateTemplate, + private deleteTemplateUsecase: DeleteTemplate, + private getUploads: GetUploads + ) {} + + @Get(':projectId') + @ApiOperation({ + summary: 'Get project templates', + }) + @ApiOkResponse({ + type: [TemplateResponseDto], + }) + getTemplates(@Param('projectId', ValidateMongoId) projectId: string): Promise { + return this.getTemplatesUsecase.execute(projectId); + } + + @Post(':projectId') + @ApiOperation({ + summary: 'Add template in project', + }) + @ApiOkResponse({ + type: TemplateResponseDto, + }) + createTemplate( + @Param('projectId', ValidateMongoId) projectId: string, + @Body() body: CreateTemplateRequestDto + ): Promise { + return this.createTemplateUsecase.execute( + CreateTemplateCommand.create({ + _projectId: projectId, + callbackUrl: body.callbackUrl, + chunkSize: body.chunkSize, + code: body.code, + name: body.name, + }) + ); + } + + @Put(':templateId') + @ApiOperation({ + summary: 'Update template', + }) + @ApiOkResponse({ + type: TemplateResponseDto, + }) + async updateTemplate( + @Param('templateId', ValidateMongoId) templateId: string, + @Body() body: UpdateTemplateRequestDto + ): Promise { + const document = await this.updateTemplateUsecase.execute( + UpdateTemplateCommand.create({ + _projectId: body._projectId, + callbackUrl: body.callbackUrl, + chunkSize: body.chunkSize, + name: body.name, + }), + templateId + ); + if (!document) { + throw new DocumentNotFoundException('Template', templateId); + } + + return document; + } + + @Delete(':templateId') + @ApiOperation({ + summary: 'Delete template', + }) + @ApiOkResponse({ + type: TemplateResponseDto, + }) + async deleteTemplate(@Param('templateId', ValidateMongoId) templateId: string): Promise { + const document = await this.deleteTemplateUsecase.execute(templateId); + if (!document) { + throw new DocumentNotFoundException('Template', templateId); + } + + return document; + } + + @Get(':templateId/uploads') + @ApiOperation({ + summary: 'Get all uploads information for template', + }) + async getAllUploads(@Param('templateId', ValidateMongoId) templateId: string): Promise { + return this.getUploads.execute( + GetUploadsCommand.create({ + _templateId: templateId, + }) + ); + } +} diff --git a/apps/api/src/app/template/template.module.ts b/apps/api/src/app/template/template.module.ts new file mode 100644 index 000000000..acd0f4b64 --- /dev/null +++ b/apps/api/src/app/template/template.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { TemplateController } from './template.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [TemplateController], +}) +export class TemplateModule {} diff --git a/apps/api/src/app/template/usecases/create-template/create-template.command.ts b/apps/api/src/app/template/usecases/create-template/create-template.command.ts new file mode 100644 index 000000000..694e4e8e6 --- /dev/null +++ b/apps/api/src/app/template/usecases/create-template/create-template.command.ts @@ -0,0 +1,24 @@ +import { IsDefined, IsString, IsNumber } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class CreateTemplateCommand extends BaseCommand { + @IsDefined() + @IsString() + name: string; + + @IsDefined() + @IsString() + code: string; + + @IsString() + @IsDefined() + callbackUrl: string; + + @IsNumber() + @IsDefined() + chunkSize: number; + + @IsString() + @IsDefined() + _projectId: string; +} diff --git a/apps/api/src/app/template/usecases/create-template/create-template.usecase.ts b/apps/api/src/app/template/usecases/create-template/create-template.usecase.ts new file mode 100644 index 000000000..5f8769046 --- /dev/null +++ b/apps/api/src/app/template/usecases/create-template/create-template.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { TemplateRepository } from '@impler/dal'; +import { CreateTemplateCommand } from './create-template.command'; + +@Injectable() +export class CreateTemplate { + constructor(private templateRepository: TemplateRepository) {} + + async execute(command: CreateTemplateCommand) { + return this.templateRepository.create(command); + } +} diff --git a/apps/api/src/app/template/usecases/delete-template/delete-template.usecase.ts b/apps/api/src/app/template/usecases/delete-template/delete-template.usecase.ts new file mode 100644 index 000000000..d33666314 --- /dev/null +++ b/apps/api/src/app/template/usecases/delete-template/delete-template.usecase.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { TemplateRepository } from '@impler/dal'; + +@Injectable() +export class DeleteTemplate { + constructor(private templateRepository: TemplateRepository) {} + + async execute(id: string) { + return this.templateRepository.delete({ _id: id }); + } +} diff --git a/apps/api/src/app/template/usecases/get-templates/get-templates.usecase.ts b/apps/api/src/app/template/usecases/get-templates/get-templates.usecase.ts new file mode 100644 index 000000000..5589f1bbe --- /dev/null +++ b/apps/api/src/app/template/usecases/get-templates/get-templates.usecase.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { TemplateRepository } from '@impler/dal'; +import { TemplateResponseDto } from '../../dtos/template-response.dto'; + +@Injectable() +export class GetTemplates { + constructor(private templateRepository: TemplateRepository) {} + + async execute(projectId: string): Promise { + const templates = await this.templateRepository.find({ projectId }); + + return templates.map((template) => ({ + _projectId: template._projectId, + callbackUrl: template.callbackUrl, + chunkSize: template.chunkSize, + code: template.code, + name: template.name, + sampleFileUrl: template.sampleFileUrl, + _id: template._id, + })); + } +} diff --git a/apps/api/src/app/template/usecases/get-uploads/get-uploads.command.ts b/apps/api/src/app/template/usecases/get-uploads/get-uploads.command.ts new file mode 100644 index 000000000..fa45e3f28 --- /dev/null +++ b/apps/api/src/app/template/usecases/get-uploads/get-uploads.command.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsMongoId, IsOptional, IsString } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class GetUploadsCommand extends BaseCommand { + @IsDefined() + @IsMongoId() + _templateId: string; + + @IsOptional() + @IsString() + select?: string; +} diff --git a/apps/api/src/app/template/usecases/get-uploads/get-uploads.usecase.ts b/apps/api/src/app/template/usecases/get-uploads/get-uploads.usecase.ts new file mode 100644 index 000000000..5fae99e32 --- /dev/null +++ b/apps/api/src/app/template/usecases/get-uploads/get-uploads.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { UploadRepository } from '@impler/dal'; +import { GetUploadsCommand } from './get-uploads.command'; + +@Injectable() +export class GetUploads { + constructor(private uploadRepository: UploadRepository) {} + + async execute(command: GetUploadsCommand) { + return this.uploadRepository.find({ _templateId: command._templateId }, command.select); + } +} diff --git a/apps/api/src/app/template/usecases/index.ts b/apps/api/src/app/template/usecases/index.ts new file mode 100644 index 000000000..584bb55f8 --- /dev/null +++ b/apps/api/src/app/template/usecases/index.ts @@ -0,0 +1,14 @@ +import { GetTemplates } from './get-templates/get-templates.usecase'; +import { CreateTemplate } from './create-template/create-template.usecase'; +import { UpdateTemplate } from './update-template/update-template.usecase'; +import { DeleteTemplate } from './delete-template/delete-template.usecase'; +import { GetUploads } from './get-uploads/get-uploads.usecase'; + +export const USE_CASES = [ + GetTemplates, + CreateTemplate, + UpdateTemplate, + DeleteTemplate, + GetUploads, + // +]; diff --git a/apps/api/src/app/template/usecases/update-template/update-template.command.ts b/apps/api/src/app/template/usecases/update-template/update-template.command.ts new file mode 100644 index 000000000..0474e19b9 --- /dev/null +++ b/apps/api/src/app/template/usecases/update-template/update-template.command.ts @@ -0,0 +1,26 @@ +import { IsString, IsNumber, IsOptional, IsNotEmpty, IsMongoId, Min } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class UpdateTemplateCommand extends BaseCommand { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + callbackUrl?: string; + + @IsNumber({ + allowNaN: false, + }) + @IsOptional() + @IsNotEmpty() + @Min(1) + chunkSize?: number; + + @IsMongoId({ + message: '_projectId is not valid', + }) + @IsOptional() + _projectId?: string; +} diff --git a/apps/api/src/app/template/usecases/update-template/update-template.usecase.ts b/apps/api/src/app/template/usecases/update-template/update-template.usecase.ts new file mode 100644 index 000000000..b96f0589d --- /dev/null +++ b/apps/api/src/app/template/usecases/update-template/update-template.usecase.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { TemplateRepository } from '@impler/dal'; +import { UpdateTemplateCommand } from './update-template.command'; + +@Injectable() +export class UpdateTemplate { + constructor(private templateRepository: TemplateRepository) {} + + async execute(command: UpdateTemplateCommand, id: string) { + return this.templateRepository.findOneAndUpdate({ _id: id }, command); + } +} diff --git a/apps/api/src/app/upload/dtos/upload-request.dto.ts b/apps/api/src/app/upload/dtos/upload-request.dto.ts new file mode 100644 index 000000000..fd14b31db --- /dev/null +++ b/apps/api/src/app/upload/dtos/upload-request.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsJSON, IsOptional, IsString } from 'class-validator'; + +export class UploadRequestDto { + @ApiProperty({ + type: 'file', + required: true, + }) + file: Express.Multer.File; + + @ApiProperty({ + description: 'Auth header value to send during webhook call', + required: false, + }) + @IsOptional() + @IsString() + authHeaderValue: string; + + @ApiProperty({ + description: 'Payload to send during webhook call', + required: false, + }) + @IsOptional() + @IsJSON() + extra: string; +} diff --git a/apps/api/src/app/upload/upload.controller.ts b/apps/api/src/app/upload/upload.controller.ts new file mode 100644 index 000000000..d81327a78 --- /dev/null +++ b/apps/api/src/app/upload/upload.controller.ts @@ -0,0 +1,74 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars +import _whatever from 'multer'; +import { Body, Controller, Get, Param, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiSecurity, ApiConsumes, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { ACCESS_KEY_NAME } from '@impler/shared'; + +import { APIKeyGuard } from '../shared/framework/auth.gaurd'; +import { UploadRequestDto } from './dtos/upload-request.dto'; +import { ValidImportFile } from '../shared/validations/valid-import-file.validation'; +import { MakeUploadEntry } from './usecases/make-upload-entry/make-upload-entry.usecase'; +import { MakeUploadEntryCommand } from './usecases/make-upload-entry/make-upload-entry.command'; +import { ValidateMongoId } from '../shared/validations/valid-mongo-id.validation'; +import { GetUpload } from '../shared/usecases/get-upload/get-upload.usecase'; +import { GetUploadCommand } from '../shared/usecases/get-upload/get-upload.command'; +import { ValidateTemplate } from '../shared/validations/valid-template.validation'; + +@Controller('/upload') +@ApiTags('Uploads') +@ApiSecurity(ACCESS_KEY_NAME) +@UseGuards(APIKeyGuard) +export class UploadController { + constructor(private makeUploadEntry: MakeUploadEntry, private getUpload: GetUpload) {} + + @Post(':template') + @ApiOperation({ + summary: `Upload file to template`, + }) + @ApiParam({ + name: 'template', + required: true, + description: 'ID or CODE of the template', + type: 'string', + }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('file')) + async uploadFile( + @UploadedFile('file', ValidImportFile) file: Express.Multer.File, + @Body() body: UploadRequestDto, + @Param('template', ValidateTemplate) templateId: string + ) { + return await this.makeUploadEntry.execute( + MakeUploadEntryCommand.create({ + file: file, + templateId, + extra: body.extra, + authHeaderValue: body.authHeaderValue, + }) + ); + } + + @Get(':uploadId') + @ApiOperation({ + summary: 'Get Upload information', + }) + getUploadInformation(@Param('uploadId', ValidateMongoId) uploadId: string) { + return this.getUpload.execute(GetUploadCommand.create({ uploadId })); + } + + @Get(':uploadId/headings') + @ApiOperation({ + summary: 'Get headings for the uploaded file', + }) + async getHeadings(@Param('uploadId', ValidateMongoId) uploadId: string): Promise { + const uploadInfo = await this.getUpload.execute( + GetUploadCommand.create({ + uploadId, + select: 'headings', + }) + ); + + return uploadInfo.headings; + } +} diff --git a/apps/api/src/app/upload/upload.module.ts b/apps/api/src/app/upload/upload.module.ts new file mode 100644 index 000000000..f03b63a44 --- /dev/null +++ b/apps/api/src/app/upload/upload.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { USE_CASES } from './usecases'; +import { UploadController } from './upload.controller'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [SharedModule], + providers: [...USE_CASES], + controllers: [UploadController], +}) +export class UploadModule {} diff --git a/apps/api/src/app/upload/usecases/index.ts b/apps/api/src/app/upload/usecases/index.ts new file mode 100644 index 000000000..b4a0b1774 --- /dev/null +++ b/apps/api/src/app/upload/usecases/index.ts @@ -0,0 +1,8 @@ +import { MakeUploadEntry } from './make-upload-entry/make-upload-entry.usecase'; +import { GetUpload } from '../../shared/usecases/get-upload/get-upload.usecase'; + +export const USE_CASES = [ + MakeUploadEntry, + GetUpload, + // +]; diff --git a/apps/api/src/app/upload/usecases/make-upload-entry/add-upload-entry.command.ts b/apps/api/src/app/upload/usecases/make-upload-entry/add-upload-entry.command.ts new file mode 100644 index 000000000..6ba553014 --- /dev/null +++ b/apps/api/src/app/upload/usecases/make-upload-entry/add-upload-entry.command.ts @@ -0,0 +1,36 @@ +import { IsDefined, IsString, IsOptional, IsJSON, IsArray, IsNumber } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class AddUploadEntryCommand extends BaseCommand { + @IsDefined() + @IsString() + _templateId: string; + + @IsDefined() + @IsString() + _allDataFileId: string; + + @IsDefined() + @IsString() + _uploadedFileId: string; + + @IsDefined() + @IsString() + uploadId: string; + + @IsOptional() + @IsJSON() + extra?: string; + + @IsOptional() + @IsString() + authHeaderValue?: string; + + @IsOptional() + @IsArray() + headings?: string[]; + + @IsOptional() + @IsNumber() + totalRecords?: number; +} diff --git a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts new file mode 100644 index 000000000..92a8183f8 --- /dev/null +++ b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.command.ts @@ -0,0 +1,19 @@ +import { IsDefined, IsString, IsOptional, IsJSON } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class MakeUploadEntryCommand extends BaseCommand { + @IsDefined() + file: Express.Multer.File; + + @IsDefined() + @IsString() + templateId: string; + + @IsOptional() + @IsJSON() + extra?: string; + + @IsOptional() + @IsString() + authHeaderValue?: string; +} diff --git a/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts new file mode 100644 index 000000000..ecf5161c9 --- /dev/null +++ b/apps/api/src/app/upload/usecases/make-upload-entry/make-upload-entry.usecase.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import { FileMimeTypesEnum, UploadStatusEnum } from '@impler/shared'; +import { CommonRepository, FileEntity, FileRepository, UploadRepository } from '@impler/dal'; +import { MakeUploadEntryCommand } from './make-upload-entry.command'; +import { FileNameService } from '../../../shared/file/name.service'; +import { StorageService } from '../../../shared/storage/storage.service'; +import { FileService } from '../../../shared/file/file.service'; +import { getFileService } from '../../../shared/helpers/file.helper'; +import { AddUploadEntryCommand } from './add-upload-entry.command'; + +@Injectable() +export class MakeUploadEntry { + constructor( + private uploadRepository: UploadRepository, + private commonRepository: CommonRepository, + private fileRepository: FileRepository, + private storageService: StorageService, + private fileNameService: FileNameService + ) {} + + async execute({ file, templateId, extra, authHeaderValue }: MakeUploadEntryCommand) { + const uploadId = this.commonRepository.generateMongoId(); + const fileEntity = await this.makeFileEntry(uploadId, file); + const fileService: FileService = getFileService(file.mimetype); + const fileInformation = await fileService.getFileInformation(this.storageService, fileEntity.path); + const allDataFile = await this.addAllDataEntry(uploadId, fileInformation.data); + + return this.addUploadEntry( + AddUploadEntryCommand.create({ + _templateId: templateId, + _uploadedFileId: fileEntity._id, + _allDataFileId: allDataFile._id, + uploadId, + extra, + authHeaderValue, + headings: fileInformation.headings, + totalRecords: fileInformation.totalRecords, + }) + ); + } + + private async makeFileEntry(uploadId: string, file: Express.Multer.File): Promise { + const uploadedFilePath = this.fileNameService.getUploadedFilePath(uploadId, file.originalname); + await this.storageService.uploadFile(uploadedFilePath, file.buffer, file.mimetype); + const uploadedFileName = this.fileNameService.getUploadedFileName(file.originalname); + const fileEntry = await this.fileRepository.create({ + mimeType: file.mimetype, + name: uploadedFileName, + originalName: file.originalname, + path: uploadedFilePath, + }); + + return fileEntry; + } + + private async addUploadEntry({ + _templateId, + _uploadedFileId, + _allDataFileId, + uploadId, + extra, + authHeaderValue, + headings, + totalRecords, + }: AddUploadEntryCommand) { + return this.uploadRepository.create({ + _id: uploadId, + _uploadedFileId, + _templateId, + _allDataFileId, + extra: extra, + headings: Array.isArray(headings) ? headings : [], + status: UploadStatusEnum.UPLOADED, + authHeaderValue: authHeaderValue, + totalRecords: totalRecords || 0, + }); + } + + private async addAllDataEntry(uploadId: string, data: Record[]): Promise { + const allDataFileName = this.fileNameService.getAllJsonDataFileName(); + const allDataFilePath = this.fileNameService.getAllJsonDataFilePath(uploadId); + await this.storageService.uploadFile(allDataFilePath, JSON.stringify(data), FileMimeTypesEnum.JSON); + + return await this.fileRepository.create({ + mimeType: FileMimeTypesEnum.JSON, + path: allDataFilePath, + name: allDataFileName, + originalName: allDataFileName, + }); + } +} diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts new file mode 100644 index 000000000..4773f1b50 --- /dev/null +++ b/apps/api/src/bootstrap.ts @@ -0,0 +1,82 @@ +import './config'; + +import { INestApplication, ValidationPipe, Logger } from '@nestjs/common'; +import * as compression from 'compression'; +import { NestFactory } from '@nestjs/core'; +import * as bodyParser from 'body-parser'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { AppModule } from './app.module'; +import { validateEnv } from './config/env-validator'; +import { ACCESS_KEY_NAME } from '@impler/shared'; + +// Validate the ENV variables after launching SENTRY, so missing variables will report to sentry +validateEnv(); + +export async function bootstrap(expressApp?): Promise { + let app; + if (expressApp) { + app = await NestFactory.create(AppModule, new ExpressAdapter(expressApp)); + } else { + app = await NestFactory.create(AppModule); + } + + app.enableCors(corsOptionsDelegate); + + app.setGlobalPrefix('v1'); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + }) + ); + + app.use(bodyParser.json()); + app.use(bodyParser.urlencoded({ extended: true })); + + app.use(compression()); + + const options = new DocumentBuilder() + .setTitle('Impler API') + .setDescription('The Impler API description') + .setVersion('1.0') + .addApiKey( + { + type: 'apiKey', // type + name: ACCESS_KEY_NAME, // Name of the key to expect in header + in: 'header', + }, + ACCESS_KEY_NAME // Name to show and used in swagger + ) + .addTag('Project') + .build(); + const document = SwaggerModule.createDocument(app, options); + + SwaggerModule.setup('api', app, document); + + if (expressApp) { + await app.init(); + } else { + await app.listen(process.env.PORT); + } + + Logger.log(`Started application in NODE_ENV=${process.env.NODE_ENV} on port ${process.env.PORT}`); + + return app; +} + +const corsOptionsDelegate = function (req, callback) { + const corsOptions = { + origin: false as boolean | string | string[], + preflightContinue: false, + allowedHeaders: ['Content-Type', ACCESS_KEY_NAME], + methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + }; + + if (['dev', 'test', 'local'].includes(process.env.NODE_ENV)) { + corsOptions.origin = '*'; + } else { + corsOptions.origin = [process.env.FRONT_BASE_URL]; + } + callback(null, corsOptions); +}; diff --git a/apps/api/src/config/env-validator.ts b/apps/api/src/config/env-validator.ts new file mode 100644 index 000000000..1efca9f6c --- /dev/null +++ b/apps/api/src/config/env-validator.ts @@ -0,0 +1,23 @@ +import { port, str, url, ValidatorSpec } from 'envalid'; +import * as envalid from 'envalid'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const validators: { [K in keyof any]: ValidatorSpec } = { + NODE_ENV: str({ + choices: ['dev', 'test', 'prod', 'ci', 'local'], + default: 'local', + }), + S3_LOCAL_STACK: str({ + default: '', + }), + S3_BUCKET_NAME: str(), + S3_REGION: str(), + PORT: port(), + FRONT_BASE_URL: url(), + MONGO_URL: str(), + RABBITMQ_CONN_URL: str(), +}; + +export function validateEnv() { + envalid.cleanEnv(process.env, validators); +} diff --git a/apps/api/src/config/index.ts b/apps/api/src/config/index.ts new file mode 100644 index 000000000..2e166f751 --- /dev/null +++ b/apps/api/src/config/index.ts @@ -0,0 +1,17 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +const envFileMapper = { + prod: '.env.production', + test: '.env.test', + ci: '.env.ci', + local: '.env', + dev: '.env.development', +}; +const selectedEnvFile = envFileMapper[process.env.NODE_ENV] || '.env'; + +const path = `${__dirname}/${process.env.E2E_RUNNER ? '..' : 'src'}/${selectedEnvFile}`; + +const { error } = dotenv.config({ path }); +if (error && !process.env.LAMBDA_TASK_ROOT) throw error; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 000000000..2fb9dfe34 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,3 @@ +import { bootstrap } from './bootstrap'; + +bootstrap(); diff --git a/apps/api/src/types/env.d.ts b/apps/api/src/types/env.d.ts new file mode 100644 index 000000000..6f2dbb19e --- /dev/null +++ b/apps/api/src/types/env.d.ts @@ -0,0 +1,14 @@ +declare namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/naming-convention + export interface ProcessEnv { + NODE_ENV: 'test' | 'prod' | 'dev' | 'ci' | 'local'; + PORT: number; + 'ACCESS-KEY'?: string; + FRONT_BASE_URL: string; + + MONGO_URL: string; + S3_LOCAL_STACK: string; + S3_REGION: string; + S3_BUCKET_NAME: string; + } +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 000000000..05ea22b05 --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "noImplicitAny": false, + "removeComments": true, + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es6", + "esModuleInterop": false, + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "src", + "paths": { + "@shared/*": ["app/shared/*"], + }, + "types": ["node"] + }, + "include": [".eslintrc.js", "src/**/*", "src/**/*.d.ts"], + "exclude": ["node_modules", "**/*.spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 000000000..3b3667322 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": true, + "types": ["node", "multer", "mocha", "chai"], + "target": "es2017", + "allowJs": false, + "esModuleInterop": false, + "declarationMap": true, + "baseUrl": "src", + "paths": { + "@shared/*": ["app/shared/*"], + } + } +} diff --git a/apps/queue-manager/.dockerignore b/apps/queue-manager/.dockerignore new file mode 100644 index 000000000..3c3629e64 --- /dev/null +++ b/apps/queue-manager/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/apps/queue-manager/.eslintrc.js b/apps/queue-manager/.eslintrc.js new file mode 100644 index 000000000..5a2cc7f1e --- /dev/null +++ b/apps/queue-manager/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ['../../.eslintrc.js'], +}; diff --git a/apps/queue-manager/nodemon.json b/apps/queue-manager/nodemon.json new file mode 100644 index 000000000..0b841991e --- /dev/null +++ b/apps/queue-manager/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": ["src/**/*.spec.ts"], + "exec": "ts-node src/index.ts" +} diff --git a/apps/queue-manager/package.json b/apps/queue-manager/package.json new file mode 100644 index 000000000..8257c499e --- /dev/null +++ b/apps/queue-manager/package.json @@ -0,0 +1,38 @@ +{ + "name": "@impler/queue-manager", + "version": "0.1.0", + "author": "knovator", + "license": "MIT", + "private": true, + "scripts": { + "prebuild": "rimraf dist", + "preinstall": "pnpm build", + "format": "prettier --write \"src/**/*.ts\"", + "build": "cross-env node_modules/.bin/tsc -p tsconfig.build.json", + "start": "cross-env TZ=UTC nodemon", + "start:prod": "cross-env TZ=UTC node ./dist/index.js", + "start:dev": "cross-env TZ=UTC nodemon", + "precommit": "lint-staged", + "lint": "eslint src", + "lint:fix": "pnpm lint -- --fix" + }, + "dependencies": { + "@impler/dal": "^0.1.0", + "@impler/shared": "^0.1.0", + "axios": "^0.26.1", + "dotenv": "^16.0.2", + "envalid": "^7.3.1" + }, + "devDependencies": { + "@types/node": "^18.7.18", + "nodemon": "^2.0.20", + "rimraf": "^3.0.2", + "ts-node": "^10.9.1", + "typescript": "^4.8.3" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint --fix" + ] + } +} diff --git a/apps/queue-manager/src/.env.development b/apps/queue-manager/src/.env.development new file mode 100644 index 000000000..fe959e4a3 --- /dev/null +++ b/apps/queue-manager/src/.env.development @@ -0,0 +1,8 @@ +NODE_ENV=local +RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 +MONGO_URL=mongodb://localhost:27017/impler-db + +# Storage +S3_REGION=us-east-1 +S3_LOCAL_STACK=http://localhost:4566 +S3_BUCKET_NAME=impler diff --git a/apps/queue-manager/src/.env.production b/apps/queue-manager/src/.env.production new file mode 100644 index 000000000..fe959e4a3 --- /dev/null +++ b/apps/queue-manager/src/.env.production @@ -0,0 +1,8 @@ +NODE_ENV=local +RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 +MONGO_URL=mongodb://localhost:27017/impler-db + +# Storage +S3_REGION=us-east-1 +S3_LOCAL_STACK=http://localhost:4566 +S3_BUCKET_NAME=impler diff --git a/apps/queue-manager/src/.env.test b/apps/queue-manager/src/.env.test new file mode 100644 index 000000000..fe959e4a3 --- /dev/null +++ b/apps/queue-manager/src/.env.test @@ -0,0 +1,8 @@ +NODE_ENV=local +RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 +MONGO_URL=mongodb://localhost:27017/impler-db + +# Storage +S3_REGION=us-east-1 +S3_LOCAL_STACK=http://localhost:4566 +S3_BUCKET_NAME=impler diff --git a/apps/queue-manager/src/.example.env b/apps/queue-manager/src/.example.env new file mode 100644 index 000000000..fe959e4a3 --- /dev/null +++ b/apps/queue-manager/src/.example.env @@ -0,0 +1,8 @@ +NODE_ENV=local +RABBITMQ_CONN_URL=amqp://guest:guest@localhost:5672 +MONGO_URL=mongodb://localhost:27017/impler-db + +# Storage +S3_REGION=us-east-1 +S3_LOCAL_STACK=http://localhost:4566 +S3_BUCKET_NAME=impler diff --git a/apps/queue-manager/src/bootstrap.ts b/apps/queue-manager/src/bootstrap.ts new file mode 100644 index 000000000..8b7067f12 --- /dev/null +++ b/apps/queue-manager/src/bootstrap.ts @@ -0,0 +1,44 @@ +import './config/env-config'; +import amqp, { ChannelWrapper } from 'amqp-connection-manager'; +import { ProcessFileConsumer } from './consumers'; +import { QueuesEnum } from '@impler/shared'; +import { DalService } from '@impler/dal'; +import { IAmqpConnectionManager } from 'amqp-connection-manager/dist/esm/AmqpConnectionManager'; +import { validateEnv } from './config/env-validator'; + +let connection: IAmqpConnectionManager, chanelWrapper: ChannelWrapper; + +validateEnv(); + +export async function bootstrap() { + // conenct dal service + const dalService = new DalService(); + await dalService.connect(process.env.MONGO_URL); + + // connect to amqp rabbitmq server + connection = amqp.connect([process.env.RABBITMQ_CONN_URL]); + connection.on('connect', () => console.log('QueueManager RabbitMQ::Connected!')); + connection.on('disconnect', (err: Error) => console.log('RabbitMQ::Disconnected.', err)); + + // create channel + chanelWrapper = connection.createChannel({ + json: true, + }); + + // initialize consumers + const processFileConsumer = new ProcessFileConsumer(); + + // add queues to channel + chanelWrapper.addSetup((channel) => { + return Promise.all([ + channel.assertQueue(QueuesEnum.PROCESS_FILE, { + durable: false, + }), + channel.consume(QueuesEnum.PROCESS_FILE, processFileConsumer.message.bind(processFileConsumer), { noAck: true }), + ]); + }); +} + +export function publishToQueue(queueName: QueuesEnum, data: any) { + chanelWrapper.sendToQueue(queueName, data); +} diff --git a/apps/queue-manager/src/config/env-config.ts b/apps/queue-manager/src/config/env-config.ts new file mode 100644 index 000000000..f82be2f2e --- /dev/null +++ b/apps/queue-manager/src/config/env-config.ts @@ -0,0 +1,17 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +const envFileMapper = { + prod: '.env.production', + test: '.env.test', + ci: '.env.ci', + local: '.env', + dev: '.env.development', +}; +const selectedEnvFile = envFileMapper[process.env.NODE_ENV] || '.env'; + +const path = `${__dirname}/${process.env.E2E_RUNNER ? '..' : '..'}/${selectedEnvFile}`; + +const { error } = dotenv.config({ path }); +if (error && !process.env.LAMBDA_TASK_ROOT) throw error; diff --git a/apps/queue-manager/src/config/env-validator.ts b/apps/queue-manager/src/config/env-validator.ts new file mode 100644 index 000000000..5f3afb0f1 --- /dev/null +++ b/apps/queue-manager/src/config/env-validator.ts @@ -0,0 +1,21 @@ +import { str, ValidatorSpec } from 'envalid'; +import * as envalid from 'envalid'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const validators: { [K in keyof any]: ValidatorSpec } = { + NODE_ENV: str({ + choices: ['dev', 'test', 'prod', 'ci', 'local'], + default: 'local', + }), + MONGO_URL: str(), + RABBITMQ_CONN_URL: str(), + S3_LOCAL_STACK: str({ + default: '', + }), + S3_BUCKET_NAME: str(), + S3_REGION: str(), +}; + +export function validateEnv() { + envalid.cleanEnv(process.env, validators); +} diff --git a/apps/queue-manager/src/consumers/base.consumer.ts b/apps/queue-manager/src/consumers/base.consumer.ts new file mode 100644 index 000000000..09197caab --- /dev/null +++ b/apps/queue-manager/src/consumers/base.consumer.ts @@ -0,0 +1,3 @@ +export abstract class BaseConsumer { + abstract message(data: any): void; +} diff --git a/apps/queue-manager/src/consumers/index.ts b/apps/queue-manager/src/consumers/index.ts new file mode 100644 index 000000000..6846af556 --- /dev/null +++ b/apps/queue-manager/src/consumers/index.ts @@ -0,0 +1 @@ +export * from './process-file'; diff --git a/apps/queue-manager/src/consumers/process-file.ts b/apps/queue-manager/src/consumers/process-file.ts new file mode 100644 index 000000000..f82b115b5 --- /dev/null +++ b/apps/queue-manager/src/consumers/process-file.ts @@ -0,0 +1,229 @@ +import axios from 'axios'; +import { + FileEncodingsEnum, + ProcessFileCachedData, + ProcessFileData, + StorageService, + StatusEnum, + UploadStatusEnum, + QueuesEnum, +} from '@impler/shared'; +import { FileEntity, UploadRepository, TemplateRepository, WebhookLogRepository, WebhookLogEntity } from '@impler/dal'; +import { BaseConsumer } from './base.consumer'; +import { getStorageServiceClass } from '../helpers/storage.helper'; +import { publishToQueue } from '../bootstrap'; +import { + ISendDataParameters, + IBuildSendDataParameters, + IGetNextDataParameters, + ISendData, +} from '../types/file-processing.types'; + +const MIN_LIMIT = 0; +const DEFAULT_PAGE = 1; + +export class ProcessFileConsumer extends BaseConsumer { + private templateRepository: TemplateRepository = new TemplateRepository(); + private uploadRepository: UploadRepository = new UploadRepository(); + private webhookLogRepository: WebhookLogRepository = new WebhookLogRepository(); + private storageService: StorageService = getStorageServiceClass(); + + async message(message) { + const data = JSON.parse(message.content) as ProcessFileData; + const uploadId = data.uploadId; + const cachedData = data.cache || (await this.getInitialCachedData(uploadId)); + + if (cachedData) { + // Get valid data information + let validDataJSON: null | any[] = null; + if (cachedData.validDataFilePath) { + const validDataContent = await this.storageService.getFileContent( + cachedData.validDataFilePath, + FileEncodingsEnum.JSON + ); + validDataJSON = JSON.parse(validDataContent); + } + + // Get invalid data information + let invalidDataJSON: null | any[] = null; + if (cachedData.processInvalidRecords && cachedData.invalidDataFilePath) { + const invalidDataContent = await this.storageService.getFileContent( + cachedData.invalidDataFilePath, + FileEncodingsEnum.JSON + ); + invalidDataJSON = JSON.parse(invalidDataContent); + } + + const sendData = this.buildSendData({ + chunkSize: cachedData.chunkSize, + data: cachedData.isInvalidRecords ? invalidDataJSON : validDataJSON, + page: cachedData.page || DEFAULT_PAGE, + isInvalidRecords: cachedData.isInvalidRecords, + template: cachedData.code, + uploadId, + extra: cachedData.extra, + }); + + const response = await this.makeApiCall({ data: sendData, method: 'POST', url: cachedData.callbackUrl }); + this.makeResponseEntry(response); + + const nextCachedData = this.getNextData({ + validData: validDataJSON, + invalidData: invalidDataJSON, + ...cachedData, + }); + + if (nextCachedData) { + // Make next call + publishToQueue(QueuesEnum.PROCESS_FILE, { + uploadId, + cache: nextCachedData, + }); + } else { + // Processing is done + this.finalizeUpload(uploadId); + } + } + } + + private async makeApiCall({ data, method, url }: ISendDataParameters): Promise> { + const baseResponse: Partial = { + _uploadId: data.uploadId, + callDate: new Date(), + pageNumber: data.page, + }; + try { + const response = await axios({ + method, + url, + data, + }); + + baseResponse.responseStatusCode = response.status; + baseResponse.status = StatusEnum.SUCCEED; + + return baseResponse; + } catch (error) { + baseResponse.status = StatusEnum.FAILED; + if (axios.isAxiosError(error)) { + if (error.response) { + baseResponse.failedReason = 'Application Error'; + baseResponse.responseStatusCode = error.response.status; + } else if (error.request) { + baseResponse.failedReason = 'Network Error'; + baseResponse.responseStatusCode = error.request.status; + } else { + baseResponse.failedReason = error.message; + baseResponse.responseStatusCode = 400; + } + } else { + baseResponse.failedReason = error.message; + baseResponse.responseStatusCode = 400; + } + + return baseResponse; + } + } + + private buildSendData({ + data, + page = DEFAULT_PAGE, + chunkSize, + isInvalidRecords, + template, + uploadId, + extra = '', + }: IBuildSendDataParameters): ISendData { + const slicedData = data.slice( + Math.max((page - DEFAULT_PAGE) * chunkSize, MIN_LIMIT), + Math.min((page + DEFAULT_PAGE) * chunkSize, data.length) + ); + + return { + data: slicedData, + extra: extra ? JSON.parse(extra) : '', + isInvalidRecords, + page, + pageSize: slicedData.length, + template, + totalPages: this.getTotalPages(data.length, chunkSize), + totalRecords: data.length, + uploadId, + }; + } + + private getNextData({ + validData, + page, + chunkSize, + invalidData, + isInvalidRecords, + ...rest + }: IGetNextDataParameters): ProcessFileCachedData | null { + const baseData = { + chunkSize, + page: page + DEFAULT_PAGE, + isInvalidRecords: isInvalidRecords || false, + ...rest, + }; + if (!isInvalidRecords && Array.isArray(validData) && validData.length > page * chunkSize) { + // there is more valid data available to send on next page + return { + ...baseData, + page: page + DEFAULT_PAGE, + isInvalidRecords: false, + }; + } else if (!isInvalidRecords && Array.isArray(invalidData) && invalidData.length > MIN_LIMIT) { + // valid data are completed, invalid-data is available, so now move to invalid data + return { + ...baseData, + page: 1, + isInvalidRecords: true, + }; + } else if (isInvalidRecords && Array.isArray(invalidData) && invalidData.length > page * chunkSize) { + // currently processing invalid data, and there is more invalid data available to send + return { + ...baseData, + page: page + DEFAULT_PAGE, + isInvalidRecords: true, + }; + } + + return null; + } + + private getTotalPages(totalRecords, pageSize): number { + return Math.ceil(totalRecords / pageSize); + } + + private async getInitialCachedData(_uploadId: string): Promise { + // Get Upload Information + const uploadata = await this.uploadRepository.getUploadProcessInformation(_uploadId); + + if (!uploadata._validDataFileId && !uploadata._invalidDataFileId) return null; + + // Get template information + const templateData = await this.templateRepository.findById(uploadata._templateId, 'callbackUrl chunkSize code'); + + return { + _templateId: uploadata._templateId, + callbackUrl: templateData.callbackUrl, + chunkSize: templateData.chunkSize, + code: templateData.code, + isInvalidRecords: uploadata._validDataFileId ? false : true, + invalidDataFilePath: (uploadata._invalidDataFileId as unknown as FileEntity)?.path, + page: 1, + processInvalidRecords: uploadata.processInvalidRecords, + validDataFilePath: (uploadata._validDataFileId as unknown as FileEntity)?.path, + extra: uploadata.extra, + }; + } + + private async makeResponseEntry(data: Partial) { + return await this.webhookLogRepository.create(data); + } + + private async finalizeUpload(uploadId: string) { + return await this.uploadRepository.update({ _id: uploadId }, { status: UploadStatusEnum.COMPLETED }); + } +} diff --git a/apps/queue-manager/src/helpers/storage.helper.ts b/apps/queue-manager/src/helpers/storage.helper.ts new file mode 100644 index 000000000..5c0d02999 --- /dev/null +++ b/apps/queue-manager/src/helpers/storage.helper.ts @@ -0,0 +1,11 @@ +import { S3StorageService, StorageService } from '@impler/shared'; + +let storageService: StorageService; + +// Implementing singleton pattern for storage service +export function getStorageServiceClass() { + if (storageService) return storageService; + storageService = new S3StorageService(); + + return storageService; +} diff --git a/apps/queue-manager/src/index.ts b/apps/queue-manager/src/index.ts new file mode 100644 index 000000000..2fb9dfe34 --- /dev/null +++ b/apps/queue-manager/src/index.ts @@ -0,0 +1,3 @@ +import { bootstrap } from './bootstrap'; + +bootstrap(); diff --git a/apps/queue-manager/src/types/env.d.ts b/apps/queue-manager/src/types/env.d.ts new file mode 100644 index 000000000..59171da87 --- /dev/null +++ b/apps/queue-manager/src/types/env.d.ts @@ -0,0 +1,10 @@ +declare namespace NodeJS { + // eslint-disable-next-line @typescript-eslint/naming-convention + export interface ProcessEnv { + MONGO_URL: string; + RABBITMQ_CONN_URL: string; + S3_LOCAL_STACK: string; + S3_REGION: string; + S3_BUCKET_NAME: string; + } +} diff --git a/apps/queue-manager/src/types/file-processing.types.ts b/apps/queue-manager/src/types/file-processing.types.ts new file mode 100644 index 000000000..8c7b664ff --- /dev/null +++ b/apps/queue-manager/src/types/file-processing.types.ts @@ -0,0 +1,37 @@ +import { ProcessFileCachedData } from '@impler/shared'; + +export interface ISendDataParameters { + data: ISendData; + url: string; + method: 'POST'; +} +export interface IBuildSendDataParameters { + data: any[]; + page: number; + chunkSize: number; + isInvalidRecords: boolean; + template: string; + uploadId: string; + extra?: string; +} +export interface IGetNextDataParameters extends ProcessFileCachedData { + validData: any[]; + invalidData: any[]; +} + +export interface ISendDataResponse { + statusCode: number; + status: 'FAILED' | 'SUCCEED'; + failedReason?: string; +} +export interface ISendData { + template: string; + uploadId: string; + data: any[]; + totalRecords: number; + totalPages: number; + page: number; + pageSize: number; + extra: string; + isInvalidRecords: boolean; +} diff --git a/apps/queue-manager/tsconfig.build.json b/apps/queue-manager/tsconfig.build.json new file mode 100644 index 000000000..3109babaf --- /dev/null +++ b/apps/queue-manager/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "declaration": true, + "noImplicitAny": false, + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/apps/queue-manager/tsconfig.json b/apps/queue-manager/tsconfig.json new file mode 100644 index 000000000..2e0513159 --- /dev/null +++ b/apps/queue-manager/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["node"] + } +} diff --git a/apps/widget-demo/.example.env b/apps/widget-demo/.example.env new file mode 100644 index 000000000..39539fe45 --- /dev/null +++ b/apps/widget-demo/.example.env @@ -0,0 +1,3 @@ +VITE_PROJECT_ID= +VITE_ACCESS_TOKEN= +VITE_TEMPLATE= \ No newline at end of file diff --git a/apps/widget-demo/.gitignore b/apps/widget-demo/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/apps/widget-demo/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/widget-demo/index.html b/apps/widget-demo/index.html new file mode 100644 index 000000000..0a6d08158 --- /dev/null +++ b/apps/widget-demo/index.html @@ -0,0 +1,14 @@ + + + + + + + Widget Demo + + +
+ + + + diff --git a/apps/widget-demo/package.json b/apps/widget-demo/package.json new file mode 100644 index 000000000..c69c0a18f --- /dev/null +++ b/apps/widget-demo/package.json @@ -0,0 +1,28 @@ +{ + "name": "@impler/widget-demo", + "version": "0.1.0", + "author": "knovator", + "license": "MIT", + "private": true, + "type": "module", + "scripts": { + "start": "vite", + "start:dev": "vite", + "prebuild": "rimraf dist", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@impler/react": "^0.1.0", + "react": "18.2.0", + "react-dom": "18.2.0" + }, + "devDependencies": { + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "@vitejs/plugin-react": "^2.2.0", + "rimraf": "^3.0.2", + "typescript": "^4.8.4", + "vite": "^3.2.3" + } +} \ No newline at end of file diff --git a/apps/widget-demo/public/vite.svg b/apps/widget-demo/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/apps/widget-demo/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/widget-demo/src/App.tsx b/apps/widget-demo/src/App.tsx new file mode 100644 index 000000000..2740f4c5b --- /dev/null +++ b/apps/widget-demo/src/App.tsx @@ -0,0 +1,13 @@ +import { Button } from '@impler/react'; + +export const App = () => { + return ( +
+
+ ); +}; diff --git a/apps/widget-demo/src/main.tsx b/apps/widget-demo/src/main.tsx new file mode 100644 index 000000000..3f2a34f65 --- /dev/null +++ b/apps/widget-demo/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { App } from './App'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +); diff --git a/apps/widget-demo/src/vite-env.d.ts b/apps/widget-demo/src/vite-env.d.ts new file mode 100644 index 000000000..8c012e64c --- /dev/null +++ b/apps/widget-demo/src/vite-env.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/// + +interface ImportMetaEnv { + readonly VITE_PROJECT_ID: string; + readonly VITE_ACCESS_TOKEN: string; + readonly VITE_TEMPLATE: string; + // more env variables... +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/apps/widget-demo/tsconfig.json b/apps/widget-demo/tsconfig.json new file mode 100644 index 000000000..3d0a51a86 --- /dev/null +++ b/apps/widget-demo/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/widget-demo/tsconfig.node.json b/apps/widget-demo/tsconfig.node.json new file mode 100644 index 000000000..9d31e2aed --- /dev/null +++ b/apps/widget-demo/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/widget-demo/vite.config.ts b/apps/widget-demo/vite.config.ts new file mode 100644 index 000000000..627a31962 --- /dev/null +++ b/apps/widget-demo/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], +}); diff --git a/apps/widget/.eslintrc.js b/apps/widget/.eslintrc.js new file mode 100644 index 000000000..0f7d8d40d --- /dev/null +++ b/apps/widget/.eslintrc.js @@ -0,0 +1,38 @@ +module.exports = { + rules: { + 'func-names': 'off', + 'react/jsx-props-no-spreading': 'off', + 'react/no-array-index-key': 'off', + 'no-empty-pattern': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'react/no-unescaped-entities': 'off', + 'react/jsx-closing-bracket-location': 'off', + '@typescript-eslint/ban-types': 'off', + 'react/jsx-wrap-multilines': 'off', + 'jsx-a11y/anchor-is-valid': 'off', + 'promise/catch-or-return': 'off', + 'react/jsx-one-expression-per-line': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'jsx-a11y/aria-role': 'off', + 'jsx-a11y/no-static-element-interactions': 'off', + 'react/require-default-props': 'off', + 'react/no-danger': 'off', + 'jsx-a11y/click-events-have-key-events': 'off', + '@typescript-eslint/naming-convention': [ + 'error', + { + filter: '_', + selector: 'variableLike', + leadingUnderscore: 'allow', + format: ['PascalCase', 'camelCase', 'UPPER_CASE'], + }, + ], + }, + ignorePatterns: ['craco.config.js'], + extends: ['../../.eslintrc.js'], + parserOptions: { + project: './tsconfig.json', + ecmaVersion: 2020, + sourceType: 'module', + }, +}; diff --git a/apps/widget/.example.env b/apps/widget/.example.env new file mode 100644 index 000000000..b4ab84278 --- /dev/null +++ b/apps/widget/.example.env @@ -0,0 +1 @@ +REACT_APP_API_URL=http://localhost:3000 \ No newline at end of file diff --git a/apps/widget/.gitignore b/apps/widget/.gitignore new file mode 100644 index 000000000..4c9ae0ca3 --- /dev/null +++ b/apps/widget/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +storybook-static +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +cypress/videos +cypress/screenshots diff --git a/apps/widget/.storybook/main.js b/apps/widget/.storybook/main.js new file mode 100644 index 000000000..3742746be --- /dev/null +++ b/apps/widget/.storybook/main.js @@ -0,0 +1,8 @@ +module.exports = { + stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: ['@storybook/addon-essentials'], + framework: '@storybook/react', + features: { + emotionAlias: false, + }, +}; diff --git a/apps/widget/.storybook/preview-head.html b/apps/widget/.storybook/preview-head.html new file mode 100644 index 000000000..5935506f7 --- /dev/null +++ b/apps/widget/.storybook/preview-head.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/widget/craco.config.js b/apps/widget/craco.config.js new file mode 100644 index 000000000..05e70caae --- /dev/null +++ b/apps/widget/craco.config.js @@ -0,0 +1,14 @@ +const path = require('path'); +module.exports = { + webpack: { + alias: { + '@store': path.resolve(__dirname, './src/store'), + '@config': path.resolve(__dirname, './src/config'), + '@ui': path.resolve(__dirname, './src/design-system'), + '@types': path.resolve(__dirname, './src/types'), + '@icons': path.resolve(__dirname, './src/icons/index.ts'), + '@util': path.resolve(__dirname, './src/util/index.ts'), + '@hooks': path.resolve(__dirname, './src/hooks'), + }, + }, +}; diff --git a/apps/widget/env.sh b/apps/widget/env.sh new file mode 100755 index 000000000..e8ce8621b --- /dev/null +++ b/apps/widget/env.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Recreate config file +rm -rf ./env-config.js +touch ./env-config.js + +# Add assignment +echo "window._env_ = {" >> ./env-config.js + +# Read each line in .env file +# Each line represents key=value pairs +while read -r line || [[ -n "$line" ]]; +do + # Split env variables by character `=` + if printf '%s\n' "$line" | grep -q -e '='; then + varname=$(printf '%s\n' "$line" | sed -e 's/=.*//') + varvalue=$(printf '%s\n' "$line" | sed -e 's/^[^=]*=//') + fi + + # Read value of current variable if exists as Environment variable + value=$(printf '%s\n' "${!varname}") + # Otherwise use value from .env file + [[ -z $value ]] && value=${varvalue} + + # Append configuration property to JS file + echo " $varname: \"$value\"," >> ./env-config.js +done < .env + +echo "}" >> ./env-config.js diff --git a/apps/widget/package.json b/apps/widget/package.json new file mode 100644 index 000000000..6990cefd6 --- /dev/null +++ b/apps/widget/package.json @@ -0,0 +1,71 @@ +{ + "name": "@impler/widget", + "version": "0.1.0", + "author": "knovator", + "license": "MIT", + "private": true, + "scripts": { + "start": "cross-env PORT=3500 BROWSER=none craco start", + "start:dev": "cross-env PORT=3500 BROWSER=none craco start", + "preinstall": "pnpm build", + "prebuild": "rimraf build", + "build": "craco build", + "precommit": "lint-staged", + "eject": "craco eject", + "lint": "eslint src", + "storybook": "start-storybook -p 6006 -s public", + "build-storybook": "build-storybook -s public", + "envsetup": "chmod +x ./env.sh && ./env.sh && mv env-config.js ./public/env-config.js", + "envsetup:docker": "chmod +x ./env.sh && ./env.sh && mv ./env-config.js ./build/env-config.js", + "start:static:build": "pnpm envsetup:docker && http-server build -p 4500 --proxy http://localhost:4500?", + "start:docker": "pnpm build && pnpm start:static:build" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint" + ] + }, + "dependencies": { + "@craco/craco": "^6.4.5", + "@emotion/react": "^11.10.5", + "@impler/client": "^0.1.0", + "@impler/shared": "^0.1.0", + "@mantine/core": "^5.6.3", + "@mantine/dropzone": "^5.6.3", + "@mantine/notifications": "5.6.3", + "@storybook/addon-essentials": "^6.5.13", + "@storybook/react": "^6.5.13", + "@tanstack/react-query": "^4.14.5", + "axios": "^0.26.1", + "cross-env": "^7.0.3", + "file-saver": "^2.0.5", + "http-server": "^14.1.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-hook-form": "^7.39.1", + "react-router-dom": "^6.4.2", + "react-scripts": "5.0.1", + "rimraf": "^3.0.2", + "web-vitals": "^3.0.4", + "webfontloader": "^1.6.28", + "webpack-dev-server": "^4.11.1" + }, + "devDependencies": { + "@types/file-saver": "^2.0.5", + "@types/react": "^18.0.21", + "@types/react-dom": "^18.0.6", + "typescript": "^4.8.3" + } +} diff --git a/apps/widget/public/arrow.svg b/apps/widget/public/arrow.svg new file mode 100644 index 000000000..b6af7db86 --- /dev/null +++ b/apps/widget/public/arrow.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/widget/public/favicon.ico b/apps/widget/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/apps/widget/public/index.html b/apps/widget/public/index.html new file mode 100644 index 000000000..3f9e0c0c8 --- /dev/null +++ b/apps/widget/public/index.html @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + Impler Widget + + + + + +
+ + + + + diff --git a/apps/widget/public/logo192.png b/apps/widget/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..fc44b0a3796c0e0a64c3d858ca038bd4570465d9 GIT binary patch literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN literal 0 HcmV?d00001 diff --git a/apps/widget/public/manifest.json b/apps/widget/public/manifest.json new file mode 100644 index 000000000..32173287a --- /dev/null +++ b/apps/widget/public/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "Impler", + "name": "Impler", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/apps/widget/public/robots.txt b/apps/widget/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/apps/widget/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/apps/widget/src/components/App.tsx b/apps/widget/src/components/App.tsx new file mode 100644 index 000000000..306a0b3a1 --- /dev/null +++ b/apps/widget/src/components/App.tsx @@ -0,0 +1,44 @@ +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { NotificationsProvider } from '@mantine/notifications'; +import { MantineProvider } from '@mantine/core'; +import { CONTEXT_PATH, mantineConfig, variables } from '@config'; +import { WidgetShell } from './ApplicationShell'; +import { Container } from './Common/Container'; +import { Widget } from './widget'; + +export function App() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + staleTime: variables.twentyFourHoursInMs, + }, + }, + }); + + return ( + + + + + + + + + + + } + /> + + + + + + ); +} diff --git a/apps/widget/src/components/ApplicationShell.tsx b/apps/widget/src/components/ApplicationShell.tsx new file mode 100644 index 000000000..bfe8abb4c --- /dev/null +++ b/apps/widget/src/components/ApplicationShell.tsx @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { Global } from '@emotion/react'; + +export function WidgetShell({ children }: { children: JSX.Element }) { + const WrapperComponent = inIframe() ? TransparentShell : MockPreviewShell; + + return {children}; +} + +function TransparentShell({ children }: { children: JSX.Element }) { + return
{children}
; +} + +function MockPreviewShell({ children }: { children: JSX.Element }) { + useEffect(() => { + if (document.querySelector('body')) { + (document.querySelector('body') as HTMLBodyElement).style.width = 'auto'; + } + }, []); + + return ( +
+ + {children} +
+ ); +} + +function inIframe() { + try { + return window.self !== window.top; + } catch (e) { + return true; + } +} diff --git a/apps/widget/src/components/Common/Container/Container.tsx b/apps/widget/src/components/Common/Container/Container.tsx new file mode 100644 index 000000000..884753369 --- /dev/null +++ b/apps/widget/src/components/Common/Container/Container.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState, PropsWithChildren } from 'react'; +import * as WebFont from 'webfontloader'; +import { useParams } from 'react-router-dom'; +import { Global } from '@emotion/react'; +import { API_URL, colors } from '@config'; +import { Provider } from '../Provider'; +import { ParentWindow } from '@util'; +import { useAuthentication } from '@hooks/useAuthentication'; +import { ApiService } from '@impler/client'; +import { EventTypesEnum, MessageHandlerDataType } from '@types'; +import { IInitPayload, IShowPayload } from '@impler/shared'; + +let api: ApiService; + +export function Container({ children }: PropsWithChildren<{}>) { + if (!api) api = new ApiService(API_URL); + const { projectId = '' } = useParams<{ projectId: string }>(); + const [showWidget, setShowWidget] = useState(false); + const [primaryPayload, setPrimaryPayload] = useState(); + const [secondaryPayload, setSecondaryPayload] = useState(); + const { isAuthenticated, refetch } = useAuthentication({ api, projectId, template: primaryPayload?.template }); + + useEffect(() => { + WebFont.load({ + google: { + families: ['Lato'], + }, + }); + }, []); + + useEffect(() => { + if (process.env.NODE_ENV === 'test') { + // eslint-disable-next-line + (window as any).initHandler = messageEventHandler; + } + + window.addEventListener('message', messageEventHandler); + + ParentWindow.Ready(); + + return () => window.removeEventListener('message', messageEventHandler); + }, []); + + function messageEventHandler({ data }: { data?: MessageHandlerDataType }) { + if (data && data.type === EventTypesEnum.INIT_IFRAME) { + setPrimaryPayload(data.value); + if (data.value?.accessToken) { + api.setAuthorizationToken(data.value.accessToken); + } + refetch(); + } + if (data && data.type === EventTypesEnum.SHOW_WIDGET) { + setShowWidget(true); + setSecondaryPayload(data.value); + } + } + + if (!isAuthenticated || !showWidget) return null; + + return ( + <> + + {primaryPayload ? ( + + {children} + + ) : null} + + ); +} diff --git a/apps/widget/src/components/Common/Container/index.ts b/apps/widget/src/components/Common/Container/index.ts new file mode 100644 index 000000000..8a1103ffa --- /dev/null +++ b/apps/widget/src/components/Common/Container/index.ts @@ -0,0 +1 @@ +export * from './Container'; diff --git a/apps/widget/src/components/Common/Footer/Footer.tsx b/apps/widget/src/components/Common/Footer/Footer.tsx new file mode 100644 index 000000000..a0a9aaa0c --- /dev/null +++ b/apps/widget/src/components/Common/Footer/Footer.tsx @@ -0,0 +1,60 @@ +import { Group } from '@mantine/core'; +import { Button } from '@ui/Button'; +import { TEXTS } from '@config'; +import { PhasesEum } from '@types'; + +interface IFooterProps { + active: PhasesEum; + primaryButtonLoading?: boolean; + secondaryButtonLoading?: boolean; + onPrevClick: () => void; + onNextClick: () => void; +} + +export function Footer(props: IFooterProps) { + const { active, onNextClick, onPrevClick, primaryButtonLoading, secondaryButtonLoading } = props; + + const FooterActions = { + [PhasesEum.UPLOAD]: ( + + ), + [PhasesEum.MAPPING]: ( + <> + + + + ), + [PhasesEum.REVIEW]: ( + <> + + + + ), + [PhasesEum.COMPLETE]: ( + <> + + + + ), + }; + + return ( + + {FooterActions[active]} + + ); +} diff --git a/apps/widget/src/components/Common/Footer/index.ts b/apps/widget/src/components/Common/Footer/index.ts new file mode 100644 index 000000000..ddcc5a9cd --- /dev/null +++ b/apps/widget/src/components/Common/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/apps/widget/src/components/Common/Heading/Heading.tsx b/apps/widget/src/components/Common/Heading/Heading.tsx new file mode 100644 index 000000000..58e9cc30c --- /dev/null +++ b/apps/widget/src/components/Common/Heading/Heading.tsx @@ -0,0 +1,41 @@ +import { Group, Title } from '@mantine/core'; +import { Stepper } from '@ui/Stepper'; +import { TEXTS } from '@config'; +import { PhasesEum } from '@types'; + +interface IHeadingProps { + active: PhasesEum; +} + +const Titles = { + [PhasesEum.UPLOAD]: TEXTS.TITLES.UPLOAD, + [PhasesEum.MAPPING]: TEXTS.TITLES.MAPPING, + [PhasesEum.REVIEW]: TEXTS.TITLES.REVIEW, + [PhasesEum.COMPLETE]: TEXTS.TITLES.COMPLETE, +}; + +const Steps = [ + { + label: TEXTS.STEPS.UPLOAD, + }, + { + label: TEXTS.STEPS.MAPPING, + }, + { + label: TEXTS.STEPS.REVIEW, + }, + { + label: TEXTS.STEPS.COMPLETE, + }, +]; + +export function Heading(props: IHeadingProps) { + const { active } = props; + + return ( + + {Titles[active]} + + + ); +} diff --git a/apps/widget/src/components/Common/Heading/index.ts b/apps/widget/src/components/Common/Heading/index.ts new file mode 100644 index 000000000..6406e7b07 --- /dev/null +++ b/apps/widget/src/components/Common/Heading/index.ts @@ -0,0 +1 @@ +export * from './Heading'; diff --git a/apps/widget/src/components/Common/Layout/Layout.tsx b/apps/widget/src/components/Common/Layout/Layout.tsx new file mode 100644 index 000000000..c8f4919ce --- /dev/null +++ b/apps/widget/src/components/Common/Layout/Layout.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren } from 'react'; +import { Heading } from 'components/Common/Heading'; +import useStyles from './Styles'; +import { PhasesEum } from '@types'; + +interface ILayoutProps { + active: PhasesEum; +} + +export function Layout(props: PropsWithChildren) { + const { classes } = useStyles(); + const { children, active } = props; + + return ( +
+ {/* Heading */} + +
{children}
+
+ ); +} diff --git a/apps/widget/src/components/Common/Layout/Styles.tsx b/apps/widget/src/components/Common/Layout/Styles.tsx new file mode 100644 index 000000000..f7726c22e --- /dev/null +++ b/apps/widget/src/components/Common/Layout/Styles.tsx @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars */ +import { createStyles, MantineTheme } from '@mantine/core'; + +export const getRootStyles = (theme: MantineTheme): React.CSSProperties => ({ + paddingRight: 24, + paddingLeft: 24, + paddingBottom: 24, + border: '1px solid transparent', + display: 'flex', + flexDirection: 'column', + height: '100%', +}); + +export const getContainerStyles = (theme: MantineTheme): React.CSSProperties => ({ + flexDirection: 'column', + width: '100%', + alignItems: 'unset', + flexGrow: 1, + display: 'flex', + gap: theme.spacing.md, +}); + +export default createStyles((theme: MantineTheme, params, getRef): Record => { + return { + root: getRootStyles(theme), + container: getContainerStyles(theme), + }; +}); diff --git a/apps/widget/src/components/Common/Layout/index.ts b/apps/widget/src/components/Common/Layout/index.ts new file mode 100644 index 000000000..9877e7f4a --- /dev/null +++ b/apps/widget/src/components/Common/Layout/index.ts @@ -0,0 +1 @@ +export * from './Layout'; diff --git a/apps/widget/src/components/Common/Provider/Provider.tsx b/apps/widget/src/components/Common/Provider/Provider.tsx new file mode 100644 index 000000000..45a1e698b --- /dev/null +++ b/apps/widget/src/components/Common/Provider/Provider.tsx @@ -0,0 +1,34 @@ +import { PropsWithChildren } from 'react'; +import { ApiService } from '@impler/client'; +import ImplerContextProvider from '@store/impler.context'; +import APIContextProvider from '@store/api.context'; +import AppContextProvider from '@store/app.context'; + +interface IProviderProps { + // api-context + api: ApiService; + // impler-context + projectId: string; + template?: string; + accessToken?: string; + extra?: string; + authHeaderValue?: string; +} + +export function Provider(props: PropsWithChildren) { + const { api, projectId, template, accessToken, extra, authHeaderValue, children } = props; + + return ( + + + {children} + + + ); +} diff --git a/apps/widget/src/components/Common/Provider/index.ts b/apps/widget/src/components/Common/Provider/index.ts new file mode 100644 index 000000000..505094109 --- /dev/null +++ b/apps/widget/src/components/Common/Provider/index.ts @@ -0,0 +1 @@ +export * from './Provider'; diff --git a/apps/widget/src/components/widget/Phases/ConfirmModal/ConfirmModal.tsx b/apps/widget/src/components/widget/Phases/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 000000000..7b4e6714f --- /dev/null +++ b/apps/widget/src/components/widget/Phases/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,36 @@ +import { Warning } from '@icons'; +import { colors, TEXTS } from '@config'; +import { Button } from '@ui/Button'; +import { Group, Modal as MantineModal, Text, Title } from '@mantine/core'; +import { replaceVariablesInString, numberFormatter } from '@impler/shared'; + +interface IConfirmModalProps { + opened: boolean; + wrongDataCount: number; + onClose: () => void; + onConfirm: (exempt: boolean) => void; +} + +export function ConfirmModal(props: IConfirmModalProps) { + const { opened, onClose, wrongDataCount, onConfirm } = props; + + return ( + + + + + {replaceVariablesInString(TEXTS.CONFIRM_MODAL.title, { count: numberFormatter(wrongDataCount) })} + + + {TEXTS.CONFIRM_MODAL.subTitle} + + + + + + + + ); +} diff --git a/apps/widget/src/components/widget/Phases/ConfirmModal/index.ts b/apps/widget/src/components/widget/Phases/ConfirmModal/index.ts new file mode 100644 index 000000000..353d4b1ef --- /dev/null +++ b/apps/widget/src/components/widget/Phases/ConfirmModal/index.ts @@ -0,0 +1 @@ +export * from './ConfirmModal'; diff --git a/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx b/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx new file mode 100644 index 000000000..b0228c40a --- /dev/null +++ b/apps/widget/src/components/widget/Phases/Phase1/Phase1.tsx @@ -0,0 +1,97 @@ +import { TEXTS } from '@config'; +import { Select } from '@ui/Select'; +import { Button } from '@ui/Button'; +import { Dropzone } from '@ui/Dropzone'; +import { LoadingOverlay } from '@ui/LoadingOverlay'; +import { Group } from '@mantine/core'; +import { Download } from '@icons'; +import useStyles from './Styles'; +import { Footer } from 'components/Common/Footer'; +import { usePhase1 } from '@hooks/Phase1/usePhase1'; +import { Controller } from 'react-hook-form'; +import { PhasesEum } from '@types'; + +interface IPhase1Props { + onNextClick: () => void; +} + +export function Phase1(props: IPhase1Props) { + const { classes } = useStyles(); + const { onNextClick: goNext } = props; + const { + showSelectTemplate, + onSubmit, + trigger, + control, + templates, + isInitialDataLoaded, + isUploadLoading, + onDownload, + isDownloadInProgress, + } = usePhase1({ + goNext, + }); + + return ( + <> + + + {showSelectTemplate && ( + ( + } + clearable + size={size} + searchable={searchable} + dropdownComponent="div" + onChange={(selectedValue) => onChange && onChange(selectedValue)} + ref={ref} + /> + + + {value ? : null} + {value ? mappingSucceedText : mappingFailedText} + + + ); +}); diff --git a/apps/widget/src/design-system/MappingItem/index.ts b/apps/widget/src/design-system/MappingItem/index.ts new file mode 100644 index 000000000..b8ba3fe12 --- /dev/null +++ b/apps/widget/src/design-system/MappingItem/index.ts @@ -0,0 +1 @@ +export * from './MappingItem'; diff --git a/apps/widget/src/design-system/Modal/Modal.stories.tsx b/apps/widget/src/design-system/Modal/Modal.stories.tsx new file mode 100644 index 000000000..16928e2c0 --- /dev/null +++ b/apps/widget/src/design-system/Modal/Modal.stories.tsx @@ -0,0 +1,36 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { Modal } from './Modal'; + +export default { + title: 'Modal', + component: Modal, + argTypes: { + size: { + control: { + type: 'select', + options: ['xs', 'sm', 'md', 'lg', 'xl'], + }, + }, + padding: { + control: { + type: 'select', + options: ['xs', 'sm', 'md', 'lg', 'xl'], + }, + }, + onClose: { + action: 'onClose', + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Simple = Template.bind({}); +Simple.args = { + title: 'Modal title', + size: '100%', + centered: true, + opened: true, + overflow: 'inside', + children: 'Content', +}; diff --git a/apps/widget/src/design-system/Modal/Modal.style.ts b/apps/widget/src/design-system/Modal/Modal.style.ts new file mode 100644 index 000000000..148abd551 --- /dev/null +++ b/apps/widget/src/design-system/Modal/Modal.style.ts @@ -0,0 +1,29 @@ +/* eslint-disable @typescript-eslint/no-unused-vars, no-unused-vars */ +import { createStyles, MantineTheme } from '@mantine/core'; +import React from 'react'; +import { colors } from '../../config/colors.config'; + +export const getHeaderStyles = (theme: MantineTheme): React.CSSProperties => ({ + marginBottom: theme.spacing.xs, + marginRight: 0, +}); + +export const getModalStyles = (theme: MantineTheme): React.CSSProperties => ({ + padding: '100px', + height: 'calc(100vh - 20%)', + width: 'calc(100vw - 20%)', + display: 'flex', + flexDirection: 'column', +}); + +export const getModalBodyStyles = (theme: MantineTheme): React.CSSProperties => ({ + flexGrow: 1, +}); + +export default createStyles((theme: MantineTheme, params, getRef): Record => { + return { + header: getHeaderStyles(theme), + modal: getModalStyles(theme), + body: getModalBodyStyles(theme), + }; +}); diff --git a/apps/widget/src/design-system/Modal/Modal.tsx b/apps/widget/src/design-system/Modal/Modal.tsx new file mode 100644 index 000000000..ec5c94641 --- /dev/null +++ b/apps/widget/src/design-system/Modal/Modal.tsx @@ -0,0 +1,34 @@ +import { PropsWithChildren } from 'react'; +import { Modal as MantineModal } from '@mantine/core'; +import useStyles from './Modal.style'; + +interface IModalProps extends JSX.ElementChildrenAttribute { + title?: string; + opened: boolean; + centered?: boolean; + onClose: () => void; + overflow?: 'inside' | 'outside'; + size?: 'sm' | 'md' | 'lg' | '100%' | number; +} + +export function Modal(props: PropsWithChildren) { + const { children, onClose, opened, title, centered = true } = props; + const { classes } = useStyles(); + + return ( + + {children} + + ); +} diff --git a/apps/widget/src/design-system/Modal/index.ts b/apps/widget/src/design-system/Modal/index.ts new file mode 100644 index 000000000..cb89ee178 --- /dev/null +++ b/apps/widget/src/design-system/Modal/index.ts @@ -0,0 +1 @@ +export * from './Modal'; diff --git a/apps/widget/src/design-system/Pagination/Pagination.stories.tsx b/apps/widget/src/design-system/Pagination/Pagination.stories.tsx new file mode 100644 index 000000000..4aa75f0ec --- /dev/null +++ b/apps/widget/src/design-system/Pagination/Pagination.stories.tsx @@ -0,0 +1,20 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { Pagination } from './Pagination'; + +export default { + title: 'Pagination', + component: Pagination, + argTypes: { + onChange: { + action: 'onChange', + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Simple = Template.bind({}); +Simple.args = { + total: 10, + page: 1, +}; diff --git a/apps/widget/src/design-system/Pagination/Pagination.tsx b/apps/widget/src/design-system/Pagination/Pagination.tsx new file mode 100644 index 000000000..736ed0cbf --- /dev/null +++ b/apps/widget/src/design-system/Pagination/Pagination.tsx @@ -0,0 +1,19 @@ +import { Pagination as MantinePagination, Group } from '@mantine/core'; + +interface IPaginationProps { + page?: number; + total: number; + size?: 'sm' | 'md'; + onChange?: (page: number) => void; +} + +export function Pagination(props: IPaginationProps) { + const defaultPage = 1; + const { total, page = defaultPage, size = 'md', onChange } = props; + + return ( + + + + ); +} diff --git a/apps/widget/src/design-system/Pagination/index.ts b/apps/widget/src/design-system/Pagination/index.ts new file mode 100644 index 000000000..e016c96b7 --- /dev/null +++ b/apps/widget/src/design-system/Pagination/index.ts @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/apps/widget/src/design-system/Select/Select.stories.tsx b/apps/widget/src/design-system/Select/Select.stories.tsx new file mode 100644 index 000000000..dc71189ed --- /dev/null +++ b/apps/widget/src/design-system/Select/Select.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { Select } from './Select'; + +export default { + title: 'Select', + component: Select, +} as ComponentMeta; + +const Template: ComponentStory = (args) =>