diff --git a/.circleci/config.yml b/.circleci/config.yml index bd401126b20..849f103540e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,7 +39,7 @@ aliases: sudo apt install python3-distutils sudo apt install -y curl gnupg git libappindicator3-1 ca-certificates binutils icnsutils graphicsmagick python3 -m pip install packaging setuptools - sudo npm install --quiet node-gyp@9.3.1 -g + sudo npm install --quiet node-gyp@10.2.0 -g sudo npm config set python /usr/bin/python - &install-yarn diff --git a/.cspell.json b/.cspell.json index 19e7be763f2..38b8408976c 100644 --- a/.cspell.json +++ b/.cspell.json @@ -12,6 +12,8 @@ "Alexi", "alish", "Alish", + "Rahul", + "rahul", "apicivo", "apicw", "apidemo", @@ -518,7 +520,8 @@ "xaxis", "wdth", "concate", - "typeahead" + "typeahead", + "maxlength" ], "useGitignore": true, "ignorePaths": [ diff --git a/.deploy/api/Dockerfile b/.deploy/api/Dockerfile index 8bbb537afee..44259ca82e1 100644 --- a/.deploy/api/Dockerfile +++ b/.deploy/api/Dockerfile @@ -128,7 +128,7 @@ RUN npm --version # Output Python3 version RUN python3 --version -RUN npm install --quiet node-gyp@9.3.1 -g +RUN npm install --quiet node-gyp@10.2.0 -g RUN npm install yarn -g --force RUN mkdir /srv/gauzy && chown -R node:node /srv/gauzy @@ -149,10 +149,14 @@ COPY --chown=node:node packages/contracts/package.json ./packages/contracts/ COPY --chown=node:node packages/auth/package.json ./packages/auth/ COPY --chown=node:node packages/core/package.json ./packages/core/ COPY --chown=node:node packages/plugin/package.json ./packages/plugin/ +COPY --chown=node:node packages/plugins/integration-ai-ui/package.json ./packages/plugins/integration-ai-ui/ COPY --chown=node:node packages/plugins/integration-ai/package.json ./packages/plugins/integration-ai/ +COPY --chown=node:node packages/plugins/integration-hubstaff-ui/package.json ./packages/plugins/integration-hubstaff-ui/ COPY --chown=node:node packages/plugins/integration-hubstaff/package.json ./packages/plugins/integration-hubstaff/ +COPY --chown=node:node packages/plugins/integration-upwork-ui/package.json ./packages/plugins/integration-upwork-ui/ COPY --chown=node:node packages/plugins/integration-upwork/package.json ./packages/plugins/integration-upwork/ COPY --chown=node:node packages/plugins/integration-github/package.json ./packages/plugins/integration-github/ +COPY --chown=node:node packages/plugins/integration-github-ui/package.json ./packages/plugins/integration-github-ui/ COPY --chown=node:node packages/plugins/integration-jira/package.json ./packages/plugins/integration-jira/ COPY --chown=node:node packages/plugins/jitsu-analytics/package.json ./packages/plugins/jitsu-analytics/ COPY --chown=node:node packages/plugins/sentry-tracing/package.json ./packages/plugins/sentry-tracing/ @@ -202,7 +206,7 @@ RUN npm --version # Output Python3 version RUN python3 --version -RUN npm install --quiet node-gyp@9.3.1 -g +RUN npm install --quiet node-gyp@10.2.0 -g RUN npm install yarn -g --force RUN mkdir /srv/gauzy && chown -R node:node /srv/gauzy @@ -218,10 +222,14 @@ COPY --chown=node:node packages/contracts/package.json ./packages/contracts/ COPY --chown=node:node packages/auth/package.json ./packages/auth/ COPY --chown=node:node packages/core/package.json ./packages/core/ COPY --chown=node:node packages/plugin/package.json ./packages/plugin/ +COPY --chown=node:node packages/plugins/integration-ai-ui/package.json ./packages/plugins/integration-ai-ui/ COPY --chown=node:node packages/plugins/integration-ai/package.json ./packages/plugins/integration-ai/ +COPY --chown=node:node packages/plugins/integration-hubstaff-ui/package.json ./packages/plugins/integration-hubstaff-ui/ COPY --chown=node:node packages/plugins/integration-hubstaff/package.json ./packages/plugins/integration-hubstaff/ +COPY --chown=node:node packages/plugins/integration-upwork-ui/package.json ./packages/plugins/integration-upwork-ui/ COPY --chown=node:node packages/plugins/integration-upwork/package.json ./packages/plugins/integration-upwork/ COPY --chown=node:node packages/plugins/integration-github/package.json ./packages/plugins/integration-github/ +COPY --chown=node:node packages/plugins/integration-github-ui/package.json ./packages/plugins/integration-github-ui/ COPY --chown=node:node packages/plugins/integration-jira/package.json ./packages/plugins/integration-jira/ COPY --chown=node:node packages/plugins/jitsu-analytics/package.json ./packages/plugins/jitsu-analytics/ COPY --chown=node:node packages/plugins/sentry-tracing/package.json ./packages/plugins/sentry-tracing/ diff --git a/.deploy/webapp/Dockerfile b/.deploy/webapp/Dockerfile index 2e198cc546b..e723c5057a5 100644 --- a/.deploy/webapp/Dockerfile +++ b/.deploy/webapp/Dockerfile @@ -89,10 +89,14 @@ COPY --chown=node:node packages/contracts/package.json ./packages/contracts/ COPY --chown=node:node packages/auth/package.json ./packages/auth/ COPY --chown=node:node packages/core/package.json ./packages/core/ COPY --chown=node:node packages/plugin/package.json ./packages/plugin/ +COPY --chown=node:node packages/plugins/integration-ai-ui/package.json ./packages/plugins/integration-ai-ui/ COPY --chown=node:node packages/plugins/integration-ai/package.json ./packages/plugins/integration-ai/ +COPY --chown=node:node packages/plugins/integration-hubstaff-ui/package.json ./packages/plugins/integration-hubstaff-ui/ COPY --chown=node:node packages/plugins/integration-hubstaff/package.json ./packages/plugins/integration-hubstaff/ +COPY --chown=node:node packages/plugins/integration-upwork-ui/package.json ./packages/plugins/integration-upwork-ui/ COPY --chown=node:node packages/plugins/integration-upwork/package.json ./packages/plugins/integration-upwork/ COPY --chown=node:node packages/plugins/integration-github/package.json ./packages/plugins/integration-github/ +COPY --chown=node:node packages/plugins/integration-github-ui/package.json ./packages/plugins/integration-github-ui/ COPY --chown=node:node packages/plugins/integration-jira/package.json ./packages/plugins/integration-jira/ COPY --chown=node:node packages/plugins/jitsu-analytics/package.json ./packages/plugins/jitsu-analytics/ COPY --chown=node:node packages/plugins/sentry-tracing/package.json ./packages/plugins/sentry-tracing/ diff --git a/.env.compose b/.env.compose index 45a66f4d43d..be9912cd3aa 100644 --- a/.env.compose +++ b/.env.compose @@ -481,5 +481,8 @@ DESKTOP_API_SERVER_APP_REPO_OWNER='ever-co' DESKTOP_API_SERVER_APP_WELCOME_TITLE= DESKTOP_API_SERVER_APP_WELCOME_CONTENT= +REGISTER_URL='https://app.gauzy.co/#/auth/register' +FORGOT_PASSWORD_URL='https://app.gauzy.co/#/auth/request-password' + # I18N Translation Files URL I18N_FILES_URL= diff --git a/.env.demo.compose b/.env.demo.compose index 8564649108d..0682a69289b 100644 --- a/.env.demo.compose +++ b/.env.demo.compose @@ -483,5 +483,8 @@ DESKTOP_API_SERVER_APP_REPO_OWNER='ever-co' DESKTOP_API_SERVER_APP_WELCOME_TITLE= DESKTOP_API_SERVER_APP_WELCOME_CONTENT= +REGISTER_URL='https://app.gauzy.co/#/auth/register' +FORGOT_PASSWORD_URL='https://app.gauzy.co/#/auth/request-password' + # I18N Translation Files URL I18N_FILES_URL= diff --git a/.env.docker b/.env.docker index 547830ae781..45432bf7338 100644 --- a/.env.docker +++ b/.env.docker @@ -462,5 +462,8 @@ DESKTOP_API_SERVER_APP_REPO_OWNER='ever-co' DESKTOP_API_SERVER_APP_WELCOME_TITLE= DESKTOP_API_SERVER_APP_WELCOME_CONTENT= +REGISTER_URL='https://app.gauzy.co/#/auth/register' +FORGOT_PASSWORD_URL='https://app.gauzy.co/#/auth/request-password' + # I18N Translation Files URL I18N_FILES_URL= diff --git a/.env.local b/.env.local index 0b0161c6f1d..f218550195f 100644 --- a/.env.local +++ b/.env.local @@ -453,5 +453,8 @@ DESKTOP_API_SERVER_APP_REPO_OWNER='ever-co' DESKTOP_API_SERVER_APP_WELCOME_TITLE= DESKTOP_API_SERVER_APP_WELCOME_CONTENT= +REGISTER_URL='https://app.gauzy.co/#/auth/register' +FORGOT_PASSWORD_URL='https://app.gauzy.co/#/auth/request-password' + # I18N Translation Files URL I18N_FILES_URL= diff --git a/.env.sample b/.env.sample index 44fb4f2bc6d..8ae3dee5c51 100644 --- a/.env.sample +++ b/.env.sample @@ -473,5 +473,8 @@ DESKTOP_API_SERVER_APP_REPO_OWNER='ever-co' DESKTOP_API_SERVER_APP_WELCOME_TITLE= DESKTOP_API_SERVER_APP_WELCOME_CONTENT= +REGISTER_URL='https://app.gauzy.co/#/auth/register' +FORGOT_PASSWORD_URL='https://app.gauzy.co/#/auth/request-password' + # I18N Translation Files URL I18N_FILES_URL= diff --git a/.github/workflows/desktop-app-prod.yml b/.github/workflows/desktop-app-prod.yml index 4665732651c..83f14d4971c 100644 --- a/.github/workflows/desktop-app-prod.yml +++ b/.github/workflows/desktop-app-prod.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -186,7 +186,8 @@ jobs: DESKTOP_APP_ID: 'com.ever.gauzydesktop' - name: Build Desktop App - run: 'yarn build:desktop:windows:release:gh' + run: | + yarn build:desktop:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -200,153 +201,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Program Files\GitHub CLI\;C:\Program Files\Amazon\AWSCLIV2\;C:\Users\runneradmin\.dotnet\tools' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.github/workflows/desktop-app-stage.yml b/.github/workflows/desktop-app-stage.yml index cc6777927e8..85127664bfc 100644 --- a/.github/workflows/desktop-app-stage.yml +++ b/.github/workflows/desktop-app-stage.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -186,7 +186,8 @@ jobs: DESKTOP_APP_ID: 'com.ever.gauzydesktop' - name: Build Desktop App - run: 'yarn build:desktop:windows:release:gh' + run: | + yarn build:desktop:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -200,153 +201,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Program Files\GitHub CLI\;C:\Program Files\Amazon\AWSCLIV2\;C:\Users\runneradmin\.dotnet\tools' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.github/workflows/desktop-timer-app-prod.yml b/.github/workflows/desktop-timer-app-prod.yml index 716e94c742c..98123b657ae 100644 --- a/.github/workflows/desktop-timer-app-prod.yml +++ b/.github/workflows/desktop-timer-app-prod.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -186,7 +186,8 @@ jobs: DESKTOP_TIMER_APP_ID: 'com.ever.gauzydesktoptimer' - name: Build Desktop Timer App - run: 'yarn build:desktop-timer:windows:release:gh' + run: | + yarn build:desktop-timer:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -200,153 +201,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Program Files\GitHub CLI\;C:\Program Files\Amazon\AWSCLIV2\;C:\Users\runneradmin\.dotnet\tools' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.github/workflows/desktop-timer-app-stage.yml b/.github/workflows/desktop-timer-app-stage.yml index a9a66887f72..f1c427c10f2 100644 --- a/.github/workflows/desktop-timer-app-stage.yml +++ b/.github/workflows/desktop-timer-app-stage.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -185,25 +185,15 @@ jobs: DESKTOP_TIMER_APP_DESCRIPTION: 'Ever Gauzy Desktop Timer' DESKTOP_TIMER_APP_ID: 'com.ever.gauzydesktoptimer' - - name: Print environment variable names - run: | - echo "Environment Variable Names:" - printenv | cut -d= -f1 - - - name: Print large environment variables names + - name: Print environment variables and their sizes shell: powershell run: | - # List all environment variables foreach ($envVar in [System.Environment]::GetEnvironmentVariables().Keys) { - # Get the value of the environment variable $value = [System.Environment]::GetEnvironmentVariable($envVar) - # Check if the value is not null if ($null -ne $value) { - # Check if the value length is greater than 100 bytes - if ([Text.Encoding]::UTF8.GetByteCount($value) -gt 100) { - Write-Output $envVar - } + $length = $value.Length + Write-Output "${envVar}: ${length}" } } @@ -214,15 +204,9 @@ jobs: Write-Output "PATH environment variable is:" Write-Output $path - - name: Print PSModulePath var value - shell: powershell - run: | - $pspath = [System.Environment]::GetEnvironmentVariable('PSModulePath') - Write-Output "PSModulePath environment variable is:" - Write-Output $pspath - - name: Build Desktop Timer App - run: 'yarn build:desktop-timer:windows:release:gh' + run: | + yarn build:desktop-timer:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -236,153 +220,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Program Files\GitHub CLI\;C:\Program Files\Amazon\AWSCLIV2\;C:\Users\runneradmin\.dotnet\tools' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.github/workflows/server-api-prod.yml b/.github/workflows/server-api-prod.yml index 278821245d1..9b787a4cc91 100644 --- a/.github/workflows/server-api-prod.yml +++ b/.github/workflows/server-api-prod.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -186,7 +186,8 @@ jobs: DESKTOP_API_SERVER_APP_ID: 'com.ever.gauzyapiserver' - name: Build Server - run: 'yarn build:gauzy-api-server:windows:release:gh' + run: | + yarn build:gauzy-api-server:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -200,153 +201,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\usr\bin;C:\Program Files\Amazon\AWSCLIV2\' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.github/workflows/server-api-stage.yml b/.github/workflows/server-api-stage.yml index 5e3acab350b..1adbb41d5be 100644 --- a/.github/workflows/server-api-stage.yml +++ b/.github/workflows/server-api-stage.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -186,7 +186,8 @@ jobs: DESKTOP_API_SERVER_APP_ID: 'com.ever.gauzyapiserver' - name: Build Server - run: 'yarn build:gauzy-api-server:windows:release:gh' + run: | + yarn build:gauzy-api-server:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -200,153 +201,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\usr\bin;C:\Program Files\Amazon\AWSCLIV2\' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.github/workflows/server-prod.yml b/.github/workflows/server-prod.yml index 703cbf80649..0b3255c2049 100644 --- a/.github/workflows/server-prod.yml +++ b/.github/workflows/server-prod.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -186,7 +186,8 @@ jobs: DESKTOP_SERVER_APP_ID: 'com.ever.gauzyserver' - name: Build Server - run: 'yarn build:gauzy-server:windows:release:gh' + run: | + yarn build:gauzy-server:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -200,153 +201,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Program Files\GitHub CLI\;C:\Program Files\Amazon\AWSCLIV2\;C:\Users\runneradmin\.dotnet\tools' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.github/workflows/server-stage.yml b/.github/workflows/server-stage.yml index b719cfd8e9b..aa6d16ebcff 100644 --- a/.github/workflows/server-stage.yml +++ b/.github/workflows/server-stage.yml @@ -42,7 +42,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -103,7 +103,7 @@ jobs: run: 'sudo npm install -g npm@9' - name: Install node-gyp package - run: 'sudo npm install --quiet -g node-gyp@9.3.1' + run: 'sudo npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -164,7 +164,7 @@ jobs: run: 'npm install -g npm@9' - name: Install node-gyp package - run: 'npm install --quiet -g node-gyp@9.3.1' + run: 'npm install --quiet -g node-gyp@10.2.0' - name: Install Yarn dependencies run: 'yarn install --network-timeout 1000000 --frozen-lockfile' @@ -186,7 +186,8 @@ jobs: DESKTOP_SERVER_APP_ID: 'com.ever.gauzyserver' - name: Build Server - run: 'yarn build:gauzy-server:windows:release:gh' + run: | + yarn build:gauzy-server:windows:release:gh env: USE_HARD_LINKS: false GH_TOKEN: ${{ secrets.GH_TOKEN }} @@ -200,153 +201,3 @@ jobs: DO_KEY_ID: ${{ secrets.DO_KEY_ID }} DO_SECRET_KEY: ${{ secrets.DO_SECRET_KEY }} NX_NO_CLOUD: true - # Override unwanted environment variables - ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE: '' - ANT_HOME: '' - AZURE_CONFIG_DIR: '' - AZURE_DEVOPS_CACHE_DIR: '' - AZURE_EXTENSION_DIR: '' - AZ_DEVOPS_GLOBAL_CONFIG_DIR: '' - CABAL_DIR: '' - ChocolateyInstall: '' - ChromeWebDriver: '' - COBERTURA_HOME: '' - # COMPUTERNAME: '' - # COMSPEC: '' - # CONDA: '' - # DEPLOYMENT_BASEPATH: '' - SBT_HOME: '' - SELENIUM_JAR_PATH: '' - STATS_BLT: '' - STATS_D: '' - STATS_D_D: '' - STATS_EXT: '' - STATS_EXTP: '' - STATS_RDCL: '' - STATS_TIS: '' - STATS_TRP: '' - STATS_UE: '' - STATS_V3PS: '' - STATS_VMD: '' - STATS_VMFE: '' - ANDROID_HOME: '' - ANDROID_NDK: '' - ANDROID_NDK_HOME: '' - ANDROID_NDK_LATEST_HOME: '' - ANDROID_NDK_ROOT: '' - ANDROID_SDK_ROOT: '' - # GITHUB_ACTION: '' - # GITHUB_ACTIONS: '' - # GITHUB_ACTION_REF: '' - # GITHUB_ACTION_REPOSITORY: '' - # GITHUB_ACTOR: '' - # GITHUB_ACTOR_ID: '' - # GITHUB_API_URL: '' - # GITHUB_BASE_REF: '' - # GITHUB_ENV: '' - # GITHUB_EVENT_NAME: '' - # GITHUB_EVENT_PATH: '' - # GITHUB_GRAPHQL_URL: '' - # GITHUB_HEAD_REF: '' - # GITHUB_JOB: '' - # GITHUB_OUTPUT: '' - # GITHUB_PATH: '' - # GITHUB_REF: '' - # GITHUB_REF_NAME: '' - # GITHUB_REF_PROTECTED: '' - # GITHUB_REF_TYPE: '' - # GITHUB_REPOSITORY: '' - # GITHUB_REPOSITORY_ID: '' - # GITHUB_REPOSITORY_OWNER: '' - # GITHUB_REPOSITORY_OWNER_ID: '' - # GITHUB_RETENTION_DAYS: '' - # GITHUB_RUN_ATTEMPT: '' - # GITHUB_RUN_ID: '' - # GITHUB_RUN_NUMBER: '' - # GITHUB_SERVER_URL: '' - # GITHUB_SHA: '' - # GITHUB_STATE: '' - # GITHUB_STEP_SUMMARY: '' - # GITHUB_TRIGGERING_ACTOR: '' - # GITHUB_WORKFLOW: '' - # GITHUB_WORKFLOW_REF: '' - # GITHUB_WORKFLOW_SHA: '' - # GITHUB_WORKSPACE: '' - GOROOT_1_20_X64: '' - GOROOT_1_21_X64: '' - GOROOT_1_22_X64: '' - GRADLE_HOME: '' - # HOMEDRIVE: '' - # HOMEPATH: '' - IEWebDriver: '' - ImageOS: '' - ImageVersion: '' - JAVA_HOME: '' - JAVA_HOME_11_X64: '' - JAVA_HOME_17_X64: '' - JAVA_HOME_21_X64: '' - JAVA_HOME_8_X64: '' - # LOCALAPPDATA: '' - # LOGONSERVER: '' - M2: '' - M2_REPO: '' - MAVEN_OPTS: '' - MonAgentClientLocation: '' - # npm_config_prefix: '' - # NUMBER_OF_PROCESSORS: '' - # OS: '' - # PATHEXT: '' - # PERFLOG_LOCATION_SETTING: '' - PGBIN: '' - PGDATA: '' - PGPASSWORD: '' - PGROOT: '' - PGUSER: '' - PHPROOT: '' - PIPX_BIN_DIR: '' - PIPX_HOME: '' - POWERSHELL_DISTRIBUTION_CHANNEL: '' - POWERSHELL_UPDATECHECK: '' - PROCESSOR_ARCHITECTURE: '' - PROCESSOR_IDENTIFIER: '' - PROCESSOR_LEVEL: '' - PROCESSOR_REVISION: '' - PSModuleAnalysisCachePath: '' - PSModulePath: '' - Path: 'C:\hostedtoolcache\windows\node\20.11.1\x64;C:\Program Files\Git\bin;C:\npm\prefix;C:\hostedtoolcache\windows\Python\3.9.13\x64\Scripts;C:\hostedtoolcache\windows\Python\3.9.13\x64;C:\Program Files\OpenSSL\bin;C:\Windows\system32;C:\Windows;C:\Windows\System32\OpenSSH\;C:\Program Files\dotnet\;C:\Program Files\PowerShell\7\;C:\Program Files\CMake\bin;C:\Program Files\nodejs\;C:\Program Files\Git\cmd;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Program Files\GitHub CLI\;C:\Program Files\Amazon\AWSCLIV2\;C:\Users\runneradmin\.dotnet\tools' - DOTNET_MULTILEVEL_LOOKUP: '' - DOTNET_NOLOGO: '' - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '' - DriverData: '' - EdgeWebDriver: '' - GCM_INTERACTIVE: '' - GeckoWebDriver: '' - GHCUP_INSTALL_BASE_PREFIX: '' - GHCUP_MSYS2: '' - # RTOOLS44_HOME: '' - RUNNER_ARCH: '' - RUNNER_ENVIRONMENT: '' - RUNNER_NAME: '' - RUNNER_OS: '' - RUNNER_PERFLOG: '' - RUNNER_TEMP: '' - RUNNER_TOOL_CACHE: '' - RUNNER_TRACKING_ID: '' - RUNNER_WORKSPACE: '' - # USERDOMAIN: '' - # USERDOMAIN_ROAMINGPROFILE: '' - # USERNAME: '' - # USERPROFILE: '' - # VCPKG_INSTALLATION_ROOT: '' - # WIX: '' - # TERM: '' - # HOME: '' - # WINDIR: '' - # ProgramData: '' - # PROGRAMFILES: '' - # ProgramW6432: '' - # ALLUSERSPROFILE: '' - # APPDATA: '' - # COMMONPROGRAMFILES: '' - # CommonProgramFiles(x86) - # CommonProgramW6432 diff --git a/.scripts/configure.ts b/.scripts/configure.ts index bd435a727b4..d9df0da12b5 100644 --- a/.scripts/configure.ts +++ b/.scripts/configure.ts @@ -78,6 +78,7 @@ if (!isDocker) { API_BASE_URL: API_BASE_URL, CLIENT_BASE_URL: CLIENT_BASE_URL, + COOKIE_DOMAIN: '${env.COOKIE_DOMAIN}', PLATFORM_WEBSITE_URL: '${env.PLATFORM_WEBSITE_URL}', PLATFORM_WEBSITE_DOWNLOAD_URL: '${env.PLATFORM_WEBSITE_DOWNLOAD_URL}', @@ -186,6 +187,9 @@ if (!isDocker) { DESKTOP_API_SERVER_APP_WELCOME_TITLE: '${env.DESKTOP_API_SERVER_APP_WELCOME_TITLE}', DESKTOP_API_SERVER_APP_WELCOME_CONTENT: '${env.DESKTOP_API_SERVER_APP_WELCOME_CONTENT}', + REGISTER_URL: '${env.REGISTER_URL}', + FORGOT_PASSWORD_URL: '${env.FORGOT_PASSWORD_URL}', + I18N_FILES_URL: '${env.I18N_FILES_URL}' }; `; @@ -218,6 +222,7 @@ if (!isDocker) { API_BASE_URL: API_BASE_URL, CLIENT_BASE_URL: CLIENT_BASE_URL, + COOKIE_DOMAIN: 'DOCKER_COOKIE_DOMAIN', PLATFORM_WEBSITE_URL: 'DOCKER_PLATFORM_WEBSITE_URL', PLATFORM_WEBSITE_DOWNLOAD_URL: 'DOCKER_PLATFORM_WEBSITE_DOWNLOAD_URL', @@ -330,6 +335,9 @@ if (!isDocker) { DESKTOP_API_SERVER_APP_WELCOME_TITLE: '${env.DESKTOP_API_SERVER_APP_WELCOME_TITLE}', DESKTOP_API_SERVER_APP_WELCOME_CONTENT: '${env.DESKTOP_API_SERVER_APP_WELCOME_CONTENT}', + REGISTER_URL: '${env.REGISTER_URL}', + FORGOT_PASSWORD_URL: '${env.FORGOT_PASSWORD_URL}', + I18N_FILES_URL: 'DOCKER_I18N_FILES_URL' }; `; diff --git a/.scripts/electron-desktop-environment/concrete-environment-content/common-environment-content.ts b/.scripts/electron-desktop-environment/concrete-environment-content/common-environment-content.ts index 35d827af6c6..8d0351825e6 100644 --- a/.scripts/electron-desktop-environment/concrete-environment-content/common-environment-content.ts +++ b/.scripts/electron-desktop-environment/concrete-environment-content/common-environment-content.ts @@ -16,6 +16,8 @@ export class CommonEnvironmentContent implements IContentGenerator { GAUZY_DESKTOP_LOGO_512X512: '${variable.GAUZY_DESKTOP_LOGO_512X512}', NO_INTERNET_LOGO: '${variable.NO_INTERNET_LOGO}', I18N_FILES_URL: '${variable.I18N_FILES_URL}', + REGISTER_URL: '${variable.REGISTER_URL}', + FORGOT_PASSWORD_URL: '${variable.FORGOT_PASSWORD_URL}', `; } } diff --git a/.scripts/env.ts b/.scripts/env.ts index b3f91436737..d010c380ec7 100644 --- a/.scripts/env.ts +++ b/.scripts/env.ts @@ -10,6 +10,8 @@ export type Env = Readonly<{ // Set to true if build / runs in Docker IS_DOCKER: boolean; + COOKIE_DOMAIN: string; + // Base URL of Gauzy UI website CLIENT_BASE_URL: string; @@ -128,6 +130,9 @@ export type Env = Readonly<{ GAUZY_UI_DEFAULT_PORT: number; SCREENSHOTS_ENGINE_METHOD: string; I18N_FILES_URL: string; + + REGISTER_URL: string; + FORGOT_PASSWORD_URL: string; }>; export const env: Env = cleanEnv( @@ -137,6 +142,8 @@ export const env: Env = cleanEnv( IS_DOCKER: bool({ default: false }), + COOKIE_DOMAIN: str({ default: '.gauzy.co' }), + CLIENT_BASE_URL: str({ default: 'http://localhost:4200' }), API_BASE_URL: str({ default: 'http://localhost:3000' }), @@ -321,7 +328,10 @@ export const env: Env = cleanEnv( }), DESKTOP_API_SERVER_APP_REPO_OWNER: str({ default: 'ever-co' }), DESKTOP_API_SERVER_APP_WELCOME_TITLE: str({ default: '' }), - DESKTOP_API_SERVER_APP_WELCOME_CONTENT: str({ default: '' }) + DESKTOP_API_SERVER_APP_WELCOME_CONTENT: str({ default: '' }), + + REGISTER_URL: str({ default: 'https://app.gauzy.co/#/auth/register' }), + FORGOT_PASSWORD_URL: str({ default: 'https://app.gauzy.co/#/auth/request-password' }) }, { strict: true, dotEnvPath: __dirname + '/../.env' } ); diff --git a/.vscode/extensions.json b/.vscode/extensions.json index fcb019de23c..e479bf08ce5 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -9,6 +9,7 @@ "jondot.vscode-hygen", "esbenp.prettier-vscode", "firsttris.vscode-jest-runner", - "vscode-icons-team.vscode-icons" + "vscode-icons-team.vscode-icons", + "github.copilot" ] } diff --git a/angular.json b/angular.json index 5ac048b5c40..e778b0af4d0 100644 --- a/angular.json +++ b/angular.json @@ -289,6 +289,146 @@ } } }, + "plugin-integration-ai-ui": { + "projectType": "library", + "root": "packages/plugins/integration-ai-ui", + "sourceRoot": "packages/plugins/integration-ai-ui", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "packages/plugins/integration-ai-ui/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "packages/plugins/integration-ai-ui/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "packages/plugins/integration-ai-ui/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "packages/plugins/integration-ai-ui/tsconfig.spec.json", + "polyfills": ["zone.js", "zone.js/testing"] + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "style": "scss" + } + } + }, + "plugin-integration-hubstaff-ui": { + "projectType": "library", + "root": "packages/plugins/integration-hubstaff-ui", + "sourceRoot": "packages/plugins/integration-hubstaff-ui", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "packages/plugins/integration-hubstaff-ui/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "packages/plugins/integration-hubstaff-ui/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "packages/plugins/integration-hubstaff-ui/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "packages/plugins/integration-hubstaff-ui/tsconfig.spec.json", + "polyfills": ["zone.js", "zone.js/testing"] + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "style": "scss" + } + } + }, + "plugin-integration-github-ui": { + "projectType": "library", + "root": "packages/plugins/integration-github-ui", + "sourceRoot": "packages/plugins/integration-github-ui", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "packages/plugins/integration-github-ui/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "packages/plugins/integration-github-ui/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "packages/plugins/integration-github-ui/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "packages/plugins/integration-github-ui/tsconfig.spec.json", + "polyfills": ["zone.js", "zone.js/testing"] + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "style": "scss" + } + } + }, + "plugin-integration-upwork-ui": { + "projectType": "library", + "root": "packages/plugins/integration-upwork-ui", + "sourceRoot": "packages/plugins/integration-upwork-ui", + "prefix": "lib", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "packages/plugins/integration-upwork-ui/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "packages/plugins/integration-upwork-ui/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "packages/plugins/integration-upwork-ui/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "tsConfig": "packages/plugins/integration-upwork-ui/tsconfig.spec.json", + "polyfills": ["zone.js", "zone.js/testing"] + } + } + }, + "schematics": { + "@nrwl/angular:component": { + "style": "scss" + } + } + }, "plugin-job-employee-ui": { "projectType": "library", "root": "packages/plugins/job-employee-ui", @@ -904,7 +1044,10 @@ "tsConfig": "apps/desktop/tsconfig.app.json", "aot": true, "stylePreprocessorOptions": { - "includePaths": ["apps/desktop-timer/src/assets/styles"] + "includePaths": [ + "apps/desktop-timer/src/assets/styles", + "packages/ui-core/static/styles" + ] }, "assets": ["apps/desktop/src/favicon.ico", "apps/desktop/src/assets"], "styles": [ @@ -1088,7 +1231,10 @@ "tsConfig": "apps/desktop-timer/tsconfig.app.json", "aot": true, "stylePreprocessorOptions": { - "includePaths": ["apps/desktop-timer/src/assets/styles"] + "includePaths": [ + "apps/desktop-timer/src/assets/styles", + "packages/ui-core/static/styles" + ] }, "assets": [ "apps/desktop-timer/src/favicon.ico", @@ -1237,7 +1383,10 @@ ], "scripts": [], "stylePreprocessorOptions": { - "includePaths": ["apps/server/src/assets/styles"] + "includePaths": [ + "apps/server/src/assets/styles", + "packages/ui-core/static/styles" + ] } }, "configurations": { @@ -1490,7 +1639,10 @@ ], "scripts": [], "stylePreprocessorOptions": { - "includePaths": ["apps/server-api/src/assets/styles"] + "includePaths": [ + "apps/server-api/src/assets/styles", + "packages/ui-core/static/styles" + ] } }, "configurations": { diff --git a/apps/desktop-timer/package.json b/apps/desktop-timer/package.json index 380939ed502..7fc24ccdd79 100644 --- a/apps/desktop-timer/package.json +++ b/apps/desktop-timer/package.json @@ -68,8 +68,6 @@ "electron-updater": "^6.1.7", "electron-util": "^0.17.2", "express": "^4.18.2", - "ffi-napi": "^4.0.3", - "iconv": "^3.0.1", "mac-screen-capture-permissions": "^2.1.0", "moment": "^2.30.1", "moment-duration-format": "^2.3.2", @@ -78,12 +76,11 @@ "mysql": "^2.18.1", "node-fetch": "^2.6.7", "node-notifier": "^8.0.0", - "pg": "^8.11.4", - "pg-query-stream": "^4.5.4", + "pg": "^8.13.1", + "pg-query-stream": "^4.7.1", "rxjs": "^7.4.0", "screenshot-desktop": "^1.15.0", "sound-play": "1.1.0", - "sqlite3": "^5.1.7", "squirrelly": "^8.0.8", "twing": "^5.0.2", "locutus": "^2.0.30", diff --git a/apps/desktop-timer/src/app/app-routing.module.ts b/apps/desktop-timer/src/app/app-routing.module.ts index 319f2b68035..5ddc475a106 100644 --- a/apps/desktop-timer/src/app/app-routing.module.ts +++ b/apps/desktop-timer/src/app/app-routing.module.ts @@ -5,8 +5,6 @@ import { AlwaysOnComponent, AuthGuard, ImageViewerComponent, - NgxLoginComponent, - NoAuthGuard, ScreenCaptureComponent, ServerDownPage, SettingsComponent, @@ -15,7 +13,6 @@ import { TimeTrackerComponent, UpdaterComponent } from '@gauzy/desktop-ui-lib'; -import { NbAuthComponent, NbRequestPasswordComponent, NbResetPasswordComponent } from '@nebular/auth'; import { AppModuleGuard } from './app.module.guards'; const routes: Routes = [ @@ -25,29 +22,8 @@ const routes: Routes = [ }, { path: 'auth', - component: NbAuthComponent, - children: [ - { - path: '', - redirectTo: 'login', - pathMatch: 'full' - }, - { - path: 'login', - component: NgxLoginComponent, - canActivate: [AppModuleGuard, NoAuthGuard] - }, - { - path: 'request-password', - component: NbRequestPasswordComponent, - canActivate: [AppModuleGuard, NoAuthGuard] - }, - { - path: 'reset-password', - component: NbResetPasswordComponent, - canActivate: [AppModuleGuard, NoAuthGuard] - } - ] + loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.authRoutes), + canActivate: [AppModuleGuard] }, { path: 'time-tracker', diff --git a/apps/desktop-timer/src/assets/styles/gauzy/theme.gauzy-dark.ts b/apps/desktop-timer/src/assets/styles/gauzy/theme.gauzy-dark.ts index 4ec88895401..9bf0e771154 100644 --- a/apps/desktop-timer/src/assets/styles/gauzy/theme.gauzy-dark.ts +++ b/apps/desktop-timer/src/assets/styles/gauzy/theme.gauzy-dark.ts @@ -45,18 +45,12 @@ const theme = { export const GAUZY_DARK = { name: 'gauzy-dark', - base: 'dark', + base: 'dark', variables: { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/desktop-timer/src/assets/styles/material/theme.material-light.ts b/apps/desktop-timer/src/assets/styles/material/theme.material-light.ts index 41fd9732d98..bb2e166e089 100644 --- a/apps/desktop-timer/src/assets/styles/material/theme.material-light.ts +++ b/apps/desktop-timer/src/assets/styles/material/theme.material-light.ts @@ -1,4 +1,4 @@ -import { NbJSThemeOptions } from '@nebular/theme'; +import { NbJSThemeOptions, NbJSThemeVariable } from '@nebular/theme'; const palette = { primary: '#6200ee', @@ -47,20 +47,14 @@ export const baseTheme: NbJSThemeOptions = { } }; -const baseThemeVariables = baseTheme.variables; +const baseThemeVariables = baseTheme.variables as NbJSThemeVariable; export const MATERIAL_LIGHT_THEME = { name: 'material-light', base: 'default', variables: { temperature: { - arcFill: [ - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary - ], + arcFill: Array(5).fill(baseThemeVariables.primary), arcEmpty: baseThemeVariables.bg2, thumbBg: baseThemeVariables.bg2, thumbBorder: baseThemeVariables.primary diff --git a/apps/desktop-timer/src/assets/styles/theme.dark.ts b/apps/desktop-timer/src/assets/styles/theme.dark.ts index 983d147d3f4..63e247fa5b4 100644 --- a/apps/desktop-timer/src/assets/styles/theme.dark.ts +++ b/apps/desktop-timer/src/assets/styles/theme.dark.ts @@ -49,13 +49,7 @@ export const DARK_THEME = { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/desktop-timer/src/index.ts b/apps/desktop-timer/src/index.ts index 54b55daa202..14000baaabd 100644 --- a/apps/desktop-timer/src/index.ts +++ b/apps/desktop-timer/src/index.ts @@ -14,7 +14,6 @@ import * as Url from 'url'; import { environment } from './environments/environment'; require('module').globalPaths.push(path.join(__dirname, 'node_modules')); -require('sqlite3'); process.env = Object.assign(process.env, environment); @@ -361,6 +360,8 @@ const getApiBaseUrl = (configs) => { app.on('ready', async () => { const configs: any = store.get('configs'); const settings: any = store.get('appSetting'); + // Set up theme listener for desktop windows + new DesktopThemeListener(); // default global global.variableGlobal = { API_BASE_URL: getApiBaseUrl(configs || {}), @@ -376,7 +377,7 @@ app.on('ready', async () => { if (!settings) { launchAtStartup(true, false); } - if (['sqlite', 'better-sqlite'].includes(provider.dialect)) { + if (['sqlite', 'better-sqlite', 'better-sqlite3'].includes(provider.dialect)) { try { const res = await knex.raw(`pragma journal_mode = WAL;`); console.log(res); @@ -445,7 +446,6 @@ app.on('ready', async () => { } removeMainListener(); ipcMainHandler(store, startServer, knex, { ...environment }, timeTrackerWindow); - new DesktopThemeListener(); }); app.on('window-all-closed', () => { diff --git a/apps/desktop-timer/src/package.json b/apps/desktop-timer/src/package.json index 14a9fffcc6b..80a563410a9 100644 --- a/apps/desktop-timer/src/package.json +++ b/apps/desktop-timer/src/package.json @@ -1,176 +1,172 @@ { - "name": "gauzy-desktop-timer", - "productName": "Gauzy Desktop Timer", - "version": "0.1.0", - "description": "Gauzy Desktop Timer", - "license": "AGPL-3.0", - "homepage": "https://gauzy.co", - "repository": { - "type": "git", - "url": "https://github.com/ever-co/ever-gauzy.git" - }, - "bugs": { - "url": "https://github.com/ever-co/ever-gauzy/issues" - }, - "private": true, - "author": { - "name": "Ever Co. LTD", - "email": "ever@ever.co", - "url": "https://ever.co" - }, - "main": "index.js", - "workspaces": { - "packages": [ - "../../../packages/contracts", - "../../../packages/ui-config", - "../../../packages/desktop-window", - "../../../packages/desktop-libs" - ] - }, - "build": { - "appId": "com.ever.gauzydesktoptimer", - "artifactName": "${name}-${version}.${ext}", - "productName": "Gauzy Desktop Timer", - "copyright": "Copyright © 2019-Present. Ever Co. LTD", - "afterSign": "tools/notarize.js", - "dmg": { - "sign": false - }, - "asar": true, - "npmRebuild": true, - "asarUnpack": [ - "node_modules/screenshot-desktop/lib/win32", - "node_modules/@sentry/electron", - "node_modules/sqlite3/lib", - "node_modules/better-sqlite3", - "node_modules/@sentry/profiling-node/lib" - ], - "directories": { - "buildResources": "icons", - "output": "../desktop-timer-packages" - }, - "publish": [ - { - "provider": "github", - "repo": "ever-gauzy-desktop-timer", - "releaseType": "release" - }, - { - "provider": "spaces", - "name": "ever", - "region": "sfo3", - "path": "/ever-gauzy-desktop-timer", - "acl": "public-read" - } - ], - "mac": { - "category": "public.app-category.developer-tools", - "icon": "icon.icns", - "target": [ - "zip", - "dmg" - ], - "asarUnpack": "**/*.node", - "artifactName": "${name}-${version}.${ext}", - "hardenedRuntime": true, - "gatekeeperAssess": false, - "entitlements": "tools/build/entitlements.mas.plist", - "entitlementsInherit": "tools/build/entitlements.mas.plist", - "extendInfo": { - "NSAppleEventsUsageDescription": "Please allow access to script browser applications to detect the current URL when triggering instant lookup.", - "NSCameraUsageDescription": "Please allow access to Gauzy Desktop Timer to make screenshot" - } - }, - "win": { - "publisherName": "Ever", - "target": [ - { - "target": "nsis", - "arch": [ - "x64" - ] - } - ], - "icon": "icon.ico", - "verifyUpdateCodeSignature": false - }, - "linux": { - "icon": "linux", - "target": [ - "AppImage", - "deb", - "tar.gz" - ], - "executableName": "gauzy-desktop-timer", - "artifactName": "${name}-${version}.${ext}", - "synopsis": "Desktop", - "category": "Development" - }, - "nsis": { - "oneClick": false, - "perMachine": true, - "createDesktopShortcut": true, - "createStartMenuShortcut": true, - "allowToChangeInstallationDirectory": true, - "allowElevation": true, - "installerIcon": "icon.ico", - "artifactName": "${name}-${version}.${ext}", - "deleteAppDataOnUninstall": true, - "menuCategory": true - }, - "extraResources": [ - "./data/**/*", - "databaseDir", - { - "from": "assets", - "to": "assets" - } - ], - "extraFiles": [ - "./**/desktop-libs/**/migrations/*" - ] - }, - "dependencies": { - "@datorama/akita": "^7.1.1", - "@datorama/akita-ngdevtools": "^7.0.0", - "@electron/remote": "^2.0.8", - "@gauzy/contracts": "^0.1.0", - "@gauzy/desktop-libs": "^0.1.0", - "@gauzy/desktop-window": "^0.1.0", - "@sentry/electron": "^4.18.0", - "@sentry/profiling-node": "^7.101.1", - "@sentry/replay": "^7.101.1", - "@sentry/node": "^7.101.1", - "@sentry/tracing": "^7.101.1", - "@sentry/types": "^7.101.1", - "auto-launch": "5.0.5", - "consolidate": "^0.16.0", - "electron-log": "^4.4.8", - "electron-store": "^8.1.0", - "electron-updater": "^6.1.7", - "electron-util": "^0.17.2", - "embedded-queue": "^0.0.11", - "ffi-napi": "^4.0.3", - "form-data": "^3.0.0", - "htmlparser2": "^8.0.2", - "iconv": "^3.0.1", - "knex": "^3.1.0", - "libsql": "^0.3.16", - "mac-screen-capture-permissions": "^2.1.0", - "moment": "^2.30.1", - "node-fetch": "^2.6.7", - "node-notifier": "^8.0.0", - "pg": "^8.11.4", - "pg-query-stream": "^4.5.4", - "screenshot-desktop": "^1.15.0", - "sound-play": "1.1.0", - "sqlite3": "^5.1.7", - "squirrelly": "^8.0.8", - "tslib": "^2.6.2", - "twing": "^5.0.2", - "locutus": "^2.0.30", - "underscore": "^1.13.3", - "undici": "^6.10.2", - "custom-electron-titlebar": "^4.2.8" - } + "name": "gauzy-desktop-timer", + "productName": "Gauzy Desktop Timer", + "version": "0.1.0", + "description": "Gauzy Desktop Timer", + "license": "AGPL-3.0", + "homepage": "https://gauzy.co", + "repository": { + "type": "git", + "url": "https://github.com/ever-co/ever-gauzy.git" + }, + "bugs": { + "url": "https://github.com/ever-co/ever-gauzy/issues" + }, + "private": true, + "author": { + "name": "Ever Co. LTD", + "email": "ever@ever.co", + "url": "https://ever.co" + }, + "main": "index.js", + "workspaces": { + "packages": [ + "../../../packages/contracts", + "../../../packages/ui-config", + "../../../packages/desktop-window", + "../../../packages/desktop-libs" + ] + }, + "build": { + "appId": "com.ever.gauzydesktoptimer", + "artifactName": "${name}-${version}.${ext}", + "productName": "Gauzy Desktop Timer", + "copyright": "Copyright © 2019-Present. Ever Co. LTD", + "afterSign": "tools/notarize.js", + "dmg": { + "sign": false + }, + "asar": true, + "npmRebuild": true, + "asarUnpack": [ + "node_modules/screenshot-desktop/lib/win32", + "node_modules/@sentry/electron", + "node_modules/better-sqlite3", + "node_modules/@sentry/profiling-node/lib" + ], + "directories": { + "buildResources": "icons", + "output": "../desktop-timer-packages" + }, + "publish": [ + { + "provider": "github", + "repo": "ever-gauzy-desktop-timer", + "releaseType": "release" + }, + { + "provider": "spaces", + "name": "ever", + "region": "sfo3", + "path": "/ever-gauzy-desktop-timer", + "acl": "public-read" + } + ], + "mac": { + "category": "public.app-category.developer-tools", + "icon": "icon.icns", + "target": [ + "zip", + "dmg" + ], + "asarUnpack": "**/*.node", + "artifactName": "${name}-${version}.${ext}", + "hardenedRuntime": true, + "gatekeeperAssess": false, + "entitlements": "tools/build/entitlements.mas.plist", + "entitlementsInherit": "tools/build/entitlements.mas.plist", + "extendInfo": { + "NSAppleEventsUsageDescription": "Please allow access to script browser applications to detect the current URL when triggering instant lookup.", + "NSCameraUsageDescription": "Please allow access to Gauzy Desktop Timer to make screenshot" + } + }, + "win": { + "publisherName": "Ever", + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "icon": "icon.ico", + "verifyUpdateCodeSignature": false + }, + "linux": { + "icon": "linux", + "target": [ + "AppImage", + "deb", + "tar.gz" + ], + "executableName": "gauzy-desktop-timer", + "artifactName": "${name}-${version}.${ext}", + "synopsis": "Desktop", + "category": "Development" + }, + "nsis": { + "oneClick": false, + "perMachine": true, + "createDesktopShortcut": true, + "createStartMenuShortcut": true, + "allowToChangeInstallationDirectory": true, + "allowElevation": true, + "installerIcon": "icon.ico", + "artifactName": "${name}-${version}.${ext}", + "deleteAppDataOnUninstall": true, + "menuCategory": true + }, + "extraResources": [ + "./data/**/*", + "databaseDir", + { + "from": "assets", + "to": "assets" + } + ], + "extraFiles": [ + "./**/desktop-libs/**/migrations/*" + ] + }, + "dependencies": { + "@datorama/akita": "^7.1.1", + "@datorama/akita-ngdevtools": "^7.0.0", + "@electron/remote": "^2.0.8", + "@gauzy/contracts": "^0.1.0", + "@gauzy/desktop-libs": "^0.1.0", + "@gauzy/desktop-window": "^0.1.0", + "@sentry/electron": "^4.18.0", + "@sentry/profiling-node": "^7.101.1", + "@sentry/replay": "^7.101.1", + "@sentry/node": "^7.101.1", + "@sentry/tracing": "^7.101.1", + "@sentry/types": "^7.101.1", + "auto-launch": "5.0.5", + "consolidate": "^0.16.0", + "electron-log": "^4.4.8", + "electron-store": "^8.1.0", + "electron-updater": "^6.1.7", + "electron-util": "^0.17.2", + "embedded-queue": "^0.0.11", + "form-data": "^3.0.0", + "htmlparser2": "^8.0.2", + "knex": "^3.1.0", + "libsql": "^0.3.16", + "mac-screen-capture-permissions": "^2.1.0", + "moment": "^2.30.1", + "node-fetch": "^2.6.7", + "node-notifier": "^8.0.0", + "pg": "^8.13.1", + "pg-query-stream": "^4.7.1", + "screenshot-desktop": "^1.15.0", + "sound-play": "1.1.0", + "squirrelly": "^8.0.8", + "tslib": "^2.6.2", + "twing": "^5.0.2", + "locutus": "^2.0.30", + "underscore": "^1.13.3", + "undici": "^6.10.2", + "custom-electron-titlebar": "^4.2.8" + } } diff --git a/apps/desktop-timer/src/preload.ts b/apps/desktop-timer/src/preload.ts index fd1459c8709..d918b204482 100644 --- a/apps/desktop-timer/src/preload.ts +++ b/apps/desktop-timer/src/preload.ts @@ -17,6 +17,10 @@ window.addEventListener('DOMContentLoaded', async () => { titleBar.refreshMenu(); }); + ipcRenderer.on('hide-menu', () => { + titleBar.dispose(); + }) + const overStyle = document.createElement('style'); overStyle.innerHTML = ` .cet-container { diff --git a/apps/desktop/README.md b/apps/desktop/README.md index c0e1e53e7fc..1619d20b5b7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -12,12 +12,6 @@ yarn install **build executable for mac** -rebuild sqlite3 for mac - -```bash -yarn build:sqlite:mac -``` - build desktop ```bash @@ -31,11 +25,6 @@ build:desktop:mac:quick ``` **build execute app for windows** -rebuild sqlite3 for windows - -```bash -yarn build:sqlite:windows -``` build desktop diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dca949ec575..d1c64121596 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -69,9 +69,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.7", "electron-util": "^0.17.2", - "ffi-napi": "^4.0.3", "form-data": "^3.0.0", - "iconv": "^3.0.1", "knex": "^3.1.0", "libsql": "^0.3.16", "locutus": "^2.0.30", @@ -82,12 +80,11 @@ "moment-timezone": "^0.5.45", "node-fetch": "^2.6.7", "node-static": "^0.7.11", - "pg": "^8.11.4", - "pg-query-stream": "^4.5.4", + "pg": "^8.13.1", + "pg-query-stream": "^4.7.1", "rxjs": "^7.4.0", "screenshot-desktop": "^1.15.0", "sound-play": "1.1.0", - "sqlite3": "^5.1.7", "squirrelly": "^8.0.8", "twing": "^5.0.2", "typeorm": "^0.3.20", diff --git a/apps/desktop/src/assets/styles/gauzy/theme.gauzy-dark.ts b/apps/desktop/src/assets/styles/gauzy/theme.gauzy-dark.ts index 4ec88895401..9bf0e771154 100644 --- a/apps/desktop/src/assets/styles/gauzy/theme.gauzy-dark.ts +++ b/apps/desktop/src/assets/styles/gauzy/theme.gauzy-dark.ts @@ -45,18 +45,12 @@ const theme = { export const GAUZY_DARK = { name: 'gauzy-dark', - base: 'dark', + base: 'dark', variables: { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/desktop/src/assets/styles/material/theme.material-light.ts b/apps/desktop/src/assets/styles/material/theme.material-light.ts index 41fd9732d98..bb2e166e089 100644 --- a/apps/desktop/src/assets/styles/material/theme.material-light.ts +++ b/apps/desktop/src/assets/styles/material/theme.material-light.ts @@ -1,4 +1,4 @@ -import { NbJSThemeOptions } from '@nebular/theme'; +import { NbJSThemeOptions, NbJSThemeVariable } from '@nebular/theme'; const palette = { primary: '#6200ee', @@ -47,20 +47,14 @@ export const baseTheme: NbJSThemeOptions = { } }; -const baseThemeVariables = baseTheme.variables; +const baseThemeVariables = baseTheme.variables as NbJSThemeVariable; export const MATERIAL_LIGHT_THEME = { name: 'material-light', base: 'default', variables: { temperature: { - arcFill: [ - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary - ], + arcFill: Array(5).fill(baseThemeVariables.primary), arcEmpty: baseThemeVariables.bg2, thumbBg: baseThemeVariables.bg2, thumbBorder: baseThemeVariables.primary diff --git a/apps/desktop/src/assets/styles/theme.dark.ts b/apps/desktop/src/assets/styles/theme.dark.ts index 983d147d3f4..63e247fa5b4 100644 --- a/apps/desktop/src/assets/styles/theme.dark.ts +++ b/apps/desktop/src/assets/styles/theme.dark.ts @@ -49,13 +49,7 @@ export const DARK_THEME = { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/desktop/src/index.ts b/apps/desktop/src/index.ts index e040db4f44e..7a194ff2bde 100644 --- a/apps/desktop/src/index.ts +++ b/apps/desktop/src/index.ts @@ -14,8 +14,6 @@ import { environment } from './environments/environment'; require('module').globalPaths.push(path.join(__dirname, 'node_modules')); -require('sqlite3'); - Object.assign(process.env, environment); app.setName(process.env.NAME); @@ -263,7 +261,7 @@ async function startServer(value, restart = false) { if (value.db === 'sqlite') { process.env.DB_PATH = sqlite3filename; process.env.DB_TYPE = 'sqlite'; - } else if (value.db === 'better-sqlite') { + } else if (value.db === 'better-sqlite' || value.db === 'better-sqlite3') { process.env.DB_PATH = sqlite3filename; process.env.DB_TYPE = 'better-sqlite3'; } else { @@ -432,7 +430,7 @@ app.on('ready', async () => { throw new AppError('MAINWININIT', error); } - if (['sqlite', 'better-sqlite'].includes(provider.dialect)) { + if (['sqlite', 'better-sqlite', 'better-sqlite3'].includes(provider.dialect)) { try { const res = await knex.raw(`pragma journal_mode = WAL;`); console.log(res); diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index f5452aa0e7f..886b1e42b8e 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -31,10 +31,14 @@ "../../../packages/ui-config", "../../../packages/ui-core", "../../../packages/plugin", + "../../../packages/plugins/integration-ai-ui", "../../../packages/plugins/integration-ai", + "../../../packages/plugins/integration-hubstaff-ui", "../../../packages/plugins/integration-hubstaff", + "../../../packages/plugins/integration-upwork-ui", "../../../packages/plugins/integration-upwork", "../../../packages/plugins/integration-github", + "../../../packages/plugins/integration-github-ui", "../../../packages/plugins/integration-jira", "../../../packages/plugins/knowledge-base", "../../../packages/plugins/product-reviews", @@ -66,7 +70,6 @@ "asarUnpack": [ "node_modules/screenshot-desktop/lib/win32", "node_modules/@sentry/electron", - "node_modules/sqlite3/lib", "node_modules/better-sqlite3", "node_modules/@sentry/profiling-node/lib" ], @@ -180,10 +183,8 @@ "electron-updater": "^6.1.7", "electron-util": "^0.17.2", "embedded-queue": "^0.0.11", - "ffi-napi": "^4.0.3", "form-data": "^3.0.0", "htmlparser2": "^8.0.2", - "iconv": "^3.0.1", "knex": "^3.1.0", "libsql": "^0.3.16", "locutus": "^2.0.30", @@ -193,11 +194,10 @@ "node-notifier": "^8.0.0", "node-static": "^0.7.11", "pdfmake": "^0.2.0", - "pg-query-stream": "^4.5.4", - "pg": "^8.11.4", + "pg-query-stream": "^4.7.1", + "pg": "^8.13.1", "screenshot-desktop": "^1.15.0", "sound-play": "1.1.0", - "sqlite3": "^5.1.7", "squirrelly": "^8.0.8", "tslib": "^2.6.2", "twing": "^5.0.2", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index dbcec63fd7b..96f890c7c53 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -21,6 +21,10 @@ window.addEventListener('DOMContentLoaded', async () => { titleBar.refreshMenu(); }); + ipcRenderer.on('hide-menu', () => { + titleBar.dispose(); + }) + ipcRenderer.on('adjust_view', () => { clearInterval(contentInterval); const headerIcon = '/html/body/div[2]/ga-app/ngx-pages/ngx-one-column-layout/nb-layout/div[1]/div/div/nb-sidebar[1]/div/div/div'; diff --git a/apps/gauzy/package.json b/apps/gauzy/package.json index 584846b9852..76b6c7f4275 100644 --- a/apps/gauzy/package.json +++ b/apps/gauzy/package.json @@ -88,14 +88,14 @@ "@sentry/types": "^7.101.1", "@sentry/utils": "^7.90.0", "@swimlane/ngx-charts": "^20.1.0", - "angular2-smart-table": "^3.2.0", + "angular2-smart-table": "^3.3.0", "angular2-toaster": "^11.0.1", - "bcrypt": "^5.1.0", "bootstrap": "^4.3.1", "brace": "^0.11.1", "camelcase": "^6.3.0", "chart.js": "^4.4.1", "chart.piecelabel.js": "^0.15.0", + "chartjs-plugin-annotation": "^3.0.1", "ckeditor4": "4.22.1", "ckeditor4-angular": "4.0.1", "core-js": "^3.8.3", @@ -184,7 +184,6 @@ "@angular/cli": "^16.2.11", "@angular/compiler-cli": "^16.2.12", "@types/async": "^3.2.5", - "@types/bcrypt": "^5.0.0", "@types/chart.js": "^2.9.37", "@types/ckeditor": "^4.9.10", "@types/d3-color": "^3.1.0", diff --git a/apps/gauzy/src/app/app.component.ts b/apps/gauzy/src/app/app.component.ts index 3f688a1c5c1..522e8159337 100644 --- a/apps/gauzy/src/app/app.component.ts +++ b/apps/gauzy/src/app/app.component.ts @@ -21,6 +21,7 @@ import { ISelectorVisibility, JitsuService, LanguagesService, + NavigationService, SelectorBuilderService, SeoService, Store @@ -47,7 +48,8 @@ export class AppComponent implements OnInit, AfterViewInit { private readonly _router: Router, private readonly _activatedRoute: ActivatedRoute, private readonly _selectorBuilderService: SelectorBuilderService, - private readonly _dateRangePickerBuilderService: DateRangePickerBuilderService + private readonly _dateRangePickerBuilderService: DateRangePickerBuilderService, + private readonly _navigationService: NavigationService ) { this.getActivateRouterDataEvent(); this.getPreferredLanguage(); @@ -200,18 +202,25 @@ export class AppComponent implements OnInit, AfterViewInit { tap( ({ datePicker, - dates + dates, + bookmarkParams }: { datePicker: IDatePickerConfig; dates: IDateRangePicker; selectors: ISelectorVisibility; + bookmarkParams: Record; }) => { + // Date Range Picker if (isNotEmpty(dates)) { this._dateRangePickerBuilderService.setDateRangePicker(dates); } + // Set Date Range Picker Default Unit const datePickerConfig = Object.assign({}, DEFAULT_DATE_PICKER_CONFIG, datePicker); this._dateRangePickerBuilderService.setDatePickerConfig(datePickerConfig); + + // Create query parameters URL builder + this._navigationService.updateQueryParams(bookmarkParams); } ), // Automatically unsubscribe when the component is destroyed @@ -226,7 +235,7 @@ export class AppComponent implements OnInit, AfterViewInit { getPreferredLanguage(): void { this._i18nService.preferredLanguage$ .pipe( - tap((preferredLanguage: string) => this._translateService.use(preferredLanguage)), + tap((lang: string) => this._translateService.use(lang)), untilDestroyed(this) ) .subscribe(); diff --git a/apps/gauzy/src/app/app.module.guard.ts b/apps/gauzy/src/app/app.module.guard.ts index 915c37794ac..aef68709acf 100644 --- a/apps/gauzy/src/app/app.module.guard.ts +++ b/apps/gauzy/src/app/app.module.guard.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core'; -import { Router, CanActivate, ActivatedRouteSnapshot } from '@angular/router'; +import { Router, ActivatedRouteSnapshot } from '@angular/router'; import { environment } from '@gauzy/ui-config'; import { Store } from '@gauzy/ui-core/core'; @Injectable() -export class AppModuleGuard implements CanActivate { +export class AppModuleGuard { constructor(private readonly router: Router, private readonly store: Store) {} /** diff --git a/apps/gauzy/src/app/pages/approval-policy/approval-policy.component.ts b/apps/gauzy/src/app/pages/approval-policy/approval-policy.component.ts index d29784cf017..bd498502292 100644 --- a/apps/gauzy/src/app/pages/approval-policy/approval-policy.component.ts +++ b/apps/gauzy/src/app/pages/approval-policy/approval-policy.component.ts @@ -174,7 +174,7 @@ export class ApprovalPolicyComponent extends PaginationFilterBaseComponent imple name: { title: this.getTranslation('APPROVAL_POLICY_PAGE.APPROVAL_POLICY_NAME'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/candidates/candidates.component.ts b/apps/gauzy/src/app/pages/candidates/candidates.component.ts index 5ada1adcc46..f6eb3d7ed20 100644 --- a/apps/gauzy/src/app/pages/candidates/candidates.component.ts +++ b/apps/gauzy/src/app/pages/candidates/candidates.component.ts @@ -343,7 +343,7 @@ export class CandidatesComponent extends PaginationFilterBaseComponent implement instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -355,7 +355,7 @@ export class CandidatesComponent extends PaginationFilterBaseComponent implement title: this.getTranslation('SM_TABLE.EMAIL'), type: 'email', class: 'email-column', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/contacts/contact-mutation/contact-mutation.component.ts b/apps/gauzy/src/app/pages/contacts/contact-mutation/contact-mutation.component.ts index ea17b97eaea..67f2154d5dd 100644 --- a/apps/gauzy/src/app/pages/contacts/contact-mutation/contact-mutation.component.ts +++ b/apps/gauzy/src/app/pages/contacts/contact-mutation/contact-mutation.component.ts @@ -394,10 +394,17 @@ export class ContactMutationComponent extends TranslationBaseComponent implement const projectMembers: IOrganizationProjectEmployee[] = members.map((employee: IEmployee) => this.transformToProjectEmployee(employee, project, this.organization) ); + const combinedMembers = [ + ...(Array.isArray(projectMembers) ? projectMembers : []), + ...(Array.isArray(project.members) ? project.members : []) + ]; + + const uniqueMembers = Array.from(new Map(combinedMembers.map((member) => [member.id, member])).values()); return { ...project, - members: Array.isArray(project.members) ? [...project.members, ...projectMembers] : [...projectMembers] + managerIds: uniqueMembers.filter((member) => member.isManager).map((member) => member.employeeId), + memberIds: uniqueMembers.filter((member) => !member.isManager).map((member) => member.employeeId) }; }); } @@ -446,6 +453,7 @@ export class ContactMutationComponent extends TranslationBaseComponent implement organization: IOrganization ): IOrganizationProjectEmployee => ({ ...employee, + employeeId: employee.id, organizationProject: project, // Assign the current project organizationProjectId: project.id, // Assign the project's ID organizationId: organization.id, // Assign the organization's ID diff --git a/apps/gauzy/src/app/pages/contacts/contacts.component.ts b/apps/gauzy/src/app/pages/contacts/contacts.component.ts index 8ff6e356177..1eda7729b12 100644 --- a/apps/gauzy/src/app/pages/contacts/contacts.component.ts +++ b/apps/gauzy/src/app/pages/contacts/contacts.component.ts @@ -222,7 +222,7 @@ export class ContactsComponent extends PaginationFilterBaseComponent implements instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -243,7 +243,7 @@ export class ContactsComponent extends PaginationFilterBaseComponent implements primaryPhone: { title: this.getTranslation('CONTACTS_PAGE.PHONE'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -254,7 +254,7 @@ export class ContactsComponent extends PaginationFilterBaseComponent implements primaryEmail: { title: this.getTranslation('CONTACTS_PAGE.EMAIL'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/dashboard/dashboard-routing.module.ts b/apps/gauzy/src/app/pages/dashboard/dashboard-routing.module.ts index 04f10478c61..ca6ed18cf58 100644 --- a/apps/gauzy/src/app/pages/dashboard/dashboard-routing.module.ts +++ b/apps/gauzy/src/app/pages/dashboard/dashboard-routing.module.ts @@ -1,6 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { PermissionsGuard } from '@gauzy/ui-core/core'; +import { PermissionsGuard, BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { PermissionsEnum } from '@gauzy/contracts'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { DashboardComponent } from './dashboard.component'; @@ -33,7 +33,8 @@ const routes: Routes = [ } }, resolve: { - dates: DateRangePickerResolver + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver } }, { @@ -48,7 +49,8 @@ const routes: Routes = [ } }, resolve: { - dates: DateRangePickerResolver + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver } }, { @@ -60,7 +62,8 @@ const routes: Routes = [ } }, resolve: { - dates: DateRangePickerResolver + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver } }, { @@ -76,7 +79,8 @@ const routes: Routes = [ } }, resolve: { - dates: DateRangePickerResolver + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver } }, { @@ -99,7 +103,8 @@ const routes: Routes = [ } }, resolve: { - dates: DateRangePickerResolver + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver } } ] diff --git a/apps/gauzy/src/app/pages/dashboard/dashboard.component.ts b/apps/gauzy/src/app/pages/dashboard/dashboard.component.ts index 94583a3f816..efbbd979601 100644 --- a/apps/gauzy/src/app/pages/dashboard/dashboard.component.ts +++ b/apps/gauzy/src/app/pages/dashboard/dashboard.component.ts @@ -1,7 +1,6 @@ import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { filter, tap } from 'rxjs/operators'; -import { NbRouteTab } from '@nebular/theme'; import { TranslateService } from '@ngx-translate/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { ISelectedEmployee, PermissionsEnum } from '@gauzy/contracts'; @@ -16,7 +15,6 @@ import { DynamicTabsComponent } from '@gauzy/ui-core/shared'; styleUrls: ['./dashboard.component.scss'] }) export class DashboardComponent extends TranslationBaseComponent implements OnInit, OnDestroy { - public tabs: NbRouteTab[] = []; public tabsetId: PageTabsetRegistryId = this._route.snapshot.data.tabsetId; // The identifier for the tabset public selectedEmployee: ISelectedEmployee; @@ -42,6 +40,7 @@ export class DashboardComponent extends TranslationBaseComponent implements OnIn tap(() => this.registerAccountingTabs()), untilDestroyed(this) ); + // Subscribe to the store employee observable storeEmployee$.subscribe(); } @@ -146,8 +145,5 @@ export class DashboardComponent extends TranslationBaseComponent implements OnIn /** * Clears the registry when the component is destroyed. */ - ngOnDestroy() { - // Delete the dashboard tabset from the registry - this._pageTabRegistryService.deleteTabset(this.tabsetId); - } + ngOnDestroy() {} } diff --git a/apps/gauzy/src/app/pages/departments/departments.component.ts b/apps/gauzy/src/app/pages/departments/departments.component.ts index d745beb6097..c7d1dd343d8 100644 --- a/apps/gauzy/src/app/pages/departments/departments.component.ts +++ b/apps/gauzy/src/app/pages/departments/departments.component.ts @@ -151,7 +151,7 @@ export class DepartmentsComponent extends PaginationFilterBaseComponent implemen name: { title: this.getTranslation('ORGANIZATIONS_PAGE.NAME'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -178,7 +178,7 @@ export class DepartmentsComponent extends PaginationFilterBaseComponent implemen instance.rowData = cell.getRow().getData(); instance.value = cell.getRawValue(); }, - isFilterable: { + filter: { type: 'custom', component: TagsColorFilterComponent }, diff --git a/apps/gauzy/src/app/pages/employees/activity/activity.module.ts b/apps/gauzy/src/app/pages/employees/activity/activity.module.ts index 65a76cc3ed2..d919a20628c 100644 --- a/apps/gauzy/src/app/pages/employees/activity/activity.module.ts +++ b/apps/gauzy/src/app/pages/employees/activity/activity.module.ts @@ -2,7 +2,7 @@ import { Inject, NgModule } from '@angular/core'; import { ROUTES, RouterModule } from '@angular/router'; import { NbCardModule, NbSpinnerModule } from '@nebular/theme'; import { TranslateModule } from '@ngx-translate/core'; -import { PageRouteRegistryService } from '@gauzy/ui-core/core'; +import { BookmarkQueryParamsResolver, PageRouteRegistryService } from '@gauzy/ui-core/core'; import { ActivityItemModule, DateRangePickerResolver, @@ -15,16 +15,11 @@ import { createActivityRoutes } from './activity.routes'; import { ActivityLayoutComponent } from './layout/layout.component'; import { AppUrlActivityComponent } from './app-url-activity/app-url-activity.component'; -// Nebular Modules -const NB_MODULES = [NbCardModule, NbSpinnerModule]; - -// Components -const COMPONENTS = [AppUrlActivityComponent]; - @NgModule({ imports: [ RouterModule.forChild([]), - ...NB_MODULES, + NbCardModule, + NbSpinnerModule, TranslateModule.forChild(), ActivityItemModule, DynamicTabsModule, @@ -32,7 +27,7 @@ const COMPONENTS = [AppUrlActivityComponent]; NoDataMessageModule, SharedModule ], - declarations: [ActivityLayoutComponent, ...COMPONENTS], + declarations: [ActivityLayoutComponent, AppUrlActivityComponent], providers: [ { provide: ROUTES, @@ -92,7 +87,10 @@ export class ActivityModule { title: 'ACTIVITY.APPS', // Register the title for the page type: 'apps' // Register the type for the page }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } }); // Register URL Activity Page Routes @@ -111,7 +109,10 @@ export class ActivityModule { title: 'ACTIVITY.VISITED_SITES', // Register the title for the page type: 'urls' // Register the type for the page }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } }); // Set the flag to true diff --git a/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.html b/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.html index 8ee87a44db7..280e1000759 100644 --- a/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.html +++ b/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.html @@ -1,8 +1,19 @@ -

- {{ title | translate }} -

+
+

+ + {{ title | translate }} + +

+ + + +
diff --git a/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.ts b/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.ts index bb184116097..62b2632ba7f 100644 --- a/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.ts +++ b/apps/gauzy/src/app/pages/employees/activity/layout/layout.component.ts @@ -1,9 +1,14 @@ import { Component, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; import { ActivatedRoute, QueryParamsHandling } from '@angular/router'; -import { tap } from 'rxjs/operators'; +import { Observable, tap } from 'rxjs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { PermissionsEnum } from '@gauzy/contracts'; -import { PageTabRegistryService, PageTabsetRegistryId, RouteUtil } from '@gauzy/ui-core/core'; +import { IDateRangePicker, PermissionsEnum } from '@gauzy/contracts'; +import { + DateRangePickerBuilderService, + PageTabRegistryService, + PageTabsetRegistryId, + RouteUtil +} from '@gauzy/ui-core/core'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -15,12 +20,14 @@ import { PageTabRegistryService, PageTabsetRegistryId, RouteUtil } from '@gauzy/ export class ActivityLayoutComponent implements OnInit, OnDestroy { public title: string; public tabsetId: PageTabsetRegistryId = this._route.snapshot.data.tabsetId; // The identifier for the tabset + public selectedDateRange$: Observable = this._dateRangePickerBuilderService.selectedDateRange$; constructor( private readonly _route: ActivatedRoute, private readonly _cdr: ChangeDetectorRef, private readonly _routeUtil: RouteUtil, - private readonly _pageTabRegistryService: PageTabRegistryService + private readonly _pageTabRegistryService: PageTabRegistryService, + private readonly _dateRangePickerBuilderService: DateRangePickerBuilderService ) {} ngOnInit(): void { diff --git a/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot-routing.module.ts b/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot-routing.module.ts index 1873b8741ee..464eca7d3e9 100644 --- a/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot-routing.module.ts +++ b/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { ScreenshotComponent } from './screenshot/screenshot.component'; @@ -17,7 +18,10 @@ const routes: Routes = [ isDisableFutureDate: true } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts b/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts index 387fcc6b6ca..7b3442caa1c 100644 --- a/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts +++ b/apps/gauzy/src/app/pages/employees/activity/screenshot/screenshot/screenshot.component.ts @@ -10,16 +10,17 @@ import moment from 'moment-timezone'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { NbDialogService } from '@nebular/theme'; import { TranslateService } from '@ngx-translate/core'; -import { DateRangePickerBuilderService, Store, TimesheetFilterService, TimesheetService } from '@gauzy/ui-core/core'; import { ITimeLogFilters, ITimeSlot, IGetTimeSlotInput, IScreenshotMap, IScreenshot, - PermissionsEnum + PermissionsEnum, + ID } from '@gauzy/contracts'; import { isEmpty, distinctUntilChange, isNotEmpty, toTimezone } from '@gauzy/ui-core/common'; +import { DateRangePickerBuilderService, Store, TimesheetFilterService, TimesheetService } from '@gauzy/ui-core/core'; import { BaseSelectorFilterComponent, DeleteConfirmationComponent, @@ -188,7 +189,7 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements * * @param slotId The ID of the time slot to toggle selection for. */ - toggleSelect(slotId?: string): void { + toggleSelect(slotId?: ID): void { if (slotId) { // Toggle the selection state of the time slot identified by slotId this.selectedIds[slotId] = !this.selectedIds[slotId]; @@ -252,11 +253,17 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements const ids = Object.keys(this.selectedIds).filter((key) => this.selectedIds[key]); // Construct request object with organization ID - const { id: organizationId } = this.organization; - const request = { ids, organizationId }; + const { id: organizationId, tenantId } = this.organization; + + // Call the deleteTimeSlots API with forceDelete set to true + const api$ = this._timesheetService.deleteTimeSlots({ + ids, + organizationId, + tenantId + }); // Convert the promise to an observable and handle deletion - return from(this._timesheetService.deleteTimeSlots(request)).pipe( + return from(api$).pipe( tap(() => this._deleteScreenshotGallery(ids)), tap(() => this.screenshots$.next(true)) ); @@ -266,10 +273,6 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements .subscribe(); } - ngOnDestroy(): void { - this._galleryService.clearGallery(); - } - /** * Groups time slots by hour and prepares data for display. * Also generates screenshot URLs and calculates employee work on the same time slots. @@ -344,7 +347,7 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements * * @param timeSlotIds An array of time slot IDs whose screenshots should be removed from the gallery. */ - private _deleteScreenshotGallery(timeSlotIds: string[]) { + private _deleteScreenshotGallery(timeSlotIds: ID[]) { if (isNotEmpty(this.originalTimeSlots)) { // Extract all screenshots from time slots that match the provided time slot IDs const screenshotsToRemove = this.originalTimeSlots @@ -361,4 +364,8 @@ export class ScreenshotComponent extends BaseSelectorFilterComponent implements this._galleryService.removeGalleryItems(screenshotsToRemove); } } + + ngOnDestroy(): void { + this._galleryService.clearGallery(); + } } diff --git a/apps/gauzy/src/app/pages/employees/activity/time-activities/time-activities-routing.module.ts b/apps/gauzy/src/app/pages/employees/activity/time-activities/time-activities-routing.module.ts index ea890a10782..31640b16a91 100644 --- a/apps/gauzy/src/app/pages/employees/activity/time-activities/time-activities-routing.module.ts +++ b/apps/gauzy/src/app/pages/employees/activity/time-activities/time-activities-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { TimeActivitiesComponent } from './time-activities/time-activities.component'; @@ -16,7 +17,10 @@ const routes: Routes = [ isSingleDatePicker: true } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.html b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.html index b6d433df26f..4bb2915e29a 100644 --- a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.html +++ b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.html @@ -1 +1 @@ - + diff --git a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.ts b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.ts index 4b364f4759b..bc67e42da33 100644 --- a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.ts +++ b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-profile.component.ts @@ -1,20 +1,21 @@ import { Component, OnDestroy, OnInit, Output, EventEmitter } from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom, Subject } from 'rxjs'; -import { debounceTime, filter, tap } from 'rxjs/operators'; -import { NbRouteTab } from '@nebular/theme'; +import { debounceTime, filter, firstValueFrom, Subject, tap } from 'rxjs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { IEmployee, IEmployeeUpdateInput, IUserUpdateInput, PermissionsEnum } from '@gauzy/contracts'; -import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; +import { ID, IEmployee, IEmployeeUpdateInput, IUserUpdateInput, PermissionsEnum } from '@gauzy/contracts'; import { EmployeesService, EmployeeStore, ErrorHandlingService, + PageTabRegistryConfig, + PageTabRegistryService, + PageTabsetRegistryId, Store, ToastrService, UsersService } from '@gauzy/ui-core/core'; +import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -24,10 +25,11 @@ import { providers: [EmployeeStore] }) export class EditEmployeeProfileComponent extends TranslationBaseComponent implements OnInit, OnDestroy { - routeParams: Params; + public tabsetId: PageTabsetRegistryId = this._route.snapshot.data.tabsetId; // The identifier for the tabset + public employeeId: ID = this._route.snapshot.params.id; + selectedEmployee: IEmployee; employeeName: string; - tabs: NbRouteTab[] = []; subject$: Subject = new Subject(); @Output() updatedImage = new EventEmitter(); @@ -40,7 +42,8 @@ export class EditEmployeeProfileComponent extends TranslationBaseComponent imple private readonly _toastrService: ToastrService, private readonly _employeeStore: EmployeeStore, private readonly _errorHandlingService: ErrorHandlingService, - private readonly _store: Store + private readonly _store: Store, + private readonly _pageTabRegistryService: PageTabRegistryService ) { super(translateService); } @@ -56,8 +59,7 @@ export class EditEmployeeProfileComponent extends TranslationBaseComponent imple this._route.params .pipe( filter((params) => !!params), - tap((params) => (this.routeParams = params)), - tap(() => this.loadTabs()), + tap(() => this._registerPageTabs()), tap(() => this.subject$.next(true)), untilDestroyed(this) ) @@ -84,73 +86,167 @@ export class EditEmployeeProfileComponent extends TranslationBaseComponent imple this._applyTranslationOnTabs(); } + /** + * Constructs a route URL for a specific tab in the 'edit-employee' view. + * + * This method dynamically generates the route URL based on the employee's ID + * and the tab passed as a parameter. It is used to navigate between + * different sections (tabs) of the employee edit page. + * + * @param {string} tab - The name of the tab for which to generate the route. + * @returns {string} - The complete route URL for the specified tab. + */ getRoute(tab: string): string { - return `/pages/employees/edit/${this.routeParams.id}/${tab}`; + return `/pages/employees/edit/${this.employeeId}/${tab}`; + } + + /** + * Registers custom tabs for the 'employee-edit' page. + * This method defines and registers the various tabs, their icons, routes, and titles. + */ + private _registerPageTabs(): void { + const tabs: PageTabRegistryConfig[] = this._createTabsConfig(); + + // Register each tab using the page tab registry service + tabs.forEach((tab: PageTabRegistryConfig) => this._pageTabRegistryService.registerPageTab(tab)); } - loadTabs() { - this.tabs = [ + /** + * Creates the configuration for the tabs used in the 'employee-edit' page. + * @returns An array of PageTabRegistryConfig objects. + */ + private _createTabsConfig(): PageTabRegistryConfig[] { + return [ { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.ACCOUNT'), - icon: 'person-outline', + tabsetId: this.tabsetId, + tabId: 'account', + tabIcon: 'person-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.ACCOUNT'), + order: 0, responsive: true, route: this.getRoute('account') }, { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.NETWORKS'), - icon: 'at-outline', + tabsetId: this.tabsetId, + tabId: 'networks', + tabIcon: 'at-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.NETWORKS'), + order: 1, responsive: true, route: this.getRoute('networks') }, { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.EMPLOYMENT'), - icon: 'browser-outline', + tabsetId: this.tabsetId, + tabId: 'employment', + tabIcon: 'browser-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.EMPLOYMENT'), + order: 2, responsive: true, route: this.getRoute('employment') }, { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.HIRING'), - icon: 'map-outline', + tabsetId: this.tabsetId, + tabId: 'hiring', + tabIcon: 'browser-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.HIRING'), + order: 3, responsive: true, route: this.getRoute('hiring') }, { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.LOCATION'), - icon: 'pin-outline', + tabsetId: this.tabsetId, + tabId: 'location', + tabIcon: 'pin-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.LOCATION'), + order: 4, responsive: true, route: this.getRoute('location') }, { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.RATES'), - icon: 'pricetags-outline', + tabsetId: this.tabsetId, + tabId: 'rates', + tabIcon: 'pricetags-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.RATES'), + order: 5, responsive: true, route: this.getRoute('rates') }, - ...(this._store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) - ? [ - { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.PROJECTS'), - icon: 'book-outline', - responsive: true, - route: this.getRoute('projects') - } - ] - : []), { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.CONTACTS'), - icon: 'book-open-outline', + tabsetId: this.tabsetId, + tabId: 'projects', + tabIcon: 'book-open-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.PROJECTS'), + order: 6, + responsive: true, + route: this.getRoute('projects'), + permissions: [PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW] + }, + { + tabsetId: this.tabsetId, + tabId: 'contacts', + tabIcon: 'book-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.CONTACTS'), + order: 7, responsive: true, route: this.getRoute('contacts') }, { - title: this.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.SETTINGS'), - icon: 'settings-outline', + tabsetId: this.tabsetId, + tabId: 'settings', + tabIcon: 'settings-outline', + tabsetType: 'route', + tabTitle: (_i18n) => _i18n.getTranslation('EMPLOYEES_PAGE.EDIT_EMPLOYEE.SETTINGS'), + order: 8, responsive: true, route: this.getRoute('settings') } ]; } + /** + * Retrieves and sets the profile of the selected employee + * + * @returns + */ + private async _getEmployeeProfile() { + try { + if (!this.employeeId) { + return; + } + + // Fetch employee data from the service + const employee = await firstValueFrom( + this._employeeService.getEmployeeById(this.employeeId, [ + 'user', + 'organizationDepartments', + 'organizationPosition', + 'organizationEmploymentTypes', + 'tags', + 'skills', + 'contact' + ]) + ); + + // Set the selected employee in the store and component + this._employeeStore.selectedEmployee = this.selectedEmployee = employee; + + // Set the employee name for display + this.employeeName = employee?.user?.name || employee?.user?.username || 'Unknown Employee'; + } catch (error) { + // Handle errors gracefully + console.error('Error fetching employee profile:', error); + this._errorHandlingService.handleError(error); + } + } + /** * Submit the employee form with updated data * @@ -218,44 +314,12 @@ export class EditEmployeeProfileComponent extends TranslationBaseComponent imple } /** - * Retrieves and sets the profile of the selected employee + * Applies translations to the page tabs. */ - private async _getEmployeeProfile() { - try { - const { id } = this.routeParams; - if (!id) { - return; - } - - // Fetch employee data from the service - const employee = await firstValueFrom( - this._employeeService.getEmployeeById(id, [ - 'user', - 'organizationDepartments', - 'organizationPosition', - 'organizationEmploymentTypes', - 'tags', - 'skills', - 'contact' - ]) - ); - - // Set the selected employee in the store and component - this._employeeStore.selectedEmployee = this.selectedEmployee = employee; - - // Set the employee name for display - this.employeeName = employee?.user?.name || employee?.user?.username || 'Unknown Employee'; - } catch (error) { - // Handle errors gracefully - console.error('Error fetching employee profile:', error); - this._errorHandlingService.handleError(error); - } - } - private _applyTranslationOnTabs() { this.translateService.onLangChange .pipe( - tap(() => this.loadTabs()), + tap(() => this._registerPageTabs()), untilDestroyed(this) ) .subscribe(); diff --git a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts index b72163f9c20..667e1235d4e 100644 --- a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts +++ b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-projects/edit-employee-projects.component.ts @@ -11,7 +11,13 @@ import { PermissionsEnum } from '@gauzy/contracts'; import { distinctUntilChange } from '@gauzy/ui-core/common'; -import { EmployeeStore, OrganizationProjectsService, Store, ToastrService } from '@gauzy/ui-core/core'; +import { + EmployeeStore, + ErrorHandlingService, + OrganizationProjectsService, + Store, + ToastrService +} from '@gauzy/ui-core/core'; import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; @UntilDestroy({ checkProperties: true }) @@ -45,11 +51,12 @@ export class EditEmployeeProjectsComponent extends TranslationBaseComponent impl public organization: IOrganization; constructor( - private readonly organizationProjectsService: OrganizationProjectsService, - private readonly toastrService: ToastrService, - private readonly employeeStore: EmployeeStore, public readonly translateService: TranslateService, - private readonly store: Store + private readonly _organizationProjectsService: OrganizationProjectsService, + private readonly _toastrService: ToastrService, + private readonly _employeeStore: EmployeeStore, + private readonly _store: Store, + private readonly _errorHandlingService: ErrorHandlingService ) { super(translateService); } @@ -61,8 +68,8 @@ export class EditEmployeeProjectsComponent extends TranslationBaseComponent impl untilDestroyed(this) ) .subscribe(); - const storeOrganization$ = this.store.selectedOrganization$; - const storeEmployee$ = this.employeeStore.selectedEmployee$; + const storeOrganization$ = this._store.selectedOrganization$; + const storeEmployee$ = this._employeeStore.selectedEmployee$; combineLatest([storeOrganization$, storeEmployee$]) .pipe( distinctUntilChange(), @@ -77,79 +84,129 @@ export class EditEmployeeProjectsComponent extends TranslationBaseComponent impl .subscribe(); } - ngOnDestroy(): void {} + /** + * Submits the form to update the employee's project association. + * + * If the `member` exists in the input, the method will either update or remove the employee's project assignment + * and provide feedback through a success or error toastr notification. + * + * @param input The input data containing information about the employee and the project. + * @param removed A flag indicating whether the employee was removed from or added to the project. + */ + async submitForm(input: IEditEntityByMemberInput, removed: boolean): Promise { + if (!this.organization || !input.member) { + return; + } + + const { id: organizationId, tenantId } = this.organization; - async submitForm(formInput: IEditEntityByMemberInput, removed: boolean) { try { - if (formInput.member) { - await this.organizationProjectsService.updateByEmployee(formInput); - this.loadProjects(); - this.toastrService.success( - removed ? 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_REMOVED' : 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_ADDED' - ); - } + // Update the employee's project assignment + await this._organizationProjectsService.updateByEmployee({ + addedProjectIds: input.addedEntityIds, + removedProjectIds: input.removedEntityIds, + member: input.member, + organizationId, + tenantId + }); + + // Show success message based on the action performed (added or removed) + const message = removed + ? 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_REMOVED' + : 'TOASTR.MESSAGE.EMPLOYEE_PROJECT_ADDED'; + this._toastrService.success(message); } catch (error) { - this.toastrService.danger('TOASTR.MESSAGE.EMPLOYEE_EDIT_ERROR'); + // Show error message in case of failure + this._toastrService.danger('TOASTR.MESSAGE.EMPLOYEE_EDIT_ERROR'); + } finally { + // Notify subscribers that the operation is complete + this.subject$.next(true); } } /** - * Load organization & employee assigned projects + * Loads organization and employee assigned projects. + * + * This method loads the projects assigned to the selected employee and all organization projects, + * then filters out the employee's assigned projects from the full list of organization projects. */ - private async loadProjects() { + private async loadProjects(): Promise { + // Load employee projects and all organization projects await this.loadSelectedEmployeeProjects(); + + // Get all organization projects const organizationProjects = await this.getOrganizationProjects(); + // Filter out employee's assigned projects from the organization projects list this.organizationProjects = organizationProjects.filter( - (item: IOrganizationProject) => - !this.employeeProjects.some((project: IOrganizationProject) => project.id === item.id) + (orgProject: IOrganizationProject) => + !this.employeeProjects.some((empProject: IOrganizationProject) => empProject.id === orgProject.id) ); } /** - * Get selected employee projects + * Fetches projects assigned to the selected employee. + * + * This method loads the projects associated with the selected employee if the user has the necessary permissions + * and the organization is available. * - * @returns + * @returns A Promise that resolves once the employee projects are loaded. */ - private async loadSelectedEmployeeProjects() { + private async loadSelectedEmployeeProjects(): Promise { if ( !this.organization || - !this.store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) + !this._store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) ) { return; } - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; + const { id: organizationId, tenantId } = this.organization; const { id: selectedEmployeeId } = this.selectedEmployee; - this.employeeProjects = await this.organizationProjectsService.getAllByEmployee(selectedEmployeeId, { - organizationId, - tenantId - }); + try { + // Fetch and assign employee projects to the component property + this.employeeProjects = await this._organizationProjectsService.getAllByEmployee(selectedEmployeeId, { + organizationId, + tenantId + }); + } catch (error) { + console.error('Error loading selected employee projects:', error); + this._errorHandlingService.handleError(error); + } } /** - * Get organization projects + * Fetches all projects within the organization. * - * @returns + * This method retrieves all projects in the organization if the user has the required permissions + * and the organization is available. + * + * @returns A Promise that resolves to an array of organization projects. */ private async getOrganizationProjects(): Promise { if ( !this.organization || - !this.store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) + !this._store.hasAnyPermission(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW) ) { - return; + return []; } - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; + const { id: organizationId, tenantId } = this.organization; - return ( - await this.organizationProjectsService.getAll([], { + try { + // Fetch and return all organization projects + const result = await this._organizationProjectsService.getAll([], { organizationId, tenantId - }) - ).items; + }); + return result.items; + } catch (error) { + console.error('Error fetching organization projects:', error); + // Handle errors + this._errorHandlingService.handleError(error); + return []; + } } + + ngOnDestroy(): void {} } diff --git a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.html b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.html index e0083798014..3c10456bc9b 100644 --- a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.html +++ b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.html @@ -9,6 +9,11 @@

{{ 'EMPLOYEES_PAGE.EDIT_EMPLOYEE.GENERAL_SETTINGS' | translate }} + +
  • + {{ 'ORGANIZATIONS_PAGE.EDIT.SETTINGS.TIMER_SETTINGS' | translate }} +
  • +
  • {{ 'EMPLOYEES_PAGE.EDIT_EMPLOYEE.INTEGRATIONS' | translate }} @@ -49,6 +54,112 @@

    + + + {{ 'ORGANIZATIONS_PAGE.EDIT.SETTINGS.TIMER_SETTINGS' | translate }} + + +
    +
    +
    +
    + + {{ 'ORGANIZATIONS_PAGE.EDIT.SETTINGS.ALLOW_MANUAL_TIME' | translate }} + + +
    +
    +
    +
    + + {{ 'ORGANIZATIONS_PAGE.EDIT.SETTINGS.ALLOW_MODIFY_TIME' | translate }} + + +
    +
    +
    +
    + + {{ 'ORGANIZATIONS_PAGE.EDIT.SETTINGS.ALLOW_DELETE_TIME' | translate }} + + +
    +
    + +
    + + {{ 'ORGANIZATIONS_PAGE.EDIT.SETTINGS.ALLOW_SCREEN_CAPTURE' | translate }} + + +
    +
    +
    +
    +
    {{ 'EMPLOYEES_PAGE.EDIT_EMPLOYEE.INTEGRATIONS' | translate }} diff --git a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.scss b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.scss index f39bdbd2305..ce5a3031253 100644 --- a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.scss +++ b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.scss @@ -303,3 +303,22 @@ nb-accordion { width: 100%; } } +:host ::ng-deep nb-toggle { + padding: 10px; + border: 1px solid nb-theme(gauzy-border-default-color); + border-radius: nb-theme(border-radius); + & > label { + margin-bottom: 0; + } +} +:host ::ng-deep .toggle { + border: 1px solid #7e7e8f !important; + background-color: #7e7e8f !important; + &.checked { + background-color: nb-theme(text-primary-color) !important; + border: 1px solid nb-theme(text-primary-color) !important; + & + span { + color: nb-theme(text-primary-color); + } + } +} diff --git a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.ts b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.ts index ab62a34c899..fe79b27c4a6 100644 --- a/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.ts +++ b/apps/gauzy/src/app/pages/employees/edit-employee/edit-employee-profile/edit-employee-settings/edit-employee-other-settings.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, OnDestroy, ChangeDetectorRef, ViewChild } from '@angular/core'; -import { UntypedFormBuilder, UntypedFormGroup, NgForm } from '@angular/forms'; -import { filter, tap } from 'rxjs/operators'; +import { FormBuilder, FormGroup, NgForm } from '@angular/forms'; +import { filter, tap } from 'rxjs'; import { NbAccordionComponent, NbAccordionItemComponent } from '@nebular/theme'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import * as moment from 'moment'; @@ -32,23 +32,28 @@ export class EditEmployeeOtherSettingsComponent implements OnInit, OnDestroy { */ @ViewChild('general') general: NbAccordionItemComponent; @ViewChild('integrations') integrations: NbAccordionItemComponent; + @ViewChild('timer') timer: NbAccordionItemComponent; /** * Employee other settings settings */ - public form: UntypedFormGroup = EditEmployeeOtherSettingsComponent.buildForm(this.fb); - static buildForm(fb: UntypedFormBuilder): UntypedFormGroup { + public form: FormGroup = EditEmployeeOtherSettingsComponent.buildForm(this.fb); + static buildForm(fb: FormBuilder): FormGroup { return fb.group({ timeZone: [], timeFormat: [], upworkId: [], - linkedInId: [] + linkedInId: [], + allowManualTime: [false], + allowModifyTime: [false], + allowDeleteTime: [false], + allowScreenshotCapture: [true] }); } constructor( private readonly cdr: ChangeDetectorRef, - private readonly fb: UntypedFormBuilder, + private readonly fb: FormBuilder, private readonly employeeStore: EmployeeStore ) {} @@ -82,7 +87,11 @@ export class EditEmployeeOtherSettingsComponent implements OnInit, OnDestroy { timeZone: user.timeZone || moment.tz.guess(), // set current timezone, if employee don't have any timezone timeFormat: user.timeFormat, upworkId: employee.upworkId, - linkedInId: employee.linkedInId + linkedInId: employee.linkedInId, + allowManualTime: employee.allowManualTime, + allowDeleteTime: employee.allowDeleteTime, + allowModifyTime: employee.allowModifyTime, + allowScreenshotCapture: employee.allowScreenshotCapture }); this.form.updateValueAndValidity(); } @@ -97,7 +106,16 @@ export class EditEmployeeOtherSettingsComponent implements OnInit, OnDestroy { return; } const { organizationId, tenantId } = this.selectedEmployee; - const { timeZone, timeFormat, upworkId, linkedInId } = this.form.value; + const { + timeZone, + timeFormat, + upworkId, + linkedInId, + allowScreenshotCapture, + allowManualTime, + allowModifyTime, + allowDeleteTime + } = this.form.value; /** Update user fields */ this.employeeStore.userForm = { @@ -110,7 +128,11 @@ export class EditEmployeeOtherSettingsComponent implements OnInit, OnDestroy { upworkId, linkedInId, organizationId, - tenantId + tenantId, + allowManualTime, + allowModifyTime, + allowDeleteTime, + allowScreenshotCapture }; } diff --git a/apps/gauzy/src/app/pages/employees/employees-routing.module.ts b/apps/gauzy/src/app/pages/employees/employees-routing.module.ts index 1814434c172..346b3b8f8eb 100644 --- a/apps/gauzy/src/app/pages/employees/employees-routing.module.ts +++ b/apps/gauzy/src/app/pages/employees/employees-routing.module.ts @@ -18,9 +18,13 @@ import { } from './edit-employee/edit-employee-profile'; import { EmployeeResolver } from './employee.resolver'; -export function redirectTo() { - return '/pages/dashboard'; -} +const selectors = { + team: false, + project: false, + employee: false, + date: false, + organization: false +}; const routes: Routes = [ { @@ -28,15 +32,19 @@ const routes: Routes = [ component: EmployeesComponent, canActivate: [PermissionsGuard], data: { + // The data table identifier for the route + dataTableId: 'employee-manage', + // The permission required to access the route permissions: { only: [PermissionsEnum.ORG_EMPLOYEES_VIEW], - redirectTo + redirectTo: '/pages/dashboard' }, + // The selectors for the route selectors: { + team: false, project: false, employee: false, - date: false, - team: false + date: false } } }, @@ -45,14 +53,17 @@ const routes: Routes = [ component: EditEmployeeComponent, canActivate: [PermissionsGuard], data: { + // The tabset identifier for the route + tabsetId: 'employee-edit', + // The permission required to access the route permissions: { only: [PermissionsEnum.ORG_EMPLOYEES_EDIT, PermissionsEnum.PROFILE_EDIT], - redirectTo - } - }, - resolve: { - employee: EmployeeResolver + redirectTo: '/pages/dashboard' + }, + // The selectors for the route + selectors }, + resolve: { employee: EmployeeResolver }, children: [ { path: '', @@ -62,106 +73,56 @@ const routes: Routes = [ { path: 'account', component: EditEmployeeMainComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } }, { path: 'networks', component: EditEmployeeNetworksComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } }, { path: 'rates', component: EditEmployeeRatesComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } }, { path: 'projects', component: EditEmployeeProjectsComponent, canActivate: [PermissionsGuard], data: { - selectors: { - project: false, - organization: false, - date: false - }, + // The selectors for the route + selectors, + // The permission required to access the route permissions: { only: [PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_PROJECT_VIEW], - redirectTo + redirectTo: '/pages/dashboard' } } }, { path: 'contacts', component: EditEmployeeContactComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } }, { path: 'location', component: EditEmployeeLocationComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } }, { path: 'hiring', component: EditEmployeeHiringComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } }, { path: 'employment', component: EditEmployeeEmploymentComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } }, { path: 'settings', component: EditEmployeeOtherSettingsComponent, - data: { - selectors: { - project: false, - organization: false, - date: false - } - } + data: { selectors } } ] }, diff --git a/apps/gauzy/src/app/pages/employees/employees.component.html b/apps/gauzy/src/app/pages/employees/employees.component.html index 6e2530a5098..5adff6fe806 100644 --- a/apps/gauzy/src/app/pages/employees/employees.component.html +++ b/apps/gauzy/src/app/pages/employees/employees.component.html @@ -12,7 +12,7 @@

    - + @@ -44,10 +44,44 @@

    - - + + + + +
    + +
    +
    + + + +
    +
    + + + +
    + +
    +
    + + + +

    {{ 'SETTINGS_MENU.NO_LAYOUT' | translate }}

    +
    +
    @@ -57,6 +91,33 @@

    + + + + + + + + + + + + + + + + + +
    @@ -146,57 +207,3 @@

    - - - - - - - - - - - - - - -
    - -
    -
    - - - -
    -
    - - -
    - -
    -
    - - - - - diff --git a/apps/gauzy/src/app/pages/employees/employees.component.ts b/apps/gauzy/src/app/pages/employees/employees.component.ts index f5e2f22c19c..41be82c458e 100644 --- a/apps/gauzy/src/app/pages/employees/employees.component.ts +++ b/apps/gauzy/src/app/pages/employees/employees.component.ts @@ -3,14 +3,16 @@ import { ActivatedRoute, ParamMap, Router } from '@angular/router'; import { HttpClient } from '@angular/common/http'; import { NbDialogService } from '@nebular/theme'; import { TranslateService } from '@ngx-translate/core'; -import { Cell } from 'angular2-smart-table'; -import { debounceTime, filter, tap } from 'rxjs/operators'; -import { Subject, firstValueFrom } from 'rxjs'; +import { Cell, IColumns, Settings } from 'angular2-smart-table'; +import { Subject, debounceTime, filter, firstValueFrom, tap } from 'rxjs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { EmployeeStore, EmployeesService, ErrorHandlingService, + PageDataTableRegistryConfig, + PageDataTableRegistryId, + PageDataTableRegistryService, ServerDataSource, Store, ToastrService @@ -36,12 +38,12 @@ import { EmployeeStartWorkComponent, InputFilterComponent, InviteMutationComponent, + PaginationFilterBaseComponent, PictureNameTagsComponent, TagsColorFilterComponent, TagsOnlyComponent, ToggleFilterComponent } from '@gauzy/ui-core/shared'; -import { PaginationFilterBaseComponent, IPaginationBase } from '@gauzy/ui-core/shared'; import { EmployeeAverageBonusComponent, EmployeeAverageExpensesComponent, @@ -52,11 +54,13 @@ import { @UntilDestroy({ checkProperties: true }) @Component({ + selector: 'ga-employees-list', templateUrl: './employees.component.html', styleUrls: ['./employees.component.scss'] }) export class EmployeesComponent extends PaginationFilterBaseComponent implements OnInit, OnDestroy { - public settingsSmartTable: object; + public dataTableId: PageDataTableRegistryId = this._route.snapshot.data.dataTableId; // The identifier for the data table + public settingsSmartTable: Settings; public smartTableSource: ServerDataSource; public selectedEmployee: EmployeeViewModel; public employees: EmployeeViewModel[] = []; @@ -68,7 +72,6 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements public disableButton: boolean = true; public includeDeleted: boolean = false; public loading: boolean = false; - public organizationInvitesAllowed: boolean = false; public organization: IOrganization; public refresh$: Subject = new Subject(); public employees$: Subject = this.subject$; @@ -96,16 +99,17 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements private readonly _errorHandlingService: ErrorHandlingService, private readonly _employeeStore: EmployeeStore, private readonly _httpClient: HttpClient, - private readonly _dateFormatPipe: DateFormatPipe + private readonly _dateFormatPipe: DateFormatPipe, + private readonly _pageDataTableRegistryService: PageDataTableRegistryService ) { super(translateService); this.setView(); } ngOnInit() { + this._registerDataTableColumns(); this._loadSmartTableSettings(); - this._applyTranslationOnSmartTable(); - + this._subscribeToQueryParams(); this.employees$ .pipe( debounceTime(300), @@ -128,22 +132,12 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements distinctUntilChange(), filter((organization: IOrganization) => !!organization), tap((organization: IOrganization) => (this.organization = organization)), - tap(({ invitesAllowed }) => (this.organizationInvitesAllowed = invitesAllowed)), tap(() => this._additionalColumns()), tap(() => this.refresh$.next(true)), tap(() => this.employees$.next(true)), untilDestroyed(this) ) .subscribe(); - this._route.queryParamMap - .pipe( - filter((params: ParamMap) => !!params), - filter((params: ParamMap) => params.get('openAddDialog') === 'true'), - debounceTime(1000), - tap(() => this.add()), - untilDestroyed(this) - ) - .subscribe(); this.refresh$ .pipe( filter(() => this.dataLayoutStyle === ComponentLayoutStyleEnum.CARDS_GRID), @@ -154,6 +148,26 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements .subscribe(); } + ngAfterViewInit(): void { + this._applyTranslationOnSmartTable(); + } + + /** + * Subscribes to the query parameters and performs actions based on the 'openAddDialog' parameter. + */ + private _subscribeToQueryParams(): void { + this._route.queryParamMap + .pipe( + // Check if 'openAddDialog' is set to 'true' and filter out falsy values + filter((params: ParamMap) => params?.get('openAddDialog') === 'true'), + // Trigger the add method + tap(() => this.add()), + // Automatically unsubscribe when component is destroyed + untilDestroyed(this) + ) + .subscribe(); + } + /** * Checks if the current user has the necessary permissions to perform button actions. * @returns A boolean indicating whether the user has the required permissions. @@ -163,7 +177,10 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements } /** - * + * @description + * This method sets the view layout for the component based on the current layout configuration. + * It listens for layout changes from the store and updates the `dataLayoutStyle`. + * Depending on the layout (e.g., if it's `CARDS_GRID`), it clears the employee list and triggers a refresh. */ setView() { this._store @@ -597,46 +614,48 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements } /** + * Maps an IEmployee object to a formatted employee object. * - * @param employee - * @returns + * @param employee The IEmployee object to map. + * @returns The formatted employee object. */ private employeeMapper(employee: IEmployee) { const { id, - user, + user = {}, isActive, endWork, tags, - averageIncome, - averageExpenses, - averageBonus, + averageIncome = 0, + averageExpenses = 0, + averageBonus = 0, startedWorkOn, isTrackingEnabled, isDeleted } = employee; + const { name = '', email = '', imageUrl = '' } = user; + + // Format start and end work dates, and create the work status range + const start = startedWorkOn ? this._dateFormatPipe.transform(startedWorkOn, null, 'LL') : ''; + const end = endWork ? this._dateFormatPipe.transform(endWork, null, 'LL') : ''; - /** - * "Range" when was hired and when exit - */ - const start = this._dateFormatPipe.transform(startedWorkOn, null, 'LL'); - const end = this._dateFormatPipe.transform(endWork, null, 'LL'); const workStatus = [start, end].filter(Boolean).join(' - '); + + // Return the mapped object return { - fullName: `${user.name}`, - email: user.email, + fullName: name || '', // Ensure default values for safety + email: email || '', id, isActive, endWork: endWork ? new Date(endWork) : '', workStatus: endWork ? workStatus : '', - imageUrl: user.imageUrl, - tags, - // TODO: load real bonus and bonusDate - bonus: this.bonusForSelectedMonth, + imageUrl: imageUrl || '', + tags: tags || [], + bonus: this.bonusForSelectedMonth, // TODO: load real bonus and bonusDate averageIncome: Math.floor(averageIncome), averageExpenses: Math.floor(averageExpenses), averageBonus: Math.floor(averageBonus), - bonusDate: Date.now(), + bonusDate: Date.now(), // Placeholder for actual bonus date employeeId: id, employee, startedWorkOn, @@ -646,143 +665,208 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements } /** - * Load Smart Table settings + * Registers custom columns for the 'employee-manage' data table. + * This method defines and registers the columns with various properties, + * including a custom filter function and a rendering component. */ - private _loadSmartTableSettings() { - const pagination: IPaginationBase = this.getPagination(); - this.settingsSmartTable = { - actions: false, - selectedRowIndex: -1, - pager: { - display: false, - perPage: pagination ? pagination.itemsPerPage : this.minItemPerPage - }, - noDataMessage: this.getTranslation('SM_TABLE.NO_DATA.EMPLOYEE'), - columns: { - fullName: { - title: this.getTranslation('SM_TABLE.FULL_NAME'), - type: 'custom', - class: 'align-row', - width: '20%', - renderComponent: PictureNameTagsComponent, - componentInitFunction: (instance: PictureNameTagsComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - instance.value = cell.getRawValue(); - }, - isFilterable: { - type: 'custom', - component: InputFilterComponent - }, - filterFunction: (value: string) => { - this.setFilter({ field: 'user.name', search: value }); - } - }, - email: { - title: this.getTranslation('SM_TABLE.EMAIL'), - type: 'email', - class: 'email-column', - width: '20%', - isFilterable: { - type: 'custom', - component: InputFilterComponent - }, - filterFunction: (value: string) => { - this.setFilter({ field: 'user.email', search: value }); - } + private _registerDataTableColumns(): void { + const columns: PageDataTableRegistryConfig[] = [ + { + dataTableId: this.dataTableId, + columnId: 'fullName', + order: 0, + title: () => this.getTranslation('SM_TABLE.FULL_NAME'), + type: 'custom', + class: 'align-row', + width: '20%', + isFilterable: true, + renderComponent: PictureNameTagsComponent, + componentInitFunction: (instance: PictureNameTagsComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + instance.value = cell.getRawValue(); }, - averageIncome: { - title: this.getTranslation('SM_TABLE.INCOME'), + filter: { type: 'custom', - isFilterable: false, - class: 'text-center', - width: '5%', - renderComponent: EmployeeAverageIncomeComponent, - componentInitFunction: (instance: EmployeeAverageIncomeComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - } + component: InputFilterComponent }, - averageExpenses: { - title: this.getTranslation('SM_TABLE.EXPENSES'), + filterFunction: this._getFilterFunction('user.name') + }, + { + dataTableId: this.dataTableId, + columnId: 'email', + order: 1, + title: () => this.getTranslation('SM_TABLE.EMAIL'), + type: 'text', + class: 'align-row', + width: '20%', + isFilterable: true, + filter: { type: 'custom', - isFilterable: false, - class: 'text-center', - width: '5%', - renderComponent: EmployeeAverageExpensesComponent, - componentInitFunction: (instance: EmployeeAverageExpensesComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - } + component: InputFilterComponent }, - averageBonus: { - title: this.getTranslation('SM_TABLE.BONUS_AVG'), + filterFunction: this._getFilterFunction('user.email') + }, + { + dataTableId: this.dataTableId, + columnId: 'averageIncome', + order: 2, + title: () => this.getTranslation('SM_TABLE.INCOME'), + type: 'custom', + isFilterable: false, + isSortable: true, + class: 'text-center', + width: '5%', + renderComponent: EmployeeAverageIncomeComponent, + componentInitFunction: (instance: EmployeeAverageIncomeComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + } + }, + { + dataTableId: this.dataTableId, + columnId: 'averageExpenses', + order: 3, + title: () => this.getTranslation('SM_TABLE.EXPENSES'), + type: 'custom', + isFilterable: false, + isSortable: true, + class: 'text-center', + width: '5%', + renderComponent: EmployeeAverageExpensesComponent, + componentInitFunction: (instance: EmployeeAverageExpensesComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + } + }, + { + dataTableId: this.dataTableId, + columnId: 'averageBonus', + order: 4, + title: () => this.getTranslation('SM_TABLE.BONUS_AVG'), + type: 'custom', + isFilterable: false, + isSortable: true, + class: 'text-center', + width: '5%', + renderComponent: EmployeeAverageBonusComponent, + componentInitFunction: (instance: EmployeeAverageBonusComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + } + }, + { + dataTableId: this.dataTableId, + columnId: 'isTrackingEnabled', + order: 5, + title: () => this.getTranslation('SM_TABLE.TIME_TRACKING'), + type: 'custom', + isFilterable: true, + isSortable: true, + class: 'text-center', + width: '5%', + filter: { type: 'custom', - isFilterable: false, - class: 'text-center', - width: '5%', - renderComponent: EmployeeAverageBonusComponent, - componentInitFunction: (instance: EmployeeAverageBonusComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - } + component: ToggleFilterComponent }, - isTrackingEnabled: { - title: this.getTranslation('SM_TABLE.TIME_TRACKING'), + filterFunction: this._getFilterFunction('isTrackingEnabled'), + renderComponent: EmployeeTimeTrackingStatusComponent, + componentInitFunction: (instance: EmployeeTimeTrackingStatusComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + } + }, + { + dataTableId: this.dataTableId, + columnId: 'tags', + order: 6, + title: () => this.getTranslation('SM_TABLE.TAGS'), + type: 'custom', + width: '20%', + isFilterable: true, + isSortable: false, + filter: { type: 'custom', - class: 'text-center', - width: '5%', - renderComponent: EmployeeTimeTrackingStatusComponent, - componentInitFunction: (instance: EmployeeTimeTrackingStatusComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - }, - isFilterable: { - type: 'custom', - component: ToggleFilterComponent - }, - filterFunction: (checked: boolean) => { - this.setFilter({ - field: 'isTrackingEnabled', - search: checked - }); - } + component: TagsColorFilterComponent }, - tags: { - title: this.getTranslation('SM_TABLE.TAGS'), - type: 'custom', - width: '20%', - renderComponent: TagsOnlyComponent, - componentInitFunction: (instance: TagsOnlyComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - instance.value = cell.getValue(); - }, - isFilterable: { - type: 'custom', - component: TagsColorFilterComponent - }, - filterFunction: (tags: ITag[]) => { - const tagIds = []; - for (const tag of tags) { - tagIds.push(tag.id); - } - this.setFilter({ field: 'tags', search: tagIds }); - }, - isSortable: false + filterFunction: (tags: ITag[]) => { + const tagIds = tags.map((tag) => tag.id); + this.setFilter({ field: 'tags', search: tagIds }); + return tags.length > 0; }, - workStatus: { - title: this.getTranslation('SM_TABLE.STATUS'), + renderComponent: TagsOnlyComponent, + componentInitFunction: (instance: TagsOnlyComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + instance.value = cell.getValue(); + } + }, + { + dataTableId: this.dataTableId, + columnId: 'workStatus', + order: 7, + title: () => this.getTranslation('SM_TABLE.STATUS'), + type: 'custom', + class: 'text-center', + width: '5%', + isFilterable: true, + isSortable: false, + filter: { type: 'custom', - class: 'text-center', - width: '5%', - renderComponent: EmployeeWorkStatusComponent, - componentInitFunction: (instance: EmployeeWorkStatusComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - }, - isFilterable: { - type: 'custom', - component: ToggleFilterComponent - }, - filterFunction: (isActive: boolean) => { - this.setFilter({ field: 'isActive', search: isActive }); - } + component: ToggleFilterComponent + }, + filterFunction: (isActive: boolean) => { + this.setFilter({ field: 'isActive', search: isActive }); + return isActive; + }, + renderComponent: EmployeeWorkStatusComponent, + componentInitFunction: (instance: EmployeeWorkStatusComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); } } + ]; + + columns.forEach((column: PageDataTableRegistryConfig) => { + this._pageDataTableRegistryService.registerPageDataTableColumn(column); + }); + } + + /** + * Helper function to create a reusable filter function for columns. + * @param field - The field to filter by. + */ + private _getFilterFunction(field: string) { + return (value: string) => { + this.setFilter({ field, search: value }); + return value.length > 0; // Return `true` if the value is non-empty + }; + } + + /** + * Retrieves the registered columns for the 'employee-manage' data table. + * + * This method fetches all the column configurations registered under the + * 'employee-manage' data table from the PageDataTableRegistryService. + * It returns the columns in the format of `IColumns`, which can be used for rendering or + * further manipulation in the smart table. + * + * @returns {IColumns} The column configurations for the 'employee-manage' table. + */ + getColumns(): IColumns { + // Fetch and return the columns for 'employee-manage' data table + return this._pageDataTableRegistryService.getPageDataTableColumns(this.dataTableId); + } + + /** + * Load Smart Table settings + */ + private _loadSmartTableSettings() { + // Get pagination settings + const { itemsPerPage } = this.getPagination() || { itemsPerPage: this.minItemPerPage }; + + // Configure Smart Table settings + this.settingsSmartTable = { + actions: false, + noDataMessage: this.getTranslation('SM_TABLE.NO_DATA.EMPLOYEE'), + pager: { + display: false, + perPage: itemsPerPage + }, + columns: { ...this.getColumns() } }; } @@ -799,52 +883,50 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements // Destructure properties for clarity const { allowScreenshotCapture } = this.organization; - // Check if screenshot capture is allowed - if (allowScreenshotCapture) { - // Configure the additional column for screenshot capture - this.settingsSmartTable['columns']['allowScreenshotCapture'] = { - title: this.getTranslation('SM_TABLE.SCREEN_CAPTURE'), + // Check if screenshot capture is allowed and hide the column if not + this._pageDataTableRegistryService.registerPageDataTableColumn({ + dataTableId: this.dataTableId, // The identifier for the data table location + columnId: 'allowScreenshotCapture', // The identifier for the column + order: 8, // The order of the column in the table + title: () => this.getTranslation('SM_TABLE.SCREEN_CAPTURE'), // The title of the column + type: 'custom', // The type of the column + class: 'text-center', // The class of the column + width: '5%', // The width of the column + isFilterable: true, // Indicates whether the column is filterable + isSortable: false, + hide: allowScreenshotCapture === false, + filter: { type: 'custom', - class: 'text-center', - editable: false, - addable: false, - notShownField: true, - // Configure custom filter for the column - isFilterable: { - type: 'custom', - component: ToggleFilterComponent - }, - // Define filter function to update the filter settings - filterFunction: (isEnable: boolean) => { - this.setFilter({ - field: 'allowScreenshotCapture', - search: isEnable - }); - }, - // Configure custom component for rendering the column - renderComponent: AllowScreenshotCaptureComponent, - // Initialize component function to set initial values - componentInitFunction: (instance: AllowScreenshotCaptureComponent, cell: Cell) => { - instance.rowData = cell.getRow().getData(); - instance.value = cell.getValue(); - - // Subscribe to the allowScreenshotCaptureChange event - instance.allowScreenshotCaptureChange.subscribe({ - next: (isAllow: boolean) => { - // Clear selected items and update allowScreenshotCapture - this.clearItem(); - this._updateAllowScreenshotCapture(instance.rowData, isAllow); - }, - error: (err: any) => { - console.warn(err); - } - }); - } - }; - } + component: ToggleFilterComponent + }, + filterFunction: (isEnable: boolean) => { + this.setFilter({ field: 'allowScreenshotCapture', search: isEnable }); + return isEnable; + }, + renderComponent: AllowScreenshotCaptureComponent, // The component to render the column + componentInitFunction: (instance: AllowScreenshotCaptureComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + instance.value = cell.getValue(); + + // Subscribe to the allowScreenshotCaptureChange event + instance.allowScreenshotCaptureChange.subscribe({ + next: (isAllow: boolean) => { + // Clear selected items and update allowScreenshotCapture + this.clearItem(); + this._updateAllowScreenshotCapture(instance.rowData, isAllow); + }, + error: (err: any) => { + console.warn(err); + } + }); + } + }); - // Copy the settingsSmartTable to trigger change detection - this.settingsSmartTable = { ...this.settingsSmartTable }; + // Update the settingsSmartTable with the new columns + this.settingsSmartTable = { + ...this.settingsSmartTable, + columns: this.getColumns() + }; } /** @@ -901,10 +983,7 @@ export class EmployeesComponent extends PaginationFilterBaseComponent implements * Clear selected item */ clearItem() { - this.selectEmployee({ - isSelected: false, - data: null - }); + this.selectEmployee({ isSelected: false, data: null }); } /** diff --git a/apps/gauzy/src/app/pages/employees/employees.module.ts b/apps/gauzy/src/app/pages/employees/employees.module.ts index 031c70f6f97..30077b3843b 100644 --- a/apps/gauzy/src/app/pages/employees/employees.module.ts +++ b/apps/gauzy/src/app/pages/employees/employees.module.ts @@ -48,7 +48,8 @@ import { SkillsInputModule, TableComponentsModule, TagsColorInputModule, - TimeZoneSelectorModule + TimeZoneSelectorModule, + DynamicTabsModule } from '@gauzy/ui-core/shared'; import { EditEmployeeContactComponent, @@ -139,7 +140,8 @@ const COMPONENTS = [ LanguageSelectorModule, SmartDataViewLayoutModule, CardGridModule, - TimeZoneSelectorModule + TimeZoneSelectorModule, + DynamicTabsModule ], declarations: [...COMPONENTS], providers: [OrganizationsService, InviteGuard, CandidatesService, OrganizationEmploymentTypesService, SkillsService] diff --git a/apps/gauzy/src/app/pages/employees/timesheet/calendar/calendar-routing.module.ts b/apps/gauzy/src/app/pages/employees/timesheet/calendar/calendar-routing.module.ts index 68b87dc0e31..33cbb9df2dc 100644 --- a/apps/gauzy/src/app/pages/employees/timesheet/calendar/calendar-routing.module.ts +++ b/apps/gauzy/src/app/pages/employees/timesheet/calendar/calendar-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { CalendarComponent } from './calendar/calendar.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/employees/timesheet/daily/daily-routing.module.ts b/apps/gauzy/src/app/pages/employees/timesheet/daily/daily-routing.module.ts index bfd14652a8e..059d3e2fcff 100644 --- a/apps/gauzy/src/app/pages/employees/timesheet/daily/daily-routing.module.ts +++ b/apps/gauzy/src/app/pages/employees/timesheet/daily/daily-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { DailyComponent } from './daily/daily.component'; @@ -14,7 +15,10 @@ const routes: Routes = [ isSingleDatePicker: true } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html b/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html index d4bf5b2094c..ddb272ac95c 100644 --- a/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html +++ b/apps/gauzy/src/app/pages/employees/timesheet/daily/daily/daily.component.html @@ -94,7 +94,7 @@
    {{ 'TIMESHEET.TODO' | translate }}: - {{ log?.task?.title | truncate : 50 }} + {{ log?.task?.title | truncate : 40 }} {{ 'TIMESHEET.NO_TODO' | translate }} diff --git a/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.html b/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.html index da4e5a46ba3..a32568087cc 100644 --- a/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.html +++ b/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.html @@ -1,10 +1,19 @@ -

    - - {{ 'MENU.TIMESHEETS' | translate }} - -

    +
    +

    + + {{ 'MENU.TIMESHEETS' | translate }} + +

    + + + +
    diff --git a/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.ts b/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.ts index 0c25ab0fc00..ee5d018a169 100644 --- a/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.ts +++ b/apps/gauzy/src/app/pages/employees/timesheet/layout/layout.component.ts @@ -1,9 +1,9 @@ import { Component, OnInit, OnDestroy } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { NbRouteTab } from '@nebular/theme'; import { TranslateService } from '@ngx-translate/core'; -import { PermissionsEnum } from '@gauzy/contracts'; -import { PageTabRegistryService, PageTabsetRegistryId } from '@gauzy/ui-core/core'; +import { Observable } from 'rxjs'; +import { IDateRangePicker, PermissionsEnum } from '@gauzy/contracts'; +import { DateRangePickerBuilderService, PageTabRegistryService, PageTabsetRegistryId } from '@gauzy/ui-core/core'; import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; @Component({ @@ -12,13 +12,14 @@ import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; styleUrls: ['./layout.component.scss'] }) export class TimesheetLayoutComponent extends TranslationBaseComponent implements OnInit, OnDestroy { - public tabs: NbRouteTab[] = []; public tabsetId: PageTabsetRegistryId = this._route.snapshot.data.tabsetId; // The identifier for the tabset + public selectedDateRange$: Observable = this._dateRangePickerBuilderService.selectedDateRange$; constructor( public readonly translateService: TranslateService, private readonly _route: ActivatedRoute, - private readonly _pageTabRegistryService: PageTabRegistryService + private readonly _pageTabRegistryService: PageTabRegistryService, + private readonly _dateRangePickerBuilderService: DateRangePickerBuilderService ) { super(translateService); } @@ -100,8 +101,5 @@ export class TimesheetLayoutComponent extends TranslationBaseComponent implement }); } - ngOnDestroy(): void { - // Delete the timesheet tabset from the registry - this._pageTabRegistryService.deleteTabset(this.tabsetId); - } + ngOnDestroy(): void {} } diff --git a/apps/gauzy/src/app/pages/employees/timesheet/weekly/weekly-routing.module.ts b/apps/gauzy/src/app/pages/employees/timesheet/weekly/weekly-routing.module.ts index e1b5657eefc..28c102c2531 100644 --- a/apps/gauzy/src/app/pages/employees/timesheet/weekly/weekly-routing.module.ts +++ b/apps/gauzy/src/app/pages/employees/timesheet/weekly/weekly-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { WeeklyComponent } from './weekly/weekly.component'; @@ -13,7 +14,10 @@ const routes: Routes = [ isLockDatePicker: true } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/equipment-sharing-policy/equipment-sharing-policy.component.ts b/apps/gauzy/src/app/pages/equipment-sharing-policy/equipment-sharing-policy.component.ts index 4b49b2bc39a..c1bef9a8943 100644 --- a/apps/gauzy/src/app/pages/equipment-sharing-policy/equipment-sharing-policy.component.ts +++ b/apps/gauzy/src/app/pages/equipment-sharing-policy/equipment-sharing-policy.component.ts @@ -120,7 +120,7 @@ export class EquipmentSharingPolicyComponent extends PaginationFilterBaseCompone name: { title: this.getTranslation('EQUIPMENT_SHARING_POLICY_PAGE.EQUIPMENT_SHARING_POLICY_NAME'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/equipment/equipment.component.ts b/apps/gauzy/src/app/pages/equipment/equipment.component.ts index e2e9aa8a375..1418cc97165 100644 --- a/apps/gauzy/src/app/pages/equipment/equipment.component.ts +++ b/apps/gauzy/src/app/pages/equipment/equipment.component.ts @@ -150,7 +150,7 @@ export class EquipmentComponent extends PaginationFilterBaseComponent implements instance.rowData = cell.getRow().getData(); instance.value = cell.getRawValue(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -161,7 +161,7 @@ export class EquipmentComponent extends PaginationFilterBaseComponent implements type: { title: this.getTranslation('EQUIPMENT_PAGE.EQUIPMENT_TYPE'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -172,7 +172,7 @@ export class EquipmentComponent extends PaginationFilterBaseComponent implements serialNumber: { title: this.getTranslation('EQUIPMENT_PAGE.EQUIPMENT_SN'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/expenses/expenses.component.ts b/apps/gauzy/src/app/pages/expenses/expenses.component.ts index 7f8421c741c..b4daf419801 100644 --- a/apps/gauzy/src/app/pages/expenses/expenses.component.ts +++ b/apps/gauzy/src/app/pages/expenses/expenses.component.ts @@ -260,7 +260,7 @@ export class ExpensesComponent extends PaginationFilterBaseComponent implements vendorName: { title: this.getTranslation('SM_TABLE.VENDOR'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: VendorFilterComponent }, @@ -272,7 +272,7 @@ export class ExpensesComponent extends PaginationFilterBaseComponent implements categoryName: { title: this.getTranslation('SM_TABLE.CATEGORY'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: ExpenseCategoryFilterComponent }, @@ -313,7 +313,7 @@ export class ExpensesComponent extends PaginationFilterBaseComponent implements title: this.getTranslation('SM_TABLE.NOTES'), type: 'text', class: 'align-row', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -325,7 +325,7 @@ export class ExpensesComponent extends PaginationFilterBaseComponent implements title: this.getTranslation('POP_UPS.PURPOSE'), type: 'string', class: 'align-row', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/hubstaff/components/hubstaff-authorize/hubstaff-authorize.component.scss b/apps/gauzy/src/app/pages/hubstaff/components/hubstaff-authorize/hubstaff-authorize.component.scss deleted file mode 100644 index f6fb46b2ae3..00000000000 --- a/apps/gauzy/src/app/pages/hubstaff/components/hubstaff-authorize/hubstaff-authorize.component.scss +++ /dev/null @@ -1 +0,0 @@ -@import '../../../upwork/components/upwork-authorize/upwork-authorize.component.scss'; diff --git a/apps/gauzy/src/app/pages/hubstaff/components/hubstaff-authorize/hubstaff-authorize.component.ts b/apps/gauzy/src/app/pages/hubstaff/components/hubstaff-authorize/hubstaff-authorize.component.ts deleted file mode 100644 index db6b32f956b..00000000000 --- a/apps/gauzy/src/app/pages/hubstaff/components/hubstaff-authorize/hubstaff-authorize.component.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { Validators, UntypedFormGroup, UntypedFormBuilder } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; -import { filter, tap } from 'rxjs/operators'; -import { IIntegration, IIntegrationTenant, IOrganization, IntegrationEnum } from '@gauzy/contracts'; -import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { HubstaffService, Store } from '@gauzy/ui-core/core'; -import { IntegrationsService } from '@gauzy/ui-core/core'; - -@UntilDestroy({ checkProperties: true }) -@Component({ - selector: 'ngx-hubstaff-authorize', - templateUrl: './hubstaff-authorize.component.html', - styleUrls: ['./hubstaff-authorize.component.scss'] -}) -export class HubstaffAuthorizeComponent implements OnInit, OnDestroy { - public hubStaffAuthorizeCode: string; - public organization: IOrganization; - - /** */ - public clientIdForm: UntypedFormGroup = HubstaffAuthorizeComponent.buildClientIdForm(this._fb); - static buildClientIdForm(fb: UntypedFormBuilder): UntypedFormGroup { - return fb.group({ - client_id: ['', Validators.required] - }); - } - - /** */ - public clientSecretForm: UntypedFormGroup = HubstaffAuthorizeComponent.buildClientSecretForm(this._fb); - static buildClientSecretForm(fb: UntypedFormBuilder): UntypedFormGroup { - return fb.group({ - client_secret: ['', Validators.required], - authorization_code: ['', Validators.required] - }); - } - - constructor( - private readonly _activatedRoute: ActivatedRoute, - private readonly _hubstaffService: HubstaffService, - private readonly _fb: UntypedFormBuilder, - private readonly _router: Router, - private readonly _store: Store, - private readonly _integrationsService: IntegrationsService - ) {} - - ngOnInit() { - this._store.selectedOrganization$ - .pipe( - filter((organization) => !!organization), - tap((organization: IOrganization) => (this.organization = organization)), - untilDestroyed(this) - ) - .subscribe(); - this._getHubstaffCode(); - } - - private _getHubstaffCode() { - this._activatedRoute.queryParams - .pipe( - filter(({ code }) => code), - tap(({ code }) => (this.hubStaffAuthorizeCode = code)), - tap(({ code, state }) => { - this.clientIdForm.patchValue({ client_id: state }); - this.clientSecretForm.patchValue({ - authorization_code: code - }); - }), - untilDestroyed(this) - ) - .subscribe(); - - if (!this.hubStaffAuthorizeCode) { - this.subscribeRouteData(); - } - } - - private subscribeRouteData() { - this._activatedRoute.data - .pipe( - // if remember state is true - filter(({ state }) => !!state && state === true), - tap(() => this._checkRememberState()), - untilDestroyed(this) - ) - .subscribe(); - } - - /** - * Hubstaff integration remember state API call - */ - private _checkRememberState() { - if (!this.organization) { - return; - } - const { id: organizationId, tenantId } = this.organization; - const state$ = this._integrationsService.getIntegrationByOptions({ - name: IntegrationEnum.HUBSTAFF, - organizationId, - tenantId - }); - state$ - .pipe( - filter((integration: IIntegrationTenant) => !!integration.id), - tap((integration: IIntegrationTenant) => { - this._redirectToHubstaffIntegration(integration.id); - }), - untilDestroyed(this) - ) - .subscribe(); - } - - /** - * Hubstaff integration remember state API call - */ - private _redirectToHubstaffIntegration(integrationId: IIntegration['id']) { - this._router.navigate(['pages/integrations/hubstaff', integrationId]); - } - - authorizeHubstaff() { - const { client_id } = this.clientIdForm.value; - this._hubstaffService.authorizeClient(client_id); - } - - addIntegration() { - if (!this.organization) { - return; - } - - const { client_secret } = this.clientSecretForm.value; - const { client_id } = this.clientIdForm.value; - const { id: organizationId } = this.organization; - - this._hubstaffService - .addIntegration({ - code: this.hubStaffAuthorizeCode, - client_secret, - client_id, - organizationId - }) - .pipe( - tap(({ id }) => { - this._redirectToHubstaffIntegration(id); - }), - untilDestroyed(this) - ) - .subscribe(); - } - - ngOnDestroy() {} -} diff --git a/apps/gauzy/src/app/pages/hubstaff/hubstaff-routing.module.ts b/apps/gauzy/src/app/pages/hubstaff/hubstaff-routing.module.ts deleted file mode 100644 index 03c36b55723..00000000000 --- a/apps/gauzy/src/app/pages/hubstaff/hubstaff-routing.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { HubstaffAuthorizeComponent } from './components/hubstaff-authorize/hubstaff-authorize.component'; -import { HubstaffComponent } from './components/hubstaff/hubstaff.component'; - -const routes: Routes = [ - { - path: '', - component: HubstaffAuthorizeComponent, - data: { state: true } - }, - { - path: 'regenerate', - component: HubstaffAuthorizeComponent, - data: { state: false } - }, - { - path: ':id', - component: HubstaffComponent - } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class HubstaffRoutingModule {} diff --git a/apps/gauzy/src/app/pages/import-export/external-redirect/external-redirect.component.ts b/apps/gauzy/src/app/pages/import-export/external-redirect/external-redirect.component.ts new file mode 100644 index 00000000000..605db3c63ac --- /dev/null +++ b/apps/gauzy/src/app/pages/import-export/external-redirect/external-redirect.component.ts @@ -0,0 +1,13 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'ngx-external-redirect', + template: '' +}) +export class ExternalRedirectComponent implements OnInit { + constructor() {} + + ngOnInit() { + console.log('Redirecting to external URL'); + } +} diff --git a/apps/gauzy/src/app/pages/import-export/import-export-routing.module.ts b/apps/gauzy/src/app/pages/import-export/import-export-routing.module.ts index 24f86c93242..0527c80923d 100644 --- a/apps/gauzy/src/app/pages/import-export/import-export-routing.module.ts +++ b/apps/gauzy/src/app/pages/import-export/import-export-routing.module.ts @@ -1,10 +1,9 @@ -import { InjectionToken, NgModule } from '@angular/core'; -import { Routes, RouterModule, ActivatedRouteSnapshot } from '@angular/router'; +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; import { PermissionsEnum } from '@gauzy/contracts'; -import { PermissionsGuard } from '@gauzy/ui-core/core'; +import { ExternalRedirectGuard, PermissionsGuard } from '@gauzy/ui-core/core'; import { ImportExportComponent } from './import-export.component'; - -const externalUrlProvider = new InjectionToken('externalUrlRedirectResolver'); +import { ExternalRedirectComponent } from './external-redirect/external-redirect.component'; const routes: Routes = [ { @@ -28,24 +27,13 @@ const routes: Routes = [ }, { path: 'external-redirect', - resolve: { - url: externalUrlProvider - }, - canActivate: [externalUrlProvider] + component: ExternalRedirectComponent, + canActivate: [ExternalRedirectGuard] } ]; @NgModule({ imports: [RouterModule.forChild(routes)], - exports: [RouterModule], - providers: [ - { - provide: externalUrlProvider, - useValue: (route: ActivatedRouteSnapshot) => { - const externalUrl = route.paramMap.get('redirect'); - window.open(externalUrl, '_blank'); - } - } - ] + exports: [RouterModule] }) export class ImportExportRoutingModule {} diff --git a/apps/gauzy/src/app/pages/import-export/import-export.html b/apps/gauzy/src/app/pages/import-export/import-export.component.html similarity index 100% rename from apps/gauzy/src/app/pages/import-export/import-export.html rename to apps/gauzy/src/app/pages/import-export/import-export.component.html diff --git a/apps/gauzy/src/app/pages/import-export/import-export.component.ts b/apps/gauzy/src/app/pages/import-export/import-export.component.ts index 0957661725b..3e15258c7fb 100644 --- a/apps/gauzy/src/app/pages/import-export/import-export.component.ts +++ b/apps/gauzy/src/app/pages/import-export/import-export.component.ts @@ -21,13 +21,19 @@ import { } from '@gauzy/contracts'; import { Environment, environment } from '@gauzy/ui-config'; import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; -import { ErrorHandlingService, Store, ToastrService, UsersOrganizationsService } from '@gauzy/ui-core/core'; -import { ExportAllService, GauzyCloudService } from '@gauzy/ui-core/core'; +import { + ErrorHandlingService, + ExportAllService, + GauzyCloudService, + Store, + ToastrService, + UsersOrganizationsService +} from '@gauzy/ui-core/core'; @UntilDestroy({ checkProperties: true }) @Component({ selector: 'ngx-import-export', - templateUrl: './import-export.html', + templateUrl: './import-export.component.html', styleUrls: ['./import-export.component.scss'] }) export class ImportExportComponent extends TranslationBaseComponent implements OnInit { diff --git a/apps/gauzy/src/app/pages/import-export/import-export.module.ts b/apps/gauzy/src/app/pages/import-export/import-export.module.ts index 9db6c88b8b0..175a72b90a0 100644 --- a/apps/gauzy/src/app/pages/import-export/import-export.module.ts +++ b/apps/gauzy/src/app/pages/import-export/import-export.module.ts @@ -6,6 +6,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { DialogsModule } from '@gauzy/ui-core/shared'; import { ImportExportRoutingModule } from './import-export-routing.module'; import { ImportExportComponent } from './import-export.component'; +import { ExternalRedirectComponent } from './external-redirect/external-redirect.component'; @NgModule({ imports: [ @@ -19,7 +20,7 @@ import { ImportExportComponent } from './import-export.component'; NgxPermissionsModule.forChild(), TranslateModule.forChild() ], - declarations: [ImportExportComponent], + declarations: [ImportExportComponent, ExternalRedirectComponent], providers: [] }) export class ImportExportModule {} diff --git a/apps/gauzy/src/app/pages/income/income.component.html b/apps/gauzy/src/app/pages/income/income.component.html index d0d384efcdb..4bd2b9cdfe1 100644 --- a/apps/gauzy/src/app/pages/income/income.component.html +++ b/apps/gauzy/src/app/pages/income/income.component.html @@ -4,12 +4,13 @@ nbSpinnerSize="large" > -
    +

    {{ 'INCOME_PAGE.INCOME' | translate }}

    +
    {{ integration?.lastSyncedAt || integration?.updatedAt | dateTimeFormat }}
    - + (onSwitched)="updateIntegrationTenant(integration, $event)" + >
    ` -}) -export class GauzyAILayoutComponent implements OnInit { - ngOnInit() {} -} diff --git a/apps/gauzy/src/app/pages/integrations/gauzy-ai/gauzy-ai.module.ts b/apps/gauzy/src/app/pages/integrations/gauzy-ai/gauzy-ai.module.ts deleted file mode 100644 index e21867b54a3..00000000000 --- a/apps/gauzy/src/app/pages/integrations/gauzy-ai/gauzy-ai.module.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NgModule } from '@angular/core'; -import { - NbButtonModule, - NbCardModule, - NbIconModule, - NbInputModule, - NbTabsetModule, - NbToggleModule, - NbTooltipModule -} from '@nebular/theme'; -import { NgxPermissionsModule } from 'ngx-permissions'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule, WorkInProgressModule } from '@gauzy/ui-core/shared'; -import { GauzyAIRoutingModule } from './gauzy-ai-routing.module'; -import { GauzyAILayoutComponent } from './gauzy-ai.layout.component'; -import { GauzyAIAuthorizationComponent } from './components/authorization/authorization.component'; -import { GauzyAIViewComponent } from './components/view/view.component'; -import { IntegrationSettingCardComponent } from './components/integration-setting-card/integration-setting-card.component'; - -@NgModule({ - declarations: [ - GauzyAILayoutComponent, - GauzyAIAuthorizationComponent, - GauzyAIViewComponent, - IntegrationSettingCardComponent - ], - imports: [ - NbButtonModule, - NbCardModule, - NbIconModule, - NbInputModule, - NbTabsetModule, - NbToggleModule, - NbToggleModule, - NbTooltipModule, - NgxPermissionsModule.forChild(), - TranslateModule.forChild(), - GauzyAIRoutingModule, - WorkInProgressModule, - SharedModule - ] -}) -export class GauzyAIModule {} diff --git a/apps/gauzy/src/app/pages/integrations/github/github-routing.module.ts b/apps/gauzy/src/app/pages/integrations/github/github-routing.module.ts deleted file mode 100644 index 920869e166f..00000000000 --- a/apps/gauzy/src/app/pages/integrations/github/github-routing.module.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { IntegrationEnum, PermissionsEnum } from '@gauzy/contracts'; -import { IntegrationResolver, PermissionsGuard } from '@gauzy/ui-core/core'; -import { GithubWizardComponent } from './components/wizard/wizard.component'; -import { GithubInstallationComponent } from './components/installation/installation.component'; -import { GithubComponent } from './github.component'; -import { GithubViewComponent } from './components/view/view.component'; - -const routes: Routes = [ - { - path: '', - component: GithubComponent, - canActivate: [PermissionsGuard], - data: { - permissions: { - only: [PermissionsEnum.INTEGRATION_ADD], - redirectTo: '/pages/dashboard' - }, - integration: IntegrationEnum.GITHUB - }, - resolve: { - integration: IntegrationResolver - }, - runGuardsAndResolvers: 'always', - children: [ - { - path: ':integrationId', - component: GithubViewComponent, - data: { selectors: false } - }, - { - path: 'setup/wizard', - component: GithubWizardComponent - } - ] - }, - { - path: 'setup/wizard/reset', - component: GithubWizardComponent, - data: { - selectors: false, - redirectTo: '/pages/integrations/github/setup/wizard' - } - }, - { - path: 'setup/installation', - component: GithubInstallationComponent - } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class GithubRoutingModule {} diff --git a/apps/gauzy/src/app/pages/integrations/integrations.module.ts b/apps/gauzy/src/app/pages/integrations/integrations.module.ts index 968455b74c5..c189665a1c0 100644 --- a/apps/gauzy/src/app/pages/integrations/integrations.module.ts +++ b/apps/gauzy/src/app/pages/integrations/integrations.module.ts @@ -18,21 +18,16 @@ import { IntegrationsComponent } from './integrations.component'; import { IntegrationLayoutComponent } from './layout/layout.component'; import { IntegrationListComponent } from './components/integration-list/list.component'; -// Nebular Modules -const NB_MODULES = [ - NbButtonModule, - NbCardModule, - NbIconModule, - NbInputModule, - NbSelectModule, - NbSpinnerModule, - NbTooltipModule -]; - @NgModule({ declarations: [IntegrationLayoutComponent, IntegrationListComponent, IntegrationsComponent], imports: [ - ...NB_MODULES, + NbButtonModule, + NbCardModule, + NbIconModule, + NbInputModule, + NbSelectModule, + NbSpinnerModule, + NbTooltipModule, RouterModule.forChild([]), NgxPermissionsModule.forChild(), TranslateModule.forChild(), @@ -76,39 +71,43 @@ export class IntegrationsModule { // Register the path 'upwork' path: 'upwork', // Register the loadChildren function to load the UpworkModule lazy module - loadChildren: () => import('../upwork/upwork.module').then((m) => m.UpworkModule) + loadChildren: () => import('@gauzy/plugin-integration-upwork-ui').then((m) => m.IntegrationUpworkUiModule) }); // Register the routes for hubstaff integration this._pageRouteRegistryService.registerPageRoute({ + // Data to be passed to the component + data: { selectors: false }, // Register the location 'integrations' location: 'integrations', // Register the path 'hubstaff' path: 'hubstaff', // Register the loadChildren function to load the HubstaffModule lazy module - loadChildren: () => import('../hubstaff/hubstaff.module').then((m) => m.HubstaffModule) + loadChildren: () => import('@gauzy/plugin-integration-hubstaff-ui').then((m) => m.IntegrationHubstaffModule) }); // Register the routes for gauzy-ai integration this._pageRouteRegistryService.registerPageRoute({ + // Data to be passed to the component + data: { selectors: false }, // Register the location 'integrations' location: 'integrations', // Register the path 'gauzy-ai' path: 'gauzy-ai', - // Register the loadChildren function to load the GauzyAIModule lazy module - loadChildren: () => import('./gauzy-ai/gauzy-ai.module').then((m) => m.GauzyAIModule), - data: { selectors: false } + // Register the loadChildren function to load the IntegrationAiUiModule lazy module + loadChildren: () => import('@gauzy/plugin-integration-ai-ui').then((m) => m.IntegrationAiUiModule) }); // Register the routes for github integration this._pageRouteRegistryService.registerPageRoute({ + // Data to be passed to the component + data: { selectors: false }, // Register the location 'integrations' location: 'integrations', // Register the path 'github' path: 'github', // Register the loadChildren function to load the GithubModule lazy module - loadChildren: () => import('./github/github.module').then((m) => m.GithubModule), - data: { selectors: false } + loadChildren: () => import('@gauzy/plugin-integration-github-ui').then((m) => m.IntegrationGithubUiModule) }); // Set hasRegisteredRoutes to true diff --git a/apps/gauzy/src/app/pages/inventory/components/manage-merchants/merchant-table/merchant-table.component.ts b/apps/gauzy/src/app/pages/inventory/components/manage-merchants/merchant-table/merchant-table.component.ts index 584cc97314f..2f289040bd2 100644 --- a/apps/gauzy/src/app/pages/inventory/components/manage-merchants/merchant-table/merchant-table.component.ts +++ b/apps/gauzy/src/app/pages/inventory/components/manage-merchants/merchant-table/merchant-table.component.ts @@ -138,7 +138,7 @@ export class MerchantTableComponent extends PaginationFilterBaseComponent implem instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -149,7 +149,7 @@ export class MerchantTableComponent extends PaginationFilterBaseComponent implem code: { title: this.getTranslation('INVENTORY_PAGE.CODE'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/inventory/components/manage-product-types/product-types.component.ts b/apps/gauzy/src/app/pages/inventory/components/manage-product-types/product-types.component.ts index 56a55b6b91f..87b0bc8b0c4 100644 --- a/apps/gauzy/src/app/pages/inventory/components/manage-product-types/product-types.component.ts +++ b/apps/gauzy/src/app/pages/inventory/components/manage-product-types/product-types.component.ts @@ -142,7 +142,7 @@ export class ProductTypesComponent extends PaginationFilterBaseComponent impleme title: this.getTranslation('INVENTORY_PAGE.NAME'), type: 'string', width: '40%', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/inventory/components/manage-warehouses/warehouses-table/warehouses-table.component.ts b/apps/gauzy/src/app/pages/inventory/components/manage-warehouses/warehouses-table/warehouses-table.component.ts index 72b461c7502..faa4c995170 100644 --- a/apps/gauzy/src/app/pages/inventory/components/manage-warehouses/warehouses-table/warehouses-table.component.ts +++ b/apps/gauzy/src/app/pages/inventory/components/manage-warehouses/warehouses-table/warehouses-table.component.ts @@ -141,7 +141,7 @@ export class WarehousesTableComponent instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -152,7 +152,7 @@ export class WarehousesTableComponent email: { title: this.getTranslation('INVENTORY_PAGE.EMAIL'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/invoices/invoice-edit/invoice-edit.component.ts b/apps/gauzy/src/app/pages/invoices/invoice-edit/invoice-edit.component.ts index e7ee22c543a..2905ed19d55 100644 --- a/apps/gauzy/src/app/pages/invoices/invoice-edit/invoice-edit.component.ts +++ b/apps/gauzy/src/app/pages/invoices/invoice-edit/invoice-edit.component.ts @@ -311,9 +311,8 @@ export class InvoiceEditComponent extends PaginationFilterBaseComponent implemen component: InvoiceProductsSelectorComponent }, valuePrepareFunction: (product: IProduct) => { - return product?.name - ? `${this.translatableService.getTranslatedProperty(product, 'name')}` - : ''; + const translatedName = this.translatableService.getTranslatedProperty(product, 'name'); + return translatedName || ''; } }; break; diff --git a/apps/gauzy/src/app/pages/invoices/invoices-received/invoices-received.component.html b/apps/gauzy/src/app/pages/invoices/invoices-received/invoices-received.component.html index f5562ad0b5c..af5701b2537 100644 --- a/apps/gauzy/src/app/pages/invoices/invoices-received/invoices-received.component.html +++ b/apps/gauzy/src/app/pages/invoices/invoices-received/invoices-received.component.html @@ -1,14 +1,12 @@ -
    +

    - {{ - (isEstimate ? 'INVOICES_PAGE.RECEIVED_ESTIMATES' : 'INVOICES_PAGE.RECEIVED_INVOICES') - | translate - }} + {{ (isEstimate ? 'INVOICES_PAGE.RECEIVED_ESTIMATES' : 'INVOICES_PAGE.RECEIVED_INVOICES') | translate }}

    +
    -
    +

    {{ (isEstimate ? 'INVOICES_PAGE.ESTIMATES.HEADER' : 'INVOICES_PAGE.HEADER') | translate }}

    +
    diff --git a/apps/gauzy/src/app/pages/organizations/edit-organization/edit-organization-settings/edit-organization-other-settings/edit-organization-other-settings.component.html b/apps/gauzy/src/app/pages/organizations/edit-organization/edit-organization-settings/edit-organization-other-settings/edit-organization-other-settings.component.html index e5ef2325971..3f51944b183 100644 --- a/apps/gauzy/src/app/pages/organizations/edit-organization/edit-organization-settings/edit-organization-other-settings/edit-organization-other-settings.component.html +++ b/apps/gauzy/src/app/pages/organizations/edit-organization/edit-organization-settings/edit-organization-other-settings/edit-organization-other-settings.component.html @@ -786,7 +786,7 @@

    }}

    + +
    +
    +
    + + + + {{ hour }} + + +
    +
    +
    @@ -1515,6 +1545,15 @@

    i + 1); // Creates an array from 1 to 24 /* * Organization Mutation Form */ - public form: UntypedFormGroup = EditOrganizationOtherSettingsComponent.buildForm(this.fb); - - /* - * Organization Task Setting - */ - public taskSettingForm: UntypedFormGroup = EditOrganizationOtherSettingsComponent.buildTaskSettingForm(this.fb); - - /** - * Nebular Accordion Item Components - */ - @ViewChild('general') general: NbAccordionItemComponent; - @ViewChild('design') design: NbAccordionItemComponent; - @ViewChild('accounting') accounting: NbAccordionItemComponent; - @ViewChild('bonus') bonus: NbAccordionItemComponent; - @ViewChild('invites') invites: NbAccordionItemComponent; - @ViewChild('dateLimit') dateLimit: NbAccordionItemComponent; - @ViewChild('timer') timer: NbAccordionItemComponent; - @ViewChild('integrations') integrations: NbAccordionItemComponent; - @ViewChild('taskSetting') taskSetting: NbAccordionItemComponent; - - /** - * Nebular Accordion Main Component - */ - accordion: NbAccordionComponent; - @ViewChild('accordion') set content(content: NbAccordionComponent) { - if (content) { - this.accordion = content; - this.cdr.detectChanges(); - } - } - + public form: UntypedFormGroup = EditOrganizationOtherSettingsComponent.buildForm(this._fb); static buildForm(fb: UntypedFormBuilder): UntypedFormGroup { + const currentYear = new Date().getFullYear(); + const startOfYear = formatDate(new Date(currentYear, 0, 1), 'yyyy-MM-dd', 'en'); // January 1st + const endOfYear = formatDate(new Date(currentYear, 11, 31), 'yyyy-MM-dd', 'en'); // December 31st + return fb.group({ name: [], currency: [], @@ -141,8 +118,8 @@ export class EditOrganizationOtherSettingsComponent bonusPercentage: [], invitesAllowed: [false], inviteExpiryPeriod: [], - fiscalStartDate: [formatDate(new Date(`01/01/${new Date().getFullYear()}`), 'yyyy-MM-dd', 'en')], - fiscalEndDate: [formatDate(new Date(`12/31/${new Date().getFullYear()}`), 'yyyy-MM-dd', 'en')], + fiscalStartDate: [startOfYear], + fiscalEndDate: [endOfYear], futureDateAllowed: [false], allowManualTime: [], allowModifyTime: [], @@ -174,10 +151,40 @@ export class EditOrganizationOtherSettingsComponent randomScreenshot: [false], trackOnSleep: [false], screenshotFrequency: [10], - enforced: [false] + enforced: [false], + standardWorkHoursPerDay: [DEFAULT_STANDARD_WORK_HOURS_PER_DAY] }); } + /* + * Organization Task Setting + */ + public taskSettingForm: UntypedFormGroup = EditOrganizationOtherSettingsComponent.buildTaskSettingForm(this._fb); + + /** + * Nebular Accordion Item Components + */ + @ViewChild('general') general: NbAccordionItemComponent; + @ViewChild('design') design: NbAccordionItemComponent; + @ViewChild('accounting') accounting: NbAccordionItemComponent; + @ViewChild('bonus') bonus: NbAccordionItemComponent; + @ViewChild('invites') invites: NbAccordionItemComponent; + @ViewChild('dateLimit') dateLimit: NbAccordionItemComponent; + @ViewChild('timer') timer: NbAccordionItemComponent; + @ViewChild('integrations') integrations: NbAccordionItemComponent; + @ViewChild('taskSetting') taskSetting: NbAccordionItemComponent; + + /** + * Nebular Accordion Main Component + */ + accordion: NbAccordionComponent; + @ViewChild('accordion') set content(content: NbAccordionComponent) { + if (content) { + this.accordion = content; + this._cdr.detectChanges(); + } + } + static buildTaskSettingForm(fb: UntypedFormBuilder): UntypedFormGroup { return fb.group({ isTasksPrivacyEnabled: [], @@ -203,25 +210,32 @@ export class EditOrganizationOtherSettingsComponent }); } + /** + * Check if the form is enforced. + */ + public get isEnforced(): boolean { + return this.form.get('enforced').value; + } + constructor( - private readonly route: ActivatedRoute, - private readonly router: Router, - private readonly fb: UntypedFormBuilder, - private readonly cdr: ChangeDetectorRef, - private readonly organizationService: OrganizationsService, - private readonly organizationTaskSettingService: OrganizationTaskSettingService, - private readonly toastrService: ToastrService, - private readonly organizationEditStore: OrganizationEditStore, public readonly translateService: TranslateService, - private readonly store: Store, - private readonly accountingTemplateService: AccountingTemplateService, - public readonly themeService: NbThemeService + public readonly themeService: NbThemeService, + private readonly _route: ActivatedRoute, + private readonly _router: Router, + private readonly _fb: UntypedFormBuilder, + private readonly _cdr: ChangeDetectorRef, + private readonly _organizationService: OrganizationsService, + private readonly _organizationTaskSettingService: OrganizationTaskSettingService, + private readonly _toastrService: ToastrService, + private readonly _organizationEditStore: OrganizationEditStore, + private readonly _store: Store, + private readonly _accountingTemplateService: AccountingTemplateService ) { super(themeService, translateService); } ngOnInit(): void { - this.route.parent.data + this._route.parent.data .pipe( debounceTime(100), filter((data) => { @@ -368,31 +382,42 @@ export class EditOrganizationOtherSettingsComponent .subscribe(); } - dateFormatPreview(format: string) { - if (format) { - return moment() - .locale(this.regionCode || RegionsEnum.EN) - .format(format); - } + /** + * Returns a preview of the current date based on the specified date format + * and the regionCode locale (defaults to English if none is provided). + * + * @param format - A string representing the desired date format (e.g., 'YYYY-MM-DD') + * @returns A formatted date string based on the specified format + */ + dateFormatPreview(format: string): string | undefined { + if (!format) return; + + const locale = this.regionCode || RegionsEnum.EN; + return moment().locale(locale).format(format); } - numberFormatPreview(format: string) { + /** + * Returns a preview of a formatted number (e.g., 12345.67) based on the specified currency format. + * The function selects the appropriate locale based on the provided currency and formats the number. + * + * @param format - A string representing the desired currency format (e.g., 'USD', 'BGN', 'ILS') + * @returns A formatted number string in the specified currency + */ + numberFormatPreview(format: string): string { const number = 12345.67; - let code: string; - switch (format) { - case CurrenciesEnum.BGN: - code = 'bg'; - break; - case CurrenciesEnum.USD: - code = 'en'; - break; - case CurrenciesEnum.ILS: - code = 'he'; - break; - } - return number.toLocaleString(`${code}`, { + const currencyLocaleMap = { + [CurrenciesEnum.BGN]: 'bg', // Bulgarian + [CurrenciesEnum.USD]: 'en', // US English + [CurrenciesEnum.ILS]: 'he' // Hebrew (Israel) + }; + + // Get the locale code based on the provided currency format + const locale = currencyLocaleMap[format] || 'en'; // Default to 'en' if no match + + // Format the number using the selected locale + return number.toLocaleString(locale, { style: 'currency', - currency: `${format}`, + currency: format, currencyDisplay: 'symbol' }); } @@ -400,33 +425,41 @@ export class EditOrganizationOtherSettingsComponent /** * Update organization settings */ - async updateOrganizationSettings() { + async updateOrganizationSettings(): Promise { + // Validate the form and check if organization exists if (this.form.invalid || !this.organization) { return; } + // Extract organization ID and update organization settings + const { id: organizationId, name } = this.organization; + try { - const { id: organizationId } = this.organization; - const organization: IOrganization = await this.organizationService.update(organizationId, this.form.value); - this.organizationEditStore.organizationAction = { + const organization: IOrganization = await this._organizationService.update(organizationId, this.form.value); + + // Update the organization in the store + this._organizationEditStore.organizationAction = { organization, action: CrudActionEnum.UPDATED }; - this.store.selectedOrganization = organization; + this._store.selectedOrganization = organization; } catch (error) { - console.log('Error while updating organization settings', error); + console.error('Error while updating organization settings', error); + return; // Exit early if an error occurs } // Update organization task settings this.updateOrganizationTaskSetting(); + // Save selected templates await this.saveTemplate(this.selectedInvoiceTemplate); await this.saveTemplate(this.selectedEstimateTemplate); await this.saveTemplate(this.selectedReceiptTemplate); - this.toastrService.success(`TOASTR.MESSAGE.ORGANIZATION_SETTINGS_UPDATED`, { - name: this.organization.name - }); + // Show success message + this._toastrService.success(`TOASTR.MESSAGE.ORGANIZATION_SETTINGS_UPDATED`, { name }); + + // Navigate back this.goBack(); } @@ -446,7 +479,8 @@ export class EditOrganizationOtherSettingsComponent // Extract organization information from the current organization. const { id: organizationId, tenantId } = this.organization; - let input: IOrganizationTaskSetting = { + // Prepare the task setting input. + const input: IOrganizationTaskSetting = { ...this.taskSettingForm.value, organizationId, tenantId @@ -454,42 +488,66 @@ export class EditOrganizationOtherSettingsComponent // Determine the service method based on the existence of organizationTaskSetting. const method$ = this.organizationTaskSetting - ? this.organizationTaskSettingService.update(this.organizationTaskSetting.id, input) - : this.organizationTaskSettingService.create(input); + ? this._organizationTaskSettingService.update(this.organizationTaskSetting.id, input) + : this._organizationTaskSettingService.create(input); // Perform the create or update operation and subscribe to the result. return method$.subscribe({ - // Handle errors during the create or update operation. + next: () => { + // You can add success logic here if needed, like displaying a success message. + }, error: () => { // Display a toastr error message if the operation fails. - this.toastrService.error(`TOASTR.MESSAGE.ORGANIZATION_TASK_SETTINGS_UPDATE_ERROR`); + this._toastrService.error(`TOASTR.MESSAGE.ORGANIZATION_TASK_SETTINGS_UPDATE_ERROR`); } }); } - goBack() { - this.router.navigate([`/pages/organizations/edit/${this.organization.id}`]); + goBack(): void { + if (this.organization && this.organization.id) { + this._router.navigate([`/pages/organizations/edit/${this.organization.id}`]); + } else { + // Handle the case where the organization ID is not available + console.warn('Organization ID is not available for navigation.'); + } } - onChangedBonusPercentage(bonusType: BonusTypeEnum) { - const bonusPercentageControl = this.form.get('bonusPercentage'); + /** + * Helper function to get the default bonus based on the bonus type. + * + * @param bonusType - The type of bonus to determine the default value for. + * @returns The default bonus percentage based on the bonus type. + */ + private getDefaultBonus(bonusType: BonusTypeEnum): number { + switch (bonusType) { + case BonusTypeEnum.PROFIT_BASED_BONUS: + return DEFAULT_PROFIT_BASED_BONUS; + case BonusTypeEnum.REVENUE_BASED_BONUS: + return DEFAULT_REVENUE_BASED_BONUS; + default: + return 0; + } + } + + /** + * Handles changes to the bonus type and updates the bonus percentage control accordingly. + * + * @param bonusType - The selected bonus type, which determines the default bonus percentage and validation rules. + */ + onChangedBonusPercentage(bonusType: BonusTypeEnum): void { + const bonusPercentageControl = this.form.get('bonusPercentage') as FormControl; + if (bonusType) { + const defaultBonus = this.getDefaultBonus(bonusType); bonusPercentageControl.setValidators([Validators.required, Validators.min(0), Validators.max(100)]); - switch (bonusType) { - case BonusTypeEnum.PROFIT_BASED_BONUS: - bonusPercentageControl.setValue(this.organization.bonusPercentage || DEFAULT_PROFIT_BASED_BONUS); - bonusPercentageControl.enable(); - break; - case BonusTypeEnum.REVENUE_BASED_BONUS: - bonusPercentageControl.setValue(this.organization.bonusPercentage || DEFAULT_REVENUE_BASED_BONUS); - bonusPercentageControl.enable(); - break; - } + bonusPercentageControl.setValue(this.organization.bonusPercentage || defaultBonus); + bonusPercentageControl.enable(); } else { bonusPercentageControl.setValidators(null); bonusPercentageControl.setValue(null); bonusPercentageControl.disable(); } + bonusPercentageControl.updateValueAndValidity(); } @@ -497,11 +555,10 @@ export class EditOrganizationOtherSettingsComponent * Invite expire toggle switch * Enabled/Disabled InviteExpiryPeriod form control * - * @param inviteExpiry - * @returns + * @param inviteExpiry - Determines whether the invite expiry feature is enabled or disabled. */ - toggleInviteExpiryPeriod(inviteExpiry: boolean) { - const inviteExpiryPeriodControl = this.form.get('inviteExpiryPeriod'); + toggleInviteExpiryPeriod(inviteExpiry: boolean): void { + const inviteExpiryPeriodControl = this.form.get('inviteExpiryPeriod') as FormControl; const { inviteExpiryPeriod } = this.organization; if (inviteExpiry) { @@ -511,6 +568,7 @@ export class EditOrganizationOtherSettingsComponent inviteExpiryPeriodControl.disable(); inviteExpiryPeriodControl.setValidators(null); } + inviteExpiryPeriodControl.setValue(inviteExpiryPeriod || DEFAULT_INVITE_EXPIRY_PERIOD); inviteExpiryPeriodControl.updateValueAndValidity(); } @@ -603,15 +661,21 @@ export class EditOrganizationOtherSettingsComponent taskAutoArchivePeriodControl.updateValueAndValidity(); } - private async _getTemplates() { + /** + * Retrieves the accounting templates for the current organization and categorizes them + * into invoice, estimate, and receipt templates. + * + * @returns A Promise that resolves when the templates are successfully retrieved and categorized. + */ + private async _getTemplates(): Promise { if (!this.organization) { return; } - const { tenantId } = this.store.user; - const { id: organizationId } = this.organization; - const { items = [] } = await this.accountingTemplateService.getAll([], { - languageCode: this.store.preferredLanguage, + const { id: organizationId, tenantId } = this.organization; + + const { items = [] } = await this._accountingTemplateService.getAll([], { + languageCode: this._store.preferredLanguage, organizationId, tenantId }); @@ -628,15 +692,26 @@ export class EditOrganizationOtherSettingsComponent this.receiptTemplates.push(template); break; default: + // Ignore templates that don't match predefined types break; } }); } - async selectTemplate(event) { - const template = await this.accountingTemplateService.getById(event); + /** + * Selects a specific template based on the event (template ID) and assigns + * it to the correct template type (INVOICE, ESTIMATE, or RECEIPT). + * + * @param event - The ID of the template selected by the user. + */ + async selectTemplate(event: string): Promise { + const template = await this._accountingTemplateService.getById(event); + + // Attach organization details to the template template['organization'] = this.organization; template['organizationId'] = this.organization.id; + + // Assign the template based on its type switch (template.templateType) { case AccountingTemplateTypeEnum.INVOICE: this.selectedInvoiceTemplate = template; @@ -648,13 +723,19 @@ export class EditOrganizationOtherSettingsComponent this.selectedReceiptTemplate = template; break; default: + // Handle unknown template types if needed break; } } - async saveTemplate(template: IAccountingTemplate) { + /** + * Saves an updated accounting template by calling the accounting template service. + * + * @param template - The accounting template to be saved. + */ + async saveTemplate(template: IAccountingTemplate): Promise { if (template) { - await this.accountingTemplateService.updateTemplate(template.id, template); + await this._accountingTemplateService.updateTemplate(template.id, template); } } @@ -669,120 +750,60 @@ export class EditOrganizationOtherSettingsComponent if (!this.organization) { return; } - this.organizationEditStore.selectedOrganization = this.organization; + this._organizationEditStore.selectedOrganization = this.organization; this._setDefaultAccountingTemplates(); this.form.patchValue({ - name: this.organization.name, - currency: this.organization.currency, - defaultValueDateType: this.organization.defaultValueDateType, - regionCode: this.organization.regionCode, - defaultAlignmentType: this.organization.defaultAlignmentType, - brandColor: this.organization.brandColor, - dateFormat: this.organization.dateFormat, - timeZone: this.organization.timeZone, - startWeekOn: this.organization.startWeekOn, - defaultStartTime: this.organization.defaultStartTime, - defaultEndTime: this.organization.defaultEndTime, - numberFormat: this.organization.numberFormat, - bonusType: this.organization.bonusType, - bonusPercentage: this.organization.bonusPercentage, - invitesAllowed: this.organization.invitesAllowed, - fiscalStartDate: this.organization.fiscalStartDate, - fiscalEndDate: this.organization.fiscalEndDate, - futureDateAllowed: this.organization.futureDateAllowed, - allowManualTime: this.organization.allowManualTime, - allowModifyTime: this.organization.allowModifyTime, - allowDeleteTime: this.organization.allowDeleteTime, - allowTrackInactivity: this.organization.allowTrackInactivity, - inactivityTimeLimit: this.organization.inactivityTimeLimit, - activityProofDuration: this.organization.activityProofDuration, - requireReason: this.organization.requireReason, - requireDescription: this.organization.requireDescription, - requireProject: this.organization.requireProject, - requireTask: this.organization.requireTask, - requireClient: this.organization.requireClient, - timeFormat: this.organization.timeFormat, - separateInvoiceItemTaxAndDiscount: this.organization.separateInvoiceItemTaxAndDiscount, - defaultInvoiceEstimateTerms: this.organization.defaultInvoiceEstimateTerms, - fiscalInformation: this.organization.fiscalInformation, - currencyPosition: this.organization.currencyPosition, - discountAfterTax: this.organization.discountAfterTax, - convertAcceptedEstimates: this.organization.convertAcceptedEstimates, - daysUntilDue: this.organization.daysUntilDue, - isDefault: this.organization.isDefault, - isRemoveIdleTime: this.organization.isRemoveIdleTime, - allowScreenshotCapture: this.organization.allowScreenshotCapture, - upworkOrganizationId: this.organization.upworkOrganizationId, - upworkOrganizationName: this.organization.upworkOrganizationName, - randomScreenshot: this.organization.randomScreenshot, - trackOnSleep: this.organization.trackOnSleep, - screenshotFrequency: this.organization.screenshotFrequency, - enforced: this.organization.enforced + ...this.organization, // This will patch all matching form controls + fiscalStartDate: this.organization.fiscalStartDate, // Apply specific formatting/transformation if needed + fiscalEndDate: this.organization.fiscalEndDate // Apply specific formatting/transformation if needed }); this.form.updateValueAndValidity(); + const { + isTasksPrivacyEnabled = false, + isTasksMultipleAssigneesEnabled = false, + isTasksManualTimeEnabled = false, + isTasksGroupEstimationEnabled = false, + isTasksEstimationInHoursEnabled = false, + isTasksEstimationInStoryPointsEnabled = false, + isTasksProofOfCompletionEnabled = false, + tasksProofOfCompletionType = DEFAULT_PROOF_COMPLETION_TYPE, + isTasksLinkedEnabled = false, + isTasksCommentsEnabled = false, + isTasksHistoryEnabled = false, + isTasksAcceptanceCriteriaEnabled = false, + isTasksDraftsEnabled = false, + isTasksNotifyLeftEnabled = false, + tasksNotifyLeftPeriodDays = DEFAULT_TASK_NOTIFY_PERIOD, + isTasksAutoCloseEnabled = false, + tasksAutoClosePeriodDays = DEFAULT_AUTO_CLOSE_ISSUE_PERIOD, + isTasksAutoArchiveEnabled = false, + tasksAutoArchivePeriodDays = DEFAULT_AUTO_ARCHIVE_ISSUE_PERIOD, + isTasksAutoStatusEnabled = false + } = this.organizationTaskSetting || {}; + this.taskSettingForm.patchValue({ - isTasksPrivacyEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksPrivacyEnabled - : false, - isTasksMultipleAssigneesEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksMultipleAssigneesEnabled - : false, - isTasksManualTimeEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksManualTimeEnabled - : false, - isTasksGroupEstimationEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksGroupEstimationEnabled - : false, - isTasksEstimationInHoursEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksEstimationInHoursEnabled - : false, - isTasksEstimationInStoryPointsEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksEstimationInStoryPointsEnabled - : false, - isTasksProofOfCompletionEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksProofOfCompletionEnabled - : false, - tasksProofOfCompletionType: this.organizationTaskSetting - ? this.organizationTaskSetting.tasksProofOfCompletionType - : DEFAULT_PROOF_COMPLETION_TYPE, - isTasksLinkedEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksLinkedEnabled - : false, - isTasksCommentsEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksCommentsEnabled - : false, - isTasksHistoryEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksHistoryEnabled - : false, - isTasksAcceptanceCriteriaEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksAcceptanceCriteriaEnabled - : false, - isTasksDraftsEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksDraftsEnabled - : false, - isTasksNotifyLeftEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksNotifyLeftEnabled - : false, - tasksNotifyLeftPeriodDays: this.organizationTaskSetting - ? this.organizationTaskSetting.tasksNotifyLeftPeriodDays - : DEFAULT_TASK_NOTIFY_PERIOD, - isTasksAutoCloseEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksAutoCloseEnabled - : false, - tasksAutoClosePeriodDays: this.organizationTaskSetting - ? this.organizationTaskSetting.tasksAutoClosePeriodDays - : DEFAULT_AUTO_CLOSE_ISSUE_PERIOD, - isTasksAutoArchiveEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksAutoArchiveEnabled - : false, - tasksAutoArchivePeriodDays: this.organizationTaskSetting - ? this.organizationTaskSetting.tasksAutoArchivePeriodDays - : DEFAULT_AUTO_ARCHIVE_ISSUE_PERIOD, - isTasksAutoStatusEnabled: this.organizationTaskSetting - ? this.organizationTaskSetting.isTasksAutoStatusEnabled - : false + isTasksPrivacyEnabled, + isTasksMultipleAssigneesEnabled, + isTasksManualTimeEnabled, + isTasksGroupEstimationEnabled, + isTasksEstimationInHoursEnabled, + isTasksEstimationInStoryPointsEnabled, + isTasksProofOfCompletionEnabled, + tasksProofOfCompletionType, + isTasksLinkedEnabled, + isTasksCommentsEnabled, + isTasksHistoryEnabled, + isTasksAcceptanceCriteriaEnabled, + isTasksDraftsEnabled, + isTasksNotifyLeftEnabled, + tasksNotifyLeftPeriodDays, + isTasksAutoCloseEnabled, + tasksAutoClosePeriodDays, + isTasksAutoArchiveEnabled, + tasksAutoArchivePeriodDays, + isTasksAutoStatusEnabled }); this.taskSettingForm.updateValueAndValidity(); @@ -831,9 +852,5 @@ export class EditOrganizationOtherSettingsComponent } } - public get isEnforced(): boolean { - return this.form.get('enforced').value; - } - ngOnDestroy(): void {} } diff --git a/apps/gauzy/src/app/pages/pages.component.ts b/apps/gauzy/src/app/pages/pages.component.ts index cc12f8a93ca..b8c2facdba0 100644 --- a/apps/gauzy/src/app/pages/pages.component.ts +++ b/apps/gauzy/src/app/pages/pages.component.ts @@ -2,12 +2,10 @@ import { AfterViewInit, Component, OnDestroy, OnInit } from '@angular/core'; import { ActivatedRoute, Data, Router } from '@angular/router'; import { NbMenuItem } from '@nebular/theme'; import { TranslateService } from '@ngx-translate/core'; -import { merge, pairwise } from 'rxjs'; -import { filter, map, take, tap } from 'rxjs/operators'; +import { filter, map, merge, pairwise, take, tap } from 'rxjs'; import { NgxPermissionsService } from 'ngx-permissions'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { chain } from 'underscore'; -import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; +import { FeatureEnum, IOrganization, IRolePermission, IUser, IntegrationEnum, PermissionsEnum } from '@gauzy/contracts'; import { AuthStrategy, IJobMatchingEntity, @@ -19,8 +17,8 @@ import { Store, UsersService } from '@gauzy/ui-core/core'; -import { FeatureEnum, IOrganization, IRolePermission, IUser, IntegrationEnum, PermissionsEnum } from '@gauzy/contracts'; import { distinctUntilChange, isNotEmpty } from '@gauzy/ui-core/common'; +import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; import { ReportService } from './reports/all-report/report.service'; @UntilDestroy({ checkProperties: true }) @@ -80,15 +78,9 @@ export class PagesComponent extends TranslationBaseComponent implements AfterVie filter((organization: IOrganization) => !!organization), distinctUntilChange(), pairwise(), // Pair each emitted value with the previous one - tap(([organization]: [IOrganization, IOrganization]) => { - const { id: organizationId, tenantId } = organization; - + tap(([previousOrganization]: [IOrganization, IOrganization]) => { // Remove the specified menu items for previous selected organization - this._navMenuBuilderService.removeNavMenuItems( - // Define the base item IDs - this.getReportMenuBaseItemIds().map((itemId) => `${itemId}-${organizationId}-${tenantId}`), - 'reports' - ); + this.removeOrganizationReportsMenuItems(previousOrganization); }), untilDestroyed(this) ) @@ -118,25 +110,21 @@ export class PagesComponent extends TranslationBaseComponent implements AfterVie .subscribe(); this.reportService.menuItems$.pipe(distinctUntilChange(), untilDestroyed(this)).subscribe((menuItems) => { - if (menuItems) { - this.reportMenuItems = chain(menuItems) - .values() - .map((item) => { - return { - id: item.slug + `-${this.organization?.id}`, - title: item.name, - link: `/pages/reports/${item.slug}`, - icon: item.iconClass, - data: { - translationKey: `${item.name}` - } - }; - }) - .value(); - } else { - this.reportMenuItems = []; - } - this.addOrganizationReportsMenuItems(); + // Convert the menuItems object to an array + const reportItems = menuItems ? Object.values(menuItems) : []; + + this.reportMenuItems = reportItems.map((item) => ({ + id: item.slug, + title: item.name, + link: `/pages/reports/${item.slug}`, + icon: item.iconClass, + data: { + translationKey: item.name + } + })); + + // Add the report menu items to the navigation menu + this.addOrRemoveOrganizationReportsMenuItems(); }); } @@ -176,49 +164,62 @@ export class PagesComponent extends TranslationBaseComponent implements AfterVie } /** - * Adds report menu items to the organization's navigation menu. + * Removes the report menu items associated with the current organization. + * + * This function checks if the organization is defined. If not, it logs a warning and exits early. + * If the organization is defined, it constructs item IDs based on the organization and tenant ID + * and removes these items from the navigation menu. + * + * @returns {void} This function does not return a value. */ - private addOrganizationReportsMenuItems() { - if (!this.organization) { - // Handle the case where this.organization is not defined - console.warn('Organization not defined. Unable to add/remove menu items.'); + private removeOrganizationReportsMenuItems(organization: IOrganization): void { + // Return early if the organization is not defined, logging a warning + if (!organization) { + console.warn(`Organization not defined. Unable to remove menu items.`); return; } - const { id: organizationId, tenantId } = this.organization; - // Remove the specified menu items for current selected organization - // Note: We need to remove old menus before constructing new menus for the organization. - this._navMenuBuilderService.removeNavMenuItems( - // Define the base item IDs - this.getReportMenuBaseItemIds().map((itemId) => `${itemId}-${organizationId}-${tenantId}`), - 'reports' + // Destructure organization properties + const { id: organizationId, tenantId } = organization; + + // Generate the item IDs to remove and call the service method + const itemIdsToRemove = this.getReportMenuBaseItemIds().map( + (itemId) => `${itemId}-${organizationId}-${tenantId}` ); - // Validate if reportMenuItems is an array and has elements - if (!Array.isArray(this.reportMenuItems) || this.reportMenuItems.length === 0) { + this._navMenuBuilderService.removeNavMenuItems(itemIdsToRemove, 'reports'); + } + + /** + * Adds report menu items to the organization's navigation menu. + */ + private addOrRemoveOrganizationReportsMenuItems() { + if (!this.organization) { + console.warn('Organization not defined. Unable to add/remove menu items.'); return; } + const { id: organizationId, tenantId } = this.organization; + + // Remove old menu items before constructing new ones for the organization + this.removeOrganizationReportsMenuItems(this.organization); + // Iterate over each report and add it to the navigation menu - try { - this.reportMenuItems.forEach((report: NavMenuSectionItem) => { - // Validate the structure of each report item - if (report && report.id && report.title) { - this._navMenuBuilderService.addNavMenuItem( - { - id: report.id, // Unique identifier for the menu item - title: report.title, // The title of the menu item - icon: report.icon, // The icon class for the menu item, using FontAwesome in this case - link: report.link, // The link where the menu item directs - data: report.data - }, - 'reports' - ); // The id of the section where this item should be added - } - }); - } catch (error) { - console.error('Error adding report menu items', error); - } + this.reportMenuItems.forEach((report: NavMenuSectionItem) => { + // Validate the structure of each report item + if (report?.id && report?.title) { + this._navMenuBuilderService.addNavMenuItem( + { + id: `${report.id}-${organizationId}-${tenantId}`, // Unique identifier for the menu item + title: report.title, // The title of the menu item + icon: report.icon, // The icon class for the menu item + link: report.link, // The link where the menu item directs + data: report.data // The data associated with the menu item + }, + 'reports' // The id of the section where this item should be added + ); + } + }); } /** @@ -402,5 +403,8 @@ export class PagesComponent extends TranslationBaseComponent implements AfterVie this.store.featureTenant = tenant.featureOrganizations.filter((item) => !item.organizationId); } - ngOnDestroy() {} + ngOnDestroy() { + // Remove the report menu items associated with the current organization before destroying the component + this.removeOrganizationReportsMenuItems(this.organization); + } } diff --git a/apps/gauzy/src/app/pages/payments/payments.component.ts b/apps/gauzy/src/app/pages/payments/payments.component.ts index 96bbce6f911..9807dd5e563 100644 --- a/apps/gauzy/src/app/pages/payments/payments.component.ts +++ b/apps/gauzy/src/app/pages/payments/payments.component.ts @@ -528,7 +528,7 @@ export class PaymentsComponent extends PaginationFilterBaseComponent implements title: this.getTranslation('PAYMENTS_PAGE.PAYMENT_METHOD'), type: 'text', width: '10%', - isFilterable: { + filter: { type: 'custom', component: PaymentMethodFilterComponent }, @@ -547,7 +547,7 @@ export class PaymentsComponent extends PaginationFilterBaseComponent implements title: this.getTranslation('PAYMENTS_PAGE.NOTE'), type: 'text', width: '10%', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -562,7 +562,7 @@ export class PaymentsComponent extends PaginationFilterBaseComponent implements componentInitFunction: (instance: ContactLinksComponent, cell: Cell) => { instance.value = cell.getRawValue(); }, - isFilterable: { + filter: { type: 'custom', component: OrganizationContactFilterComponent }, @@ -597,7 +597,7 @@ export class PaymentsComponent extends PaginationFilterBaseComponent implements instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: TagsColorFilterComponent }, diff --git a/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts b/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts index db71f8ade2f..3fd9e42c4ea 100644 --- a/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts +++ b/apps/gauzy/src/app/pages/pipelines/pipelines.component.ts @@ -232,7 +232,7 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements type: 'string', title: this.getTranslation('SM_TABLE.NAME'), width: '30%', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -244,7 +244,7 @@ export class PipelinesComponent extends PaginationFilterBaseComponent implements type: 'string', title: this.getTranslation('SM_TABLE.DESCRIPTION'), width: '30%', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts b/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts index ceeeebc9e9b..43766c65d56 100644 --- a/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts +++ b/apps/gauzy/src/app/pages/projects/components/project-list/list.component.ts @@ -15,7 +15,8 @@ import { ITag, IOrganizationProject, ID, - IOrganizationProjectEmployee + IOrganizationProjectEmployee, + IEmployee } from '@gauzy/contracts'; import { API_PREFIX, ComponentEnum, distinctUntilChange } from '@gauzy/ui-core/common'; import { @@ -32,6 +33,7 @@ import { DateViewComponent, DeleteConfirmationComponent, EmployeesMergedTeamsComponent, + EmployeeWithLinksComponent, PaginationFilterBaseComponent, ProjectOrganizationComponent, ProjectOrganizationEmployeesComponent, @@ -247,9 +249,8 @@ export class ProjectListComponent extends PaginationFilterBaseComponent implemen resultMap: (project: IOrganizationProject) => { return Object.assign({}, project, { ...this.privatePublicProjectMapper(project), - employeesMergedTeams: [ - project.members.map((member: IOrganizationProjectEmployee) => member.employee) - ] + managers: this.getProjectManagers(project), + employeesMergedTeams: this.getNonManagerEmployees(project) }); }, finalize: () => { @@ -265,6 +266,32 @@ export class ProjectListComponent extends PaginationFilterBaseComponent implemen }); } + /** + * Retrieves the project managers from the list of members. + * + * @param project - The project containing members. + * @returns A list of manager employees. + */ + getProjectManagers(project: IOrganizationProject): IEmployee[] { + return project.members + .filter((member: IOrganizationProjectEmployee) => member.isManager) + .map((member: IOrganizationProjectEmployee) => member.employee); + } + + /** + * Retrieves the non-manager employees from the list of members. + * + * @param project - The project containing members. + * @returns A list of non-manager employees as merged teams. + */ + getNonManagerEmployees(project: IOrganizationProject): IEmployee[][] { + return [ + project.members + .filter((member: IOrganizationProjectEmployee) => !member.isManager) + .map((member: IOrganizationProjectEmployee) => member.employee) + ]; + } + /** * Checks if the current data layout style is grid card layout. * @returns `true` if the layout is grid card, `false` otherwise. @@ -417,6 +444,17 @@ export class ProjectListComponent extends PaginationFilterBaseComponent implemen instance.value = cell.getValue(); } }, + managers: { + title: this.getTranslation('ORGANIZATIONS_PAGE.EDIT.TEAMS_PAGE.MANAGERS'), + type: 'custom', + isFilterable: false, + renderComponent: EmployeeWithLinksComponent, + componentInitFunction: (instance: EmployeeWithLinksComponent, cell: Cell) => { + instance.rowData = cell.getRow().getData(); + instance.value = cell.getRawValue(); + } + }, + employeesMergedTeams: { title: this.getTranslation('ORGANIZATIONS_PAGE.EDIT.MEMBERS'), type: 'custom', @@ -435,7 +473,7 @@ export class ProjectListComponent extends PaginationFilterBaseComponent implemen instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: TagsColorFilterComponent }, diff --git a/apps/gauzy/src/app/pages/reports/amounts-owed-report/amounts-owed-report-routing.module.ts b/apps/gauzy/src/app/pages/reports/amounts-owed-report/amounts-owed-report-routing.module.ts index 7af94d4a39d..719cfd8f8dd 100644 --- a/apps/gauzy/src/app/pages/reports/amounts-owed-report/amounts-owed-report-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/amounts-owed-report/amounts-owed-report-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { AmountsOwedReportComponent } from './amounts-owed-report/amounts-owed-report.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/apps-urls-report/apps-urls-report-routing.module.ts b/apps/gauzy/src/app/pages/reports/apps-urls-report/apps-urls-report-routing.module.ts index dfb8800d79e..3771886456d 100644 --- a/apps/gauzy/src/app/pages/reports/apps-urls-report/apps-urls-report-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/apps-urls-report/apps-urls-report-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { AppsUrlsReportComponent } from './apps-urls-report/apps-urls-report.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report-routing.module.ts b/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report-routing.module.ts index 6c7ff57d5a2..d1c7331c622 100644 --- a/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { ClientBudgetsReportComponent } from './client-budgets-report/client-budgets-report.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report/client-budgets-report.component.ts b/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report/client-budgets-report.component.ts index a048429dda5..34fc9fc8580 100644 --- a/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report/client-budgets-report.component.ts +++ b/apps/gauzy/src/app/pages/reports/client-budgets-report/client-budgets-report/client-budgets-report.component.ts @@ -108,9 +108,9 @@ export class ClientBudgetsReportComponent extends BaseSelectorFilterComponent im } /** - * retrieves client budget reports, updates the 'clients' property, + * Asynchronously retrieves client budget reports and updates the 'clients' property. * - * @returns + * @returns {Promise} */ async getClientBudgetReport(): Promise { // Check if organization or request is not provided, resolve the Promise without further action @@ -118,7 +118,8 @@ export class ClientBudgetsReportComponent extends BaseSelectorFilterComponent im return; } - // Set the loading flag to true + // Clear previous client data and set the loading flag to true + this.clients = []; this.loading = true; try { @@ -129,7 +130,8 @@ export class ClientBudgetsReportComponent extends BaseSelectorFilterComponent im this.clients = await this.timesheetService.getClientBudgetLimit(payloads); } catch (error) { // Log any errors during the process - console.error('Error while retrieving client budget reports', error); + console.error('Error while retrieving client budget reports:', error); + // Optionally: this.notificationService.showError('Failed to retrieve client budget reports.'); } finally { // Set the loading flag to false, regardless of success or failure this.loading = false; diff --git a/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report-routing.module.ts b/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report-routing.module.ts index 85db03a12ad..a792f72b962 100644 --- a/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { ExpensesReportComponent } from './expenses-report/expenses-report.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report/expenses-report.component.ts b/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report/expenses-report.component.ts index 0692dbff81f..0a04c416bd7 100644 --- a/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report/expenses-report.component.ts +++ b/apps/gauzy/src/app/pages/reports/expenses-report/expenses-report/expenses-report.component.ts @@ -35,9 +35,9 @@ export class ExpensesReportComponent extends BaseSelectorFilterComponent impleme constructor( public readonly translateService: TranslateService, - private readonly expensesService: ExpensesService, - private readonly cd: ChangeDetectorRef, - private readonly timesheetFilterService: TimesheetFilterService, + private readonly _expensesService: ExpensesService, + private readonly _cd: ChangeDetectorRef, + private readonly _timesheetFilterService: TimesheetFilterService, protected readonly store: Store, protected readonly dateRangePickerBuilderService: DateRangePickerBuilderService, protected readonly timeZoneService: TimeZoneService @@ -71,7 +71,7 @@ export class ExpensesReportComponent extends BaseSelectorFilterComponent impleme } ngAfterViewInit() { - this.cd.detectChanges(); + this._cd.detectChanges(); } /** @@ -100,7 +100,7 @@ export class ExpensesReportComponent extends BaseSelectorFilterComponent impleme filterByCategory(event) { if (this.gauzyFiltersComponent.saveFilters) { // If true, set the timesheetFilterService's filter property to the provided filters - this.timesheetFilterService.filter = this.filters; + this._timesheetFilterService.filter = this.filters; } this.filters = Object.assign( {}, @@ -121,7 +121,7 @@ export class ExpensesReportComponent extends BaseSelectorFilterComponent impleme // Check if the saveFilters property of the gauzyFiltersComponent is truthy if (this.gauzyFiltersComponent.saveFilters) { // If true, set the timesheetFilterService's filter property to the provided filters - this.timesheetFilterService.filter = filters; + this._timesheetFilterService.filter = filters; } // Create a shallow copy of the filters object and assign it to the component's filters property @@ -131,46 +131,52 @@ export class ExpensesReportComponent extends BaseSelectorFilterComponent impleme this.subject$.next(true); } - async updateChart() { + /** + * Asynchronously fetch and update the expense report chart data. + * + * @returns {Promise} + */ + async updateChart(): Promise { // Check if the organization is not defined or if the request is empty if (!this.organization || isEmpty(this.request)) { return; } - // Set the loading flag to true + // Clear previous chart data and set the loading flag to true + this.charts = null; this.loading = true; try { // Get the current value of the payloads$ observable const payloads = this.payloads$.getValue(); - // Call the expensesService to fetch report chart data based on the payloads - const logs: any[] = await this.expensesService.getExpenseReportCharts(payloads); + // Fetch the expense report chart data from the expensesService + const logs: any[] = await this._expensesService.getExpenseReportCharts(payloads); - // Define a datasets array with the fetched data + // Prepare datasets for the chart using the fetched data const datasets = [ { - label: this.getTranslation('REPORT_PAGE.EXPENSE'), // Label for the dataset, likely representing expenses - data: logs.map((log) => log.value['expense']), // An array of data points representing expenses - borderColor: ChartUtil.CHART_COLORS.red, // Color of the dataset border - backgroundColor: ChartUtil.transparentize(ChartUtil.CHART_COLORS.red, 1), // Background color with transparency - borderWidth: 2, // Width of the dataset border - pointRadius: 2, // Radius of the data points - pointHoverRadius: 4, // Radius of the data points on hover - pointHoverBorderWidth: 4, // Width of the border of data points on hover - tension: 0.4, // Tension of the spline curve connecting data points - fill: false // Whether to fill the area under the line or not + label: this.getTranslation('REPORT_PAGE.EXPENSE'), // Label for the dataset, representing expenses + data: logs.map((log) => log.value['expense']), // Extract expense values from logs + borderColor: ChartUtil.CHART_COLORS.red, // Set the color of the dataset border + backgroundColor: ChartUtil.transparentize(ChartUtil.CHART_COLORS.red, 1), // Transparent background color + borderWidth: 2, // Set the dataset border width + pointRadius: 2, // Set the data point radius + pointHoverRadius: 4, // Data point radius on hover + pointHoverBorderWidth: 4, // Border width of data points on hover + tension: 0.4, // Set the curve tension between data points + fill: false // Disable filling the area under the line } ]; // Update the chartData property with the new data this.charts = { - labels: pluck(logs, 'date'), // Assuming pluck is a function to extract 'date' property from each log - datasets + labels: pluck(logs, 'date'), // Extract 'date' from each log for chart labels + datasets // Set the datasets for the chart }; } catch (error) { - // Log any errors that occur during the data retrieval process - console.log('Error while retrieving expense reports chart', error); + // Log any errors that occur during data retrieval + console.error('Error while retrieving expense reports chart:', error); } finally { // Set the loading flag to false regardless of success or failure this.loading = false; diff --git a/apps/gauzy/src/app/pages/reports/manual-time/manual-time-routing.module.ts b/apps/gauzy/src/app/pages/reports/manual-time/manual-time-routing.module.ts index e0f91e24b32..99356f820c5 100644 --- a/apps/gauzy/src/app/pages/reports/manual-time/manual-time-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/manual-time/manual-time-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { ManualTimeComponent } from './manual-time/manual-time.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/manual-time/manual-time/manual-time.component.ts b/apps/gauzy/src/app/pages/reports/manual-time/manual-time/manual-time.component.ts index d911763e493..d3284c54ae1 100644 --- a/apps/gauzy/src/app/pages/reports/manual-time/manual-time/manual-time.component.ts +++ b/apps/gauzy/src/app/pages/reports/manual-time/manual-time/manual-time.component.ts @@ -38,11 +38,11 @@ export class ManualTimeComponent extends BaseSelectorFilterComponent implements payloads$: BehaviorSubject = new BehaviorSubject(null); constructor( - private readonly cd: ChangeDetectorRef, - private readonly errorHandlingService: ErrorHandlingService, - private readonly timesheetService: TimesheetService, - private readonly timesheetFilterService: TimesheetFilterService, public readonly translateService: TranslateService, + private readonly _cd: ChangeDetectorRef, + private readonly _errorHandlingService: ErrorHandlingService, + private readonly _timesheetService: TimesheetService, + private readonly _timesheetFilterService: TimesheetFilterService, protected readonly store: Store, protected readonly dateRangePickerBuilderService: DateRangePickerBuilderService, protected readonly timeZoneService: TimeZoneService @@ -72,7 +72,7 @@ export class ManualTimeComponent extends BaseSelectorFilterComponent implements * */ ngAfterViewInit() { - this.cd.detectChanges(); + this._cd.detectChanges(); this.control.valueChanges .pipe( distinctUntilChanged(), @@ -107,7 +107,7 @@ export class ManualTimeComponent extends BaseSelectorFilterComponent implements */ filtersChange(filters: ITimeLogFilters) { if (this.gauzyFiltersComponent.saveFilters) { - this.timesheetFilterService.filter = filters; + this._timesheetFilterService.filter = filters; } this.filters = Object.assign({}, filters); this.subject$.next(true); @@ -117,15 +117,16 @@ export class ManualTimeComponent extends BaseSelectorFilterComponent implements * Asynchronously fetch manual time logs and update the component's state. * Handles loading state, API request, data processing, and errors. * - * @returns A promise resolving to an array of objects containing date and timeLogs. + * @returns {Promise} A promise resolving to an array of objects containing date and timeLogs. */ - async getManualLogs() { + async getManualLogs(): Promise { // Check if organization and request data are available if (!this.organization || isEmpty(this.request)) { return; } - // Set loading state to true + // Clear previous data and set loading state to true + this.dailyData = []; this.loading = true; try { @@ -133,13 +134,19 @@ export class ManualTimeComponent extends BaseSelectorFilterComponent implements const payloads = this.payloads$.getValue(); // Call the timesheetService to fetch time logs - const logs: ITimeLog[] = await this.timesheetService.getTimeLogs(payloads, [ + const logs: ITimeLog[] = await this._timesheetService.getTimeLogs(payloads, [ 'task', 'project', 'employee', 'employee.user' ]); + // Check if logs are empty and handle gracefully + if (logs.length === 0) { + console.log('No manual logs found for the given request.'); + return; + } + // Process the fetched logs and update the component's state this.dailyData = chain(logs) .groupBy((log: ITimeLog) => moment(log.startedAt).format('YYYY-MM-DD')) @@ -147,8 +154,8 @@ export class ManualTimeComponent extends BaseSelectorFilterComponent implements .value(); } catch (error) { // Handle any exceptions or errors during the fetch - console.log('Error fetching manual logs:', error); - this.errorHandlingService.handleError(error); + console.error('Error fetching manual logs:', error); + this._errorHandlingService.handleError(error); } finally { // Set loading state to false regardless of success or failure this.loading = false; diff --git a/apps/gauzy/src/app/pages/reports/payment-report/payment-report-routing.module.ts b/apps/gauzy/src/app/pages/reports/payment-report/payment-report-routing.module.ts index 5bf6bdc1993..064efd1f2f1 100644 --- a/apps/gauzy/src/app/pages/reports/payment-report/payment-report-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/payment-report/payment-report-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { PaymentReportComponent } from './payment-report/payment-report.component'; @@ -15,7 +16,10 @@ const routes: Routes = [ employee: false } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/payment-report/payment-report/payment-report.component.ts b/apps/gauzy/src/app/pages/reports/payment-report/payment-report/payment-report.component.ts index c1f5493fbe8..eae530f1160 100644 --- a/apps/gauzy/src/app/pages/reports/payment-report/payment-report/payment-report.component.ts +++ b/apps/gauzy/src/app/pages/reports/payment-report/payment-report/payment-report.component.ts @@ -120,9 +120,9 @@ export class PaymentReportComponent extends BaseSelectorFilterComponent implemen } /** - * Updates the chart with payment report data. + * Asynchronously updates the chart with payment report data. * - * @returns + * @returns {Promise} */ private async updateChart(): Promise { // Check if organization or request is not provided, resolve the Promise without further action @@ -130,7 +130,8 @@ export class PaymentReportComponent extends BaseSelectorFilterComponent implemen return; } - // Set the loading flag to true + // Clear previous chart data and set the loading flag to true + this.charts = null; this.loading = true; try { @@ -140,7 +141,7 @@ export class PaymentReportComponent extends BaseSelectorFilterComponent implemen // Fetch payment report chart data from the paymentService const logs: IPaymentReportChartData[] = await this.paymentService.getPaymentsReportCharts(payloads); - // Extract payment values and create a dataset + // Extract payment values and create a dataset for the chart const datasets = [ { label: this.getTranslation('REPORT_PAGE.PAYMENT'), // Label for the dataset, translated using a translation function @@ -156,14 +157,15 @@ export class PaymentReportComponent extends BaseSelectorFilterComponent implemen } ]; - // Update the chart data with the retrieved data + // Update the chart with the new data this.charts = { - labels: pluck(logs, 'date'), - datasets + labels: pluck(logs, 'date'), // Extract the dates from the logs for chart labels + datasets // Assign the datasets to the chart }; } catch (error) { // Log any errors during the process - console.error('Error while retrieving payment reports chart', error); + console.error('Error while retrieving payment reports chart:', error); + // Optionally: this.notificationService.showError('Failed to retrieve payment reports chart.'); } finally { // Set the loading flag to false, regardless of success or failure this.loading = false; diff --git a/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report-routing.module.ts b/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report-routing.module.ts index 90ef92cec37..eac2729cd37 100644 --- a/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { ProjectBudgetsReportComponent } from './project-budgets-report/project-budgets-report.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report/project-budgets-report.component.ts b/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report/project-budgets-report.component.ts index 7a09b734395..05656972e29 100644 --- a/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report/project-budgets-report.component.ts +++ b/apps/gauzy/src/app/pages/reports/project-budgets-report/project-budgets-report/project-budgets-report.component.ts @@ -106,9 +106,9 @@ export class ProjectBudgetsReportComponent extends BaseSelectorFilterComponent i } /** - * retrieves project budget reports, updates the 'projects' property, and handles loading state. + * Asynchronously retrieves project budget reports, updates the 'projects' property, and handles the loading state. * - * @returns + * @returns {Promise} */ async getProjectBudgetReport(): Promise { // Check if organization or request is not provided, resolve the Promise without further action @@ -116,7 +116,8 @@ export class ProjectBudgetsReportComponent extends BaseSelectorFilterComponent i return; } - // Set the loading flag to true + // Clear previous project data and set the loading flag to true + this.projects = []; this.loading = true; try { @@ -127,7 +128,8 @@ export class ProjectBudgetsReportComponent extends BaseSelectorFilterComponent i this.projects = await this.timesheetService.getProjectBudgetLimit(payloads); } catch (error) { // Log any errors during the process - console.error('Error while retrieving project budget chart', error); + console.error('Error while retrieving project budget chart:', error); + // Optionally: this.notificationService.showError('Failed to retrieve project budget reports.'); } finally { // Set the loading flag to false, regardless of success or failure this.loading = false; diff --git a/apps/gauzy/src/app/pages/reports/time-limit-report/time-limit-report-routing.module.ts b/apps/gauzy/src/app/pages/reports/time-limit-report/time-limit-report-routing.module.ts index 3359401d40e..00c43432e41 100644 --- a/apps/gauzy/src/app/pages/reports/time-limit-report/time-limit-report-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/time-limit-report/time-limit-report-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { TimeLimitReportComponent } from './time-limit-report/time-limit-report.component'; @@ -14,7 +15,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/time-reports/time-reports-routing.module.ts b/apps/gauzy/src/app/pages/reports/time-reports/time-reports-routing.module.ts index 7c73c9b7f68..a96a2af0d9d 100644 --- a/apps/gauzy/src/app/pages/reports/time-reports/time-reports-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/time-reports/time-reports-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { TimeReportsComponent } from './time-reports/time-reports.component'; @@ -12,7 +13,10 @@ const routes: Routes = [ unitOfTime: 'week' } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/time-reports/time-reports/time-reports.component.html b/apps/gauzy/src/app/pages/reports/time-reports/time-reports/time-reports.component.html index cc08aefa7f2..6cfb11b0f88 100644 --- a/apps/gauzy/src/app/pages/reports/time-reports/time-reports/time-reports.component.html +++ b/apps/gauzy/src/app/pages/reports/time-reports/time-reports/time-reports.component.html @@ -33,8 +33,12 @@

    - - + +
    diff --git a/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports-routing.module.ts b/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports-routing.module.ts index 0fdbb4fad34..9539ae10428 100644 --- a/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports-routing.module.ts +++ b/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { BookmarkQueryParamsResolver } from '@gauzy/ui-core/core'; import { DateRangePickerResolver } from '@gauzy/ui-core/shared'; import { WeeklyTimeReportsComponent } from './weekly-time-reports/weekly-time-reports.component'; @@ -13,7 +14,10 @@ const routes: Routes = [ isLockDatePicker: true } }, - resolve: { dates: DateRangePickerResolver } + resolve: { + dates: DateRangePickerResolver, + bookmarkParams: BookmarkQueryParamsResolver + } } ]; diff --git a/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.html b/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.html index 8292931783c..62f0dc58cff 100644 --- a/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.html +++ b/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.html @@ -32,8 +32,12 @@

    - - + +
    diff --git a/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.ts b/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.ts index 174bf420446..409f5344d54 100644 --- a/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.ts +++ b/apps/gauzy/src/app/pages/reports/weekly-time-reports/weekly-time-reports/weekly-time-reports.component.ts @@ -5,6 +5,7 @@ import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslateService } from '@ngx-translate/core'; import { pluck, pick } from 'underscore'; import * as randomColor from 'randomcolor'; +import { IGetTimeLogReportInput, ITimeLogFilters, ReportDayData } from '@gauzy/contracts'; import { DateRangePickerBuilderService, moment, @@ -12,7 +13,6 @@ import { TimesheetFilterService, TimesheetService } from '@gauzy/ui-core/core'; -import { IGetTimeLogReportInput, ITimeLogFilters, ReportDayData } from '@gauzy/contracts'; import { distinctUntilChange, isEmpty, progressStatus } from '@gauzy/ui-core/common'; import { BaseSelectorFilterComponent, @@ -143,7 +143,7 @@ export class WeeklyTimeReportsComponent extends BaseSelectorFilterComponent impl /** * Asynchronously retrieves weekly time logs reports, processes the data, and updates the class properties. * - * @returns + * @returns {Promise} */ async getWeeklyLogs(): Promise { // Check if organization or request is not provided, resolve the Promise without further action @@ -151,7 +151,10 @@ export class WeeklyTimeReportsComponent extends BaseSelectorFilterComponent impl return; } - // Set the loading flag to true + // Clear existing weekly logs + this.weekLogs = []; + + // Set loading to true to indicate that the logs are being fetched this.loading = true; try { @@ -159,7 +162,10 @@ export class WeeklyTimeReportsComponent extends BaseSelectorFilterComponent impl const payloads = this.payloads$.getValue(); // Fetch the weekly logs from the timesheetService - this.weekLogs = await this.timesheetService.getWeeklyReportChart(payloads); + const newLogs = await this.timesheetService.getWeeklyReportChart(payloads); + + // Update weekLogs with the newly fetched logs + this.weekLogs = newLogs; // Process and map the logs for chart presentation await this._mapLogs(this.weekLogs); diff --git a/apps/gauzy/src/app/pages/tasks/components/task/task.component.ts b/apps/gauzy/src/app/pages/tasks/components/task/task.component.ts index 5e0e1a16c1f..d91a77e3bfa 100644 --- a/apps/gauzy/src/app/pages/tasks/components/task/task.component.ts +++ b/apps/gauzy/src/app/pages/tasks/components/task/task.component.ts @@ -164,7 +164,7 @@ export class TaskComponent extends PaginationFilterBaseComponent implements OnIn title: this.getTranslation('TASKS_PAGE.TASK_ID'), type: 'string', width: '10%', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -184,7 +184,7 @@ export class TaskComponent extends PaginationFilterBaseComponent implements OnIn instance.value = cell.getValue(); instance.rowData = cell.getRow().getData(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -219,7 +219,7 @@ export class TaskComponent extends PaginationFilterBaseComponent implements OnIn instance.value = cell.getValue(); instance.rowData = cell.getRow().getData(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -234,7 +234,7 @@ export class TaskComponent extends PaginationFilterBaseComponent implements OnIn dueDate: { title: this.getTranslation('TASKS_PAGE.DUE_DATE'), type: 'custom', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -256,7 +256,7 @@ export class TaskComponent extends PaginationFilterBaseComponent implements OnIn instance.value = cell.getValue(); instance.rowData = cell.getRow().getData(); }, - isFilterable: { + filter: { type: 'custom', component: TaskStatusFilterComponent }, @@ -309,7 +309,7 @@ export class TaskComponent extends PaginationFilterBaseComponent implements OnIn instance.value = cell.getValue(); instance.rowData = cell.getRow().getData(); }, - isFilterable: { + filter: { type: 'custom', component: OrganizationTeamFilterComponent }, diff --git a/apps/gauzy/src/app/pages/teams/teams-mutation/teams-mutation.component.ts b/apps/gauzy/src/app/pages/teams/teams-mutation/teams-mutation.component.ts index 518a18fc9bc..22ae6d9c93e 100644 --- a/apps/gauzy/src/app/pages/teams/teams-mutation/teams-mutation.component.ts +++ b/apps/gauzy/src/app/pages/teams/teams-mutation/teams-mutation.component.ts @@ -78,8 +78,15 @@ export class TeamsMutationComponent implements OnInit { // Check if there is a valid team if (this.team) { // Extract employee and manager IDs from the team - const selectedEmployees = this.team.members.map((member) => member.id); - const selectedManagers = this.team.managers.map((manager) => manager.id); + const selectedManagers = [...new Set(this.team.managers.map((manager) => manager.id))]; + + const selectedEmployees = [ + ...new Set( + this.team.members + .filter((member) => !selectedManagers.includes(member.id)) + .map((member) => member.id) + ) + ]; // Patch form values with team information this.form.patchValue({ diff --git a/apps/gauzy/src/app/pages/teams/teams.component.ts b/apps/gauzy/src/app/pages/teams/teams.component.ts index 5cfa0d169c5..19a5f3d7be4 100644 --- a/apps/gauzy/src/app/pages/teams/teams.component.ts +++ b/apps/gauzy/src/app/pages/teams/teams.component.ts @@ -426,7 +426,7 @@ export class TeamsComponent extends PaginationFilterBaseComponent implements OnI name: { title: this.getTranslation('SM_TABLE.NAME'), type: 'string', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -463,7 +463,7 @@ export class TeamsComponent extends PaginationFilterBaseComponent implements OnI instance.rowData = cell.getRow().getData(); instance.value = cell.getRawValue(); }, - isFilterable: { + filter: { type: 'custom', component: TagsColorFilterComponent }, diff --git a/apps/gauzy/src/app/pages/time-off/time-off.component.ts b/apps/gauzy/src/app/pages/time-off/time-off.component.ts index 36e9562f6a5..41494c9da5d 100644 --- a/apps/gauzy/src/app/pages/time-off/time-off.component.ts +++ b/apps/gauzy/src/app/pages/time-off/time-off.component.ts @@ -410,13 +410,13 @@ export class TimeOffComponent extends PaginationFilterBaseComponent implements O instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, filterFunction: (value) => { this.setFilter({ - field: 'user.firstName', + field: 'user.name', search: value }); } @@ -424,7 +424,7 @@ export class TimeOffComponent extends PaginationFilterBaseComponent implements O extendedDescription: { title: this.getTranslation('SM_TABLE.DESCRIPTION'), type: 'html', - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, @@ -440,7 +440,7 @@ export class TimeOffComponent extends PaginationFilterBaseComponent implements O instance.rowData = cell.getRow().getData(); instance.value = cell.getRawValue(); }, - isFilterable: { + filter: { type: 'custom', component: InputFilterComponent }, diff --git a/apps/gauzy/src/app/pages/upwork/upwork-routing.module.ts b/apps/gauzy/src/app/pages/upwork/upwork-routing.module.ts deleted file mode 100644 index e02a2430c3b..00000000000 --- a/apps/gauzy/src/app/pages/upwork/upwork-routing.module.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; -import { UpworkComponent } from './components/upwork/upwork.component'; -import { UpworkAuthorizeComponent } from './components/upwork-authorize/upwork-authorize.component'; -import { TransactionsComponent } from './components/transactions/transactions.component'; -import { ContractsComponent } from './components/contracts/contracts.component'; -import { ReportsComponent } from './components/reports/reports.component'; - -const routes: Routes = [ - { - path: '', - component: UpworkAuthorizeComponent, - data: { state: true } - }, - { - path: 'regenerate', - component: UpworkAuthorizeComponent, - data: { state: false } - }, - { - path: ':id', - component: UpworkComponent, - children: [ - { - path: '', - redirectTo: 'contracts', - pathMatch: 'full' - }, - { - path: 'activities', - component: TransactionsComponent - }, - { - path: 'reports', - component: ReportsComponent, - data: { - selectors: { - project: false - } - } - }, - { - path: 'transactions', - component: TransactionsComponent - }, - { - path: 'contracts', - component: ContractsComponent - } - ] - }, - { - path: ':id/settings', - loadChildren: () => import('@gauzy/ui-core/shared').then((m) => m.WorkInProgressModule) - } -]; - -@NgModule({ - imports: [RouterModule.forChild(routes)], - exports: [RouterModule] -}) -export class UpworkRoutingModule {} diff --git a/apps/gauzy/src/app/pages/users/users.component.ts b/apps/gauzy/src/app/pages/users/users.component.ts index fbea6ba0521..aab5f3aef47 100644 --- a/apps/gauzy/src/app/pages/users/users.component.ts +++ b/apps/gauzy/src/app/pages/users/users.component.ts @@ -470,7 +470,7 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI instance.rowData = cell.getRow().getData(); instance.value = cell.getValue(); }, - isFilterable: { + filter: { type: 'custom', component: TagsColorFilterComponent }, diff --git a/apps/gauzy/src/assets/images/avatars/rahul.png b/apps/gauzy/src/assets/images/avatars/rahul.png new file mode 100644 index 00000000000..de035cd440b Binary files /dev/null and b/apps/gauzy/src/assets/images/avatars/rahul.png differ diff --git a/apps/server-api/package.json b/apps/server-api/package.json index 82d3ca27f09..c1508fd03e0 100644 --- a/apps/server-api/package.json +++ b/apps/server-api/package.json @@ -68,9 +68,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.7", "electron-util": "^0.17.2", - "ffi-napi": "^4.0.3", "form-data": "^3.0.0", - "iconv": "^3.0.1", "knex": "^3.1.0", "libsql": "^0.3.16", "locutus": "^2.0.30", @@ -80,10 +78,9 @@ "moment-timezone": "^0.5.40", "node-fetch": "^2.6.7", "node-static": "^0.7.11", - "pg": "^8.11.4", - "pg-query-stream": "^4.5.4", + "pg": "^8.13.1", + "pg-query-stream": "^4.7.1", "rxjs": "^7.4.0", - "sqlite3": "^5.1.7", "squirrelly": "^8.0.8", "twing": "^5.0.2", "typeorm": "^0.3.20", diff --git a/apps/server-api/src/assets/styles/gauzy/theme.gauzy-dark.ts b/apps/server-api/src/assets/styles/gauzy/theme.gauzy-dark.ts index 4ec88895401..9bf0e771154 100644 --- a/apps/server-api/src/assets/styles/gauzy/theme.gauzy-dark.ts +++ b/apps/server-api/src/assets/styles/gauzy/theme.gauzy-dark.ts @@ -45,18 +45,12 @@ const theme = { export const GAUZY_DARK = { name: 'gauzy-dark', - base: 'dark', + base: 'dark', variables: { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/server-api/src/assets/styles/material/theme.material-dark.ts b/apps/server-api/src/assets/styles/material/theme.material-dark.ts index fa30be941ca..9eb1709ff77 100644 --- a/apps/server-api/src/assets/styles/material/theme.material-dark.ts +++ b/apps/server-api/src/assets/styles/material/theme.material-dark.ts @@ -54,13 +54,7 @@ export const MATERIAL_DARK_THEME = { base: 'default', variables: { temperature: { - arcFill: [ - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary - ], + arcFill: Array(5).fill(baseThemeVariables.primary), arcEmpty: baseThemeVariables.bg2, thumbBg: baseThemeVariables.bg2, thumbBorder: baseThemeVariables.primary diff --git a/apps/server-api/src/assets/styles/material/theme.material-light.ts b/apps/server-api/src/assets/styles/material/theme.material-light.ts index 53cec035f34..bb2e166e089 100644 --- a/apps/server-api/src/assets/styles/material/theme.material-light.ts +++ b/apps/server-api/src/assets/styles/material/theme.material-light.ts @@ -54,13 +54,7 @@ export const MATERIAL_LIGHT_THEME = { base: 'default', variables: { temperature: { - arcFill: [ - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary - ], + arcFill: Array(5).fill(baseThemeVariables.primary), arcEmpty: baseThemeVariables.bg2, thumbBg: baseThemeVariables.bg2, thumbBorder: baseThemeVariables.primary diff --git a/apps/server-api/src/assets/styles/theme.dark.ts b/apps/server-api/src/assets/styles/theme.dark.ts index 983d147d3f4..63e247fa5b4 100644 --- a/apps/server-api/src/assets/styles/theme.dark.ts +++ b/apps/server-api/src/assets/styles/theme.dark.ts @@ -49,13 +49,7 @@ export const DARK_THEME = { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/server-api/src/index.ts b/apps/server-api/src/index.ts index 183b5cf8846..724ff3bf72c 100644 --- a/apps/server-api/src/index.ts +++ b/apps/server-api/src/index.ts @@ -23,8 +23,6 @@ import { environment } from './environments/environment'; require('module').globalPaths.push(path.join(__dirname, 'node_modules')); -require('sqlite3'); - process.env = Object.assign(process.env, environment); app.setName(process.env.NAME); @@ -520,7 +518,7 @@ ipcMain.on('check_database_connection', async (event, arg) => { }; } else { databaseOptions = { - client: 'sqlite', + client: 'better-sqlite3', connection: { filename: sqlite3filename } diff --git a/apps/server-api/src/package.json b/apps/server-api/src/package.json index c8601e7c083..6ec43e5b3c4 100755 --- a/apps/server-api/src/package.json +++ b/apps/server-api/src/package.json @@ -57,7 +57,6 @@ "npmRebuild": true, "asarUnpack": [ "node_modules/@sentry/electron", - "node_modules/sqlite3/lib", "node_modules/better-sqlite3", "node_modules/@sentry/profiling-node/lib" ], @@ -168,10 +167,8 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.7", "electron-util": "^0.17.2", - "ffi-napi": "^4.0.3", "form-data": "^3.0.0", "htmlparser2": "^8.0.2", - "iconv": "^3.0.1", "knex": "^3.1.0", "libsql": "^0.3.16", "locutus": "^2.0.30", @@ -180,10 +177,9 @@ "node-notifier": "^8.0.0", "node-static": "^0.7.11", "pdfmake": "^0.2.0", - "pg-query-stream": "^4.5.4", - "pg": "^8.11.4", + "pg-query-stream": "^4.7.1", + "pg": "^8.13.1", "sound-play": "1.1.0", - "sqlite3": "^5.1.7", "squirrelly": "^8.0.8", "tslib": "^2.6.2", "twing": "^5.0.2", diff --git a/apps/server-api/src/preload/preload.ts b/apps/server-api/src/preload/preload.ts index c9b66089034..b5ba9a2fc97 100644 --- a/apps/server-api/src/preload/preload.ts +++ b/apps/server-api/src/preload/preload.ts @@ -17,6 +17,10 @@ window.addEventListener('DOMContentLoaded', async () => { titleBar.refreshMenu(); }); + ipcRenderer.on('hide-menu', () => { + titleBar.dispose(); + }) + const overStyle = document.createElement('style'); overStyle.innerHTML = ` .cet-menubar-menu-container { diff --git a/apps/server/README.md b/apps/server/README.md index 5015098c24a..c7fccaeab29 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -12,12 +12,6 @@ yarn install **build executable for mac** -rebuild sqlite3 for mac - -```bash -yarn build:sqlite:mac -``` - build desktop ```bash @@ -31,11 +25,6 @@ build:desktop:mac:quick ``` **build execute app for windows** -rebuild sqlite3 for windows - -```bash -yarn build:sqlite:windows -``` build desktop diff --git a/apps/server/package.json b/apps/server/package.json index 6a2a96582d9..9727292a863 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -68,9 +68,7 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.7", "electron-util": "^0.17.2", - "ffi-napi": "^4.0.3", "form-data": "^3.0.0", - "iconv": "^3.0.1", "knex": "^3.1.0", "libsql": "^0.3.16", "locutus": "^2.0.30", @@ -80,10 +78,9 @@ "moment-timezone": "^0.5.45", "node-fetch": "^2.6.7", "node-static": "^0.7.11", - "pg": "^8.11.4", - "pg-query-stream": "^4.5.4", + "pg": "^8.13.1", + "pg-query-stream": "^4.7.1", "rxjs": "^7.4.0", - "sqlite3": "^5.1.7", "squirrelly": "^8.0.8", "twing": "^5.0.2", "typeorm": "^0.3.20", diff --git a/apps/server/src/assets/styles/gauzy/_gauzy-dialogs.scss b/apps/server/src/assets/styles/gauzy/_gauzy-dialogs.scss index b5c00201c50..b3b2a2c797b 100644 --- a/apps/server/src/assets/styles/gauzy/_gauzy-dialogs.scss +++ b/apps/server/src/assets/styles/gauzy/_gauzy-dialogs.scss @@ -113,4 +113,4 @@ $shadow: 0 0 0 nb-theme(button-outline-width) rgba($color: $green, } @include dialog(var(--gauzy-card-1), var(--gauzy-sidebar-background-4)); -} \ No newline at end of file +} diff --git a/apps/server/src/assets/styles/gauzy/_gauzy-table.scss b/apps/server/src/assets/styles/gauzy/_gauzy-table.scss index ad4334cda60..a66381765ad 100644 --- a/apps/server/src/assets/styles/gauzy/_gauzy-table.scss +++ b/apps/server/src/assets/styles/gauzy/_gauzy-table.scss @@ -110,4 +110,4 @@ button { border-radius: nb-theme(border-radius); box-shadow: var(--gauzy-shadow) inset; } -} \ No newline at end of file +} diff --git a/apps/server/src/assets/styles/gauzy/index.ts b/apps/server/src/assets/styles/gauzy/index.ts index 178f1f36dbc..368e51a156c 100644 --- a/apps/server/src/assets/styles/gauzy/index.ts +++ b/apps/server/src/assets/styles/gauzy/index.ts @@ -1,2 +1,2 @@ export * from './theme.gauzy-dark'; -export * from './theme.gauzy-light'; \ No newline at end of file +export * from './theme.gauzy-light'; diff --git a/apps/server/src/assets/styles/gauzy/theme.gauzy-dark.ts b/apps/server/src/assets/styles/gauzy/theme.gauzy-dark.ts index 4ec88895401..9bf0e771154 100644 --- a/apps/server/src/assets/styles/gauzy/theme.gauzy-dark.ts +++ b/apps/server/src/assets/styles/gauzy/theme.gauzy-dark.ts @@ -45,18 +45,12 @@ const theme = { export const GAUZY_DARK = { name: 'gauzy-dark', - base: 'dark', + base: 'dark', variables: { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/server/src/assets/styles/material/theme.material-dark.ts b/apps/server/src/assets/styles/material/theme.material-dark.ts index fa30be941ca..9eb1709ff77 100644 --- a/apps/server/src/assets/styles/material/theme.material-dark.ts +++ b/apps/server/src/assets/styles/material/theme.material-dark.ts @@ -54,13 +54,7 @@ export const MATERIAL_DARK_THEME = { base: 'default', variables: { temperature: { - arcFill: [ - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary - ], + arcFill: Array(5).fill(baseThemeVariables.primary), arcEmpty: baseThemeVariables.bg2, thumbBg: baseThemeVariables.bg2, thumbBorder: baseThemeVariables.primary diff --git a/apps/server/src/assets/styles/material/theme.material-light.ts b/apps/server/src/assets/styles/material/theme.material-light.ts index 53cec035f34..bb2e166e089 100644 --- a/apps/server/src/assets/styles/material/theme.material-light.ts +++ b/apps/server/src/assets/styles/material/theme.material-light.ts @@ -54,13 +54,7 @@ export const MATERIAL_LIGHT_THEME = { base: 'default', variables: { temperature: { - arcFill: [ - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary, - baseThemeVariables.primary - ], + arcFill: Array(5).fill(baseThemeVariables.primary), arcEmpty: baseThemeVariables.bg2, thumbBg: baseThemeVariables.bg2, thumbBorder: baseThemeVariables.primary diff --git a/apps/server/src/assets/styles/theme.dark.ts b/apps/server/src/assets/styles/theme.dark.ts index 983d147d3f4..63e247fa5b4 100644 --- a/apps/server/src/assets/styles/theme.dark.ts +++ b/apps/server/src/assets/styles/theme.dark.ts @@ -49,13 +49,7 @@ export const DARK_THEME = { ...theme, temperature: { - arcFill: [ - theme.primary, - theme.primary, - theme.primary, - theme.primary, - theme.primary - ], + arcFill: Array(5).fill(theme.primary), arcEmpty: theme.bg2, thumbBg: theme.bg2, thumbBorder: theme.primary diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2ae698e89f8..4a47e7ccc98 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -23,8 +23,6 @@ import { environment } from './environments/environment'; require('module').globalPaths.push(path.join(__dirname, 'node_modules')); -require('sqlite3'); - process.env = Object.assign(process.env, environment); app.setName(process.env.NAME); @@ -534,7 +532,7 @@ ipcMain.on('check_database_connection', async (event, arg) => { }; } else { databaseOptions = { - client: 'sqlite', + client: 'better-sqlite3', connection: { filename: sqlite3filename } diff --git a/apps/server/src/package.json b/apps/server/src/package.json index 8e762b94898..d1c932713c2 100755 --- a/apps/server/src/package.json +++ b/apps/server/src/package.json @@ -32,10 +32,14 @@ "../../../packages/ui-config", "../../../packages/ui-core", "../../../packages/plugin", + "../../../packages/plugins/integration-ai-ui", "../../../packages/plugins/integration-ai", + "../../../packages/plugins/integration-hubstaff-ui", "../../../packages/plugins/integration-hubstaff", + "../../../packages/plugins/integration-upwork-ui", "../../../packages/plugins/integration-upwork", "../../../packages/plugins/integration-github", + "../../../packages/plugins/integration-github-ui", "../../../packages/plugins/integration-jira", "../../../packages/plugins/product-reviews", "../../../packages/plugins/knowledge-base", @@ -66,7 +70,6 @@ "npmRebuild": true, "asarUnpack": [ "node_modules/@sentry/electron", - "node_modules/sqlite3/lib", "node_modules/better-sqlite3", "node_modules/@sentry/profiling-node/lib" ], @@ -177,10 +180,8 @@ "electron-store": "^8.1.0", "electron-updater": "^6.1.7", "electron-util": "^0.17.2", - "ffi-napi": "^4.0.3", "form-data": "^3.0.0", "htmlparser2": "^8.0.2", - "iconv": "^3.0.1", "knex": "^3.1.0", "libsql": "^0.3.16", "locutus": "^2.0.30", @@ -189,10 +190,9 @@ "node-notifier": "^8.0.0", "node-static": "^0.7.11", "pdfmake": "^0.2.0", - "pg-query-stream": "^4.5.4", - "pg": "^8.11.4", + "pg-query-stream": "^4.7.1", + "pg": "^8.13.1", "sound-play": "1.1.0", - "sqlite3": "^5.1.7", "squirrelly": "^8.0.8", "tslib": "^2.6.2", "twing": "^5.0.2", diff --git a/apps/server/src/preload/preload.ts b/apps/server/src/preload/preload.ts index c9b66089034..b5ba9a2fc97 100644 --- a/apps/server/src/preload/preload.ts +++ b/apps/server/src/preload/preload.ts @@ -17,6 +17,10 @@ window.addEventListener('DOMContentLoaded', async () => { titleBar.refreshMenu(); }); + ipcRenderer.on('hide-menu', () => { + titleBar.dispose(); + }) + const overStyle = document.createElement('style'); overStyle.innerHTML = ` .cet-menubar-menu-container { diff --git a/package.json b/package.json index 3a43560d102..b02c6f432c7 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "config:prod": "yarn run config -- --environment=prod", "start": "yarn build:package:all && yarn concurrently --raw --kill-others \"yarn start:api\" \"yarn start:gauzy\"", "start:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn build:package:all && yarn concurrently --raw --kill-others \"yarn start:api:prod\" \"yarn start:gauzy:prod\"", - "start:gauzy": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn run postinstall.web && yarn ng serve gauzy --open", + "start:gauzy": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn run postinstall.web && yarn build:package:ui-config && yarn ng serve gauzy --open", "start:gauzy:forever": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn run config:dev && forever start node_modules/@angular/cli/bin/ng serve gauzy --disable-host-check --host 0.0.0.0", "start:gauzy:pm2": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn build:package:all && yarn build:gauzy && yarn ts-node ./apps/gauzy/src/pm2bootstrap.ts", "start:gauzy:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn run config:prod && yarn ng serve gauzy --configuration production --disable-host-check --host 0.0.0.0 --prod", @@ -109,8 +109,6 @@ "build:desktop:mac:quick": "npm config set cache .cache && yarn electron-builder -c.electronVersion=30.0.1 build --mac --project dist/apps/desktop", "build:desktop:local:quick": "yarn electron dist/apps/desktop --prod", "build:desktop:windows:quick": "npm config set cache .cache && yarn electron-builder -c.electronVersion=30.0.1 build --windows --project dist/apps/desktop", - "build:sqlite:mac": "cd node_modules/sqlite3 && HOME=~/.electron-gyp node-pre-gyp rebuild --target=8.3.0 --arch=x64 --dist-url=https://electronjs.org/headers", - "build:sqlite:windows": "cd node_modules/sqlite3 && cross-env HOME=~/.electron-gyp node-pre-gyp rebuild --target=8.3.0 --arch=x64 --target_platform=win32 --dist-url=https://electronjs.org/headers", "debug:desktop:app:mac": "lldb ./dist/apps/desktop-packages/mac/GauzyDesktop.app && run", "start:desktop:ui": "yarn run postinstall.web && yarn ng serve desktop-ui", "build:desktop:ui": "yarn run postinstall.web && yarn ng:prod build desktop-ui --prod --base-href ./", @@ -137,8 +135,8 @@ "build:package:config:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/config build:prod", "build:package:plugin": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugin build", "build:package:plugin:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugin build", - "build:package:plugins:pre": "yarn run build:package:ui-config && yarn run build:package:ui-core && yarn run build:package:ui-auth && yarn run build:package:plugin:onboarding-ui && yarn run build:package:plugin:legal-ui && yarn run build:package:plugin:job-search-ui && yarn run build:package:plugin:job-matching-ui && yarn run build:package:plugin:job-employee-ui && yarn run build:package:plugin:job-proposal-ui && yarn run build:package:plugin:public-layout-ui && yarn run build:package:plugin:maintenance-ui", - "build:package:plugins:pre:prod": "yarn run build:package:ui-config:prod && yarn run build:package:ui-core:prod && yarn run build:package:ui-auth && yarn run build:package:plugin:onboarding-ui:prod && yarn run build:package:plugin:legal-ui:prod && yarn run build:package:plugin:job-search-ui:prod && yarn run build:package:plugin:job-matching-ui:prod && yarn run build:package:plugin:job-employee-ui:prod && yarn run build:package:plugin:job-proposal-ui:prod && yarn run build:package:plugin:public-layout-ui:prod && yarn run build:package:plugin:maintenance-ui:prod", + "build:package:plugins:pre": "yarn run build:package:ui-config && yarn run build:package:ui-core && yarn run build:package:ui-auth && yarn run build:package:plugin:onboarding-ui && yarn run build:package:plugin:legal-ui && yarn run build:package:plugin:job-search-ui && yarn run build:package:plugin:job-matching-ui && yarn run build:package:plugin:job-employee-ui && yarn run build:package:plugin:job-proposal-ui && yarn run build:package:plugin:public-layout-ui && yarn run build:package:plugin:maintenance-ui && yarn run build:integration-ui-plugins", + "build:package:plugins:pre:prod": "yarn run build:package:ui-config:prod && yarn run build:package:ui-core:prod && yarn run build:package:ui-auth:prod && yarn run build:package:plugin:onboarding-ui:prod && yarn run build:package:plugin:legal-ui:prod && yarn run build:package:plugin:job-search-ui:prod && yarn run build:package:plugin:job-matching-ui:prod && yarn run build:package:plugin:job-employee-ui:prod && yarn run build:package:plugin:job-proposal-ui:prod && yarn run build:package:plugin:public-layout-ui:prod && yarn run build:package:plugin:maintenance-ui:prod && yarn run build:integration-ui-plugins:prod", "build:package:plugins:post": "yarn run build:package:plugin:integration-jira && yarn run build:package:plugin:integration-ai && yarn run build:package:plugin:sentry && yarn run build:package:plugin:jitsu-analytic && yarn run build:package:plugin:product-reviews && yarn run build:package:plugin:job-search && yarn run build:package:plugin:job-proposal && yarn run build:package:plugin:integration-github && yarn run build:package:plugin:knowledge-base && yarn run build:package:plugin:changelog && yarn run build:package:plugin:integration-hubstaff && yarn run build:package:plugin:integration-upwork", "build:package:plugins:post:prod": "yarn run build:package:plugin:integration-jira:prod && yarn run build:package:plugin:integration-ai:prod && yarn run build:package:plugin:sentry:prod && yarn run build:package:plugin:jitsu-analytic:prod && yarn run build:package:plugin:product-reviews:prod && yarn run build:package:plugin:job-search:prod && yarn run build:package:plugin:job-proposal:prod && yarn run build:package:plugin:integration-github:prod && yarn run build:package:plugin:knowledge-base:prod && yarn run build:package:plugin:changelog:prod && yarn run build:package:plugin:integration-hubstaff:prod && yarn run build:package:plugin:integration-upwork:prod", "build:package:plugin:integration-ai": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-ai build", @@ -159,6 +157,16 @@ "build:package:plugin:product-reviews:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/product-reviews build:prod", "build:package:plugin:job-search": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/job-search build", "build:package:plugin:job-search:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/job-search build:prod", + "build:package:plugin:integration-ai-ui": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-ai-ui lib:build", + "build:package:plugin:integration-hubstaff-ui": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-hubstaff-ui lib:build", + "build:package:plugin:integration-github-ui": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-github-ui lib:build", + "build:package:plugin:integration-upwork-ui": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-upwork-ui lib:build", + "build:package:plugin:integration-ai-ui:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-ai-ui lib:build:prod", + "build:package:plugin:integration-hubstaff-ui:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-hubstaff-ui lib:build:prod", + "build:package:plugin:integration-github-ui:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-github-ui lib:build:prod", + "build:package:plugin:integration-upwork-ui:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/integration-upwork-ui lib:build:prod", + "build:integration-ui-plugins": "yarn run build:package:plugin:integration-ai-ui && yarn run build:package:plugin:integration-hubstaff-ui && yarn run build:package:plugin:integration-upwork-ui && yarn run build:package:plugin:integration-github-ui", + "build:integration-ui-plugins:prod": "yarn run build:package:plugin:integration-ai-ui:prod && yarn run build:package:plugin:integration-hubstaff-ui:prod && yarn run build:package:plugin:integration-upwork-ui:prod && yarn run build:package:plugin:integration-github-ui:prod", "build:package:plugin:job-employee-ui": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/job-employee-ui lib:build", "build:package:plugin:job-employee-ui:prod": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/job-employee-ui lib:build:prod", "build:package:plugin:job-matching-ui": "cross-env NODE_ENV=development NODE_OPTIONS=--max-old-space-size=12288 yarn --cwd ./packages/plugins/job-matching-ui lib:build", @@ -329,13 +337,11 @@ "@nebular/security": "^12.0.0", "@nebular/theme": "^12.0.0", "@ng-select/ng-select": "^11.2.0", - "angular2-smart-table": "^3.2.0", + "angular2-smart-table": "^3.3.0", "autoprefixer": "10.4.14", - "bcrypt": "^5.1.0", + "bcrypt": "^5.1.1", "camelcase": "^6.3.0", "dotenv": "^16.0.3", - "ffi-napi": "^4.0.3", - "iconv": "^3.0.1", "jsdom": "^23.0.1", "lodash-es": "^4.17.21", "parse5": "^7.1.2", @@ -384,6 +390,7 @@ "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^9.2.6", "@semantic-release/npm": "^11.0.0", + "@types/bcrypt": "^5.0.2", "@types/jest": "^29.4.4", "@types/jsdom": "^21.1.6", "@types/node": "^20.14.9", @@ -421,7 +428,7 @@ "lighthouse": "^6.3.0", "lint-staged": "^10.4.0", "ng-packagr": "~16.0.0", - "node-gyp": "^9.3.1", + "node-gyp": "^10.2.0", "npm-run-all": "^4.1.5", "nx": "15.9.4", "nx-cloud": "^16.5.2", @@ -458,9 +465,6 @@ "prebuild": { "node-gyp": "$node-gyp" }, - "iconv": { - "node-gyp": "9.3.1" - }, "twing": { "locutus": "2.0.30" } diff --git a/packages/auth/package.json b/packages/auth/package.json index 11edb56b088..80e5571f4e7 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -44,7 +44,7 @@ "@nestjs/config": "^3.2.0", "@nestjs/passport": "^10.0.3", "axios": "^1.7.4", - "bcrypt": "^5.1.0", + "bcrypt": "^5.1.1", "express": "^4.18.2", "passport": "^0.6.0", "passport-auth0": "^1.3.3", @@ -59,7 +59,7 @@ "tslib": "^2.6.2" }, "devDependencies": { - "@types/bcrypt": "^5.0.0", + "@types/bcrypt": "^5.0.2", "@types/jest": "^29.4.4", "@types/node": "^20.14.9", "@types/passport": "^1.0.9", diff --git a/packages/common/src/utils/shared-utils.ts b/packages/common/src/utils/shared-utils.ts index 0816237653c..312a88b499b 100644 --- a/packages/common/src/utils/shared-utils.ts +++ b/packages/common/src/utils/shared-utils.ts @@ -243,12 +243,16 @@ export const addHttpsPrefix = (url: string, prefix = 'https'): string => { * @returns A string representation of the query parameters. */ export function createQueryParamsString(queryParams: { [key: string]: string | string[] | boolean }): string { - return Object.keys(queryParams) - .map((key) => { - const value = queryParams[key]; + return Object.entries(queryParams) + .map(([key, value]) => { if (Array.isArray(value)) { + // Handle array values by joining them with '&' return value.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`).join('&'); + } else if (typeof value === 'boolean') { + // Convert boolean to string explicitly (true/false) + return `${encodeURIComponent(key)}=${value}`; } else { + // Handle string or other types return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; } }) diff --git a/packages/config/package.json b/packages/config/package.json index 9abaf6e9dcc..06d805bc7b6 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -51,7 +51,6 @@ "@mikro-orm/mysql": "^6.2.3", "@mikro-orm/nestjs": "^5.2.3", "@mikro-orm/postgresql": "^6.2.3", - "@mikro-orm/sqlite": "^6.2.3", "@nestjs/common": "^10.3.7", "@nestjs/config": "^3.2.0", "@nestjs/typeorm": "^10.0.2", diff --git a/packages/config/src/database.ts b/packages/config/src/database.ts index 5cae1326a95..ae07ad16815 100644 --- a/packages/config/src/database.ts +++ b/packages/config/src/database.ts @@ -3,8 +3,7 @@ import * as chalk from 'chalk'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { MikroOrmModuleOptions } from '@mikro-orm/nestjs'; import { EntityCaseNamingStrategy } from '@mikro-orm/core'; -import { SoftDeleteHandler } from "mikro-orm-soft-delete"; -import { SqliteDriver, Options as MikroOrmSqliteOptions } from '@mikro-orm/sqlite'; +import { SoftDeleteHandler } from 'mikro-orm-soft-delete'; import { BetterSqliteDriver, Options as MikroOrmBetterSqliteOptions } from '@mikro-orm/better-sqlite'; import { PostgreSqlDriver, Options as MikroOrmPostgreSqlOptions } from '@mikro-orm/postgresql'; import { Options as MikroOrmMySqlOptions, MySqlDriver } from '@mikro-orm/mysql'; @@ -63,7 +62,9 @@ const dbConnectionTimeout = process.env.DB_CONNECTION_TIMEOUT ? parseInt(process const idleTimeoutMillis = process.env.DB_IDLE_TIMEOUT ? parseInt(process.env.DB_IDLE_TIMEOUT) : 10000; // 10 seconds -const dbSlowQueryLoggingTimeout = process.env.DB_SLOW_QUERY_LOGGING_TIMEOUT ? parseInt(process.env.DB_SLOW_QUERY_LOGGING_TIMEOUT) : 10000; // 10 seconds default +const dbSlowQueryLoggingTimeout = process.env.DB_SLOW_QUERY_LOGGING_TIMEOUT + ? parseInt(process.env.DB_SLOW_QUERY_LOGGING_TIMEOUT) + : 10000; // 10 seconds default const dbSslMode = process.env.DB_SSL_MODE === 'true'; @@ -276,52 +277,16 @@ switch (dbType) { break; case DatabaseTypeEnum.sqlite: - const dbPath = process.env.DB_PATH || path.join(process.cwd(), ...['apps', 'api', 'data'], 'gauzy.sqlite3'); - console.log('Sqlite DB Path: ' + dbPath); - - // MikroORM DB Config (SQLite3) - const mikroOrmSqliteConfig: MikroOrmSqliteOptions = { - driver: SqliteDriver, - dbName: dbPath, - persistOnCreate: true, - extensions: [SoftDeleteHandler], - namingStrategy: EntityCaseNamingStrategy, - debug: getLoggingMikroOptions(process.env.DB_LOGGING) // by default set to false only - }; - mikroOrmConnectionConfig = mikroOrmSqliteConfig; - - // TypeORM DB Config (SQLite3) - const typeOrmSqliteConfig: DataSourceOptions = { - type: dbType, - database: dbPath, - logging: 'all', - logger: 'file', //Removes console logging, instead logs all queries in a file ormlogs.log - synchronize: process.env.DB_SYNCHRONIZE === 'true' // We are using migrations, synchronize should be set to false. - }; - typeOrmConnectionConfig = typeOrmSqliteConfig; - - // Knex DB Config (SQLite3) - const knexSqliteConfig: KnexModuleOptions = { - config: { - client: 'sqlite3', - connection: { - filename: dbPath - }, - useNullAsDefault: true // Specify whether to use null as the default for unspecified fields - } - }; - knexConnectionConfig = knexSqliteConfig; - - break; - case DatabaseTypeEnum.betterSqlite3: - const betterSqlitePath = process.env.DB_PATH || path.join(process.cwd(), ...['apps', 'api', 'data'], 'gauzy.sqlite3'); - console.log('Better Sqlite DB Path: ' + betterSqlitePath); + const sqlitePath = + process.env.DB_PATH || path.join(process.cwd(), ...['apps', 'api', 'data'], 'gauzy.sqlite3'); + + console.log('Better Sqlite DB Path: ' + sqlitePath); // MikroORM DB Config (Better-SQLite3) const mikroOrmBetterSqliteConfig: MikroOrmBetterSqliteOptions = { driver: BetterSqliteDriver, - dbName: betterSqlitePath, + dbName: sqlitePath, persistOnCreate: true, extensions: [SoftDeleteHandler], namingStrategy: EntityCaseNamingStrategy, @@ -332,7 +297,7 @@ switch (dbType) { // TypeORM DB Config (Better-SQLite3) const typeOrmBetterSqliteConfig: DataSourceOptions = { type: dbType, - database: betterSqlitePath, + database: sqlitePath, logging: 'all', logger: 'file', // Removes console logging, instead logs all queries in a file ormlogs.log synchronize: process.env.DB_SYNCHRONIZE === 'true', // We are using migrations, synchronize should be set to false. @@ -350,7 +315,7 @@ switch (dbType) { config: { client: 'better-sqlite3', connection: { - filename: betterSqlitePath + filename: sqlitePath }, useNullAsDefault: true // Specify whether to use null as the default for unspecified fields } diff --git a/packages/contracts/src/activity-log.model.ts b/packages/contracts/src/activity-log.model.ts index a2a4d60fdda..008d456a979 100644 --- a/packages/contracts/src/activity-log.model.ts +++ b/packages/contracts/src/activity-log.model.ts @@ -1,14 +1,17 @@ -import { ActorTypeEnum, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; +import { + ActorTypeEnum, + BaseEntityEnum, + IBasePerTenantAndOrganizationEntityModel, + ID, + JsonData +} from './base-entity.model'; import { IUser } from './user.model'; -// Define a type for JSON data -export type JsonData = Record | string; - /** * Interface representing an activity log entry. */ export interface IActivityLog extends IBasePerTenantAndOrganizationEntityModel { - entity: ActivityLogEntityEnum; // Entity / Table name concerned by activity log + entity: BaseEntityEnum; // Entity / Table name concerned by activity log entityId: ID; // The ID of the element we are interacting with (a task, an organization, an employee, ...) action: ActionTypeEnum; actorType?: ActorTypeEnum; @@ -36,29 +39,6 @@ export enum ActionTypeEnum { Deleted = 'Deleted' } -/** - * Enum for entities that can be involved in activity logs. - */ -export enum ActivityLogEntityEnum { - Candidate = 'Candidate', - Contact = 'Contact', - Employee = 'Employee', - Expense = 'Expense', - DailyPlan = 'DailyPlan', - Invoice = 'Invoice', - Income = 'Income', - Organization = 'Organization', - OrganizationContact = 'OrganizationContact', - OrganizationDepartment = 'OrganizationDepartment', - OrganizationDocument = 'OrganizationDocument', - OrganizationProject = 'OrganizationProject', - OrganizationTeam = 'OrganizationTeam', - OrganizationProjectModule = 'OrganizationProjectModule', - OrganizationSprint = 'OrganizationSprint', - Task = 'Task', - User = 'User' -} - /** * Input type for activity log creation, excluding `creatorId` and `creator`. */ diff --git a/packages/contracts/src/api-call-log.model.ts b/packages/contracts/src/api-call-log.model.ts new file mode 100644 index 00000000000..0cae2252f3a --- /dev/null +++ b/packages/contracts/src/api-call-log.model.ts @@ -0,0 +1,36 @@ +import { IBasePerTenantAndOrganizationEntityModel, ID, JsonData } from './base-entity.model'; +import { IRelationalUser } from './user.model'; + +/** + * Enum representing the HTTP method used in the request. + */ +export enum RequestMethod { + GET = 0, + POST = 1, + PUT = 2, + DELETE = 3, + PATCH = 4, + ALL = 5, + OPTIONS = 6, + HEAD = 7, + SEARCH = 8 +} + +/** + * Interface representing an API call log entry. + */ +export interface IApiCallLog extends IBasePerTenantAndOrganizationEntityModel, IRelationalUser { + correlationId: ID; // Correlation ID to track the request across services. + url: string; // The request URL that was called. + method: RequestMethod; // The HTTP method (GET, POST, etc.) used in the request. + statusCode: number; // The HTTP status code returned from the request. + requestHeaders: JsonData; // Request headers stored as JSON string. + requestBody: JsonData; // Request body stored as JSON string. + responseBody: JsonData; // Response body stored as JSON string. + requestTime: Date; // The timestamp when the request was initiated. + responseTime: Date; // The timestamp when the response was completed. + ipAddress: string; // The IP address of the client making the request. + protocol: string; // The protocol used in the request (HTTP, HTTPS). + userAgent: string; // User-Agent string of the client making the request (could be a browser, desktop app, Postman, etc.). + origin: string; // Origin from where the request was initiated (web, mobile, desktop, etc.). +} diff --git a/packages/contracts/src/base-entity.model.ts b/packages/contracts/src/base-entity.model.ts index 9e030be25a3..81951b07154 100644 --- a/packages/contracts/src/base-entity.model.ts +++ b/packages/contracts/src/base-entity.model.ts @@ -1,6 +1,9 @@ import { ITenant } from './tenant.model'; import { IOrganization } from './organization.model'; +// Define a type for JSON data +export type JsonData = Record | string; + /** * @description * An entity ID. Represents a unique identifier as a string. @@ -58,6 +61,31 @@ export interface IBasePerTenantAndOrganizationEntityMutationInput extends Partia // Actor type defines if it's User or system performed some action export enum ActorTypeEnum { - System = 0, // System performed the action - User = 1 // User performed the action + System = 'System', // System performed the action + User = 'User' // User performed the action +} + +export enum BaseEntityEnum { + Candidate = 'Candidate', + Contact = 'Contact', + Currency = 'Currency', + Employee = 'Employee', + Expense = 'Expense', + DailyPlan = 'DailyPlan', + Invoice = 'Invoice', + Income = 'Income', + Language = 'Language', + Organization = 'Organization', + OrganizationContact = 'OrganizationContact', + OrganizationDepartment = 'OrganizationDepartment', + OrganizationDocument = 'OrganizationDocument', + OrganizationProject = 'OrganizationProject', + OrganizationTeam = 'OrganizationTeam', + OrganizationProjectModule = 'OrganizationProjectModule', + OrganizationSprint = 'OrganizationSprint', + ResourceLink = 'ResourceLink', + OrganizationVendor = 'OrganizationVendor', + Task = 'Task', + TaskView = 'TaskView', + User = 'User' } diff --git a/packages/contracts/src/comment.model.ts b/packages/contracts/src/comment.model.ts index 3e5308fed6e..63d42b4b0d3 100644 --- a/packages/contracts/src/comment.model.ts +++ b/packages/contracts/src/comment.model.ts @@ -1,10 +1,10 @@ -import { ActorTypeEnum, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; +import { ActorTypeEnum, BaseEntityEnum, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { IUser } from './user.model'; import { IEmployee } from './employee.model'; import { IOrganizationTeam } from './organization-team.model'; export interface IComment extends IBasePerTenantAndOrganizationEntityModel { - entity: CommentEntityEnum; + entity: BaseEntityEnum; entityId: ID; // Indicate the ID of entity record comment related to comment: string; creator?: IUser; @@ -22,27 +22,9 @@ export interface IComment extends IBasePerTenantAndOrganizationEntityModel { replies?: IComment[]; } -export enum CommentEntityEnum { - Contact = 'Contact', - Employee = 'Employee', - Expense = 'Expense', - DailyPlan = 'DailyPlan', - Invoice = 'Invoice', - Income = 'Income', - Organization = 'Organization', - OrganizationContact = 'OrganizationContact', - OrganizationDepartment = 'OrganizationDepartment', - OrganizationDocument = 'OrganizationDocument', - OrganizationProject = 'OrganizationProject', - OrganizationTeam = 'OrganizationTeam', - OrganizationProjectModule = 'OrganizationProjectModule', - OrganizationSprint = 'OrganizationSprint', - Task = 'Task' -} - export interface ICommentCreateInput { comment: string; - entity: CommentEntityEnum; + entity: BaseEntityEnum; entityId: ID; parentId?: ID; members?: IEmployee[]; diff --git a/packages/contracts/src/daily-plan.model.ts b/packages/contracts/src/daily-plan.model.ts index ee6c702daf7..e4e1071821c 100644 --- a/packages/contracts/src/daily-plan.model.ts +++ b/packages/contracts/src/daily-plan.model.ts @@ -38,7 +38,7 @@ export interface IDailyPlanTasksUpdateInput // Interface for data type should be sent when need to delete a task from many daily plans export interface IDailyPlansTasksUpdateInput - extends Pick, + extends Pick, IBasePerTenantAndOrganizationEntityModel { plansIds: ID[]; } diff --git a/packages/contracts/src/employee.model.ts b/packages/contracts/src/employee.model.ts index fea497de79a..3bbd3957f24 100644 --- a/packages/contracts/src/employee.model.ts +++ b/packages/contracts/src/employee.model.ts @@ -26,6 +26,7 @@ import { IOrganizationProjectModule } from './organization-project-module.model' import { CurrenciesEnum } from './currency.model'; import { IFavorite } from './favorite.model'; import { IComment } from './comment.model'; +import { IOrganizationSprint } from './organization-sprint.model'; export interface IFindMembersInput extends IBasePerTenantAndOrganizationEntityModel { organizationTeamId: ID; @@ -79,6 +80,7 @@ export interface IEmployee extends IBasePerTenantAndOrganizationEntityModel, ITa timesheets?: ITimesheet[]; tasks?: ITask[]; modules?: IOrganizationProjectModule[]; + sprints?: IOrganizationSprint[]; assignedComments?: IComment[]; timeSlots?: ITimeSlot[]; contact?: IContact; @@ -111,6 +113,10 @@ export interface IEmployee extends IBasePerTenantAndOrganizationEntityModel, ITa isTrackingEnabled: boolean; isDeleted?: boolean; allowScreenshotCapture?: boolean; + allowManualTime?: boolean; + allowModifyTime?: boolean; + allowDeleteTime?: boolean; + /** Upwork ID For Gauzy AI*/ upworkId?: string; /** LinkedIn ID For Gauzy AI*/ @@ -174,6 +180,9 @@ export interface IEmployeeUpdateInput extends IBasePerTenantAndOrganizationEntit upworkUrl?: string; profile_link?: string; allowScreenshotCapture?: boolean; + allowManualTime?: boolean; + allowModifyTime?: boolean; + allowDeleteTime?: boolean; /** Upwork ID For Gauzy AI*/ upworkId?: string; /** LinkedIn ID For Gauzy AI*/ @@ -280,3 +289,8 @@ export interface IEmployeeStoreState { export interface IEmployeeUpdateProfileStatus extends IBasePerTenantAndOrganizationEntityModel { readonly isActive: boolean; } + +export interface IMemberEntityBased extends IBasePerTenantAndOrganizationEntityModel { + memberIds?: ID[]; // Members of the given entity + managerIds?: ID[]; // Managers of the given entity +} diff --git a/packages/contracts/src/favorite.model.ts b/packages/contracts/src/favorite.model.ts index d311a232f6b..fc4264f6527 100644 --- a/packages/contracts/src/favorite.model.ts +++ b/packages/contracts/src/favorite.model.ts @@ -1,20 +1,9 @@ -import { IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; +import { BaseEntityEnum, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { IEmployeeEntityInput } from './employee.model'; export interface IFavorite extends IBasePerTenantAndOrganizationEntityModel, IEmployeeEntityInput { - entity: FavoriteEntityEnum; + entity: BaseEntityEnum; entityId: ID; // Indicate the ID of entity record marked as favorite } -export enum FavoriteEntityEnum { - Organization = 'Organization', - OrganizationProject = 'OrganizationProject', - OrganizationTeam = 'OrganizationTeam', - OrganizationProjectModule = 'OrganizationProjectModule', - Currency = 'Currency', - Language = 'Language', - OrganizationVendor = 'OrganizationVendor', - OrganizationSprint = 'OrganizationSprint' -} - export interface IFavoriteCreateInput extends IFavorite {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6e24c420870..bee9550ceac 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export * from './accounting-template.model'; /** App Setting Model */ export * from './activity-log.model'; +export * from './api-call-log.model'; export * from './app.model'; export * from './appointment-employees.model'; export * from './approval-policy.model'; @@ -104,6 +105,7 @@ export * from './report.model'; export * from './request-approval-employee.model'; export * from './request-approval-team.model'; export * from './request-approval.model'; +export * from './resource-link.model'; export * from './role-permission.model'; export * from './role.model'; export * from './screenshot.model'; @@ -120,6 +122,7 @@ export * from './task-related-issue-type.model'; export * from './task-size.model'; export * from './task-status.model'; export * from './task-version.model'; +export * from './task-view.model'; export * from './task.model'; export * from './daily-plan.model'; export * from './tenant.model'; @@ -134,13 +137,16 @@ export * from './user.model'; export * from './wakatime.model'; export * from './activity-watch.model'; -export { IBaseEntityModel as BaseEntityModel, ID } from './base-entity.model'; export { + IBaseEntityModel as BaseEntityModel, + ID, IBasePerTenantAndOrganizationEntityModel, IBasePerTenantEntityModel, IBaseSoftDeleteEntityModel, IBaseRelationsEntityModel, - ActorTypeEnum + ActorTypeEnum, + JsonData, + BaseEntityEnum } from './base-entity.model'; export * from './proxy.model'; diff --git a/packages/contracts/src/issue-type.model.ts b/packages/contracts/src/issue-type.model.ts index c0a3a7a7146..53309356856 100644 --- a/packages/contracts/src/issue-type.model.ts +++ b/packages/contracts/src/issue-type.model.ts @@ -27,3 +27,10 @@ export interface IIssueTypeUpdateInput extends Partial { export interface IIssueTypeFindInput extends IBasePerTenantAndOrganizationEntityModel, Pick {} + +export enum TaskTypeEnum { + EPIC = 'epic', + STORY = 'story', + TASK = 'task', + BUG = 'bug' +} diff --git a/packages/contracts/src/organization-projects.model.ts b/packages/contracts/src/organization-projects.model.ts index a96211c39b7..9b1c7892285 100644 --- a/packages/contracts/src/organization-projects.model.ts +++ b/packages/contracts/src/organization-projects.model.ts @@ -1,4 +1,4 @@ -import { IEmployee, IEmployeeEntityInput } from './employee.model'; +import { IEmployee, IEmployeeEntityInput, IMemberEntityBased } from './employee.model'; import { IRelationalOrganizationContact } from './organization-contact.model'; import { ITaggable } from './tag.model'; import { ITask } from './task.model'; @@ -83,12 +83,10 @@ export interface IOrganizationProjectsFindInput billable?: boolean; billingFlat?: boolean; organizationTeamId?: ID; + members?: Partial; } -export interface IOrganizationProjectCreateInput extends IOrganizationProjectBase { - memberIds?: ID[]; // Manager of the organization project - managerIds?: ID[]; // Manager of the organization project -} +export interface IOrganizationProjectCreateInput extends IOrganizationProjectBase, IMemberEntityBased {} export interface IOrganizationProjectUpdateInput extends IOrganizationProjectCreateInput {} @@ -97,6 +95,8 @@ export interface IOrganizationProjectStoreState { action: CrudActionEnum; } +export interface IOrganizationProjectEmployeeFindInput extends Partial {} + export interface IOrganizationProjectEmployee extends IBasePerTenantAndOrganizationEntityModel, IEmployeeEntityInput, @@ -122,5 +122,5 @@ export enum OrganizationProjectBudgetTypeEnum { export interface IOrganizationProjectEditByEmployeeInput extends IBasePerTenantAndOrganizationEntityModel { addedProjectIds?: ID[]; removedProjectIds?: ID[]; - member: IOrganizationProjectEmployee; + member: IEmployee; } diff --git a/packages/contracts/src/organization-sprint.model.ts b/packages/contracts/src/organization-sprint.model.ts index 083147fbfcc..92980c0db47 100644 --- a/packages/contracts/src/organization-sprint.model.ts +++ b/packages/contracts/src/organization-sprint.model.ts @@ -1,21 +1,45 @@ import { IOrganizationProjectModule } from './organization-project-module.model'; -import { IBasePerTenantAndOrganizationEntityModel } from './base-entity.model'; +import { IBasePerTenantAndOrganizationEntityModel, ID, JsonData } from './base-entity.model'; import { IOrganizationProject } from './organization-projects.model'; import { ITask } from './task.model'; +import { IEmployeeEntityInput, IMemberEntityBased } from './employee.model'; +import { IRelationalRole } from './role.model'; +import { IUser } from './user.model'; -export interface IOrganizationSprint extends IBasePerTenantAndOrganizationEntityModel { - name: string; - projectId: string; +// Base interface with optional properties +export interface IRelationalOrganizationSprint { + organizationSprint?: IOrganizationSprint; + organizationSprintId?: ID; +} + +export interface IOrganizationSprintBase extends IBasePerTenantAndOrganizationEntityModel { + name?: string; goal?: string; - length: number; // Duration of Sprint. Default value - 7 (days) + length?: number; // Duration of Sprint. Default value - 7 (days) startDate?: Date; endDate?: Date; + status?: OrganizationSprintStatusEnum; dayStart?: number; // Enum ((Sunday-Saturday) => (0-7)) + sprintProgress?: JsonData; // Stores the current state and metrics of the sprint's progress project?: IOrganizationProject; + projectId?: ID; tasks?: ITask[]; + members?: IOrganizationSprintEmployee[]; modules?: IOrganizationProjectModule[]; + taskSprints?: IOrganizationSprintTask[]; + fromSprintTaskHistories?: IOrganizationSprintTaskHistory[]; + toSprintTaskHistories?: IOrganizationSprintTaskHistory[]; } +export interface IOrganizationSprint extends IOrganizationSprintBase { + name: string; + length: number; + project: IOrganizationProject; + projectId: ID; +} + +export interface IOrganizationSprintCreateInput extends IOrganizationSprintBase, IMemberEntityBased {} + export enum SprintStartDayEnum { SUNDAY = 1, MONDAY = 2, @@ -26,14 +50,41 @@ export enum SprintStartDayEnum { SATURDAY = 7 } -export interface IOrganizationSprintUpdateInput { - name: string; - goal?: string; - length: number; - startDate?: Date; - endDate?: Date; - dayStart?: number; - project?: IOrganizationProject; - isActive?: boolean; - tasks?: ITask[]; +export enum OrganizationSprintStatusEnum { + ACTIVE = 'active', + COMPLETED = 'completed', + DRAFT = 'draft', + UPCOMING = 'upcoming' +} + +export interface IOrganizationSprintUpdateInput extends IOrganizationSprintCreateInput {} + +export interface IOrganizationSprintEmployee + extends IBasePerTenantAndOrganizationEntityModel, + IEmployeeEntityInput, + IRelationalRole { + organizationSprint: IOrganizationSprint; + organizationSprintId: ID; + isManager?: boolean; + assignedAt?: Date; +} + +export interface IOrganizationSprintTask extends IBasePerTenantAndOrganizationEntityModel { + organizationSprint: IOrganizationSprint; + organizationSprintId: ID; + task?: ITask; + taskId: ID; + totalWorkedHours?: number; +} + +export interface IOrganizationSprintTaskHistory extends IBasePerTenantAndOrganizationEntityModel { + reason?: string; + task?: ITask; + taskId?: ID; + fromSprint?: IOrganizationSprint; + fromSprintId?: ID; + toSprint?: IOrganizationSprint; + toSprintId?: ID; + movedBy?: IUser; + movedById?: ID; } diff --git a/packages/contracts/src/organization-team.model.ts b/packages/contracts/src/organization-team.model.ts index 1d49272cc4d..7df3d4bfc70 100644 --- a/packages/contracts/src/organization-team.model.ts +++ b/packages/contracts/src/organization-team.model.ts @@ -1,16 +1,16 @@ -import { IEmployeeEntityInput } from './employee.model'; +import { IEmployeeEntityInput, IMemberEntityBased } from './employee.model'; import { IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { IOrganizationTeamEmployee } from './organization-team-employee-model'; -import { ITag } from './tag.model'; +import { ITaggable } from './tag.model'; import { ITask } from './task.model'; import { ITimerStatusInput } from './timesheet.model'; import { IRelationalImageAsset } from './image-asset.model'; import { CrudActionEnum } from './organization.model'; -import { IOrganizationProject } from './organization-projects.model'; +import { IOrganizationProject, IOrganizationProjectCreateInput } from './organization-projects.model'; import { IOrganizationProjectModule } from './organization-project-module.model'; import { IComment } from './comment.model'; -export interface IOrganizationTeam extends IBasePerTenantAndOrganizationEntityModel, IRelationalImageAsset { +export interface IOrganizationTeam extends IBasePerTenantAndOrganizationEntityModel, IRelationalImageAsset, ITaggable { name: string; color?: string; emoji?: string; @@ -26,7 +26,6 @@ export interface IOrganizationTeam extends IBasePerTenantAndOrganizationEntityMo projects?: IOrganizationProject[]; modules?: IOrganizationProjectModule[]; assignedComments?: IComment[]; - tags?: ITag[]; tasks?: ITask[]; } @@ -38,7 +37,11 @@ export interface IOrganizationTeamFindInput extends IBasePerTenantAndOrganizatio members?: IOrganizationTeamEmployee; } -export interface IOrganizationTeamCreateInput extends IBasePerTenantAndOrganizationEntityModel, IRelationalImageAsset { +export interface IOrganizationTeamCreateInput + extends IBasePerTenantAndOrganizationEntityModel, + IRelationalImageAsset, + IMemberEntityBased, + ITaggable { name: string; emoji?: string; teamSize?: string; @@ -49,14 +52,10 @@ export interface IOrganizationTeamCreateInput extends IBasePerTenantAndOrganizat requirePlanToTrack?: boolean; public?: boolean; profile_link?: string; - memberIds?: ID[]; - managerIds?: ID[]; - tags?: ITag[]; - projects?: IOrganizationProject[]; + projects?: IOrganizationProjectCreateInput[]; } export interface IOrganizationTeamUpdateInput extends Partial { - id: string; shareProfileView?: boolean; requirePlanToTrack?: boolean; public?: boolean; diff --git a/packages/contracts/src/organization.model.ts b/packages/contracts/src/organization.model.ts index 3b7ccc24d53..cc3c0adc7a7 100644 --- a/packages/contracts/src/organization.model.ts +++ b/packages/contracts/src/organization.model.ts @@ -8,7 +8,7 @@ import { IOrganizationAward } from './organization-award.model'; import { IOrganizationLanguage } from './organization-language.model'; import { IOrganizationSprint } from './organization-sprint.model'; import { ISkill } from './skill-entity.model'; -import { ITag } from './tag.model'; +import { ITag, ITaggable } from './tag.model'; import { ITenant } from './tenant.model'; import { IReportOrganization } from './report.model'; import { IRelationalImageAsset } from './image-asset.model'; @@ -31,7 +31,11 @@ export enum ListsInputTypeEnum { VENDORS = 'VENDORS' } -export interface IOrganization extends IBasePerTenantEntityModel, IRelationalImageAsset, IOrganizationTimerSetting { +export interface IOrganization + extends IBasePerTenantEntityModel, + IRelationalImageAsset, + IOrganizationTimerSetting, + ITaggable { name: string; isDefault: boolean; profile_link: string; @@ -63,12 +67,11 @@ export interface IOrganization extends IBasePerTenantEntityModel, IRelationalIma startWeekOn?: WeekDaysEnum; taxId?: string; numberFormat?: string; - bonusType?: string; + bonusType?: BonusTypeEnum; bonusPercentage?: number; employees?: IEmployee[]; invitesAllowed?: boolean; inviteExpiryPeriod?: number; - tags: ITag[]; futureDateAllowed?: boolean; allowManualTime?: boolean; allowModifyTime?: boolean; @@ -140,6 +143,7 @@ export interface IOrganizationCreateInput extends IContact, IRegisterAsEmployee, show_clients_count?: boolean; show_employees_count?: boolean; defaultValueDateType?: DefaultValueDateTypeEnum; + standardWorkHoursPerDay?: number; dateFormat?: string; timeZone?: string; officialName?: string; @@ -274,10 +278,6 @@ export enum MinimumProjectSizeEnum { ONE_HUNDRED_THOUSAND = '100000+' } -export const DEFAULT_PROFIT_BASED_BONUS = 75; -export const DEFAULT_REVENUE_BASED_BONUS = 10; -export const DEFAULT_INVITE_EXPIRY_PERIOD = 7; - export interface IOrganizationStoreState { organization: IOrganization; action: CrudActionEnum; @@ -300,17 +300,19 @@ export enum TimeZoneEnum { MINE_TIMEZONE = 'mine' } -export const DEFAULT_TIME_FORMATS: number[] = [TimeFormatEnum.FORMAT_12_HOURS, TimeFormatEnum.FORMAT_24_HOURS]; -export const DEFAULT_DATE_FORMATS: string[] = ['L', 'LL', 'dddd, LL']; - export interface IKeyValuePair { key: string; value: boolean | string; } +export const DEFAULT_STANDARD_WORK_HOURS_PER_DAY = 8; // Adjust the default value if needed +export const DEFAULT_TIME_FORMATS: number[] = [TimeFormatEnum.FORMAT_12_HOURS, TimeFormatEnum.FORMAT_24_HOURS]; +export const DEFAULT_PROFIT_BASED_BONUS = 75; +export const DEFAULT_REVENUE_BASED_BONUS = 10; +export const DEFAULT_INVITE_EXPIRY_PERIOD = 7; +export const DEFAULT_DATE_FORMATS: string[] = ['L', 'LL', 'dddd, LL']; export const DEFAULT_INACTIVITY_TIME_LIMITS: number[] = [1, 5, 10, 20, 30]; export const DEFAULT_ACTIVITY_PROOF_DURATIONS: number[] = [1, 3, 5, 10]; - export const DEFAULT_SCREENSHOT_FREQUENCY_OPTIONS: number[] = [1, 3, 5, 10]; export interface IOrganizationTimerSetting { @@ -323,4 +325,5 @@ export interface IOrganizationTimerSetting { trackOnSleep?: boolean; screenshotFrequency?: number; enforced?: boolean; + standardWorkHoursPerDay?: number; } diff --git a/packages/contracts/src/resource-link.model.ts b/packages/contracts/src/resource-link.model.ts new file mode 100644 index 00000000000..1fed06631f9 --- /dev/null +++ b/packages/contracts/src/resource-link.model.ts @@ -0,0 +1,19 @@ +import { IURLMetaData } from './timesheet.model'; +import { BaseEntityEnum, IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; +import { IUser } from './user.model'; + +export interface IResourceLink extends IBasePerTenantAndOrganizationEntityModel { + entity: BaseEntityEnum; + entityId: ID; + title: string; + url: string; + creator?: IUser; + creatorId?: ID; + metaData?: string | IURLMetaData; +} + +export interface IResourceLinkCreateInput extends Omit {} + +export interface IResourceLinkUpdateInput extends Partial> {} + +export interface IResourceLinkFindInput extends Pick {} diff --git a/packages/contracts/src/role-permission.model.ts b/packages/contracts/src/role-permission.model.ts index 42ace6cb4db..5ed229fff73 100644 --- a/packages/contracts/src/role-permission.model.ts +++ b/packages/contracts/src/role-permission.model.ts @@ -121,8 +121,10 @@ export enum PermissionsEnum { VIEW_SALES_PIPELINES = 'VIEW_SALES_PIPELINES', EDIT_SALES_PIPELINES = 'EDIT_SALES_PIPELINES', CAN_APPROVE_TIMESHEET = 'CAN_APPROVE_TIMESHEET', + ORG_SPRINT_ADD = 'ORG_SPRINT_ADD', ORG_SPRINT_VIEW = 'ORG_SPRINT_VIEW', ORG_SPRINT_EDIT = 'ORG_SPRINT_EDIT', + ORG_SPRINT_DELETE = 'ORG_SPRINT_DELETE', ORG_CONTACT_EDIT = 'ORG_CONTACT_EDIT', ORG_CONTACT_VIEW = 'ORG_CONTACT_VIEW', ORG_PROJECT_ADD = 'ORG_PROJECT_ADD', @@ -207,7 +209,10 @@ export enum PermissionsEnum { PROJECT_MODULE_CREATE = 'PROJECT_MODULE_CREATE', PROJECT_MODULE_READ = 'PROJECT_MODULE_READ', PROJECT_MODULE_UPDATE = 'PROJECT_MODULE_UPDATE', - PROJECT_MODULE_DELETE = 'PROJECT_MODULE_DELETE' + PROJECT_MODULE_DELETE = 'PROJECT_MODULE_DELETE', + /** API Call Log */ + API_CALL_LOG_READ = 'API_CALL_LOG_READ', + API_CALL_LOG_DELETE = 'API_CALL_LOG_DELETE' } export const PermissionGroups = { @@ -299,8 +304,10 @@ export const PermissionGroups = { PermissionsEnum.VIEW_SALES_PIPELINES, PermissionsEnum.EDIT_SALES_PIPELINES, PermissionsEnum.CAN_APPROVE_TIMESHEET, + PermissionsEnum.ORG_SPRINT_ADD, PermissionsEnum.ORG_SPRINT_EDIT, PermissionsEnum.ORG_SPRINT_VIEW, + PermissionsEnum.ORG_SPRINT_DELETE, PermissionsEnum.ORG_PROJECT_ADD, PermissionsEnum.ORG_PROJECT_VIEW, PermissionsEnum.ORG_PROJECT_EDIT, @@ -381,6 +388,8 @@ export const PermissionGroups = { PermissionsEnum.IMPORT_ADD, PermissionsEnum.EXPORT_ADD, PermissionsEnum.ACCESS_DELETE_ALL_DATA, - PermissionsEnum.TENANT_SETTING + PermissionsEnum.TENANT_SETTING, + PermissionsEnum.API_CALL_LOG_READ, + PermissionsEnum.API_CALL_LOG_DELETE ] }; diff --git a/packages/contracts/src/screenshot.model.ts b/packages/contracts/src/screenshot.model.ts index 3d0f080ebbc..3fec57225bb 100644 --- a/packages/contracts/src/screenshot.model.ts +++ b/packages/contracts/src/screenshot.model.ts @@ -1,6 +1,6 @@ import { IBasePerTenantAndOrganizationEntityModel, ID } from './base-entity.model'; import { FileStorageProvider } from './file-provider'; -import { ITimeSlot } from './timesheet.model'; +import { IDeleteEntity, ITimeSlot } from './timesheet.model'; import { IRelationalUser } from './user.model'; export interface IScreenshot extends IBasePerTenantAndOrganizationEntityModel, IRelationalUser { @@ -36,3 +36,9 @@ export interface IScreenshotCreateInput extends IBasePerTenantAndOrganizationEnt thumb?: string; recordedAt: Date | string; } + +/** + * Interface for deleting time slots. + * Includes an array of time slot IDs to be deleted. + */ +export interface IDeleteScreenshot extends IDeleteEntity {} diff --git a/packages/contracts/src/task-view.model.ts b/packages/contracts/src/task-view.model.ts new file mode 100644 index 00000000000..47a29647467 --- /dev/null +++ b/packages/contracts/src/task-view.model.ts @@ -0,0 +1,36 @@ +import { IBasePerTenantAndOrganizationEntityModel, JsonData } from './base-entity.model'; +import { IRelationalOrganizationTeam } from './organization-team.model'; +import { IRelationalOrganizationProject } from './organization-projects.model'; +import { IRelationalOrganizationProjectModule } from './organization-project-module.model'; +import { IRelationalOrganizationSprint } from './organization-sprint.model'; + +export interface ITaskViewBase + extends IBasePerTenantAndOrganizationEntityModel, + IRelationalOrganizationTeam, + IRelationalOrganizationProject, + IRelationalOrganizationProjectModule, + IRelationalOrganizationSprint { + name?: string; + description?: string; + visibilityLevel?: VisibilityLevelEnum; + queryParams?: JsonData; + filterOptions?: JsonData; + displayOptions?: JsonData; + properties?: Record; + isLocked?: boolean; +} + +export interface ITaskView extends ITaskViewBase { + name: string; // Ensure name is always defined +} + +export interface ITaskViewCreateInput extends ITaskViewBase {} + +export interface ITaskViewUpdateInput extends ITaskViewCreateInput {} + +export enum VisibilityLevelEnum { + MODULE_AND_SPRINT = 0, + TEAM_AND_PROJECT = 1, + ORGANIZATION = 2, + WORKSPACE = 3 +} diff --git a/packages/contracts/src/task.model.ts b/packages/contracts/src/task.model.ts index b8bb62e2b48..441710a0036 100644 --- a/packages/contracts/src/task.model.ts +++ b/packages/contracts/src/task.model.ts @@ -2,7 +2,11 @@ import { IBasePerTenantAndOrganizationEntityModel, IBaseRelationsEntityModel, ID import { IEmployee } from './employee.model'; import { IInvoiceItem } from './invoice-item.model'; import { IRelationalOrganizationProject } from './organization-projects.model'; -import { IOrganizationSprint } from './organization-sprint.model'; +import { + IOrganizationSprint, + IRelationalOrganizationSprint, + IOrganizationSprintTaskHistory +} from './organization-sprint.model'; import { IOrganizationTeam } from './organization-team.model'; import { ITag } from './tag.model'; import { IUser } from './user.model'; @@ -10,8 +14,12 @@ import { ITaskStatus, TaskStatusEnum } from './task-status.model'; import { ITaskPriority, TaskPriorityEnum } from './task-priority.model'; import { ITaskSize, TaskSizeEnum } from './task-size.model'; import { IOrganizationProjectModule } from './organization-project-module.model'; +import { TaskTypeEnum } from './issue-type.model'; -export interface ITask extends IBasePerTenantAndOrganizationEntityModel, IRelationalOrganizationProject { +export interface ITask + extends IBasePerTenantAndOrganizationEntityModel, + IRelationalOrganizationProject, + IRelationalOrganizationSprint { title: string; number?: number; public?: boolean; @@ -27,8 +35,8 @@ export interface ITask extends IBasePerTenantAndOrganizationEntityModel, IRelati invoiceItems?: IInvoiceItem[]; teams?: IOrganizationTeam[]; modules?: IOrganizationProjectModule[]; - organizationSprint?: IOrganizationSprint; - organizationSprintId?: ID; + taskSprints?: IOrganizationSprint[]; + taskSprintHistories?: IOrganizationSprintTaskHistory[]; creator?: IUser; creatorId?: ID; isDraft?: boolean; // Define if task is still draft (E.g : Task description not completed yet) @@ -66,8 +74,31 @@ export type ITaskCreateInput = ITask; export interface ITaskUpdateInput extends ITaskCreateInput { id?: string; + taskSprintMoveReason?: string; } export interface IGetTaskById { includeRootEpic?: boolean; } + +export interface IGetTasksByViewFilters extends IBasePerTenantAndOrganizationEntityModel { + projects?: ID[]; + teams?: ID[]; + modules?: ID[]; + sprints?: ID[]; + members?: ID[]; + tags?: ID[]; + statusIds?: ID[]; + statuses?: TaskStatusEnum[]; + priorityIds?: ID[]; + priorities?: TaskPriorityEnum[]; + sizeIds?: ID[]; + sizes?: TaskSizeEnum[]; + types?: TaskTypeEnum[]; + startDates?: Date[]; + dueDates?: Date[]; + creators?: ID[]; + + // Relations + relations?: string[]; +} diff --git a/packages/contracts/src/tenant.model.ts b/packages/contracts/src/tenant.model.ts index 3519095c249..f76103b9deb 100644 --- a/packages/contracts/src/tenant.model.ts +++ b/packages/contracts/src/tenant.model.ts @@ -9,15 +9,12 @@ import { } from './file-provider'; import { IOrganization } from './organization.model'; import { IRolePermission } from './role-permission.model'; +import { IBaseEntityModel, ID } from './base-entity.model'; -export interface ITenant extends IRelationalImageAsset { - id?: string; +export interface ITenant extends IBaseEntityModel, IRelationalImageAsset { name?: string; logo?: string; - - readonly createdAt?: Date; - readonly updatedAt?: Date; - + standardWorkHoursPerDay?: number; organizations?: IOrganization[]; rolePermissions?: IRolePermission[]; featureOrganizations?: IFeatureOrganization[]; @@ -27,7 +24,7 @@ export interface ITenant extends IRelationalImageAsset { export interface ITenantCreateInput extends ITenantUpdateInput { isImporting?: boolean; sourceId?: string; - userSourceId?: string; + userSourceId?: ID; } export interface ITenantUpdateInput extends IRelationalImageAsset { @@ -35,6 +32,9 @@ export interface ITenantUpdateInput extends IRelationalImageAsset { logo?: string; } -export interface ITenantSetting extends IS3FileStorageProviderConfig, IWasabiFileStorageProviderConfig, ICloudinaryFileStorageProviderConfig { +export interface ITenantSetting + extends IS3FileStorageProviderConfig, + IWasabiFileStorageProviderConfig, + ICloudinaryFileStorageProviderConfig { fileStorageProvider?: FileStorageProviderEnum; } diff --git a/packages/contracts/src/timesheet-statistics.model.ts b/packages/contracts/src/timesheet-statistics.model.ts index 59bc0b8a0db..6dbc334c2e1 100644 --- a/packages/contracts/src/timesheet-statistics.model.ts +++ b/packages/contracts/src/timesheet-statistics.model.ts @@ -108,6 +108,19 @@ export interface ICountsStatistics { todayDuration: number; } +/** + * Weekly Statistics Activities + */ +export interface IWeeklyStatisticsActivities { + overall: number; + duration: number; +} + +/** + * Today Statistics Activities + */ +export interface ITodayStatisticsActivities extends IWeeklyStatisticsActivities {} + export interface ISelectedDateRange { startDate: Date; endDate: Date; diff --git a/packages/contracts/src/timesheet.model.ts b/packages/contracts/src/timesheet.model.ts index 2d79dda174c..64b9622b946 100644 --- a/packages/contracts/src/timesheet.model.ts +++ b/packages/contracts/src/timesheet.model.ts @@ -32,13 +32,13 @@ export interface ITimesheet extends IBasePerTenantAndOrganizationEntityModel { lockedAt?: Date; editedAt?: Date; isBilled?: boolean; - status: string; + status: TimesheetStatus; isEdited?: boolean; } export interface ITimesheetCreateInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - approvedById?: string; + employeeId: ID; + approvedById?: ID; duration: number; keyboard: number; mouse: number; @@ -53,8 +53,8 @@ export interface ITimesheetCreateInput extends IBasePerTenantAndOrganizationEnti } export interface ITimeSheetFindInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - approvedById?: string; + employeeId: ID; + approvedById?: ID; employee: IEmployeeFindInput; isBilled?: boolean; status?: string; @@ -73,12 +73,12 @@ export enum TimesheetStatus { } export interface IUpdateTimesheetStatusInput extends IBasePerTenantAndOrganizationEntityModel { - ids: string | string[]; + ids: ID | ID[]; status?: TimesheetStatus; } export interface ISubmitTimesheetInput extends IBasePerTenantAndOrganizationEntityModel { - ids: string | string[]; + ids: ID | ID[]; status: 'submit' | 'unsubmit'; } @@ -86,9 +86,9 @@ export interface IGetTimesheetInput extends IBasePerTenantAndOrganizationEntityM onlyMe?: boolean; startDate?: Date | string; endDate?: Date | string; - projectIds?: string[]; - clientId?: string[]; - employeeIds?: string[]; + projectIds?: ID[]; + clientId?: ID[]; + employeeIds?: ID[]; } export interface IDateRange { @@ -98,7 +98,8 @@ export interface IDateRange { export interface ITimeLog extends IBasePerTenantAndOrganizationEntityModel, IRelationalOrganizationProject, - IRelationalOrganizationTeam, ITaggable { + IRelationalOrganizationTeam, + ITaggable { employee: IEmployee; employeeId: ID; timesheet?: ITimesheet; @@ -124,10 +125,10 @@ export interface ITimeLog } export interface ITimeLogCreateInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - timesheetId?: string; - taskId?: string; - projectId: string; + employeeId: ID; + timesheetId?: ID; + taskId?: ID; + projectId: ID; startedAt?: Date; stoppedAt?: Date; logType: TimeLogType; @@ -138,7 +139,7 @@ export interface ITimeLogCreateInput extends IBasePerTenantAndOrganizationEntity } export interface ITimeSlotCreateInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; + employeeId: ID; duration: number; keyboard: number; mouse: number; @@ -175,19 +176,19 @@ export interface ITimeLogFilters extends IBasePerTenantAndOrganizationEntityMode startDate?: Date | string; endDate?: Date | string; isCustomDate?: boolean; - projectIds?: string[]; - teamIds?: string[]; - employeeIds?: string[]; + employeeIds?: ID[]; + projectIds?: ID[]; + teamIds?: ID[]; + taskIds?: ID[]; logType?: TimeLogType[]; source?: TimeLogSourceEnum[]; activityLevel?: { start: number; end: number; }; - taskIds?: string[]; defaultRange?: boolean; unitOfTime?: any; - categoryId?: string; + categoryId?: ID; timeZone?: string; timeFormat?: TimeFormatEnum; } @@ -199,14 +200,14 @@ export interface ITimeLogTodayFilters extends IBasePerTenantAndOrganizationEntit export interface ITimeSlot extends IBasePerTenantAndOrganizationEntityModel { [x: string]: any; - employeeId: string; + employeeId: ID; employee?: IEmployee; activities?: IActivity[]; screenshots?: IScreenshot[]; timeLogs?: ITimeLog[]; timeSlotMinutes?: ITimeSlotMinute[]; project?: IOrganizationProject; - projectId?: string; + projectId?: ID; duration?: number; keyboard?: number; mouse?: number; @@ -223,13 +224,13 @@ export interface ITimeSlot extends IBasePerTenantAndOrganizationEntityModel { export interface ITimeSlotTimeLogs extends IBasePerTenantAndOrganizationEntityModel { timeLogs: ITimeLog[]; timeSlots: ITimeSlot[]; - timeLogId: string; - timeSlotId: string; + timeLogId: ID; + timeSlotId: ID; } export interface ITimeSlotMinute extends IBasePerTenantAndOrganizationEntityModel { timeSlot?: ITimeSlot; - timeSlotId?: string; + timeSlotId?: ID; keyboard?: number; mouse?: number; datetime?: Date; @@ -239,20 +240,19 @@ export interface IActivity extends IBasePerTenantAndOrganizationEntityModel { title: string; description?: string; employee?: IEmployee; - employeeId?: string; + employeeId?: ID; timeSlot?: ITimeSlot; - timeSlotId?: string; + timeSlotId?: ID; project?: IOrganizationProject; - projectId?: string; + projectId?: ID; task?: ITask; - taskId?: string; + taskId?: ID; metaData?: string | IURLMetaData; date: string; time: string; duration?: number; type?: string; source?: string; - id?: string; activityTimestamp?: string; recordedAt?: Date; } @@ -261,7 +261,7 @@ export interface IDailyActivity { [x: string]: any; sessions?: number; duration?: number; - employeeId?: string; + employeeId?: ID; date?: string; title?: string; description?: string; @@ -270,15 +270,15 @@ export interface IDailyActivity { } export interface ICreateActivityInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId?: string; - projectId?: string; + employeeId?: ID; + projectId?: ID; + timeSlotId?: ID; duration?: number; keyboard?: number; mouse?: number; overall?: number; startedAt?: Date; stoppedAt?: Date; - timeSlotId?: string; type: string; title: string; data?: string; @@ -302,7 +302,7 @@ export interface ITimerStatusInput IEmployeeEntityInput, IRelationalOrganizationTeam { source?: TimeLogSourceEnum; - employeeIds?: string[]; + employeeIds?: ID[]; } export interface ITimerStatus { @@ -329,9 +329,9 @@ export interface ITimerPosition { export interface ITimerToggleInput extends IBasePerTenantAndOrganizationEntityModel, Pick { - projectId?: string; - taskId?: string; - organizationContactId?: string; + projectId?: ID; + taskId?: ID; + organizationContactId?: ID; description?: string; logType?: TimeLogType; source?: TimeLogSourceEnum; @@ -344,11 +344,10 @@ export interface ITimerToggleInput } export interface IManualTimeInput extends IBasePerTenantAndOrganizationEntityModel { - id?: string; - employeeId?: string; - projectId?: string; - taskId?: string; - organizationContactId?: string; + employeeId?: ID; + projectId?: ID; + taskId?: ID; + organizationContactId?: ID; logType?: TimeLogType; description?: string; reason?: string; @@ -361,7 +360,7 @@ export interface IManualTimeInput extends IBasePerTenantAndOrganizationEntityMod export interface IGetTimeLogInput extends ITimeLogFilters, IBaseRelationsEntityModel { onlyMe?: boolean; - timesheetId?: string; + timesheetId?: ID; } export interface IGetTimeLogReportInput extends IGetTimeLogInput { @@ -370,10 +369,10 @@ export interface IGetTimeLogReportInput extends IGetTimeLogInput { } export interface IGetTimeLogConflictInput extends IBasePerTenantAndOrganizationEntityModel, IBaseRelationsEntityModel { - ignoreId?: string | string[]; + ignoreId?: ID | ID[]; startDate: string | Date; endDate: string | Date; - employeeId: string; + employeeId: ID; } export interface IGetTimeSlotInput extends ITimeLogFilters, IBaseRelationsEntityModel { @@ -387,8 +386,8 @@ export interface IGetActivitiesInput extends ITimeLogFilters, IPaginationInput, } export interface IBulkActivitiesInput extends IBasePerTenantAndOrganizationEntityModel { - employeeId: string; - projectId?: string; + employeeId: ID; + projectId?: ID; activities: IActivity[]; } @@ -397,7 +396,7 @@ export interface IReportDayGroupByDate { logs: { project: IOrganizationProject; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -419,7 +418,7 @@ export interface IReportDayGroupByDate { logs: { project: IOrganizationProject; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -435,7 +434,7 @@ export interface IReportDayGroupByEmployee { sum: number; activity: number; project: IOrganizationProject; - task: ITask; + tasks: ITask[]; }[]; }[]; } @@ -445,7 +444,7 @@ export interface IReportDayGroupByProject { logs: { date: string; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -460,7 +459,7 @@ export interface IReportDayGroupByClient { logs: { date: string; employeeLogs: { - task: ITask; + tasks: ITask[]; employee: IEmployee; sum: number; activity: number; @@ -507,11 +506,26 @@ export interface IClientBudgetLimitReport { remainingBudget?: number; } -export interface IDeleteTimeSlot extends IBasePerTenantAndOrganizationEntityModel { - ids: string[]; +/** + * Base interface for delete operations that include forceDelete flag + * and extend the tenant and organization properties. + */ +export interface IDeleteEntity extends IBasePerTenantAndOrganizationEntityModel { + forceDelete?: boolean; +} + +/** + * Interface for deleting time slots. + * Includes an array of time slot IDs to be deleted. + */ +export interface IDeleteTimeSlot extends IDeleteEntity { + ids: ID[]; } -export interface IDeleteTimeLog extends IBasePerTenantAndOrganizationEntityModel { - logIds: string[]; - forceDelete: boolean; +/** + * Interface for deleting time logs. + * Includes an array of log IDs to be deleted. + */ +export interface IDeleteTimeLog extends IDeleteEntity { + logIds: ID[]; } diff --git a/packages/contracts/src/user.model.ts b/packages/contracts/src/user.model.ts index a54ab475275..b6d92d48920 100644 --- a/packages/contracts/src/user.model.ts +++ b/packages/contracts/src/user.model.ts @@ -28,8 +28,8 @@ export interface IFindMeUser extends IBaseRelationsEntityModel { } export interface IRelationalUser { - user?: IUser; - userId?: IUser['id']; + user?: IUser; // User who performed the action (if applicable). + userId?: ID; // The ID of the user who performed the action (if applicable). } export interface IUser extends IBasePerTenantEntityModel, IRelationalImageAsset { diff --git a/packages/core/package.json b/packages/core/package.json index 474a6b5bbc7..3c7e2c32569 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -61,7 +61,6 @@ "@mikro-orm/mysql": "^6.2.3", "@mikro-orm/nestjs": "^5.2.3", "@mikro-orm/postgresql": "^6.2.3", - "@mikro-orm/sqlite": "^6.2.3", "@nestjs/apollo": "^12.1.0", "@nestjs/axios": "^3.0.2", "@nestjs/cache-manager": "^2.2.1", @@ -118,8 +117,8 @@ "archiver": "^5.3.0", "atlassian-connect-express": "^8.5.0", "axios": "^1.7.4", - "bcrypt": "^5.1.0", - "better-sqlite3": "^9.4.3", + "bcrypt": "^5.1.1", + "better-sqlite3": "^9.5.0", "cache-manager": "^5.3.2", "cache-manager-redis-yet": "^4.1.2", "camelcase": "^6.3.0", @@ -176,8 +175,8 @@ "passport": "^0.6.0", "passport-jwt": "^4.0.0", "pdfmake": "^0.2.0", - "pg": "^8.11.4", - "pg-query-stream": "^4.5.4", + "pg": "^8.13.1", + "pg-query-stream": "^4.7.1", "prettier": "^2.8.4", "redis": "^4.6.12", "reflect-metadata": "^0.1.13", @@ -185,7 +184,6 @@ "rimraf": "^3.0.2", "rxjs": "^7.4.0", "sql.js": "^1.5.0", - "sqlite3": "^5.1.7", "streamifier": "^0.1.1", "swagger-ui-express": "^5.0.0", "typeorm": "^0.3.20", @@ -200,7 +198,7 @@ "@nestjs/cli": "^10.3.2", "@nestjs/schematics": "^10.1.1", "@nestjs/testing": "^10.3.7", - "@types/bcrypt": "^5.0.0", + "@types/bcrypt": "^5.0.2", "@types/email-templates": "^7.1.0", "@types/express": "^4.17.13", "@types/fs-extra": "5.0.2", diff --git a/packages/core/src/activity-log/activity-log.controller.ts b/packages/core/src/activity-log/activity-log.controller.ts index 448aab59753..4e57f679b18 100644 --- a/packages/core/src/activity-log/activity-log.controller.ts +++ b/packages/core/src/activity-log/activity-log.controller.ts @@ -10,7 +10,7 @@ import { ActivityLogService } from './activity-log.service'; @Permissions() @Controller('/activity-log') export class ActivityLogController { - constructor(readonly _activityLogService: ActivityLogService) {} + constructor(readonly _activityLogService: ActivityLogService) {} /** * Retrieves activity logs based on query parameters. @@ -20,10 +20,8 @@ export class ActivityLogController { * @returns A list of activity logs. */ @Get('/') - @UseValidationPipe() - async getActivityLogs( - @Query() query: GetActivityLogsDTO - ): Promise> { + @UseValidationPipe({ transform: true }) + async getActivityLogs(@Query() query: GetActivityLogsDTO): Promise> { return await this._activityLogService.findActivityLogs(query); } } diff --git a/packages/core/src/activity-log/activity-log.entity.ts b/packages/core/src/activity-log/activity-log.entity.ts index 4b1c9eaa902..f43272f9e7c 100644 --- a/packages/core/src/activity-log/activity-log.entity.ts +++ b/packages/core/src/activity-log/activity-log.entity.ts @@ -3,29 +3,22 @@ import { EntityRepositoryType } from '@mikro-orm/core'; import { JoinColumn, RelationId } from 'typeorm'; import { IsArray, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { isMySQL, isPostgres } from '@gauzy/config'; -import { - ActivityLogEntityEnum, - ActionTypeEnum, - ActorTypeEnum, - IActivityLog, - ID, - IUser, - JsonData -} from '@gauzy/contracts'; +import { BaseEntityEnum, ActionTypeEnum, ActorTypeEnum, IActivityLog, ID, IUser, JsonData } from '@gauzy/contracts'; import { TenantOrganizationBaseEntity, User } from '../core/entities/internal'; import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { ActorTypeTransformerPipe } from '../shared/pipes'; import { MikroOrmActivityLogRepository } from './repository/mikro-orm-activity-log.repository'; @MultiORMEntity('activity_log', { mikroOrmRepository: () => MikroOrmActivityLogRepository }) export class ActivityLog extends TenantOrganizationBaseEntity implements IActivityLog { [EntityRepositoryType]?: MikroOrmActivityLogRepository; - @ApiProperty({ type: () => String, enum: ActivityLogEntityEnum }) + @ApiProperty({ enum: BaseEntityEnum }) @IsNotEmpty() - @IsEnum(ActivityLogEntityEnum) + @IsEnum(BaseEntityEnum) @ColumnIndex() @MultiORMColumn() - entity: ActivityLogEntityEnum; + entity: BaseEntityEnum; @ApiProperty({ type: () => String }) @IsNotEmpty() @@ -34,19 +27,19 @@ export class ActivityLog extends TenantOrganizationBaseEntity implements IActivi @MultiORMColumn() entityId: ID; - @ApiProperty({ type: () => String, enum: ActionTypeEnum }) + @ApiProperty({ enum: ActionTypeEnum }) @IsNotEmpty() @IsEnum(ActionTypeEnum) @ColumnIndex() @MultiORMColumn() action: ActionTypeEnum; - @ApiPropertyOptional({ type: () => String, enum: ActorTypeEnum }) + @ApiPropertyOptional({ enum: ActorTypeEnum }) @IsOptional() @IsEnum(ActorTypeEnum) @ColumnIndex() - @MultiORMColumn({ nullable: true }) - actorType?: ActorTypeEnum; + @MultiORMColumn({ type: 'int', nullable: true, transformer: new ActorTypeTransformerPipe() }) + actorType?: ActorTypeEnum; // Will be stored as 0 or 1 in DB @ApiPropertyOptional({ type: () => String }) @IsOptional() diff --git a/packages/core/src/activity-log/activity-log.helper.ts b/packages/core/src/activity-log/activity-log.helper.ts index 9389d2eb0da..b9ef57e8c76 100644 --- a/packages/core/src/activity-log/activity-log.helper.ts +++ b/packages/core/src/activity-log/activity-log.helper.ts @@ -1,9 +1,9 @@ -import { ActionTypeEnum, ActivityLogEntityEnum } from "@gauzy/contracts"; +import { ActionTypeEnum, BaseEntityEnum, IActivityLogUpdatedValues } from '@gauzy/contracts'; const ActivityTemplates = { [ActionTypeEnum.Created]: `{action} a new {entity} called "{entityName}"`, [ActionTypeEnum.Updated]: `{action} {entity} "{entityName}"`, - [ActionTypeEnum.Deleted]: `{action} {entity} "{entityName}"`, + [ActionTypeEnum.Deleted]: `{action} {entity} "{entityName}"` }; /** @@ -15,7 +15,7 @@ const ActivityTemplates = { */ export function generateActivityLogDescription( action: ActionTypeEnum, - entity: ActivityLogEntityEnum, + entity: BaseEntityEnum, entityName: string ): string { // Get the template corresponding to the action @@ -31,7 +31,33 @@ export function generateActivityLogDescription( case 'entityName': return entityName; default: - return ''; + return ''; } }); } + +/** + * @description Log updated field names, old and new values for Activity Log Updated Actions + * @template T + * @param {T} originalValues - Old values before update + * @param {Partial} updated - Updated values + * @returns An object with updated fields, their old and new values + */ +export function activityLogUpdatedFieldsAndValues(originalValues: T, updated: Partial) { + const updatedFields: string[] = []; + const previousValues: IActivityLogUpdatedValues[] = []; + const updatedValues: IActivityLogUpdatedValues[] = []; + + for (const key of Object.keys(updated)) { + if (originalValues[key] !== updated[key]) { + // Add updated field + updatedFields.push(key); + + // Add old and new values + previousValues.push({ [key]: originalValues[key] }); + updatedValues.push({ [key]: updated[key] }); + } + } + + return { updatedFields, previousValues, updatedValues }; +} diff --git a/packages/core/src/activity-log/activity-log.module.ts b/packages/core/src/activity-log/activity-log.module.ts index 26bf823dfea..42505f1b37f 100644 --- a/packages/core/src/activity-log/activity-log.module.ts +++ b/packages/core/src/activity-log/activity-log.module.ts @@ -1,5 +1,5 @@ import { CqrsModule } from '@nestjs/cqrs'; -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RolePermissionModule } from '../role-permission/role-permission.module'; @@ -9,6 +9,7 @@ import { ActivityLogService } from './activity-log.service'; import { EventHandlers } from './events/handlers'; import { TypeOrmActivityLogRepository } from './repository/type-orm-activity-log.repository'; +@Global() @Module({ imports: [ TypeOrmModule.forFeature([ActivityLog]), diff --git a/packages/core/src/activity-log/activity-log.service.ts b/packages/core/src/activity-log/activity-log.service.ts index c125074cc7b..fa18e7334ac 100644 --- a/packages/core/src/activity-log/activity-log.service.ts +++ b/packages/core/src/activity-log/activity-log.service.ts @@ -1,8 +1,20 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { EventBus } from '@nestjs/cqrs'; import { FindManyOptions, FindOptionsOrder, FindOptionsWhere } from 'typeorm'; -import { IActivityLog, IActivityLogInput, IPagination } from '@gauzy/contracts'; +import { + ActionTypeEnum, + ActorTypeEnum, + BaseEntityEnum, + IActivityLog, + IActivityLogInput, + ID, + IPagination +} from '@gauzy/contracts'; +import { isNotNullOrUndefined } from '@gauzy/common'; import { TenantAwareCrudService } from './../core/crud'; import { RequestContext } from '../core/context'; +import { activityLogUpdatedFieldsAndValues, generateActivityLogDescription } from './activity-log.helper'; +import { ActivityLogEvent } from './events/activity-log.event'; import { GetActivityLogsDTO, allowedOrderDirections, allowedOrderFields } from './dto/get-activity-logs.dto'; import { ActivityLog } from './activity-log.entity'; import { MikroOrmActivityLogRepository, TypeOrmActivityLogRepository } from './repository'; @@ -11,15 +23,39 @@ import { MikroOrmActivityLogRepository, TypeOrmActivityLogRepository } from './r export class ActivityLogService extends TenantAwareCrudService { constructor( readonly typeOrmActivityLogRepository: TypeOrmActivityLogRepository, - readonly mikroOrmActivityLogRepository: MikroOrmActivityLogRepository + readonly mikroOrmActivityLogRepository: MikroOrmActivityLogRepository, + private readonly _eventBus: EventBus ) { super(typeOrmActivityLogRepository, mikroOrmActivityLogRepository); } /** - * Finds and retrieves activity logs based on the given filter criteria. + * Creates a new activity log entry with the provided input, while associating it with the current user and tenant. * - * @param {GetActivityLogsDTO} filter - Filter criteria to find activity logs, including entity, entityId, action, actorType, isActive, isArchived, orderBy, and order. + * @param input - The data required to create an activity log entry. + * @returns The created activity log entry. + * @throws BadRequestException when the log creation fails. + */ + async create(input: IActivityLogInput): Promise { + try { + // Retrieve the current user's ID from the request context + const creatorId = RequestContext.currentUserId(); + + // Retrieve the current tenant ID from the request context or use the provided tenantId + const tenantId = RequestContext.currentTenantId() || input.tenantId; + + // Create the activity log entry using the provided input along with the tenantId and creatorId + return await super.create({ ...input, tenantId, creatorId }); + } catch (error) { + console.log('Error while creating activity log:', error); + throw new BadRequestException('Error while creating activity log', error); + } + } + + /** + * Finds and retrieves activity logs based on the given filters criteria. + * + * @param {GetActivityLogsDTO} filters - Filter criteria to find activity logs, including entity, entityId, action, actorType, isActive, isArchived, orderBy, and order. * @returns {Promise>} - A promise that resolves to a paginated list of activity logs. * * Example usage: @@ -32,8 +68,9 @@ export class ActivityLogService extends TenantAwareCrudService { * }); * ``` */ - public async findActivityLogs(filter: GetActivityLogsDTO): Promise> { + public async findActivityLogs(filters: GetActivityLogsDTO): Promise> { const { + organizationId, entity, entityId, action, @@ -42,56 +79,97 @@ export class ActivityLogService extends TenantAwareCrudService { isArchived = false, orderBy = 'createdAt', order = 'DESC', - relations = [], - skip, - take - } = filter; + relations = [] + } = filters; + + // Fallback to default if invalid orderBy/order values are provided + const orderField = allowedOrderFields.includes(orderBy) ? orderBy : 'createdAt'; + const orderDirection = allowedOrderDirections.includes(order.toUpperCase()) ? order.toUpperCase() : 'DESC'; + + // Define order option + const orderOption: FindOptionsOrder = { [orderField]: orderDirection }; // Build the 'where' condition using concise syntax const where: FindOptionsWhere = { + ...(organizationId && { organizationId }), ...(entity && { entity }), ...(entityId && { entityId }), ...(action && { action }), - ...(actorType && { actorType }), + ...(isNotNullOrUndefined(actorType) && { actorType }), isActive, isArchived }; - // Fallback to default if invalid orderBy/order values are provided - const orderField = allowedOrderFields.includes(orderBy) ? orderBy : 'createdAt'; - const orderDirection = allowedOrderDirections.includes(order.toUpperCase()) ? order.toUpperCase() : 'DESC'; + const take = filters.take ? filters.take : 100; // Default take value if not provided + // Pagination: ensure `filters.skip` is a positive integer starting from 1 + const skip = filters.skip && Number.isInteger(filters.skip) && filters.skip > 0 ? filters.skip : 1; - // Define order option - const orderOption: FindOptionsOrder = { [orderField]: orderDirection }; - - // Define find options - const findOptions: FindManyOptions = { + // Ensure that filters are properly defined + const queryOptions: FindManyOptions = { where, - order: orderOption, - ...(skip && { skip }), - ...(take && { take }), - ...(relations && { relations }) + ...(relations && { relations }), + take: take, + skip: take * (skip - 1) // Calculate offset (skip) based on validated skip value }; + // Apply sorting options (if provided) + queryOptions.order = orderOption; + // Retrieve activity logs using the base class method - return await super.findAll(findOptions); + return await super.findAll(queryOptions); } /** - * Creates a new activity log entry with the provided input, while associating it with the current user and tenant. - * - * @param input - The data required to create an activity log entry. - * @returns The created activity log entry. - * @throws BadRequestException when the log creation fails. + * @description Create or Update Activity Log + * @template T + * @param {BaseEntityEnum} entityType - Entity type for whom creating activity log (E.g : Task, OrganizationProject, etc.) + * @param {string} entityName - Name or Title of the entity + * @param {ActorTypeEnum} actor - The actor type performing the action (User or System) + * @param {ID} organizationId + * @param {ID} tenantId + * @param {ActionTypeEnum} actionType - Action performed (Created or Updated) + * @param {T} data - Entity data (for Created action) or Updated entity data (for Updated action) + * @param {Partial} [originalValues] - Entity data before update (optional for Update action) + * @param {Partial} [newValues] - Entity updated data per field (optional for Update action) */ - async logActivity(input: IActivityLogInput): Promise { - try { - const creatorId = RequestContext.currentUserId(); // Retrieve the current user's ID from the request context - // Create the activity log entry using the provided input along with the tenantId and creatorId - return await super.create({ ...input, creatorId }); - } catch (error) { - console.log('Error while creating activity log:', error); - throw new BadRequestException('Error while creating activity log', error); + logActivity( + entity: BaseEntityEnum, + actionType: ActionTypeEnum, + actor: ActorTypeEnum, + entityId: ID, + entityName: string, + data: T, + organizationId: ID, + tenantId: ID, + originalValues?: Partial, + newValues?: Partial + ) { + let jsonFields: Record = new Object(); + + // If it's an update action, add updated fields and values + if (actionType === ActionTypeEnum.Updated && originalValues && newValues) { + const { updatedFields, previousValues, updatedValues } = activityLogUpdatedFieldsAndValues( + originalValues, + newValues + ); + + // Add updated fields and values to the log + jsonFields = Object.assign({}, { updatedFields, previousValues, updatedValues }); } + + // Emit the event to log the activity + this._eventBus.publish( + new ActivityLogEvent({ + entity, + entityId, + action: actionType, + actorType: actor, + description: generateActivityLogDescription(actionType, entity, entityName), + data, + organizationId, + tenantId, + ...jsonFields + }) + ); } } diff --git a/packages/core/src/activity-log/activity-log.subscriber.ts b/packages/core/src/activity-log/activity-log.subscriber.ts index 8ff9891d18e..77313c327b0 100644 --- a/packages/core/src/activity-log/activity-log.subscriber.ts +++ b/packages/core/src/activity-log/activity-log.subscriber.ts @@ -14,26 +14,88 @@ export class ActivityLogSubscriber extends BaseEntityEventSubscriber { + if (Array.isArray(entity[field]) || typeof entity[field] === 'object') { + entity[field] = JSON.stringify(entity[field]); + } + }); + } + + /** + * @description de-serialize Activity Log fields to support SQLite DB after load data + * @param {ActivityLog} entity - The ActivityLog entity that is about to be loaded or updated. + * @param {string[]} fields - Array fields to be de-serialized + */ + private deserializeFields(entity: ActivityLog, fields: string[]): void { + fields.forEach((field) => { + if (entity[field] && typeof entity[field] === 'string') { + entity[field] = JSON.parse(entity[field]); + } + }); + } + + /** + * Called before an ActivityLog entity is inserted or updated in the database. + * This method prepares the entity for insertion or update by serializing the data property to a JSON string * for SQLite databases. * - * @param entity The ActivityLog entity that is about to be created. - * @returns {Promise} A promise that resolves when the pre-creation processing is complete. + * @param entity The ActivityLog entity that is about to be created or updated. + * @returns {Promise} A promise that resolves when the pre-creation or pre-update processing is complete. */ - async beforeEntityCreate(entity: ActivityLog): Promise { + async serializeDataForSQLite(entity: ActivityLog): Promise { try { - // Check if the database is SQLite and the entity's metaData is a JavaScript object + // Check if the database is SQLite if (isSqlite() || isBetterSqlite3()) { - // ToDo: If need convert data to JSON before save - entity.data = JSON.stringify(entity.data); + // Serialize the `data` field if it's an object + if (typeof entity.data === 'object') { + entity.data = JSON.stringify(entity.data); + } + + // Serialize `updatedValues`, `previousValues`, `updatedEntities`, `previousEntities` if they are arrays or objects + this.serializeFields(entity, [ + 'updatedValues', + 'previousValues', + 'updatedEntities', + 'previousEntities' + ]); } } catch (error) { - // In case of error during JSON serialization, reset metaData to an empty object - entity.data = JSON.stringify({}); + // Log the error and reset the data to an empty object if JSON parsing fails + console.error('Error stringify data in serializeDataForSQLite:', error); + entity.data = '{}'; + ['updatedValues', 'previousValues', 'updatedEntities', 'previousEntities'].forEach((field) => { + entity[field] = '{}'; + }); } } + /** + * Called before an ActivityLog entity is inserted or created in the database. + * This method prepares the entity for insertion, particularly by serializing the data property to a JSON string + * + * @param entity The ActivityLog entity that is about to be created. + * @returns {Promise} A promise that resolves when the pre-insertion processing is complete. + */ + async beforeEntityCreate(entity: ActivityLog): Promise { + await this.serializeDataForSQLite(entity); + } + + /** + * Called before an ActivityLog entity is updated in the database. + * This method prepares the entity for update, particularly by serializing the data property to a JSON string + * + * @param entity The ActivityLog entity that is about to be updated. + * @returns {Promise} A promise that resolves when the pre-update processing is complete. + */ + async beforeEntityUpdate(entity: ActivityLog): Promise { + await this.serializeDataForSQLite(entity); + } + /** * Handles the parsing of JSON data after the ActivityLog entity is loaded from the database. * This function ensures that if the database is SQLite, the `data` field, stored as a JSON string, @@ -45,14 +107,28 @@ export class ActivityLogSubscriber extends BaseEntityEventSubscriber { try { - // Check if the database is SQLite and if `data` is a non-null string - if ((isSqlite() || isBetterSqlite3()) && entity.data && typeof entity.data === 'string') { - entity.data = JSON.parse(entity.data); + // Check if the database is SQLite + if (isSqlite() || isBetterSqlite3()) { + // Parse the `data` field if it's a string + if (entity.data && typeof entity.data === 'string') { + entity.data = JSON.parse(entity.data); + } + + // Parse `updatedValues`, `previousValues`, `updatedEntities`, `previousEntities` if they are strings + this.deserializeFields(entity, [ + 'updatedValues', + 'previousValues', + 'updatedEntities', + 'previousEntities' + ]); } } catch (error) { // Log the error and reset the data to an empty object if JSON parsing fails console.error('Error parsing JSON data in afterEntityLoad:', error); entity.data = {}; + ['updatedValues', 'previousValues', 'updatedEntities', 'previousEntities'].forEach((field) => { + entity[field] = {}; + }); } } } diff --git a/packages/core/src/activity-log/dto/get-activity-logs.dto.ts b/packages/core/src/activity-log/dto/get-activity-logs.dto.ts index c416cd21c93..39c4180e86d 100644 --- a/packages/core/src/activity-log/dto/get-activity-logs.dto.ts +++ b/packages/core/src/activity-log/dto/get-activity-logs.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional, IntersectionType, PickType } from '@nestjs/swagger'; import { IsEnum, IsIn, IsOptional, IsString, IsUUID } from 'class-validator'; -import { ActionTypeEnum, ActivityLogEntityEnum, ActorTypeEnum, ID } from '@gauzy/contracts'; +import { ActionTypeEnum, BaseEntityEnum, ID } from '@gauzy/contracts'; import { PaginationParams } from '../../core/crud'; import { TenantOrganizationBaseDTO } from '../../core/dto'; import { ActivityLog } from '../activity-log.entity'; @@ -15,13 +15,13 @@ export const allowedOrderDirections = ['ASC', 'DESC', 'asc', 'desc']; export class GetActivityLogsDTO extends IntersectionType( TenantOrganizationBaseDTO, PickType(PaginationParams, ['skip', 'take', 'relations']), - PickType(ActivityLog, ['isActive', 'isArchived']) + PickType(ActivityLog, ['isActive', 'isArchived', 'actorType']) ) { // Filter by entity (example: Organization, Task, OrganizationContact) - @ApiPropertyOptional({ type: () => String, enum: ActivityLogEntityEnum }) + @ApiPropertyOptional({ enum: BaseEntityEnum }) @IsOptional() - @IsEnum(ActivityLogEntityEnum) - entity: ActivityLogEntityEnum; + @IsEnum(BaseEntityEnum) + entity: BaseEntityEnum; // Filter by entityId (example: projectId, taskId, organizationContactId) @ApiPropertyOptional({ type: () => String }) @@ -30,17 +30,11 @@ export class GetActivityLogsDTO extends IntersectionType( entityId: ID; // Filter by action (example: CREATED, UPDATED, DELETED) - @ApiPropertyOptional({ type: () => String, enum: ActionTypeEnum }) + @ApiPropertyOptional({ enum: ActionTypeEnum }) @IsOptional() @IsEnum(ActionTypeEnum) action: ActionTypeEnum; - // Filter by actorType (example: SYSTEM, USER) - @ApiPropertyOptional({ type: () => String, enum: ActorTypeEnum }) - @IsOptional() - @IsEnum(ActorTypeEnum) - actorType?: ActorTypeEnum; - // Filter by orderBy (example: createdAt, updatedAt, entity, action) @ApiPropertyOptional({ type: () => String, enum: allowedOrderFields }) @IsOptional() diff --git a/packages/core/src/activity-log/events/handlers/activity-log.handler.ts b/packages/core/src/activity-log/events/handlers/activity-log.handler.ts index cd1d8c6ceaf..cde3bb5574e 100644 --- a/packages/core/src/activity-log/events/handlers/activity-log.handler.ts +++ b/packages/core/src/activity-log/events/handlers/activity-log.handler.ts @@ -15,6 +15,6 @@ export class ActivityLogEventHandler implements IEventHandler */ async handle(event: ActivityLogEvent) { // Extract the input from the event and create a new activity log entry - return await this.activityLogService.logActivity(event.input); + return await this.activityLogService.create(event.input); } } diff --git a/packages/core/src/api-call-log/api-call-log-middleware.ts b/packages/core/src/api-call-log/api-call-log-middleware.ts new file mode 100644 index 00000000000..72a70690517 --- /dev/null +++ b/packages/core/src/api-call-log/api-call-log-middleware.ts @@ -0,0 +1,159 @@ +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import * as jwt from 'jsonwebtoken'; +import { ID } from '@gauzy/contracts'; +import { RequestContext } from '../core/context'; +import { ApiCallLogService } from './api-call-log.service'; +import { ApiCallLog } from './api-call-log.entity'; + +@Injectable() +export class ApiCallLogMiddleware implements NestMiddleware { + private readonly logger = new Logger(ApiCallLogMiddleware.name); + + constructor(private readonly apiCallLogService: ApiCallLogService) {} + + /** + * Middleware for logging API requests and responses to the database. + * This middleware generates a unique `correlationId` for each request + * and captures key details about the incoming request and outgoing response. + * + * @param req The incoming HTTP request object. + * @param res The outgoing HTTP response object. + * @param next The next middleware function in the request-response cycle. + */ + async use(req: Request, res: Response, next: NextFunction): Promise { + let responseBody = ''; + const startTime = Date.now(); // Capture request start time + + // Generate a unique correlation ID if not provided + const correlationId = RequestContext.getContextId() ?? uuidv4(); + this.logger.debug(`Logging API call with correlation ID: ${correlationId}`); + + // Retrieve the organization ID and tenant ID from request headers + const organizationId = (req.headers['organization-id'] as ID) || null; + const tenantId = (req.headers['tenant-id'] as ID) || null; + + // Get user ID from request context or JWT token + let userId = RequestContext.currentUserId(); + + try { + // Get the authorization header + const authHeader = req.headers['authorization']; + + // Initialize token variable + let token: string | undefined; + + // Check if the authorization header exists + if (authHeader) { + // Use a regular expression to extract the token + const bearerTokenMatch = authHeader.match(/^Bearer\s+(.+)$/i); + + if (bearerTokenMatch && bearerTokenMatch[1]) { + token = bearerTokenMatch[1]; + } + } + + // Decode the JWT token and retrieve the user ID + if (!userId && token) { + const jwtPayload: string | jwt.JwtPayload = jwt.decode(token); + userId = typeof jwtPayload === 'object' ? jwtPayload['sub'] || jwtPayload['id'] : null; + } + } catch (error) { + this.logger.error('Failed to decode JWT token or retrieve user ID', error.stack); + } + + // Redact sensitive data from request headers and body + const requestHeaders = this.redactSensitiveData(req.headers, ['authorization', 'Authorization', 'token']); + const requestBody = this.redactSensitiveData(req.body, ['password', 'hash', 'token']); + + // Capture the original end method of the response object to log the response body + const originalEnd = res.end; + res.end = (chunk: any, encoding?: any, callback?: any) => { + if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk) || chunk instanceof Uint8Array)) { + // Convert chunk to a string if it's a Buffer + let chunkContent = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); + + // Try to parse the chunk string as JSON, fallback to a string if it's not JSON + try { + responseBody = JSON.parse(chunkContent); // Store as object if JSON + } catch (error) { + responseBody = chunkContent; // If not JSON, store as string + } + } + return originalEnd.call(res, chunk, encoding, callback); // Call original res.end with the arguments + }; + + // Listen for the 'finish' event to log the API call after the response is completed + res.on('finish', async () => { + // Redact sensitive data from response body + responseBody = this.redactSensitiveData(responseBody, ['password']); + + const entity = new ApiCallLog({ + correlationId, + organizationId, + tenantId, + method: req.method, + url: req.originalUrl, + protocol: req.protocol || null, + ipAddress: req.ip || null, + origin: req.headers['origin'] || null, + userAgent: req.headers['user-agent'] || '', + requestHeaders, + requestBody, + statusCode: res.statusCode, + responseBody: responseBody || {}, + requestTime: new Date(startTime), + responseTime: new Date(), + userId: userId || null + }); + + this.logger.debug(`ApiCallLogMiddleware: logging API call entity: ${JSON.stringify(entity)}`); + + try { + // Asynchronously log the API call to the database + await this.apiCallLogService.create(entity); + } catch (error) { + this.logger.error('Failed to log API call', error.stack); + } + }); + + next(); // Pass control to the next middleware + } + + /** + * Redacts sensitive fields like passwords and tokens from request data. + * This function removes or masks sensitive data from headers and body before logging. + * + * @param {any} data - The data object to clean (headers or body). + * @param {string[]} sensitiveFields - The list of sensitive fields to redact. + * @returns {any} - The cleaned data object. + */ + redactSensitiveData(data: any, sensitiveFields: string[] = ['password', 'Authorization', 'token']): any { + // If data is not an object or array, return it as-is + if (typeof data !== 'object' || data === null) { + return data; + } + + // If data is an array, process each element + if (Array.isArray(data)) { + return data.map((item) => this.redactSensitiveData(item, sensitiveFields)); + } + + // If data is an object, create a shallow copy to avoid mutating the original + const cleanedData = { ...data }; + + // Iterate through the object's keys and redact sensitive fields + for (const key of Object.keys(cleanedData)) { + if (sensitiveFields.includes(key)) { + // Redact the sensitive field + cleanedData[key] = '[REDACTED]'; + } else if (typeof cleanedData[key] === 'object' && cleanedData[key] !== null) { + // Recursively process nested objects and arrays + cleanedData[key] = this.redactSensitiveData(cleanedData[key], sensitiveFields); + } + } + + return cleanedData; + } +} diff --git a/packages/core/src/api-call-log/api-call-log.controller.ts b/packages/core/src/api-call-log/api-call-log.controller.ts new file mode 100644 index 00000000000..ece1661135f --- /dev/null +++ b/packages/core/src/api-call-log/api-call-log.controller.ts @@ -0,0 +1,66 @@ +import { Controller, Get, Delete, Query, UseGuards, Param } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { DeleteResult } from 'typeorm'; +import { IApiCallLog, ID, IPagination, PermissionsEnum } from '@gauzy/contracts'; +import { Permissions } from '../shared/decorators'; +import { PermissionGuard, TenantPermissionGuard } from '../shared/guards'; +import { UseValidationPipe } from '../shared/pipes'; +import { ApiCallLogService } from './api-call-log.service'; +import { ApiCallLogFilterDTO } from './dto/api-call-log-filter.dto'; +import { DeleteApiCallLogDTO } from './dto/api-call-log-delete.dto'; +import { ApiCallLog } from './api-call-log.entity'; + +@UseGuards(TenantPermissionGuard, PermissionGuard) +@Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.API_CALL_LOG_READ) +@Controller('/api-call-log') +export class ApiCallLogController { + constructor(private readonly _apiCallLogService: ApiCallLogService) {} + + /** + * Retrieves a paginated and filtered list of all API call logs from the system. + * + * @param filters DTO containing filtering options like `organizationId`, `correlationId`, `url`, etc. + * @returns A promise that resolves to a paginated list of `IApiCallLog` objects. + */ + @ApiOperation({ summary: 'Get all API call logs with mandatory organizationId and optional filters' }) + @ApiResponse({ + status: 200, + description: 'Returns a list of all API call logs with filters applied.' + }) + @ApiResponse({ status: 500, description: 'Internal server error.' }) + @Get('/') + @UseValidationPipe() + async findAll(@Query() filters: ApiCallLogFilterDTO): Promise> { + return this._apiCallLogService.findAllLogs(filters); + } + + /** + * Deletes an API call log by its ID. + * + * @param id The ID of the API call log to be deleted. + * @returns A promise that resolves to an object indicating the delete status. + */ + @ApiOperation({ summary: 'Delete an API call log by ID' }) + @ApiResponse({ + status: 200, + description: 'API call log deleted successfully.' + }) + @ApiResponse({ + status: 404, + description: 'API call log not found.' + }) + @ApiResponse({ + status: 500, + description: 'Internal server error.' + }) + @Delete('/:id') + @UseValidationPipe() + async deleteById(@Param('id') id: ID, @Query() filters: DeleteApiCallLogDTO): Promise { + // If the forceDelete flag is set, perform a hard delete + if (filters.forceDelete) { + return this._apiCallLogService.delete(id, { where: { ...filters } }); + } + // Otherwise, perform a soft delete + return this._apiCallLogService.softDelete(id, { where: { ...filters } }); + } +} diff --git a/packages/core/src/api-call-log/api-call-log.entity.ts b/packages/core/src/api-call-log/api-call-log.entity.ts new file mode 100644 index 00000000000..db8d8d32e21 --- /dev/null +++ b/packages/core/src/api-call-log/api-call-log.entity.ts @@ -0,0 +1,164 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityRepositoryType } from '@mikro-orm/core'; +import { JoinColumn, RelationId } from 'typeorm'; +import { IsEnum, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; +import { isMySQL, isPostgres } from '@gauzy/config'; +import { IApiCallLog, ID, IUser, JsonData, RequestMethod } from '@gauzy/contracts'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { TenantOrganizationBaseEntity, User } from '../core/entities/internal'; +import { HttpMethodTransformerPipe } from '../shared/pipes'; +import { MikroOrmApiCallLogRepository } from './repository/mikro-orm-api-call-log.repository'; + +@MultiORMEntity('api_call_log', { mikroOrmRepository: () => MikroOrmApiCallLogRepository }) +export class ApiCallLog extends TenantOrganizationBaseEntity implements IApiCallLog { + [EntityRepositoryType]?: MikroOrmApiCallLogRepository; + + /** + * Correlation ID to track the request across services + */ + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @ColumnIndex() + @MultiORMColumn() + correlationId: ID; + + /** + * The request URL that was called + */ + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @ColumnIndex() + @MultiORMColumn() + url: string; + + /** + * The HTTP method (GET, POST, etc.) used in the request. + * It is transformed from enum to string using HttpMethodTransformerPipe. + */ + @ApiProperty({ enum: RequestMethod }) + @IsNotEmpty() + @IsEnum(RequestMethod) + @ColumnIndex() + @MultiORMColumn({ transformer: new HttpMethodTransformerPipe() }) + method: RequestMethod; + + /** + * Request headers stored as JSON string + */ + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text' }) + requestHeaders: JsonData; + + /** + * Request body stored as JSON string + */ + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text' }) + requestBody: JsonData; + + /** + * Response body stored as JSON string + */ + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text' }) + responseBody: JsonData; + + /** + * The HTTP status code returned from the request + */ + @ApiProperty({ type: () => Number }) + @IsNotEmpty() + @ColumnIndex() + @MultiORMColumn() + statusCode: number; + + /** + * The timestamp when the request was initiated + */ + @ApiProperty({ type: () => Date }) + @IsNotEmpty() + @ColumnIndex() + @MultiORMColumn() + requestTime: Date; + + /** + * The timestamp when the response was completed + */ + @ApiProperty({ type: () => Date }) + @IsNotEmpty() + @ColumnIndex() + @MultiORMColumn() + responseTime: Date; + + /** + * IP Address of the client making the request + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + ipAddress: string; + + /** + * The protocol used in the request (HTTP, HTTPS) + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + protocol: string; + + /** + * User-Agent string of the client making the request. + * This could be a browser, desktop app, Postman, or any other API client. + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @MultiORMColumn({ nullable: true }) + userAgent: string; + + /** + * Origin from where the request was initiated (web, mobile, desktop, etc.). + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @MultiORMColumn({ nullable: true }) + origin: string; // Added column to track request origin + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * User who performed the action, if applicable. + * + * This relationship is nullable and uses the User entity. + */ + @MultiORMManyToOne(() => User, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + @JoinColumn() + user?: IUser; + + /** + * The ID of the user who performed the action. + * This column stores the user ID as a foreign key, if applicable. + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: ApiCallLog) => it.user) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + userId?: ID; +} diff --git a/packages/core/src/api-call-log/api-call-log.module.ts b/packages/core/src/api-call-log/api-call-log.module.ts new file mode 100644 index 00000000000..44885d20e5c --- /dev/null +++ b/packages/core/src/api-call-log/api-call-log.module.ts @@ -0,0 +1,45 @@ +import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RolePermissionModule } from '../role-permission/role-permission.module'; +import { ApiCallLogController } from './api-call-log.controller'; +import { ApiCallLog } from './api-call-log.entity'; +import { ApiCallLogService } from './api-call-log.service'; +import { ApiCallLogMiddleware } from './api-call-log-middleware'; +import { TypeOrmApiCallLogRepository } from './repository/type-orm-api-call-log.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([ApiCallLog]), MikroOrmModule.forFeature([ApiCallLog]), RolePermissionModule], + controllers: [ApiCallLogController], + providers: [ApiCallLogService, ApiCallLogMiddleware, TypeOrmApiCallLogRepository], + exports: [ApiCallLogService] +}) +export class ApiCallLogModule implements NestModule { + /** + * Configures the middleware for Time Tracking routes (POST, PUT, PATCH, DELETE) + * excluding the '/timesheet/statistics' route. + * + * @param consumer The middleware consumer used to apply the middleware to specific routes. + */ + configure(consumer: MiddlewareConsumer) { + consumer.apply(ApiCallLogMiddleware).forRoutes( + // POST Routes + { path: '/timesheet/timer/*', method: RequestMethod.POST }, + { path: '/timesheet/time-log', method: RequestMethod.POST }, + { path: '/timesheet/activity/bulk', method: RequestMethod.POST }, + { path: '/timesheet/time-slot', method: RequestMethod.POST }, + { path: '/timesheet/screenshot', method: RequestMethod.POST }, + + // PUT Routes + { path: '/timesheet/time-log/:id', method: RequestMethod.PUT }, + { path: '/timesheet/time-slot/:id', method: RequestMethod.PUT }, + { path: '/timesheet/status', method: RequestMethod.PUT }, + { path: '/timesheet/submit', method: RequestMethod.PUT }, + + // DELETE Routes + { path: '/timesheet/time-log', method: RequestMethod.DELETE }, + { path: '/timesheet/time-slot', method: RequestMethod.DELETE }, + { path: '/timesheet/screenshot/:id', method: RequestMethod.DELETE } + ); + } +} diff --git a/packages/core/src/api-call-log/api-call-log.service.ts b/packages/core/src/api-call-log/api-call-log.service.ts new file mode 100644 index 00000000000..7526e32cf1e --- /dev/null +++ b/packages/core/src/api-call-log/api-call-log.service.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { Between, FindManyOptions } from 'typeorm'; +import * as moment from 'moment'; +import { IApiCallLog, IPagination } from '@gauzy/contracts'; +import { TenantAwareCrudService } from './../core/crud'; +import { ApiCallLog } from './api-call-log.entity'; +import { MikroOrmApiCallLogRepository } from './repository/mikro-orm-api-call-log.repository'; +import { TypeOrmApiCallLogRepository } from './repository/type-orm-api-call-log.repository'; +import { ApiCallLogFilterDTO } from './dto/api-call-log-filter.dto'; + +@Injectable() +export class ApiCallLogService extends TenantAwareCrudService { + constructor( + readonly typeOrmApiCallLogRepository: TypeOrmApiCallLogRepository, + readonly mikroOrmApiCallLogRepository: MikroOrmApiCallLogRepository + ) { + super(typeOrmApiCallLogRepository, mikroOrmApiCallLogRepository); + } + + /** + * Retrieves a paginated list of API call logs with optional filters applied. + * + * @param filters Object containing filtering options such as `correlationId`, `url`, `method`, etc. + * @returns A promise that resolves to a paginated list of `IApiCallLog` objects. + */ + async findAllLogs(filters: ApiCallLogFilterDTO): Promise> { + // Ensure that filters are properly defined + const queryOptions: FindManyOptions = { + where: {}, + take: filters.take ?? 100, // Default to 100 if not provided + skip: filters.skip ? filters.take * (filters.skip - 1) : 0 // Calculate offset + }; + + // Apply sorting options (if provided) + if (filters.order) { + queryOptions.order = filters.order; // Order, in which entities should be ordered. Default to ASC if no order is provided. + } + + // Check if `filters.where` is an array or an object, then apply individual filters + if (!Array.isArray(filters)) { + if (filters.organizationId) { + queryOptions.where['organizationId'] = filters.organizationId; + } + if (filters.correlationId) { + queryOptions.where['correlationId'] = filters.correlationId; + } + if (filters.statusCode) { + queryOptions.where['statusCode'] = filters.statusCode; + } + if (filters.ipAddress) { + queryOptions.where['ipAddress'] = filters.ipAddress; + } + if (filters.method) { + queryOptions.where['method'] = filters.method; + } + if (filters.userId) { + queryOptions.where['userId'] = filters.userId; + } + // Apply date range filters for requestTime + if (filters.startRequestTime || filters.endRequestTime) { + // The start date for filtering, defaults to the start of today. + const start = filters.startRequestTime + ? moment(filters.startRequestTime).toDate() + : moment().startOf('day').toDate(); + + // The end date for filtering, defaults to the end of today. + const end = filters.endRequestTime + ? moment(filters.endRequestTime).toDate() + : moment().endOf('day').toDate(); // Default to end of today if no end date is provided + + // Retrieves a date range filter using the `start` and `end` values. + queryOptions.where['requestTime'] = Between(start, end); + } + } + + // Perform the query with filters, sorting, and pagination applied + return await super.findAll(queryOptions); + } +} diff --git a/packages/core/src/api-call-log/api-call-log.subscriber.ts b/packages/core/src/api-call-log/api-call-log.subscriber.ts new file mode 100644 index 00000000000..382772e5bcd --- /dev/null +++ b/packages/core/src/api-call-log/api-call-log.subscriber.ts @@ -0,0 +1,76 @@ +import { Logger } from '@nestjs/common'; +import { EventSubscriber } from 'typeorm'; +import { isBetterSqlite3, isSqlite } from '@gauzy/config'; +import { BaseEntityEventSubscriber } from '../core/entities/subscribers/base-entity-event.subscriber'; +import { MultiOrmEntityManager } from '../core/entities/subscribers/entity-event-subscriber.types'; +import { ApiCallLog } from './api-call-log.entity'; + +@EventSubscriber() +export class ApiCallLogSubscriber extends BaseEntityEventSubscriber { + private readonly logger = new Logger(ApiCallLogSubscriber.name); + /** + * Indicates that this subscriber only listen to ApiCallLog events. + */ + listenTo() { + return ApiCallLog; + } + + /** + * Called before an ApiCallLog entity is inserted or created in the database. + * This method prepares the entity for insertion, particularly by serializing the data property to a JSON string + * for SQLite databases. + * + * @param entity The ApiCallLog entity that is about to be created. + * @returns {Promise} A promise that resolves when the pre-creation processing is complete. + */ + async beforeEntityCreate(entity: ApiCallLog): Promise { + try { + // Check if the database is SQLite and ensure that requestHeaders, requestBody, and responseBody are strings + if (isSqlite() || isBetterSqlite3()) { + ['requestHeaders', 'requestBody', 'responseBody'].forEach((field) => { + try { + if (typeof entity[field] === 'object') { + entity[field] = JSON.stringify(entity[field]); // Convert to JSON string + } + } catch (error) { + console.error(`Failed to stringify ${field}:`, error); + entity[field] = '{}'; // Set to an empty JSON object string in case of an error + } + }); + } + } catch (error) { + // In case of error during JSON serialization, reset metaData to an empty object + this.logger.error('Error parsing JSON data in beforeEntityCreate:', error); + } + } + + /** + * Handles the parsing of JSON data after the ApiCallLog entity is loaded from the database. + * This function ensures that if the database is SQLite, the `data` field, stored as a JSON string, + * is parsed back into a JavaScript object. + * + * @param {ApiCallLog} entity - The ApiCallLog entity that has been loaded from the database. + * @param {MultiOrmEntityManager} [em] - The optional EntityManager instance, if provided. + * @returns {Promise} A promise that resolves once the after-load processing is complete. + */ + async afterEntityLoad(entity: ApiCallLog, em?: MultiOrmEntityManager): Promise { + try { + // Check if the database is SQLite and attempt to parse JSON fields + if (isSqlite() || isBetterSqlite3()) { + ['requestHeaders', 'requestBody', 'responseBody'].forEach((field) => { + if (entity[field] && typeof entity[field] === 'string') { + try { + entity[field] = JSON.parse(entity[field]); + } catch (error) { + console.error(`Failed to parse ${field}:`, error); + entity[field] = {}; // Set to an empty object in case of a parsing error + } + } + }); + } + } catch (error) { + // Log the error and reset the data to an empty object if JSON parsing fails + this.logger.error('Error parsing JSON data in afterEntityLoad:', error); + } + } +} diff --git a/packages/core/src/api-call-log/dto/api-call-log-delete.dto.ts b/packages/core/src/api-call-log/dto/api-call-log-delete.dto.ts new file mode 100644 index 00000000000..6e9895f0951 --- /dev/null +++ b/packages/core/src/api-call-log/dto/api-call-log-delete.dto.ts @@ -0,0 +1,8 @@ +import { ForceDeleteBaseDTO } from '../../core/dto'; +import { ApiCallLog } from '../api-call-log.entity'; + +/** + * Data Transfer Object (DTO) for deleting an API call log with the `forceDelete` flag. + * This DTO extends the `ForceDeleteBaseDTO` to include the `forceDelete` flag. + */ +export class DeleteApiCallLogDTO extends ForceDeleteBaseDTO {} diff --git a/packages/core/src/api-call-log/dto/api-call-log-filter.dto.ts b/packages/core/src/api-call-log/dto/api-call-log-filter.dto.ts new file mode 100644 index 00000000000..a6112828bd5 --- /dev/null +++ b/packages/core/src/api-call-log/dto/api-call-log-filter.dto.ts @@ -0,0 +1,38 @@ +import { ApiPropertyOptional, IntersectionType, PickType } from '@nestjs/swagger'; +import { IsDateString, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { ID } from '@gauzy/contracts'; +import { PaginationParams } from '../../core/crud/pagination-params'; +import { TenantOrganizationBaseDTO } from '../../core/dto/tenant-organization-base.dto'; +import { ApiCallLog } from '../api-call-log.entity'; + +/** + * DTO for API call log filtering. + */ +export class ApiCallLogFilterDTO extends IntersectionType( + TenantOrganizationBaseDTO, + PickType(PaginationParams, ['skip', 'take', 'order']), + PickType(ApiCallLog, ['userId', 'ipAddress', 'method'] as const) +) { + // Correlation ID to filter the request against. + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + correlationId?: ID; + + // Status Code to filter the request against. + @ApiPropertyOptional({ type: () => Number }) + @IsOptional() + @IsNumber() + statusCode?: number; + + // Date range filters for requestTime and responseTime + @ApiPropertyOptional({ type: () => Date }) + @IsOptional() + @IsDateString() + startRequestTime?: Date; + + @ApiPropertyOptional({ type: () => Date }) + @IsOptional() + @IsDateString() + endRequestTime?: Date; +} diff --git a/packages/core/src/api-call-log/repository/mikro-orm-api-call-log.repository.ts b/packages/core/src/api-call-log/repository/mikro-orm-api-call-log.repository.ts new file mode 100644 index 00000000000..84a97996cad --- /dev/null +++ b/packages/core/src/api-call-log/repository/mikro-orm-api-call-log.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { ApiCallLog } from '../api-call-log.entity'; + +export class MikroOrmApiCallLogRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/api-call-log/repository/type-orm-api-call-log.repository.ts b/packages/core/src/api-call-log/repository/type-orm-api-call-log.repository.ts new file mode 100644 index 00000000000..18b7404d043 --- /dev/null +++ b/packages/core/src/api-call-log/repository/type-orm-api-call-log.repository.ts @@ -0,0 +1,11 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { ApiCallLog } from '../api-call-log.entity'; + +@Injectable() +export class TypeOrmApiCallLogRepository extends Repository { + constructor(@InjectRepository(ApiCallLog) readonly repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/app.controller.ts b/packages/core/src/app.controller.ts index 177fe6add34..a3f395327dd 100644 --- a/packages/core/src/app.controller.ts +++ b/packages/core/src/app.controller.ts @@ -7,10 +7,7 @@ import { IAppSetting } from '@gauzy/contracts'; @Controller() @Public() // This seems to be a custom decorator indicating that this controller's endpoints are public export class AppController { - - constructor( - private readonly _configService: ConfigService - ) { } + constructor(private readonly _configService: ConfigService) {} /** * This is a controller method for handling the HTTP GET request to the root endpoint ('/'). diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index 13e22b0170e..c012aa199fc 100644 --- a/packages/core/src/app.module.ts +++ b/packages/core/src/app.module.ts @@ -148,6 +148,9 @@ import { CommentModule } from './comment/comment.module'; import { StatsModule } from './stats/stats.module'; // Global Stats Module import { ReactionModule } from './reaction/reaction.module'; import { ActivityLogModule } from './activity-log/activity-log.module'; +import { ApiCallLogModule } from './api-call-log/api-call-log.module'; // Global Api Call Log Module +import { TaskViewModule } from './tasks/views/view.module'; +import { ResourceLinkModule } from './resource-link/resource-link.module'; const { unleashConfig } = environment; @@ -445,7 +448,10 @@ if (environment.THROTTLE_ENABLED) { StatsModule, // Global Stats Module ReactionModule, CommentModule, - ActivityLogModule + ActivityLogModule, + ApiCallLogModule, + TaskViewModule, + ResourceLinkModule // Task views Module ], controllers: [AppController], providers: [ diff --git a/packages/core/src/auth/auth.service.ts b/packages/core/src/auth/auth.service.ts index 60be8666b6e..2439c8a41a0 100644 --- a/packages/core/src/auth/auth.service.ts +++ b/packages/core/src/auth/auth.service.ts @@ -1,6 +1,12 @@ import { CommandBus } from '@nestjs/cqrs'; import { HttpService } from '@nestjs/axios'; -import { BadRequestException, Injectable, InternalServerErrorException, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + NotFoundException, + UnauthorizedException +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, MoreThanOrEqual, Not, SelectQueryBuilder } from 'typeorm'; import * as bcrypt from 'bcrypt'; @@ -36,7 +42,7 @@ import { } from '@gauzy/contracts'; import { environment } from '@gauzy/config'; import { SocialAuthService } from '@gauzy/auth'; -import { IAppIntegrationConfig, deepMerge, isNotEmpty } from '@gauzy/common'; +import { IAppIntegrationConfig, createQueryParamsString, deepMerge, isNotEmpty } from '@gauzy/common'; import { AccountRegistrationEvent } from '../event-bus/events'; import { EventBus } from '../event-bus/event-bus'; import { ALPHA_NUMERIC_CODE_LENGTH, DEMO_PASSWORD_LESS_MAGIC_CODE } from './../constants'; @@ -406,13 +412,14 @@ export class AuthService extends SocialAuthService { // Fetch users with specific criteria const users = await this.fetchUsers(email); + // Throw an exception if no matching users are found if (users.length === 0) { throw new BadRequestException('Forgot password request failed!'); } // Initialize an array to store reset links along with tenant and user information - const tenantUsersMap: { resetLink: string; tenant: ITenant; user: IUser }[] = []; + const tenantUsersMap: { resetLink: string; tenant?: ITenant; user: IUser }[] = []; // Iterate through users and generate reset links for await (const user of users) { @@ -423,15 +430,22 @@ export class AuthService extends SocialAuthService { if (!!token && !!email) { try { // Create a password reset request and generate a reset link - const request = await this.commandBus.execute( + await this.commandBus.execute( new PasswordResetCreateCommand({ email, tenantId, token }) ); - const resetLink = `${environment.clientBaseUrl}/#/auth/reset-password?token=${request.token}&tenantId=${tenantId}&email=${email}`; - tenantUsersMap.push({ resetLink, tenant: user.tenant, user }); + + // Initialize Base URL + let baseURL = `${environment.clientBaseUrl}/#/auth/reset-password`; + + // Generate the reset link using the helper function + const resetLink = this.generateResetLink(baseURL, token, email, tenantId); + + // Add the reset link, tenant, and user to the tenantUsersMap array + tenantUsersMap.push({ resetLink, tenant: user.tenant ?? undefined, user }); } catch (error) { throw new BadRequestException('Forgot password request failed!'); } @@ -460,6 +474,31 @@ export class AuthService extends SocialAuthService { } } + /** + * Generates a password reset link. + * + * @param baseURL The base URL for the reset password page. + * @param token The token generated for the password reset. + * @param email The email of the user. + * @param tenantId The tenant ID (optional). + * @returns The password reset link. + */ + generateResetLink(baseURL: string, token: string, email: string, tenantId?: ID): string { + // Initialize an object to store query parameters + const params: { [key: string]: string | ID } = { token, email }; + + // Add tenantId to the reset link only if it's available + if (tenantId) { + params['tenantId'] = tenantId; + } + + // Convert query params object to a string + const queryString = createQueryParamsString(params); + + // Combine base URL with query params + return `${baseURL}?${queryString}`; + } + /** * Fetch users from the repository based on specific criteria. * @@ -467,48 +506,51 @@ export class AuthService extends SocialAuthService { * @returns {Promise} A Promise that resolves to an array of User objects. */ async fetchUsers(email: IUserEmailInput['email']): Promise { + // Find users matching the criteria return await this.typeOrmUserRepository.find({ - where: { - email, - isActive: true, - isArchived: false - }, - relations: { - tenant: true, - role: true - } + where: { email, isActive: true, isArchived: false }, + relations: { tenant: true, role: true } }); } /** - * Reset password - * @param request - * @returns + * Resets the user's password based on a valid password reset token. + * + * @param request - The request object containing the new password and the reset token. + * @returns A boolean indicating whether the password reset was successful. + * @throws {BadRequestException} - If the password reset fails due to an invalid or expired token, or if there is an issue updating the password. */ async resetPassword(request: IChangePasswordRequest) { try { const { password, token } = request; + + // Validate the password reset token const record: IPasswordReset = await this.commandBus.execute(new PasswordResetGetCommand({ token })); if (record.expired) { - throw new BadRequestException('Password Reset Failed.'); + throw new BadRequestException('Password Reset Failed: Token has expired.'); } + + // Verify the token and extract user information const { id, tenantId } = verify(token, environment.JWT_SECRET) as { - id: string; - tenantId: string; + id: ID; + tenantId: ID; }; - try { - const user = await this.userService.findOneByIdString(id, { - where: { tenantId }, - relations: { tenant: true } - }); - if (user) { - const hash = await this.getPasswordHash(password); - await this.userService.changePassword(user.id, hash); - return true; - } - } catch (error) { - throw new BadRequestException('Password Reset Failed.'); + + // Fetch the user by ID and tenant + const user = await this.userService.findOneByIdString(id, { + where: { tenantId }, + relations: { tenant: true } + }); + + if (!user) { + throw new NotFoundException('Password Reset Failed.'); } + + // Hash the new password and update it for the user + const hash = await this.getPasswordHash(password); + await this.userService.changePassword(user.id, hash); + + return true; } catch (error) { throw new BadRequestException('Password Reset Failed.'); } @@ -787,7 +829,7 @@ export class AuthService extends SocialAuthService { // Create a payload for the JWT token const payload: JwtPayload = { id: user.id, - tenantId: user.tenantId, + tenantId: user.tenantId ?? null, employeeId: employee ? employee.id : null, role: user.role ? user.role.name : null, permissions: user.role?.rolePermissions?.filter((rp) => rp.enabled).map((rp) => rp.permission) ?? null diff --git a/packages/core/src/auth/commands/auth.login.command.ts b/packages/core/src/auth/commands/auth.login.command.ts index 7c51319c68f..c7604ae2050 100644 --- a/packages/core/src/auth/commands/auth.login.command.ts +++ b/packages/core/src/auth/commands/auth.login.command.ts @@ -4,7 +4,5 @@ import { IUserLoginInput } from '@gauzy/contracts'; export class AuthLoginCommand implements ICommand { static readonly type = '[Auth] Login'; - constructor( - public readonly input: IUserLoginInput - ) { } + constructor(public readonly input: IUserLoginInput) {} } diff --git a/packages/core/src/comment/comment.entity.ts b/packages/core/src/comment/comment.entity.ts index 1b99250913b..f7e583a8350 100644 --- a/packages/core/src/comment/comment.entity.ts +++ b/packages/core/src/comment/comment.entity.ts @@ -3,7 +3,7 @@ import { EntityRepositoryType } from '@mikro-orm/core'; import { JoinColumn, JoinTable, RelationId } from 'typeorm'; import { IsArray, IsBoolean, IsDate, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; import { Type } from 'class-transformer'; -import { ActorTypeEnum, CommentEntityEnum, IComment, ID, IEmployee, IOrganizationTeam, IUser } from '@gauzy/contracts'; +import { ActorTypeEnum, BaseEntityEnum, IComment, ID, IEmployee, IOrganizationTeam, IUser } from '@gauzy/contracts'; import { Employee, OrganizationTeam, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; import { ColumnIndex, @@ -13,18 +13,19 @@ import { MultiORMManyToOne, MultiORMOneToMany } from '../core/decorators/entity'; +import { ActorTypeTransformerPipe } from '../shared/pipes'; import { MikroOrmCommentRepository } from './repository/mikro-orm-comment.repository'; @MultiORMEntity('comment', { mikroOrmRepository: () => MikroOrmCommentRepository }) export class Comment extends TenantOrganizationBaseEntity implements IComment { [EntityRepositoryType]?: MikroOrmCommentRepository; - @ApiProperty({ type: () => String, enum: CommentEntityEnum }) + @ApiProperty({ type: () => String, enum: BaseEntityEnum }) @IsNotEmpty() - @IsEnum(CommentEntityEnum) + @IsEnum(BaseEntityEnum) @ColumnIndex() @MultiORMColumn() - entity: CommentEntityEnum; + entity: BaseEntityEnum; // Indicate the ID of entity record commented @ApiProperty({ type: () => String }) @@ -40,12 +41,12 @@ export class Comment extends TenantOrganizationBaseEntity implements IComment { @MultiORMColumn({ type: 'text' }) comment: string; - @ApiPropertyOptional({ type: () => String, enum: ActorTypeEnum }) - @IsNotEmpty() + @ApiPropertyOptional({ enum: ActorTypeEnum }) + @IsOptional() @IsEnum(ActorTypeEnum) @ColumnIndex() - @MultiORMColumn({ nullable: true }) - actorType?: ActorTypeEnum; + @MultiORMColumn({ type: 'int', nullable: true, transformer: new ActorTypeTransformerPipe() }) + actorType?: ActorTypeEnum; // Will be stored as 0 or 1 in DB @ApiPropertyOptional({ type: Boolean }) @IsOptional() diff --git a/packages/core/src/core/context/request-context.middleware.ts b/packages/core/src/core/context/request-context.middleware.ts index 2132b51b0e8..555a1b9f449 100644 --- a/packages/core/src/core/context/request-context.middleware.ts +++ b/packages/core/src/core/context/request-context.middleware.ts @@ -1,26 +1,57 @@ -import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; import { Request, Response, NextFunction } from 'express'; +import { v4 as uuidv4 } from 'uuid'; +import { ID } from '@gauzy/contracts'; import { RequestContext } from './request-context'; -import { ClsService } from 'nestjs-cls'; @Injectable() export class RequestContextMiddleware implements NestMiddleware { - constructor(private clsService: ClsService) {} + private readonly logger = new Logger(RequestContextMiddleware.name); + private readonly loggingEnabled = true; + + constructor(private readonly clsService: ClsService) {} + /** + * Middleware to manage request context and log request lifecycle. + * + * This middleware generates a `RequestContext` for each incoming request, + * logs the start and end of the request if logging is enabled, and ensures that + * the context is preserved during the request lifecycle using `nestjs-cls`. + * + * @param req The incoming HTTP request. + * @param res The outgoing HTTP response. + * @param next The next middleware function in the request-response cycle. + */ use(req: Request, res: Response, next: NextFunction) { + // Start a new context using the ClsService this.clsService.run(() => { - const context = new RequestContext({ req, res }); + const correlationId = req.headers['x-correlation-id'] as ID; // Retrieve the correlation ID from the request headers + const id = correlationId ?? uuidv4(); // If no correlation ID is provided, generate a new one + + const context = new RequestContext({ id, req, res }); this.clsService.set(RequestContext.name, context); + // Build the full request URL const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`; - console.log(`Context ${context.id}. Request URL: ${fullUrl} started...`); - // Capture the original res.end + // Log the start of the request if logging is enabled + if (this.loggingEnabled) { + const contextId = RequestContext.getContextId(); + this.logger.log(`Context ${contextId}: ${req.method} request to ${fullUrl} started.`); + } + + // Capture the original res.end function const originalEnd = res.end.bind(res); - // Override res.end + // Override the res.end function to log when the response finishes res.end = (...args: any[]): Response => { - console.log(`Context ${context.id}. Request to ${fullUrl} completed.`); + if (this.loggingEnabled) { + const contextId = RequestContext.getContextId(); + this.logger.log( + `Context ${contextId}: ${req.method} request to ${fullUrl} completed with status ${res.statusCode}.` + ); + } // Call the original res.end and return its result return originalEnd(...args); diff --git a/packages/core/src/core/context/request-context.ts b/packages/core/src/core/context/request-context.ts index de7b690be2f..957a8a5b54c 100644 --- a/packages/core/src/core/context/request-context.ts +++ b/packages/core/src/core/context/request-context.ts @@ -3,23 +3,24 @@ // Copyright (c) 2018 Sumanth Chinthagunta import { HttpException, HttpStatus } from '@nestjs/common'; -import { ClsService } from 'nestjs-cls'; +import { CLS_ID, ClsService } from 'nestjs-cls'; import { v4 as uuidv4 } from 'uuid'; import { Request, Response } from 'express'; import { ExtractJwt } from 'passport-jwt'; import { JsonWebTokenError, verify } from 'jsonwebtoken'; -import { IUser, PermissionsEnum, LanguagesEnum, RolesEnum } from '@gauzy/contracts'; +import { IUser, PermissionsEnum, LanguagesEnum, RolesEnum, ID } from '@gauzy/contracts'; import { environment as env } from '@gauzy/config'; import { isNotEmpty } from '@gauzy/common'; import { SerializedRequestContext } from './types'; export class RequestContext { - private static logging: boolean = true; - protected readonly _id: string; - protected readonly _res: Response; + protected static clsService: ClsService; + private static loggingEnabled: boolean = false; + + private readonly _id: ID; + private readonly _res: Response; private readonly _req: Request; private readonly _languageCode: LanguagesEnum; - protected static clsService: ClsService; /** * Gets the language code. @@ -35,7 +36,7 @@ export class RequestContext { * * @returns The id. */ - get id(): string { + get id(): ID { return this._id; } @@ -49,21 +50,51 @@ export class RequestContext { * @param options.isAuthorized - Optional flag indicating whether the user is authorized. */ constructor(options: { - id?: string; + id?: ID; req?: Request; res?: Response; languageCode?: LanguagesEnum; isAuthorized?: boolean; }) { - // Destructure options to extract individual properties. - const { req, res, id, languageCode } = options; + // Set the context ID + const contextId = options.id || uuidv4(); // If 'id' is not provided, generate a random ID. + RequestContext.setContextId(contextId); + // Assign values to instance properties. - this._id = id || uuidv4().toString(); // If 'id' is not provided, generate a random ID. - this._req = req; - this._res = res; - this._languageCode = languageCode; + this._id = contextId; + this._req = options.req; + this._res = options.res; + this._languageCode = options.languageCode; - if (RequestContext.logging) console.log('RequestContext: setting context with Id:', this._id); + if (RequestContext.loggingEnabled) { + console.log('RequestContext: setting context with generated Id:', RequestContext.getContextId()); + } + } + + /** + * Static method to set the context ID in the ClsService. + * + * @param cls The ClsService instance used to set the context ID. + * @param id The ID to set in the ClsService context. + */ + public static setContextId(id: ID): void { + // Check if the ClsService is available + if (RequestContext.clsService) { + RequestContext.clsService.set(CLS_ID, id); + } + } + + /** + * Static method to get the context ID from the ClsService. + * + * @param cls The ClsService instance used to retrieve the context ID. + * @returns The context ID or undefined if not set. + */ + public static getContextId(): ID | undefined { + // Check if the ClsService is available + if (RequestContext.clsService) { + return RequestContext.clsService.get(CLS_ID); + } } /** @@ -81,9 +112,19 @@ export class RequestContext { * @returns The current RequestContext instance. */ static currentRequestContext(): RequestContext { - if (RequestContext?.logging) console.log('RequestContext: getting context ...'); - const context = RequestContext?.clsService?.get(RequestContext.name); - if (RequestContext?.logging) console.log('RequestContext: got context with Id:', context?._id); + // Log if logging is enabled + if (RequestContext.loggingEnabled) { + console.log('RequestContext: retrieving context...'); + } + + // Retrieve the context from the ClsService + const context = RequestContext.clsService?.get(RequestContext.name); + + // Log context ID if logging is enabled + if (RequestContext.loggingEnabled) { + console.log('RequestContext: context retrieved with ID:', context?.id); + } + return context; } @@ -125,13 +166,9 @@ export class RequestContext { * * @returns {string | null} - The current tenant ID or null if not available. */ - static currentTenantId(): string | null { - try { - const user: IUser | null = RequestContext.currentUser(); - return user ? user.tenantId : null; - } catch (error) { - return null; - } + static currentTenantId(): ID | null { + const user: IUser | null = RequestContext.currentUser(); + return user?.tenantId || null; } /** @@ -140,13 +177,9 @@ export class RequestContext { * * @returns {string | null} - The current user ID or null if not available. */ - static currentUserId(): string | null { - try { - const user: IUser | null = RequestContext.currentUser(); - return user ? user.id : null; - } catch (error) { - return null; - } + static currentUserId(): ID | null { + const user: IUser | null = RequestContext.currentUser(); + return user?.id || null; } /** @@ -155,13 +188,9 @@ export class RequestContext { * * @returns {string | null} - The current role ID or null if not available. */ - static currentRoleId(): string | null { - try { - const user: IUser | null = RequestContext.currentUser(); - return user ? user.roleId : null; - } catch (error) { - return null; - } + static currentRoleId(): ID | null { + const user: IUser | null = RequestContext.currentUser(); + return user?.roleId || null; } /** diff --git a/packages/core/src/core/decorators/is-favoritable.ts b/packages/core/src/core/decorators/is-favoritable.ts index 7d22c4e854a..acc4924a84d 100644 --- a/packages/core/src/core/decorators/is-favoritable.ts +++ b/packages/core/src/core/decorators/is-favoritable.ts @@ -1,7 +1,7 @@ -import { FavoriteEntityEnum } from '@gauzy/contracts'; +import { BaseEntityEnum } from '@gauzy/contracts'; import { SetMetadata } from '@nestjs/common'; export const FAVORITE_SERVICE = 'FAVORITE_SERVICE'; export const FAVORITABLE_TYPE = 'favoriteEntity'; -export const FavoriteService = (type: FavoriteEntityEnum) => SetMetadata(FAVORITABLE_TYPE, type); +export const FavoriteService = (type: BaseEntityEnum) => SetMetadata(FAVORITABLE_TYPE, type); diff --git a/packages/core/src/core/dto/force-delete-base.dto.ts b/packages/core/src/core/dto/force-delete-base.dto.ts new file mode 100644 index 00000000000..6c416847a90 --- /dev/null +++ b/packages/core/src/core/dto/force-delete-base.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsOptional, IsBoolean } from 'class-validator'; +import { parseToBoolean } from '@gauzy/common'; +import { DeleteQueryDTO } from '../../shared/dto'; + +/** + * Common base DTO with the `forceDelete` flag. + * If `true`, a hard delete will be performed; otherwise, a soft delete is used. + * This field is optional and defaults to `false`. + */ +export class ForceDeleteBaseDTO extends DeleteQueryDTO { + /** + * A flag to determine whether to force delete the records. + * If `true`, a hard delete will be performed; otherwise, a soft delete is used. + * This field is optional and defaults to `false`. + */ + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() + @Transform(({ value }: TransformFnParams) => (value ? parseToBoolean(value) : false)) + readonly forceDelete: boolean; +} diff --git a/packages/core/src/core/dto/index.ts b/packages/core/src/core/dto/index.ts index 7d1f8c6ee94..185dc58c13c 100644 --- a/packages/core/src/core/dto/index.ts +++ b/packages/core/src/core/dto/index.ts @@ -1,3 +1,5 @@ export * from './tenant-base.dto'; export * from './tenant-organization-base.dto'; export * from './translate-base-dto'; +export * from './member-entity-based.dto'; +export * from './force-delete-base.dto'; diff --git a/packages/core/src/core/dto/member-entity-based.dto.ts b/packages/core/src/core/dto/member-entity-based.dto.ts new file mode 100644 index 00000000000..98807a9326d --- /dev/null +++ b/packages/core/src/core/dto/member-entity-based.dto.ts @@ -0,0 +1,24 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; +import { ID, IMemberEntityBased } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from './tenant-organization-base.dto'; + +export class MemberEntityBasedDTO extends TenantOrganizationBaseDTO implements IMemberEntityBased { + /** + * Array of member UUIDs. + */ + @ApiPropertyOptional({ type: Array }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + memberIds?: ID[] = []; + + /** + * Array of manager UUIDs. + */ + @ApiPropertyOptional({ type: Array }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + managerIds?: ID[] = []; +} diff --git a/packages/core/src/core/entities/index.ts b/packages/core/src/core/entities/index.ts index 6759156bf65..4ddfdad5c6b 100644 --- a/packages/core/src/core/entities/index.ts +++ b/packages/core/src/core/entities/index.ts @@ -2,6 +2,7 @@ import { AccountingTemplate, Activity, ActivityLog, + ApiCallLog, AppointmentEmployee, ApprovalPolicy, AvailabilitySlot, @@ -83,6 +84,9 @@ import { OrganizationProjectEmployee, OrganizationProjectModule, OrganizationRecurringExpense, + OrganizationSprintEmployee, + OrganizationSprintTask, + OrganizationSprintTaskHistory, OrganizationSprint, OrganizationTaskSetting, OrganizationTeam, @@ -113,6 +117,7 @@ import { RequestApproval, RequestApprovalEmployee, RequestApprovalTeam, + ResourceLink, Role, RolePermission, Screenshot, @@ -127,6 +132,7 @@ import { TaskSize, TaskStatus, TaskVersion, + TaskView, Tenant, TenantSetting, TimeLog, @@ -146,6 +152,7 @@ export const coreEntities = [ AccountingTemplate, Activity, ActivityLog, + ApiCallLog, AppointmentEmployee, ApprovalPolicy, AvailabilitySlot, @@ -227,6 +234,9 @@ export const coreEntities = [ OrganizationProjectEmployee, OrganizationProjectModule, OrganizationRecurringExpense, + OrganizationSprintEmployee, + OrganizationSprintTask, + OrganizationSprintTaskHistory, OrganizationSprint, OrganizationTaskSetting, OrganizationTeam, @@ -257,6 +267,7 @@ export const coreEntities = [ RequestApproval, RequestApprovalEmployee, RequestApprovalTeam, + ResourceLink, Role, RolePermission, Screenshot, @@ -271,6 +282,7 @@ export const coreEntities = [ TaskSize, TaskStatus, TaskVersion, + TaskView, Tenant, TenantSetting, TimeLog, diff --git a/packages/core/src/core/entities/internal.ts b/packages/core/src/core/entities/internal.ts index 620fa43fb91..5f8cdfebeec 100644 --- a/packages/core/src/core/entities/internal.ts +++ b/packages/core/src/core/entities/internal.ts @@ -7,6 +7,7 @@ export * from './translate-base'; //core entities export * from '../../accounting-template/accounting-template.entity'; export * from '../../activity-log/activity-log.entity'; +export * from '../../api-call-log/api-call-log.entity'; export * from '../../appointment-employees/appointment-employees.entity'; export * from '../../approval-policy/approval-policy.entity'; export * from '../../availability-slots/availability-slots.entity'; @@ -87,6 +88,9 @@ export * from '../../organization-project/organization-project-employee.entity'; export * from '../../organization-project-module/organization-project-module.entity'; export * from '../../organization-recurring-expense/organization-recurring-expense.entity'; export * from '../../organization-sprint/organization-sprint.entity'; +export * from '../../organization-sprint/organization-sprint-employee.entity'; +export * from '../../organization-sprint/organization-sprint-task.entity'; +export * from '../../organization-sprint/organization-sprint-task-history.entity'; export * from '../../organization-task-setting/organization-task-setting.entity'; export * from '../../organization-team-employee/organization-team-employee.entity'; export * from '../../organization-team-join-request/organization-team-join-request.entity'; @@ -117,6 +121,7 @@ export * from '../../reports/report.entity'; export * from '../../request-approval-employee/request-approval-employee.entity'; export * from '../../request-approval-team/request-approval-team.entity'; export * from '../../request-approval/request-approval.entity'; +export * from '../../resource-link/resource-link.entity'; export * from '../../role-permission/role-permission.entity'; export * from '../../role/role.entity'; export * from '../../skills/skill.entity'; @@ -131,6 +136,7 @@ export * from '../../tasks/sizes/size.entity'; export * from '../../tasks/statuses/status.entity'; export * from '../../tasks/task.entity'; export * from '../../tasks/versions/version.entity'; +export * from '../../tasks/views/view.entity'; export * from '../../tenant/tenant-setting/tenant-setting.entity'; export * from '../../tenant/tenant.entity'; export * from '../../time-off-policy/time-off-policy.entity'; @@ -149,6 +155,7 @@ export * from '../../warehouse/warehouse.entity'; //core subscribers export * from '../../activity-log/activity-log.subscriber'; +export * from '../../api-call-log/api-call-log.subscriber'; export * from '../../candidate/candidate.subscriber'; export * from '../../custom-smtp/custom-smtp.subscriber'; export * from '../../email-reset/email-reset.subscriber'; @@ -172,6 +179,7 @@ export * from '../../payment/payment.subscriber'; export * from '../../pipeline/pipeline.subscriber'; export * from '../../product-category/product-category.subscriber'; export * from '../../reports/report.subscriber'; +export * from '../../resource-link/resource-link.subscriber'; export * from '../../role/role.subscriber'; export * from '../../tags/tag.subscriber'; export * from '../../tasks/issue-type/issue-type.subscriber'; @@ -185,5 +193,6 @@ export * from '../../tenant/tenant.subscriber'; export * from '../../time-off-request/time-off-request.subscriber'; export * from '../../time-tracking/activity/activity.subscriber'; export * from '../../time-tracking/screenshot/screenshot.subscriber'; +export * from '../../time-tracking/timesheet/timesheet.subscriber'; export * from '../../time-tracking/time-slot/time-slot.subscriber'; export * from '../../user/user.subscriber'; diff --git a/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts b/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts index 338d00aee62..43bfe0e7608 100644 --- a/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts +++ b/packages/core/src/core/entities/subscribers/base-entity-event.subscriber.ts @@ -1,119 +1,114 @@ import { EntityName } from '@mikro-orm/core'; -import { EntityEventSubscriber } from './entity-event.subsciber'; +import { EntityEventSubscriber } from './entity-event.subscriber'; import { IEntityEventSubscriber, MultiOrmEntityManager } from './entity-event-subscriber.types'; /** * An abstract class that provides a base implementation for IEntityEventSubscriber. * This class can be extended to create specific event subscribers for different entities. */ -export abstract class BaseEntityEventSubscriber extends EntityEventSubscriber implements IEntityEventSubscriber { +export abstract class BaseEntityEventSubscriber + extends EntityEventSubscriber + implements IEntityEventSubscriber +{ + /** + * An optional method that can be implemented by subclasses. + * It should return either a constructor function (a class) or a string + * representing the name of the entity to which this subscriber will listen. + * The default implementation returns undefined. + * + * @returns {Function | string | undefined} The entity class or its name, or undefined. + */ + listenTo(): Function | string | undefined { + return; + } - /** - * An optional method that can be implemented by subclasses. - * It should return either a constructor function (a class) or a string - * representing the name of the entity to which this subscriber will listen. - * The default implementation returns undefined. - * - * @returns {Function | string | undefined} The entity class or its name, or undefined. - */ - listenTo(): Function | string | undefined { - return; - } + /** + * Returns the array of entities this subscriber is subscribed to. + * If listenTo is not defined, it returns an empty array. + * + * @returns {EntityName[]} An array containing the entities to which this subscriber listens. + */ + getSubscribedEntities(): EntityName[] { + if (this.listenTo()) { + return [this.listenTo()]; + } + return []; + } - /** - * Returns the array of entities this subscriber is subscribed to. - * If listenTo is not defined, it returns an empty array. - * - * @returns {EntityName[]} An array containing the entities to which this subscriber listens. - */ - getSubscribedEntities(): EntityName[] { - if (this.listenTo()) { - return [this.listenTo()]; - } - return []; - } + /** + * Called before a new entity is persisted. Override in subclasses to define custom pre-creation logic. + * + * @param entity The entity that is about to be created. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async beforeEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Called before a new entity is persisted. Override in subclasses to define custom pre-creation logic. - * - * @param entity The entity that is about to be created. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async beforeEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Invoked before an entity update. Use in subclasses for specific update preparation. + * + * @param entity The entity being updated. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async beforeEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Invoked before an entity update. Use in subclasses for specific update preparation. - * - * @param entity The entity being updated. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async beforeEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Invoked after an entity update. Use in subclasses for specific update preparation. + * + * @param entity The entity being updated. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Invoked after an entity update. Use in subclasses for specific update preparation. - * - * @param entity The entity being updated. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Executed after an entity is created. Subclasses can override for post-creation actions. + * + * @param entity The newly created entity. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Executed after an entity is created. Subclasses can override for post-creation actions. - * - * @param entity The newly created entity. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Called following the loading of an entity. Ideal for post-load processing in subclasses. + * + * @param entity The entity that was loaded. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityLoad(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Called following the loading of an entity. Ideal for post-load processing in subclasses. - * - * @param entity The entity that was loaded. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityLoad( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Called following the deletion of an entity. Ideal for post-deletion processing in subclasses. + * + * @param entity The entity that was deleted. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntityDelete(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } - /** - * Called following the deletion of an entity. Ideal for post-deletion processing in subclasses. - * - * @param entity The entity that was deleted. - * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - async afterEntityDelete( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise { - // Default empty implementation - } + /** + * Called following the soft removal of an entity. Ideal for post-deletion processing in subclasses. + * + * @param entity The entity that was soft removed. + * @param em The optional EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + async afterEntitySoftRemove(entity: Entity, em?: MultiOrmEntityManager): Promise { + // Default empty implementation + } } diff --git a/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts b/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts index 00ed930e176..456abf7316b 100644 --- a/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts +++ b/packages/core/src/core/entities/subscribers/entity-event-subscriber.types.ts @@ -73,4 +73,13 @@ export interface IEntityEventSubscriber { * @returns {Promise} */ afterEntityDelete(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Optional method that is called after an entity is soft removed. + * Implement this method to define specific logic to be executed after an entity soft removal event. + * + * @param entity The entity that has been soft removed. + * @param em An optional entity manager which can be either from TypeORM or MikroORM, used for further database operations if needed. + */ + afterEntitySoftRemove(entity: Entity, em?: MultiOrmEntityManager): Promise; } diff --git a/packages/core/src/core/entities/subscribers/entity-event.subsciber.ts b/packages/core/src/core/entities/subscribers/entity-event.subsciber.ts deleted file mode 100644 index 79669c7293d..00000000000 --- a/packages/core/src/core/entities/subscribers/entity-event.subsciber.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { - EventArgs, - EventSubscriber as MikroEntitySubscriberInterface -} from '@mikro-orm/core'; -import { - InsertEvent, - LoadEvent, - RemoveEvent, - EntitySubscriberInterface as TypeOrmEntitySubscriberInterface, - UpdateEvent -} from 'typeorm'; -import { - MultiORM, - MultiORMEnum, - getORMType -} from '../../../core/utils'; -import { MultiOrmEntityManager } from './entity-event-subscriber.types'; - -// Get the type of the Object-Relational Mapping (ORM) used in the application. -const ormType: MultiORM = getORMType(); - -/** - * Implements event handling for entity creation. - * This class should be extended or integrated into your ORM event subscriber. - */ -export abstract class EntityEventSubscriber implements MikroEntitySubscriberInterface, TypeOrmEntitySubscriberInterface { - - /** - * Invoked when an entity is loaded in TypeORM. - * - * @param entity The loaded entity. - * @param event The load event details, if available. - * @returns {void | Promise} Can perform asynchronous operations. - */ - async afterLoad(entity: Entity, event?: LoadEvent): Promise { - try { - if (entity) { - await this.afterEntityLoad(entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterLoad:", error); - } - } - - /** - * Invoked when an entity is loaded in MikroORM. - * - * @param args The event arguments provided by MikroORM. - * @returns {void | Promise} Can perform asynchronous operations. - */ - async onLoad(args: EventArgs): Promise { - try { - if (args.entity) { - await this.afterEntityLoad(args.entity, args.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in onLoad:", error); - } - } - - /** - * Abstract method for processing after an entity is loaded. Implement in subclasses for custom behavior. - * - * @param entity The entity that has been loaded. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityLoad( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the event before an entity is created in MikroORM. - * - * @param args The event arguments provided by MikroORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async beforeCreate(args: EventArgs): Promise { - try { - if (args.entity) { - await this.beforeEntityCreate(args.entity, args.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in beforeCreate:", error); - } - } - - /** - * Handles the event before an entity is inserted in TypeORM. - * - * @param event The insert event provided by TypeORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async beforeInsert(event: InsertEvent): Promise { - try { - if (event.entity) { - await this.beforeEntityCreate(event.entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in beforeInsert:", error); - } - } - - /** - * Abstract method for pre-creation logic of an entity. Implement in subclasses for custom actions. - * - * @param entity The entity that is about to be updated. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract beforeEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the event after an entity has been created in MikroORM. - * - * @param args - The event arguments provided by MikroORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async afterCreate(args: EventArgs): Promise { - try { - if (args.entity) { - await this.afterEntityCreate(args.entity, args.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterCreate:", error); - } - } - - /** - * Handles the event after an entity has been inserted in TypeORM. - * - * @param event - The insert event provided by TypeORM. - * @returns {Promise} - Can perform asynchronous operations. - */ - async afterInsert(event: InsertEvent): Promise { - try { - if (event.entity) { - await this.afterEntityCreate(event.entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterInsert:", error); - } - } - - /** - * Abstract method for post-creation actions on an entity. Override in subclasses to define behavior. - * - * @param entity The entity that is about to be created. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityCreate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the 'before update' event for both MikroORM and TypeORM entities. It determines the - * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. - * - * @param event The event object which can be either EventArgs from MikroORM or UpdateEvent from TypeORM. - * @returns {Promise} A promise that resolves when the pre-update process is complete. Any errors during processing are caught and logged. - */ - async beforeUpdate(event: EventArgs | UpdateEvent): Promise { - try { - let entity: Entity; - let entityManager: MultiOrmEntityManager; - - switch (ormType) { - case MultiORMEnum.MikroORM: - entity = (event as EventArgs).entity; - entityManager = (event as EventArgs).em; - break; - case MultiORMEnum.TypeORM: - entity = (event as UpdateEvent).entity as Entity; - entityManager = (event as UpdateEvent).manager; - break; - default: - throw new Error(`Unsupported ORM type: ${ormType}`); - } - - if (entity) { - await this.beforeEntityUpdate(entity, entityManager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in beforeUpdate:", error); - } - } - - /** - * Abstract method for actions before updating an entity. Override in subclasses for specific logic. - * - * @param entity The entity that is about to be updated. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract beforeEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Handles the 'after update' event for both MikroORM and TypeORM entities. It determines the - * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. - * - * @param event - * @returns {Promise} A promise that resolves when the post-update process is complete. Any errors during processing are caught and logged. - */ - async afterUpdate(event: EventArgs | UpdateEvent): Promise { - try { - let entity: Entity; - let entityManager: MultiOrmEntityManager; - - switch (ormType) { - case MultiORMEnum.MikroORM: - entity = (event as EventArgs).entity; - entityManager = (event as EventArgs).em; - break; - case MultiORMEnum.TypeORM: - entity = (event as UpdateEvent).entity as Entity; - entityManager = (event as UpdateEvent).manager; - break; - default: - throw new Error(`Unsupported ORM type: ${ormType}`); - } - - if (entity) { - await this.afterEntityUpdate(entity, entityManager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterUpdate:", error); - } - } - - /** - * Abstract method for actions after updating an entity. Override in subclasses for specific logic. - * - * @param entity The entity that is about to be updated. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityUpdate( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; - - /** - * Invoked when an entity is deleted in MikroORM. - * - * @param args The details of the delete event, including the deleted entity. - * @returns {void | Promise} Can perform asynchronous operations. - */ - async afterDelete(event: EventArgs): Promise { - try { - if (event.entity) { - await this.afterEntityDelete(event.entity, event.em); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterDelete:", error); - } - } - - /** - * Invoked when an entity is removed in TypeORM. - * - * @param event The remove event details, including the removed entity. - * @returns {Promise} Can perform asynchronous operations. - */ - async afterRemove(event: RemoveEvent): Promise { - try { - if (event.entity && event.entityId) { - event.entity['id'] = event.entityId; - await this.afterEntityDelete(event.entity, event.manager); - } - } catch (error) { - console.error("EntityEventSubscriber: Error in afterRemove:", error); - } - } - - /** - * Abstract method for processing after an entity is deleted. Implement in subclasses for custom behavior. - * - * @param entity The entity that has been deleted. - * @param em The EntityManager, which can be either from TypeORM or MikroORM. - * @returns {Promise} - */ - protected abstract afterEntityDelete( - entity: Entity, - em?: MultiOrmEntityManager - ): Promise; -} diff --git a/packages/core/src/core/entities/subscribers/entity-event.subscriber.ts b/packages/core/src/core/entities/subscribers/entity-event.subscriber.ts new file mode 100644 index 00000000000..604f405f989 --- /dev/null +++ b/packages/core/src/core/entities/subscribers/entity-event.subscriber.ts @@ -0,0 +1,296 @@ +import { EventArgs, EventSubscriber as MikroEntitySubscriberInterface } from '@mikro-orm/core'; +import { + InsertEvent, + LoadEvent, + RemoveEvent, + EntitySubscriberInterface as TypeOrmEntitySubscriberInterface, + UpdateEvent +} from 'typeorm'; +import { MultiORM, MultiORMEnum, getORMType } from '../../utils'; +import { MultiOrmEntityManager } from './entity-event-subscriber.types'; + +// Get the type of the Object-Relational Mapping (ORM) used in the application. +const ormType: MultiORM = getORMType(); + +/** + * Implements event handling for entity creation. + * This class should be extended or integrated into your ORM event subscriber. + */ +export abstract class EntityEventSubscriber + implements MikroEntitySubscriberInterface, TypeOrmEntitySubscriberInterface +{ + /** + * Invoked when an entity is loaded in TypeORM. + * + * @param entity The loaded entity. + * @param event The load event details, if available. + * @returns {void | Promise} Can perform asynchronous operations. + */ + async afterLoad(entity: Entity, event?: LoadEvent): Promise { + try { + if (entity) { + await this.afterEntityLoad(entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterLoad:', error); + } + } + + /** + * Invoked when an entity is loaded in MikroORM. + * + * @param args The event arguments provided by MikroORM. + * @returns {void | Promise} Can perform asynchronous operations. + */ + async onLoad(args: EventArgs): Promise { + try { + if (args.entity) { + await this.afterEntityLoad(args.entity, args.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in onLoad:', error); + } + } + + /** + * Abstract method for processing after an entity is loaded. Implement in subclasses for custom behavior. + * + * @param entity The entity that has been loaded. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityLoad(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the event before an entity is created in MikroORM. + * + * @param args The event arguments provided by MikroORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async beforeCreate(args: EventArgs): Promise { + try { + if (args.entity) { + await this.beforeEntityCreate(args.entity, args.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in beforeCreate:', error); + } + } + + /** + * Handles the event before an entity is inserted in TypeORM. + * + * @param event The insert event provided by TypeORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async beforeInsert(event: InsertEvent): Promise { + try { + if (event.entity) { + await this.beforeEntityCreate(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in beforeInsert:', error); + } + } + + /** + * Abstract method for pre-creation logic of an entity. Implement in subclasses for custom actions. + * + * @param entity The entity that is about to be updated. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract beforeEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the event after an entity has been created in MikroORM. + * + * @param args - The event arguments provided by MikroORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async afterCreate(args: EventArgs): Promise { + try { + if (args.entity) { + await this.afterEntityCreate(args.entity, args.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterCreate:', error); + } + } + + /** + * Handles the event after an entity has been inserted in TypeORM. + * + * @param event - The insert event provided by TypeORM. + * @returns {Promise} - Can perform asynchronous operations. + */ + async afterInsert(event: InsertEvent): Promise { + try { + if (event.entity) { + await this.afterEntityCreate(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterInsert:', error); + } + } + + /** + * Abstract method for post-creation actions on an entity. Override in subclasses to define behavior. + * + * @param entity The entity that is about to be created. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityCreate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the 'before update' event for both MikroORM and TypeORM entities. It determines the + * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. + * + * @param event The event object which can be either EventArgs from MikroORM or UpdateEvent from TypeORM. + * @returns {Promise} A promise that resolves when the pre-update process is complete. Any errors during processing are caught and logged. + */ + async beforeUpdate(event: EventArgs | UpdateEvent): Promise { + try { + let entity: Entity; + let entityManager: MultiOrmEntityManager; + + switch (ormType) { + case MultiORMEnum.MikroORM: + entity = (event as EventArgs).entity; + entityManager = (event as EventArgs).em; + break; + case MultiORMEnum.TypeORM: + entity = (event as UpdateEvent).entity as Entity; + entityManager = (event as UpdateEvent).manager; + break; + default: + throw new Error(`Unsupported ORM type: ${ormType}`); + } + + if (entity) { + await this.beforeEntityUpdate(entity, entityManager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in beforeUpdate:', error); + } + } + + /** + * Abstract method for actions before updating an entity. Override in subclasses for specific logic. + * + * @param entity The entity that is about to be updated. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract beforeEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Handles the 'after update' event for both MikroORM and TypeORM entities. It determines the + * type of ORM being used and appropriately casts the event to either EventArgs or UpdateEvent. + * + * @param event + * @returns {Promise} A promise that resolves when the post-update process is complete. Any errors during processing are caught and logged. + */ + async afterUpdate(event: EventArgs | UpdateEvent): Promise { + try { + let entity: Entity; + let entityManager: MultiOrmEntityManager; + + switch (ormType) { + case MultiORMEnum.MikroORM: + entity = (event as EventArgs).entity; + entityManager = (event as EventArgs).em; + break; + case MultiORMEnum.TypeORM: + entity = (event as UpdateEvent).entity as Entity; + entityManager = (event as UpdateEvent).manager; + break; + default: + throw new Error(`Unsupported ORM type: ${ormType}`); + } + + if (entity) { + await this.afterEntityUpdate(entity, entityManager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterUpdate:', error); + } + } + + /** + * Abstract method for actions after updating an entity. Override in subclasses for specific logic. + * + * @param entity The entity that is about to be updated. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityUpdate(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Invoked when an entity is deleted in MikroORM. + * + * @param args The details of the delete event, including the deleted entity. + * @returns {void | Promise} Can perform asynchronous operations. + */ + async afterDelete(event: EventArgs): Promise { + try { + if (event.entity) { + await this.afterEntityDelete(event.entity, event.em); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterDelete:', error); + } + } + + /** + * Invoked when an entity is removed in TypeORM. + * + * @param event The remove event details, including the removed entity. + * @returns {Promise} Can perform asynchronous operations. + */ + async afterRemove(event: RemoveEvent): Promise { + try { + if (event.entity && event.entityId) { + event.entity['id'] = event.entityId; + await this.afterEntityDelete(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterRemove:', error); + } + } + + /** + * Abstract method for processing after an entity is deleted. Implement in subclasses for custom behavior. + * + * @param entity The entity that has been deleted. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * @returns {Promise} + */ + protected abstract afterEntityDelete(entity: Entity, em?: MultiOrmEntityManager): Promise; + + /** + * Called after entity is soft removed from the database. + * + * @param event The remove event details, including the removed entity. + * @returns {Promise} Can perform asynchronous operations. + */ + async afterSoftRemove(event: RemoveEvent): Promise { + try { + if (event.entity && event.entityId) { + await this.afterEntitySoftRemove(event.entity, event.manager); + } + } catch (error) { + console.error('EntityEventSubscriber: Error in afterSoftRemove:', error); + } + } + + /** + * Abstract method for processing after an entity is soft removed. Implement in subclasses for custom behavior. + * + * @param entity The entity that has been soft removed. + * @param em The EntityManager, which can be either from TypeORM or MikroORM. + * + */ + protected abstract afterEntitySoftRemove(entity: Entity, em?: MultiOrmEntityManager): Promise; +} diff --git a/packages/core/src/core/entities/subscribers/index.ts b/packages/core/src/core/entities/subscribers/index.ts index 811058e95e1..ae9e3cd7f8b 100644 --- a/packages/core/src/core/entities/subscribers/index.ts +++ b/packages/core/src/core/entities/subscribers/index.ts @@ -5,6 +5,7 @@ import { MultiORMEnum, getORMType } from '../../utils'; import { ActivitySubscriber, ActivityLogSubscriber, + ApiCallLogSubscriber, CandidateSubscriber, CustomSmtpSubscriber, EmailResetSubscriber, @@ -29,6 +30,7 @@ import { PipelineSubscriber, ProductCategorySubscriber, ReportSubscriber, + ResourceLinkSubscriber, RoleSubscriber, ScreenshotSubscriber, TagSubscriber, @@ -40,6 +42,7 @@ import { TaskVersionSubscriber, TenantSubscriber, TimeOffRequestSubscriber, + TimesheetSubscriber, TimeSlotSubscriber, UserSubscriber } from '../internal'; @@ -55,6 +58,7 @@ export const coreSubscribers = [ ...(ormType === MultiORMEnum.MikroORM ? [TenantOrganizationBaseEntityEventSubscriber] : []), ActivitySubscriber, ActivityLogSubscriber, + ApiCallLogSubscriber, CandidateSubscriber, CustomSmtpSubscriber, EmailResetSubscriber, @@ -79,6 +83,7 @@ export const coreSubscribers = [ PipelineSubscriber, ProductCategorySubscriber, ReportSubscriber, + ResourceLinkSubscriber, RoleSubscriber, ScreenshotSubscriber, TagSubscriber, @@ -90,6 +95,7 @@ export const coreSubscribers = [ TaskVersionSubscriber, TenantSubscriber, TimeOffRequestSubscriber, + TimesheetSubscriber, TimeSlotSubscriber, UserSubscriber ]; diff --git a/packages/core/src/core/file-storage/tenant-settings.middleware.ts b/packages/core/src/core/file-storage/tenant-settings.middleware.ts index ddeeb567d4d..b991bac28d7 100644 --- a/packages/core/src/core/file-storage/tenant-settings.middleware.ts +++ b/packages/core/src/core/file-storage/tenant-settings.middleware.ts @@ -7,12 +7,12 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; @Injectable() export class TenantSettingsMiddleware implements NestMiddleware { - private logging = true; + private logging = false; constructor( @Inject(CACHE_MANAGER) private cacheManager: Cache, private readonly tenantSettingService: TenantSettingService - ) { } + ) {} /** * @@ -43,7 +43,10 @@ export class TenantSettingsMiddleware implements NestMiddleware { if (!tenantSettings) { if (this.logging) { - console.log('Tenant settings NOT loaded from Cache for tenantId: %s', decodedToken.tenantId); + console.log( + 'Tenant settings NOT loaded from Cache for tenantId: %s', + decodedToken.tenantId + ); } // Fetch tenant settings based on the decoded tenantId @@ -54,11 +57,14 @@ export class TenantSettingsMiddleware implements NestMiddleware { }); if (tenantSettings) { - const ttl = 5 * 60 * 1000 // 5 min caching period for Tenants Settings + const ttl = 5 * 60 * 1000; // 5 min caching period for Tenants Settings await this.cacheManager.set(cacheKey, tenantSettings, ttl); if (this.logging) { - console.log('Tenant settings loaded from DB and stored in Cache for tenantId: %s', decodedToken.tenantId); + console.log( + 'Tenant settings loaded from DB and stored in Cache for tenantId: %s', + decodedToken.tenantId + ); } } } else { diff --git a/packages/core/src/core/seeds/data/default-email-templates/password/bg/html.mjml b/packages/core/src/core/seeds/data/default-email-templates/password/bg/html.mjml index b1f7cdc2b40..91a7e864898 100644 --- a/packages/core/src/core/seeds/data/default-email-templates/password/bg/html.mjml +++ b/packages/core/src/core/seeds/data/default-email-templates/password/bg/html.mjml @@ -13,8 +13,8 @@

    Здравейте {{userName}}!

    - Получихме заявка за промяна на паролата за вашия Gauzy акаунт за - {{tenantName}}. + Получихме заявка за промяна на паролата за вашия Gauzy акаунт + {{#if tenantName}} за {{tenantName}}{{/if}}.

    Ако сте поискали да нулирате паролата си, щракнете върху бутона по-долу:

    diff --git a/packages/core/src/core/seeds/data/default-email-templates/password/bg/subject.hbs b/packages/core/src/core/seeds/data/default-email-templates/password/bg/subject.hbs index e11c973da6a..393c3c3ef9e 100644 --- a/packages/core/src/core/seeds/data/default-email-templates/password/bg/subject.hbs +++ b/packages/core/src/core/seeds/data/default-email-templates/password/bg/subject.hbs @@ -1 +1 @@ -Reset Your Password: Secure Your {{appName}} Account: {{email}} +Сменете паролата си: Защитете {{appName}} акаунта си: {{email}} diff --git a/packages/core/src/core/seeds/data/default-email-templates/password/en/html.mjml b/packages/core/src/core/seeds/data/default-email-templates/password/en/html.mjml index ab9578cb783..cbb4b8d44b5 100644 --- a/packages/core/src/core/seeds/data/default-email-templates/password/en/html.mjml +++ b/packages/core/src/core/seeds/data/default-email-templates/password/en/html.mjml @@ -13,8 +13,8 @@

    Hello {{userName}}!

    - We received a password change request for your Gauzy account for the - {{tenantName}}. + We received a request to reset the password for your Gauzy account + {{#if tenantName}} for {{tenantName}}{{/if}}.

    If you requested to reset your password, click the button below:

    diff --git a/packages/core/src/core/seeds/data/default-email-templates/password/he/html.mjml b/packages/core/src/core/seeds/data/default-email-templates/password/he/html.mjml index 37d68e944b2..9d07ef222d4 100644 --- a/packages/core/src/core/seeds/data/default-email-templates/password/he/html.mjml +++ b/packages/core/src/core/seeds/data/default-email-templates/password/he/html.mjml @@ -13,8 +13,8 @@

    שלום {{userName}}!

    - קיבלנו בקשה לשינוי סיסמה עבור חשבון Gauzy שלך עבור - {{tenantName}}. + קיבלנו בקשה לשינוי סיסמה עבור חשבון Gauzy שלך + {{#if tenantName}} עבור {{tenantName}}{{/if}}.

    אם ביקשת לאפס את הסיסמה שלך, לחץ על הכפתור למטה:

    diff --git a/packages/core/src/core/seeds/data/default-email-templates/password/he/subject.hbs b/packages/core/src/core/seeds/data/default-email-templates/password/he/subject.hbs index e11c973da6a..35f44e67d2e 100644 --- a/packages/core/src/core/seeds/data/default-email-templates/password/he/subject.hbs +++ b/packages/core/src/core/seeds/data/default-email-templates/password/he/subject.hbs @@ -1 +1 @@ -Reset Your Password: Secure Your {{appName}} Account: {{email}} +אפס את הסיסמה שלך: אבטח את חשבונך ב{{appName}}: {{email}} diff --git a/packages/core/src/core/seeds/data/default-email-templates/password/ru/html.mjml b/packages/core/src/core/seeds/data/default-email-templates/password/ru/html.mjml index 4bdeb2d2f80..2b58f8f61be 100644 --- a/packages/core/src/core/seeds/data/default-email-templates/password/ru/html.mjml +++ b/packages/core/src/core/seeds/data/default-email-templates/password/ru/html.mjml @@ -13,8 +13,8 @@

    Привет {{userName}}!

    - Мы получили запрос на изменение пароля для вашей учетной записи Gauzy для - {{tenantName}}. + Мы получили запрос на изменение пароля для вашей учетной записи Gauzy + {{#if tenantName}} для {{tenantName}}{{/if}}.

    Если вы запросили сброс пароля, нажмите кнопку ниже:

    diff --git a/packages/core/src/core/seeds/data/default-email-templates/password/ru/subject.hbs b/packages/core/src/core/seeds/data/default-email-templates/password/ru/subject.hbs index e11c973da6a..dd0de687eca 100644 --- a/packages/core/src/core/seeds/data/default-email-templates/password/ru/subject.hbs +++ b/packages/core/src/core/seeds/data/default-email-templates/password/ru/subject.hbs @@ -1 +1 @@ -Reset Your Password: Secure Your {{appName}} Account: {{email}} +Сбросьте пароль: Защитите ваш аккаунт в {{appName}}: {{email}} diff --git a/packages/core/src/core/utils.ts b/packages/core/src/core/utils.ts index f47ebc26490..0cdc0641199 100644 --- a/packages/core/src/core/utils.ts +++ b/packages/core/src/core/utils.ts @@ -1,6 +1,5 @@ import { BadRequestException } from '@nestjs/common'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; -import { SqliteDriver } from '@mikro-orm/sqlite'; import { FindOptions as MikroORMFindOptions, FilterQuery as MikroFilterQuery, @@ -316,8 +315,6 @@ export function validateDateRange(startedAt: Date, stoppedAt: Date): void { const start = moment(startedAt); const end = moment(stoppedAt); - console.log('------ Timer Date Range ------', start.toDate(), end.toDate()); - // Validate that both dates are valid if (!start.isValid() || !end.isValid()) { throw new BadRequestException('Started and Stopped date must be valid dates.'); @@ -399,9 +396,7 @@ export function getDBType(dbConnection?: IDBConnectionOptions): any { let dbType: any; switch (dbORM) { case MultiORMEnum.MikroORM: - if (dbConnection.driver instanceof SqliteDriver) { - dbType = DatabaseTypeEnum.sqlite; - } else if (dbConnection.driver instanceof BetterSqliteDriver) { + if (dbConnection.driver instanceof BetterSqliteDriver) { dbType = DatabaseTypeEnum.betterSqlite3; } else if (dbConnection.driver instanceof PostgreSqlDriver) { dbType = DatabaseTypeEnum.postgres; @@ -727,5 +722,10 @@ export function replacePlaceholders(query: string, dbType: DatabaseTypeEnum): st if ([DatabaseTypeEnum.sqlite, DatabaseTypeEnum.betterSqlite3, DatabaseTypeEnum.mysql].includes(dbType)) { return query.replace(/\$\d+/g, '?'); } + if ([DatabaseTypeEnum.mysql].includes(dbType)) { + // Replace double quotes with backticks for MySQL + query = query.replace(/"/g, '`'); + } + return query; } diff --git a/packages/core/src/database/migration-executor.ts b/packages/core/src/database/migration-executor.ts index 6b1b78a22d0..36707a3674e 100644 --- a/packages/core/src/database/migration-executor.ts +++ b/packages/core/src/database/migration-executor.ts @@ -265,6 +265,7 @@ function queryParams(parameters: any[] | undefined): string { */ function getTemplate(connection: DataSource, name: string, timestamp: number, upSqls: string[], downSqls: string[]): string { return ` +import { Logger } from '@nestjs/common'; import { MigrationInterface, QueryRunner } from "typeorm"; import { yellow } from "chalk"; import { DatabaseTypeEnum } from "@gauzy/config"; @@ -279,7 +280,7 @@ export class ${camelCase(name, true)}${timestamp} implements MigrationInterface * @param queryRunner */ public async up(queryRunner: QueryRunner): Promise { - console.log(yellow(this.name + ' start running!')); + Logger.debug(yellow(this.name + ' start running!'), 'Migration'); switch (queryRunner.connection.options.type) { case DatabaseTypeEnum.sqlite: diff --git a/packages/core/src/database/migrations/1727954184608-AlterTokenColumnTypeToTextInPasswordResetTable.ts b/packages/core/src/database/migrations/1727954184608-AlterTokenColumnTypeToTextInPasswordResetTable.ts new file mode 100644 index 00000000000..69767aba691 --- /dev/null +++ b/packages/core/src/database/migrations/1727954184608-AlterTokenColumnTypeToTextInPasswordResetTable.ts @@ -0,0 +1,194 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class AlterTokenColumnTypeToTextInPasswordResetTable1727954184608 implements MigrationInterface { + name = 'AlterTokenColumnTypeToTextInPasswordResetTable1727954184608'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_36e929b98372d961bb63bd4b4e"`); + // Modify the column type to 'text' without dropping the column + await queryRunner.query(`ALTER TABLE "password_reset" ALTER COLUMN "token" TYPE text`); + await queryRunner.query(`CREATE INDEX "IDX_36e929b98372d961bb63bd4b4e" ON "password_reset" ("token") `); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_36e929b98372d961bb63bd4b4e"`); + // Revert the column type back to 'character varying' + await queryRunner.query(`ALTER TABLE "password_reset" ALTER COLUMN "token" TYPE character varying`); + await queryRunner.query(`CREATE INDEX "IDX_36e929b98372d961bb63bd4b4e" ON "password_reset" ("token") `); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_36e929b98372d961bb63bd4b4e"`); + await queryRunner.query(`DROP INDEX "IDX_1c88db6e50f0704688d1f1978c"`); + await queryRunner.query(`DROP INDEX "IDX_380c03025a41ad032191f1ef2d"`); + await queryRunner.query(`DROP INDEX "IDX_e71a736d52820b568f6b0ca203"`); + await queryRunner.query(`DROP INDEX "IDX_1fa632f2d12a06ef8dcc00858f"`); + await queryRunner.query( + `CREATE TABLE "temporary_password_reset" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "email" varchar NOT NULL, "token" varchar NOT NULL, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "tenantId" varchar, "archivedAt" datetime, CONSTRAINT "FK_1fa632f2d12a06ef8dcc00858ff" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_password_reset"("id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt") SELECT "id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt" FROM "password_reset"` + ); + await queryRunner.query(`DROP TABLE "password_reset"`); + await queryRunner.query(`ALTER TABLE "temporary_password_reset" RENAME TO "password_reset"`); + await queryRunner.query(`CREATE INDEX "IDX_36e929b98372d961bb63bd4b4e" ON "password_reset" ("token") `); + await queryRunner.query(`CREATE INDEX "IDX_1c88db6e50f0704688d1f1978c" ON "password_reset" ("email") `); + await queryRunner.query(`CREATE INDEX "IDX_380c03025a41ad032191f1ef2d" ON "password_reset" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_e71a736d52820b568f6b0ca203" ON "password_reset" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_1fa632f2d12a06ef8dcc00858f" ON "password_reset" ("tenantId") `); + await queryRunner.query(`DROP INDEX "IDX_36e929b98372d961bb63bd4b4e"`); + await queryRunner.query(`DROP INDEX "IDX_1c88db6e50f0704688d1f1978c"`); + await queryRunner.query(`DROP INDEX "IDX_380c03025a41ad032191f1ef2d"`); + await queryRunner.query(`DROP INDEX "IDX_e71a736d52820b568f6b0ca203"`); + await queryRunner.query(`DROP INDEX "IDX_1fa632f2d12a06ef8dcc00858f"`); + await queryRunner.query( + `CREATE TABLE "temporary_password_reset" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "email" varchar NOT NULL, "token" text NOT NULL, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "tenantId" varchar, "archivedAt" datetime, CONSTRAINT "FK_1fa632f2d12a06ef8dcc00858ff" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_password_reset"("id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt") SELECT "id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt" FROM "password_reset"` + ); + await queryRunner.query(`DROP TABLE "password_reset"`); + await queryRunner.query(`ALTER TABLE "temporary_password_reset" RENAME TO "password_reset"`); + await queryRunner.query(`CREATE INDEX "IDX_36e929b98372d961bb63bd4b4e" ON "password_reset" ("token") `); + await queryRunner.query(`CREATE INDEX "IDX_1c88db6e50f0704688d1f1978c" ON "password_reset" ("email") `); + await queryRunner.query(`CREATE INDEX "IDX_380c03025a41ad032191f1ef2d" ON "password_reset" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_e71a736d52820b568f6b0ca203" ON "password_reset" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_1fa632f2d12a06ef8dcc00858f" ON "password_reset" ("tenantId") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_1fa632f2d12a06ef8dcc00858f"`); + await queryRunner.query(`DROP INDEX "IDX_e71a736d52820b568f6b0ca203"`); + await queryRunner.query(`DROP INDEX "IDX_380c03025a41ad032191f1ef2d"`); + await queryRunner.query(`DROP INDEX "IDX_1c88db6e50f0704688d1f1978c"`); + await queryRunner.query(`DROP INDEX "IDX_36e929b98372d961bb63bd4b4e"`); + await queryRunner.query(`ALTER TABLE "password_reset" RENAME TO "temporary_password_reset"`); + await queryRunner.query( + `CREATE TABLE "password_reset" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "email" varchar NOT NULL, "token" varchar NOT NULL, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "tenantId" varchar, "archivedAt" datetime, CONSTRAINT "FK_1fa632f2d12a06ef8dcc00858ff" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "password_reset"("id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt") SELECT "id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt" FROM "temporary_password_reset"` + ); + await queryRunner.query(`DROP TABLE "temporary_password_reset"`); + await queryRunner.query(`CREATE INDEX "IDX_1fa632f2d12a06ef8dcc00858f" ON "password_reset" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_e71a736d52820b568f6b0ca203" ON "password_reset" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_380c03025a41ad032191f1ef2d" ON "password_reset" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_1c88db6e50f0704688d1f1978c" ON "password_reset" ("email") `); + await queryRunner.query(`CREATE INDEX "IDX_36e929b98372d961bb63bd4b4e" ON "password_reset" ("token") `); + await queryRunner.query(`DROP INDEX "IDX_1fa632f2d12a06ef8dcc00858f"`); + await queryRunner.query(`DROP INDEX "IDX_e71a736d52820b568f6b0ca203"`); + await queryRunner.query(`DROP INDEX "IDX_380c03025a41ad032191f1ef2d"`); + await queryRunner.query(`DROP INDEX "IDX_1c88db6e50f0704688d1f1978c"`); + await queryRunner.query(`DROP INDEX "IDX_36e929b98372d961bb63bd4b4e"`); + await queryRunner.query(`ALTER TABLE "password_reset" RENAME TO "temporary_password_reset"`); + await queryRunner.query( + `CREATE TABLE "password_reset" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "email" varchar NOT NULL, "token" varchar NOT NULL, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "tenantId" varchar, "archivedAt" datetime, CONSTRAINT "FK_1fa632f2d12a06ef8dcc00858ff" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "password_reset"("id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt") SELECT "id", "createdAt", "updatedAt", "email", "token", "isActive", "isArchived", "deletedAt", "tenantId", "archivedAt" FROM "temporary_password_reset"` + ); + await queryRunner.query(`DROP TABLE "temporary_password_reset"`); + await queryRunner.query(`CREATE INDEX "IDX_1fa632f2d12a06ef8dcc00858f" ON "password_reset" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_e71a736d52820b568f6b0ca203" ON "password_reset" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_380c03025a41ad032191f1ef2d" ON "password_reset" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_1c88db6e50f0704688d1f1978c" ON "password_reset" ("email") `); + await queryRunner.query(`CREATE INDEX "IDX_36e929b98372d961bb63bd4b4e" ON "password_reset" ("token") `); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + // Drop the original index without the key length restriction + await queryRunner.query(`DROP INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\``); + // Alter the `token` column to `TEXT` and modify the index with a length + await queryRunner.query(`ALTER TABLE \`password_reset\` MODIFY \`token\` TEXT NOT NULL`); + // Recreate the original index without the key length restriction + await queryRunner.query( + `CREATE INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\` (\`token\`(255))` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\``); + // Revert the `token` column back to `VARCHAR(255)` + await queryRunner.query(`ALTER TABLE \`password_reset\` MODIFY \`token\` VARCHAR(255) NOT NULL`); + // Recreate the original index without the key length restriction + await queryRunner.query(`CREATE INDEX \`IDX_36e929b98372d961bb63bd4b4e\` ON \`password_reset\` (\`token\`)`); + } +} diff --git a/packages/core/src/database/migrations/1728024420021-MigratePasswordResetEmailTemplates.ts b/packages/core/src/database/migrations/1728024420021-MigratePasswordResetEmailTemplates.ts new file mode 100644 index 00000000000..4639b0dfa95 --- /dev/null +++ b/packages/core/src/database/migrations/1728024420021-MigratePasswordResetEmailTemplates.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { EmailTemplateEnum } from '@gauzy/contracts'; +import { EmailTemplateUtils } from '../../email-template/utils'; + +export class MigratePasswordResetEmailTemplates1728024420021 implements MigrationInterface { + name = 'MigratePasswordResetEmailTemplates1728024420021'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(`${this.name} started running!`)); + + // Migrate each template + try { + await EmailTemplateUtils.migrateEmailTemplates(queryRunner, EmailTemplateEnum.PASSWORD_RESET); + } catch (error) { + console.error(`Error while migrating email templates for ${EmailTemplateEnum.PASSWORD_RESET}:`, error); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/packages/core/src/database/migrations/1728301971790-MigrateOrganizationSprintsRelatedEntities.ts b/packages/core/src/database/migrations/1728301971790-MigrateOrganizationSprintsRelatedEntities.ts new file mode 100644 index 00000000000..b2eadc4c0b7 --- /dev/null +++ b/packages/core/src/database/migrations/1728301971790-MigrateOrganizationSprintsRelatedEntities.ts @@ -0,0 +1,876 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class MigrateOrganizationSprintsRelatedEntities1728301971790 implements MigrationInterface { + name = 'MigrateOrganizationSprintsRelatedEntities1728301971790'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "organization_sprint_employee" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "isManager" boolean DEFAULT false, "assignedAt" TIMESTAMP, "organizationSprintId" uuid NOT NULL, "employeeId" uuid NOT NULL, "roleId" uuid, CONSTRAINT "PK_b31f2517142a7bf8b5c863a5f72" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "totalWorkedHours" integer, "organizationSprintId" uuid NOT NULL, "taskId" uuid NOT NULL, CONSTRAINT "PK_7baee3cc404e521605aaf5a74d2" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task_history" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "reason" text, "taskId" uuid NOT NULL, "fromSprintId" uuid NOT NULL, "toSprintId" uuid NOT NULL, "movedById" uuid, CONSTRAINT "PK_372b66962438094dc3c6ab926b5" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + await queryRunner.query(`ALTER TABLE "organization_sprint" ADD "status" character varying`); + await queryRunner.query(`ALTER TABLE "organization_sprint" ADD "sprintProgress" jsonb`); + await queryRunner.query(`CREATE INDEX "IDX_1cbe898fb849e4cffbddb60a87" ON "organization_sprint" ("status") `); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_30d8b0ec5e73c133f3e3d9afef1" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_2e74f97c99716d6a0a892ec6de7" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_68ea808c69795593450737c9923" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_e5ec88b5022e0d71ac88d876de2" FOREIGN KEY ("employeeId") REFERENCES "employee"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" ADD CONSTRAINT "FK_f516713776cfc5662cdbf2f3c4b" FOREIGN KEY ("roleId") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_be3cb56b953b535835ad8683916" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_6a84b0cec9f10178a027f200981" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_889c9fd5c577a89f5f30facde42" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" ADD CONSTRAINT "FK_e50cfbf82eec3f0b1d004a5c6e8" FOREIGN KEY ("taskId") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_d5203d63179a7baf703e29a628c" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_8f686ac0104c90e95ef10f6c229" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_3030e5dca58343e09ae1af01082" FOREIGN KEY ("taskId") REFERENCES "task"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_555c5952e67b2e93d4c7067f72d" FOREIGN KEY ("fromSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_da46a676d25c64bb06fc4536b34" FOREIGN KEY ("toSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" ADD CONSTRAINT "FK_7723913546575c33ab09ecfd508" FOREIGN KEY ("movedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_7723913546575c33ab09ecfd508"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_da46a676d25c64bb06fc4536b34"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_555c5952e67b2e93d4c7067f72d"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_3030e5dca58343e09ae1af01082"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_8f686ac0104c90e95ef10f6c229"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" DROP CONSTRAINT "FK_d5203d63179a7baf703e29a628c"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_e50cfbf82eec3f0b1d004a5c6e8"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_889c9fd5c577a89f5f30facde42"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_6a84b0cec9f10178a027f200981"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" DROP CONSTRAINT "FK_be3cb56b953b535835ad8683916"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_f516713776cfc5662cdbf2f3c4b"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_e5ec88b5022e0d71ac88d876de2"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_68ea808c69795593450737c9923"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_2e74f97c99716d6a0a892ec6de7"` + ); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" DROP CONSTRAINT "FK_30d8b0ec5e73c133f3e3d9afef1"` + ); + await queryRunner.query(`DROP INDEX "public"."IDX_1cbe898fb849e4cffbddb60a87"`); + await queryRunner.query(`ALTER TABLE "organization_sprint" DROP COLUMN "sprintProgress"`); + await queryRunner.query(`ALTER TABLE "organization_sprint" DROP COLUMN "status"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query(`DROP INDEX "public"."IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task_history"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "public"."IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "public"."IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "public"."IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "public"."IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "public"."IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query(`DROP TABLE "organization_sprint_employee"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "organization_sprint_employee" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "isManager" boolean DEFAULT (0), "assignedAt" datetime, "organizationSprintId" varchar NOT NULL, "employeeId" varchar NOT NULL, "roleId" varchar)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "totalWorkedHours" integer, "organizationSprintId" varchar NOT NULL, "taskId" varchar NOT NULL)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task_history" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "reason" text, "taskId" varchar NOT NULL, "fromSprintId" varchar NOT NULL, "toSprintId" varchar NOT NULL, "movedById" varchar)` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + await queryRunner.query(`DROP INDEX "IDX_76e53f9609ca05477d50980743"`); + await queryRunner.query(`DROP INDEX "IDX_5596b4fa7fb2ceb0955580becd"`); + await queryRunner.query(`DROP INDEX "IDX_f57ad03c4e471bd8530494ea63"`); + await queryRunner.query(`DROP INDEX "IDX_8a1fe8afb3aa672bae5993fbe7"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "projectId" varchar NOT NULL, "goal" varchar, "length" integer NOT NULL DEFAULT (7), "startDate" datetime, "endDate" datetime, "dayStart" integer, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "archivedAt" datetime, "status" varchar, "sprintProgress" text, CONSTRAINT "FK_f57ad03c4e471bd8530494ea63d" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_8a1fe8afb3aa672bae5993fbe7d" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_a140b7e30ff3455551a0fd599fb" FOREIGN KEY ("projectId") REFERENCES "organization_project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt" FROM "organization_sprint"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint"`); + await queryRunner.query(`ALTER TABLE "temporary_organization_sprint" RENAME TO "organization_sprint"`); + await queryRunner.query( + `CREATE INDEX "IDX_76e53f9609ca05477d50980743" ON "organization_sprint" ("isArchived") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_5596b4fa7fb2ceb0955580becd" ON "organization_sprint" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_f57ad03c4e471bd8530494ea63" ON "organization_sprint" ("tenantId") `); + await queryRunner.query( + `CREATE INDEX "IDX_8a1fe8afb3aa672bae5993fbe7" ON "organization_sprint" ("organizationId") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_1cbe898fb849e4cffbddb60a87" ON "organization_sprint" ("status") `); + await queryRunner.query(`DROP INDEX "IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query(`DROP INDEX "IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint_employee" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "isManager" boolean DEFAULT (0), "assignedAt" datetime, "organizationSprintId" varchar NOT NULL, "employeeId" varchar NOT NULL, "roleId" varchar, CONSTRAINT "FK_30d8b0ec5e73c133f3e3d9afef1" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_2e74f97c99716d6a0a892ec6de7" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_68ea808c69795593450737c9923" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e5ec88b5022e0d71ac88d876de2" FOREIGN KEY ("employeeId") REFERENCES "employee" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f516713776cfc5662cdbf2f3c4b" FOREIGN KEY ("roleId") REFERENCES "role" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint_employee"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId" FROM "organization_sprint_employee"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint_employee"`); + await queryRunner.query( + `ALTER TABLE "temporary_organization_sprint_employee" RENAME TO "organization_sprint_employee"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query(`DROP INDEX "IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint_task" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "totalWorkedHours" integer, "organizationSprintId" varchar NOT NULL, "taskId" varchar NOT NULL, CONSTRAINT "FK_be3cb56b953b535835ad8683916" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6a84b0cec9f10178a027f200981" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_889c9fd5c577a89f5f30facde42" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e50cfbf82eec3f0b1d004a5c6e8" FOREIGN KEY ("taskId") REFERENCES "task" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint_task"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId" FROM "organization_sprint_task"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint_task"`); + await queryRunner.query( + `ALTER TABLE "temporary_organization_sprint_task" RENAME TO "organization_sprint_task"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query(`DROP INDEX "IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization_sprint_task_history" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "reason" text, "taskId" varchar NOT NULL, "fromSprintId" varchar NOT NULL, "toSprintId" varchar NOT NULL, "movedById" varchar, CONSTRAINT "FK_d5203d63179a7baf703e29a628c" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_8f686ac0104c90e95ef10f6c229" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_3030e5dca58343e09ae1af01082" FOREIGN KEY ("taskId") REFERENCES "task" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_555c5952e67b2e93d4c7067f72d" FOREIGN KEY ("fromSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_da46a676d25c64bb06fc4536b34" FOREIGN KEY ("toSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_7723913546575c33ab09ecfd508" FOREIGN KEY ("movedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization_sprint_task_history"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById" FROM "organization_sprint_task_history"` + ); + await queryRunner.query(`DROP TABLE "organization_sprint_task_history"`); + await queryRunner.query( + `ALTER TABLE "temporary_organization_sprint_task_history" RENAME TO "organization_sprint_task_history"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query(`DROP INDEX "IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task_history" RENAME TO "temporary_organization_sprint_task_history"` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task_history" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "reason" text, "taskId" varchar NOT NULL, "fromSprintId" varchar NOT NULL, "toSprintId" varchar NOT NULL, "movedById" varchar)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint_task_history"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "reason", "taskId", "fromSprintId", "toSprintId", "movedById" FROM "temporary_organization_sprint_task_history"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint_task_history"`); + await queryRunner.query( + `CREATE INDEX "IDX_7723913546575c33ab09ecfd50" ON "organization_sprint_task_history" ("movedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_da46a676d25c64bb06fc4536b3" ON "organization_sprint_task_history" ("toSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_555c5952e67b2e93d4c7067f72" ON "organization_sprint_task_history" ("fromSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_3030e5dca58343e09ae1af0108" ON "organization_sprint_task_history" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_ca809b0756488e63bc88918950" ON "organization_sprint_task_history" ("reason") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f686ac0104c90e95ef10f6c22" ON "organization_sprint_task_history" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5203d63179a7baf703e29a628" ON "organization_sprint_task_history" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cde33e0a580277a2c1ed36a6b" ON "organization_sprint_task_history" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_92db6225d2efc127ded3bdb5f1" ON "organization_sprint_task_history" ("isActive") ` + ); + await queryRunner.query(`DROP INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query(`DROP INDEX "IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query( + `ALTER TABLE "organization_sprint_task" RENAME TO "temporary_organization_sprint_task"` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_task" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "totalWorkedHours" integer, "organizationSprintId" varchar NOT NULL, "taskId" varchar NOT NULL)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint_task"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "totalWorkedHours", "organizationSprintId", "taskId" FROM "temporary_organization_sprint_task"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint_task"`); + await queryRunner.query( + `CREATE INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e" ON "organization_sprint_task" ("taskId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_889c9fd5c577a89f5f30facde4" ON "organization_sprint_task" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_434665081d927127495623ad27" ON "organization_sprint_task" ("totalWorkedHours") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a84b0cec9f10178a027f20098" ON "organization_sprint_task" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_be3cb56b953b535835ad868391" ON "organization_sprint_task" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0a8b958a5716e73467c1937ec" ON "organization_sprint_task" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2ceba05d5de64b36d361af6a34" ON "organization_sprint_task" ("isActive") ` + ); + await queryRunner.query(`DROP INDEX "IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query(`DROP INDEX "IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query( + `ALTER TABLE "organization_sprint_employee" RENAME TO "temporary_organization_sprint_employee"` + ); + await queryRunner.query( + `CREATE TABLE "organization_sprint_employee" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "isManager" boolean DEFAULT (0), "assignedAt" datetime, "organizationSprintId" varchar NOT NULL, "employeeId" varchar NOT NULL, "roleId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint_employee"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "isManager", "assignedAt", "organizationSprintId", "employeeId", "roleId" FROM "temporary_organization_sprint_employee"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint_employee"`); + await queryRunner.query( + `CREATE INDEX "IDX_f516713776cfc5662cdbf2f3c4" ON "organization_sprint_employee" ("roleId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5ec88b5022e0d71ac88d876de" ON "organization_sprint_employee" ("employeeId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_68ea808c69795593450737c992" ON "organization_sprint_employee" ("organizationSprintId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbcc76b45f43996c20936f229c" ON "organization_sprint_employee" ("assignedAt") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_953b6df10eaabf11f5762bbee0" ON "organization_sprint_employee" ("isManager") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e74f97c99716d6a0a892ec6de" ON "organization_sprint_employee" ("organizationId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_30d8b0ec5e73c133f3e3d9afef" ON "organization_sprint_employee" ("tenantId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_62b4c285d16b0759ae29791dd5" ON "organization_sprint_employee" ("isArchived") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa0d179e320c8680cc32a41d70" ON "organization_sprint_employee" ("isActive") ` + ); + await queryRunner.query(`DROP INDEX "IDX_1cbe898fb849e4cffbddb60a87"`); + await queryRunner.query(`DROP INDEX "IDX_8a1fe8afb3aa672bae5993fbe7"`); + await queryRunner.query(`DROP INDEX "IDX_f57ad03c4e471bd8530494ea63"`); + await queryRunner.query(`DROP INDEX "IDX_5596b4fa7fb2ceb0955580becd"`); + await queryRunner.query(`DROP INDEX "IDX_76e53f9609ca05477d50980743"`); + await queryRunner.query(`ALTER TABLE "organization_sprint" RENAME TO "temporary_organization_sprint"`); + await queryRunner.query( + `CREATE TABLE "organization_sprint" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "projectId" varchar NOT NULL, "goal" varchar, "length" integer NOT NULL DEFAULT (7), "startDate" datetime, "endDate" datetime, "dayStart" integer, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "archivedAt" datetime, CONSTRAINT "FK_f57ad03c4e471bd8530494ea63d" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_8a1fe8afb3aa672bae5993fbe7d" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_a140b7e30ff3455551a0fd599fb" FOREIGN KEY ("projectId") REFERENCES "organization_project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "organization_sprint"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "name", "projectId", "goal", "length", "startDate", "endDate", "dayStart", "isActive", "isArchived", "deletedAt", "archivedAt" FROM "temporary_organization_sprint"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization_sprint"`); + await queryRunner.query( + `CREATE INDEX "IDX_8a1fe8afb3aa672bae5993fbe7" ON "organization_sprint" ("organizationId") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_f57ad03c4e471bd8530494ea63" ON "organization_sprint" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_5596b4fa7fb2ceb0955580becd" ON "organization_sprint" ("isActive") `); + await queryRunner.query( + `CREATE INDEX "IDX_76e53f9609ca05477d50980743" ON "organization_sprint" ("isArchived") ` + ); + await queryRunner.query(`DROP INDEX "IDX_7723913546575c33ab09ecfd50"`); + await queryRunner.query(`DROP INDEX "IDX_da46a676d25c64bb06fc4536b3"`); + await queryRunner.query(`DROP INDEX "IDX_555c5952e67b2e93d4c7067f72"`); + await queryRunner.query(`DROP INDEX "IDX_3030e5dca58343e09ae1af0108"`); + await queryRunner.query(`DROP INDEX "IDX_ca809b0756488e63bc88918950"`); + await queryRunner.query(`DROP INDEX "IDX_8f686ac0104c90e95ef10f6c22"`); + await queryRunner.query(`DROP INDEX "IDX_d5203d63179a7baf703e29a628"`); + await queryRunner.query(`DROP INDEX "IDX_8cde33e0a580277a2c1ed36a6b"`); + await queryRunner.query(`DROP INDEX "IDX_92db6225d2efc127ded3bdb5f1"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task_history"`); + await queryRunner.query(`DROP INDEX "IDX_e50cfbf82eec3f0b1d004a5c6e"`); + await queryRunner.query(`DROP INDEX "IDX_889c9fd5c577a89f5f30facde4"`); + await queryRunner.query(`DROP INDEX "IDX_434665081d927127495623ad27"`); + await queryRunner.query(`DROP INDEX "IDX_6a84b0cec9f10178a027f20098"`); + await queryRunner.query(`DROP INDEX "IDX_be3cb56b953b535835ad868391"`); + await queryRunner.query(`DROP INDEX "IDX_b0a8b958a5716e73467c1937ec"`); + await queryRunner.query(`DROP INDEX "IDX_2ceba05d5de64b36d361af6a34"`); + await queryRunner.query(`DROP TABLE "organization_sprint_task"`); + await queryRunner.query(`DROP INDEX "IDX_f516713776cfc5662cdbf2f3c4"`); + await queryRunner.query(`DROP INDEX "IDX_e5ec88b5022e0d71ac88d876de"`); + await queryRunner.query(`DROP INDEX "IDX_68ea808c69795593450737c992"`); + await queryRunner.query(`DROP INDEX "IDX_fbcc76b45f43996c20936f229c"`); + await queryRunner.query(`DROP INDEX "IDX_953b6df10eaabf11f5762bbee0"`); + await queryRunner.query(`DROP INDEX "IDX_2e74f97c99716d6a0a892ec6de"`); + await queryRunner.query(`DROP INDEX "IDX_30d8b0ec5e73c133f3e3d9afef"`); + await queryRunner.query(`DROP INDEX "IDX_62b4c285d16b0759ae29791dd5"`); + await queryRunner.query(`DROP INDEX "IDX_fa0d179e320c8680cc32a41d70"`); + await queryRunner.query(`DROP TABLE "organization_sprint_employee"`); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`organization_sprint_employee\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`isManager\` tinyint NULL DEFAULT 0, \`assignedAt\` datetime NULL, \`organizationSprintId\` varchar(255) NOT NULL, \`employeeId\` varchar(255) NOT NULL, \`roleId\` varchar(255) NULL, INDEX \`IDX_fa0d179e320c8680cc32a41d70\` (\`isActive\`), INDEX \`IDX_62b4c285d16b0759ae29791dd5\` (\`isArchived\`), INDEX \`IDX_30d8b0ec5e73c133f3e3d9afef\` (\`tenantId\`), INDEX \`IDX_2e74f97c99716d6a0a892ec6de\` (\`organizationId\`), INDEX \`IDX_953b6df10eaabf11f5762bbee0\` (\`isManager\`), INDEX \`IDX_fbcc76b45f43996c20936f229c\` (\`assignedAt\`), INDEX \`IDX_68ea808c69795593450737c992\` (\`organizationSprintId\`), INDEX \`IDX_e5ec88b5022e0d71ac88d876de\` (\`employeeId\`), INDEX \`IDX_f516713776cfc5662cdbf2f3c4\` (\`roleId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `CREATE TABLE \`organization_sprint_task\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`totalWorkedHours\` int NULL, \`organizationSprintId\` varchar(255) NOT NULL, \`taskId\` varchar(255) NOT NULL, INDEX \`IDX_2ceba05d5de64b36d361af6a34\` (\`isActive\`), INDEX \`IDX_b0a8b958a5716e73467c1937ec\` (\`isArchived\`), INDEX \`IDX_be3cb56b953b535835ad868391\` (\`tenantId\`), INDEX \`IDX_6a84b0cec9f10178a027f20098\` (\`organizationId\`), INDEX \`IDX_434665081d927127495623ad27\` (\`totalWorkedHours\`), INDEX \`IDX_889c9fd5c577a89f5f30facde4\` (\`organizationSprintId\`), INDEX \`IDX_e50cfbf82eec3f0b1d004a5c6e\` (\`taskId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `CREATE TABLE \`organization_sprint_task_history\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`reason\` text NULL, \`taskId\` varchar(255) NOT NULL, \`fromSprintId\` varchar(255) NOT NULL, \`toSprintId\` varchar(255) NOT NULL, \`movedById\` varchar(255) NULL, INDEX \`IDX_92db6225d2efc127ded3bdb5f1\` (\`isActive\`), INDEX \`IDX_8cde33e0a580277a2c1ed36a6b\` (\`isArchived\`), INDEX \`IDX_d5203d63179a7baf703e29a628\` (\`tenantId\`), INDEX \`IDX_8f686ac0104c90e95ef10f6c22\` (\`organizationId\`), INDEX \`IDX_ca809b0756488e63bc88918950\` (\`reason\`(255)), INDEX \`IDX_3030e5dca58343e09ae1af0108\` (\`taskId\`), INDEX \`IDX_555c5952e67b2e93d4c7067f72\` (\`fromSprintId\`), INDEX \`IDX_da46a676d25c64bb06fc4536b3\` (\`toSprintId\`), INDEX \`IDX_7723913546575c33ab09ecfd50\` (\`movedById\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` ADD \`status\` varchar(255) NULL`); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` ADD \`sprintProgress\` json NULL`); + await queryRunner.query( + `CREATE INDEX \`IDX_1cbe898fb849e4cffbddb60a87\` ON \`organization_sprint\` (\`status\`)` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_30d8b0ec5e73c133f3e3d9afef1\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_2e74f97c99716d6a0a892ec6de7\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_68ea808c69795593450737c9923\` FOREIGN KEY (\`organizationSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_e5ec88b5022e0d71ac88d876de2\` FOREIGN KEY (\`employeeId\`) REFERENCES \`employee\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` ADD CONSTRAINT \`FK_f516713776cfc5662cdbf2f3c4b\` FOREIGN KEY (\`roleId\`) REFERENCES \`role\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_be3cb56b953b535835ad8683916\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_6a84b0cec9f10178a027f200981\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_889c9fd5c577a89f5f30facde42\` FOREIGN KEY (\`organizationSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` ADD CONSTRAINT \`FK_e50cfbf82eec3f0b1d004a5c6e8\` FOREIGN KEY (\`taskId\`) REFERENCES \`task\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_d5203d63179a7baf703e29a628c\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_8f686ac0104c90e95ef10f6c229\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_3030e5dca58343e09ae1af01082\` FOREIGN KEY (\`taskId\`) REFERENCES \`task\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_555c5952e67b2e93d4c7067f72d\` FOREIGN KEY (\`fromSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_da46a676d25c64bb06fc4536b34\` FOREIGN KEY (\`toSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` ADD CONSTRAINT \`FK_7723913546575c33ab09ecfd508\` FOREIGN KEY (\`movedById\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_7723913546575c33ab09ecfd508\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_da46a676d25c64bb06fc4536b34\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_555c5952e67b2e93d4c7067f72d\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_3030e5dca58343e09ae1af01082\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_8f686ac0104c90e95ef10f6c229\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task_history\` DROP FOREIGN KEY \`FK_d5203d63179a7baf703e29a628c\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_e50cfbf82eec3f0b1d004a5c6e8\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_889c9fd5c577a89f5f30facde42\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_6a84b0cec9f10178a027f200981\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_task\` DROP FOREIGN KEY \`FK_be3cb56b953b535835ad8683916\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_f516713776cfc5662cdbf2f3c4b\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_e5ec88b5022e0d71ac88d876de2\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_68ea808c69795593450737c9923\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_2e74f97c99716d6a0a892ec6de7\`` + ); + await queryRunner.query( + `ALTER TABLE \`organization_sprint_employee\` DROP FOREIGN KEY \`FK_30d8b0ec5e73c133f3e3d9afef1\`` + ); + await queryRunner.query(`DROP INDEX \`IDX_1cbe898fb849e4cffbddb60a87\` ON \`organization_sprint\``); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` DROP COLUMN \`sprintProgress\``); + await queryRunner.query(`ALTER TABLE \`organization_sprint\` DROP COLUMN \`status\``); + await queryRunner.query( + `DROP INDEX \`IDX_7723913546575c33ab09ecfd50\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_da46a676d25c64bb06fc4536b3\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_555c5952e67b2e93d4c7067f72\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_3030e5dca58343e09ae1af0108\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_ca809b0756488e63bc88918950\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_8f686ac0104c90e95ef10f6c22\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_d5203d63179a7baf703e29a628\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_8cde33e0a580277a2c1ed36a6b\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query( + `DROP INDEX \`IDX_92db6225d2efc127ded3bdb5f1\` ON \`organization_sprint_task_history\`` + ); + await queryRunner.query(`DROP TABLE \`organization_sprint_task_history\``); + await queryRunner.query(`DROP INDEX \`IDX_e50cfbf82eec3f0b1d004a5c6e\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_889c9fd5c577a89f5f30facde4\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_434665081d927127495623ad27\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_6a84b0cec9f10178a027f20098\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_be3cb56b953b535835ad868391\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_b0a8b958a5716e73467c1937ec\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_2ceba05d5de64b36d361af6a34\` ON \`organization_sprint_task\``); + await queryRunner.query(`DROP TABLE \`organization_sprint_task\``); + await queryRunner.query(`DROP INDEX \`IDX_f516713776cfc5662cdbf2f3c4\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_e5ec88b5022e0d71ac88d876de\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_68ea808c69795593450737c992\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_fbcc76b45f43996c20936f229c\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_953b6df10eaabf11f5762bbee0\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_2e74f97c99716d6a0a892ec6de\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_30d8b0ec5e73c133f3e3d9afef\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_62b4c285d16b0759ae29791dd5\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP INDEX \`IDX_fa0d179e320c8680cc32a41d70\` ON \`organization_sprint_employee\``); + await queryRunner.query(`DROP TABLE \`organization_sprint_employee\``); + } +} diff --git a/packages/core/src/database/migrations/1728461410740-CreateTableIssueView.ts b/packages/core/src/database/migrations/1728461410740-CreateTableIssueView.ts new file mode 100644 index 00000000000..917c0eaebf0 --- /dev/null +++ b/packages/core/src/database/migrations/1728461410740-CreateTableIssueView.ts @@ -0,0 +1,275 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class CreateTableIssueView1728461410740 implements MigrationInterface { + name = 'CreateTableIssueView1728461410740'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "task_view" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "name" character varying NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" jsonb, "filterOptions" jsonb, "displayOptions" jsonb, "properties" jsonb, "isLocked" boolean NOT NULL DEFAULT false, "projectId" uuid, "organizationTeamId" uuid, "projectModuleId" uuid, "organizationSprintId" uuid, CONSTRAINT "PK_f4c3a51cd56250a117c9bbb3af6" PRIMARY KEY ("id"))` + ); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_ee94e92fccbbf8898221cb4eb53" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_c1c6e1c8d7c7971e234a768419c" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_1e5e43bbd58c370c538dac0b17c" FOREIGN KEY ("projectId") REFERENCES "organization_project"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_3c1eb880f298e646d43736e911a" FOREIGN KEY ("organizationTeamId") REFERENCES "organization_team"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_e58e58a3fd113bf4b336c90997b" FOREIGN KEY ("projectModuleId") REFERENCES "organization_project_module"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "task_view" ADD CONSTRAINT "FK_4814ca7712537f79bed938d9a15" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_4814ca7712537f79bed938d9a15"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_e58e58a3fd113bf4b336c90997b"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_3c1eb880f298e646d43736e911a"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_1e5e43bbd58c370c538dac0b17c"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_c1c6e1c8d7c7971e234a768419c"`); + await queryRunner.query(`ALTER TABLE "task_view" DROP CONSTRAINT "FK_ee94e92fccbbf8898221cb4eb53"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "public"."IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "public"."IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`DROP TABLE "task_view"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "task_view" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" text, "filterOptions" text, "displayOptions" text, "properties" text, "isLocked" boolean NOT NULL DEFAULT (0), "projectId" varchar, "organizationTeamId" varchar, "projectModuleId" varchar, "organizationSprintId" varchar)` + ); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + await queryRunner.query(`DROP INDEX "IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`DROP INDEX "IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query( + `CREATE TABLE "temporary_task_view" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" text, "filterOptions" text, "displayOptions" text, "properties" text, "isLocked" boolean NOT NULL DEFAULT (0), "projectId" varchar, "organizationTeamId" varchar, "projectModuleId" varchar, "organizationSprintId" varchar, CONSTRAINT "FK_ee94e92fccbbf8898221cb4eb53" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_c1c6e1c8d7c7971e234a768419c" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_1e5e43bbd58c370c538dac0b17c" FOREIGN KEY ("projectId") REFERENCES "organization_project" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_3c1eb880f298e646d43736e911a" FOREIGN KEY ("organizationTeamId") REFERENCES "organization_team" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_e58e58a3fd113bf4b336c90997b" FOREIGN KEY ("projectModuleId") REFERENCES "organization_project_module" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_4814ca7712537f79bed938d9a15" FOREIGN KEY ("organizationSprintId") REFERENCES "organization_sprint" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_task_view"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId" FROM "task_view"` + ); + await queryRunner.query(`DROP TABLE "task_view"`); + await queryRunner.query(`ALTER TABLE "temporary_task_view" RENAME TO "task_view"`); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query(`DROP INDEX "IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`ALTER TABLE "task_view" RENAME TO "temporary_task_view"`); + await queryRunner.query( + `CREATE TABLE "task_view" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "name" varchar NOT NULL, "description" text, "visibilityLevel" integer, "queryParams" text, "filterOptions" text, "displayOptions" text, "properties" text, "isLocked" boolean NOT NULL DEFAULT (0), "projectId" varchar, "organizationTeamId" varchar, "projectModuleId" varchar, "organizationSprintId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "task_view"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "name", "description", "visibilityLevel", "queryParams", "filterOptions", "displayOptions", "properties", "isLocked", "projectId", "organizationTeamId", "projectModuleId", "organizationSprintId" FROM "temporary_task_view"` + ); + await queryRunner.query(`DROP TABLE "temporary_task_view"`); + await queryRunner.query( + `CREATE INDEX "IDX_4814ca7712537f79bed938d9a1" ON "task_view" ("organizationSprintId") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_e58e58a3fd113bf4b336c90997" ON "task_view" ("projectModuleId") `); + await queryRunner.query(`CREATE INDEX "IDX_3c1eb880f298e646d43736e911" ON "task_view" ("organizationTeamId") `); + await queryRunner.query(`CREATE INDEX "IDX_1e5e43bbd58c370c538dac0b17" ON "task_view" ("projectId") `); + await queryRunner.query(`CREATE INDEX "IDX_d4c182a7adfffa1a57315e8bfc" ON "task_view" ("visibilityLevel") `); + await queryRunner.query(`CREATE INDEX "IDX_cf779d00641c3bd276a5a7e4df" ON "task_view" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_c1c6e1c8d7c7971e234a768419" ON "task_view" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_ee94e92fccbbf8898221cb4eb5" ON "task_view" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_7c4f8a4d5b859c23c42ab5f984" ON "task_view" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_38bcdf0455ac5ab5a925a015ab" ON "task_view" ("isActive") `); + await queryRunner.query(`DROP INDEX "IDX_4814ca7712537f79bed938d9a1"`); + await queryRunner.query(`DROP INDEX "IDX_e58e58a3fd113bf4b336c90997"`); + await queryRunner.query(`DROP INDEX "IDX_3c1eb880f298e646d43736e911"`); + await queryRunner.query(`DROP INDEX "IDX_1e5e43bbd58c370c538dac0b17"`); + await queryRunner.query(`DROP INDEX "IDX_d4c182a7adfffa1a57315e8bfc"`); + await queryRunner.query(`DROP INDEX "IDX_cf779d00641c3bd276a5a7e4df"`); + await queryRunner.query(`DROP INDEX "IDX_c1c6e1c8d7c7971e234a768419"`); + await queryRunner.query(`DROP INDEX "IDX_ee94e92fccbbf8898221cb4eb5"`); + await queryRunner.query(`DROP INDEX "IDX_7c4f8a4d5b859c23c42ab5f984"`); + await queryRunner.query(`DROP INDEX "IDX_38bcdf0455ac5ab5a925a015ab"`); + await queryRunner.query(`DROP TABLE "task_view"`); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`task_view\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`name\` varchar(255) NOT NULL, \`description\` text NULL, \`visibilityLevel\` int NULL, \`queryParams\` json NULL, \`filterOptions\` json NULL, \`displayOptions\` json NULL, \`properties\` json NULL, \`isLocked\` tinyint NOT NULL DEFAULT 0, \`projectId\` varchar(255) NULL, \`organizationTeamId\` varchar(255) NULL, \`projectModuleId\` varchar(255) NULL, \`organizationSprintId\` varchar(255) NULL, INDEX \`IDX_38bcdf0455ac5ab5a925a015ab\` (\`isActive\`), INDEX \`IDX_7c4f8a4d5b859c23c42ab5f984\` (\`isArchived\`), INDEX \`IDX_ee94e92fccbbf8898221cb4eb5\` (\`tenantId\`), INDEX \`IDX_c1c6e1c8d7c7971e234a768419\` (\`organizationId\`), INDEX \`IDX_cf779d00641c3bd276a5a7e4df\` (\`name\`), INDEX \`IDX_d4c182a7adfffa1a57315e8bfc\` (\`visibilityLevel\`), INDEX \`IDX_1e5e43bbd58c370c538dac0b17\` (\`projectId\`), INDEX \`IDX_3c1eb880f298e646d43736e911\` (\`organizationTeamId\`), INDEX \`IDX_e58e58a3fd113bf4b336c90997\` (\`projectModuleId\`), INDEX \`IDX_4814ca7712537f79bed938d9a1\` (\`organizationSprintId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_ee94e92fccbbf8898221cb4eb53\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_c1c6e1c8d7c7971e234a768419c\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_1e5e43bbd58c370c538dac0b17c\` FOREIGN KEY (\`projectId\`) REFERENCES \`organization_project\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_3c1eb880f298e646d43736e911a\` FOREIGN KEY (\`organizationTeamId\`) REFERENCES \`organization_team\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_e58e58a3fd113bf4b336c90997b\` FOREIGN KEY (\`projectModuleId\`) REFERENCES \`organization_project_module\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`task_view\` ADD CONSTRAINT \`FK_4814ca7712537f79bed938d9a15\` FOREIGN KEY (\`organizationSprintId\`) REFERENCES \`organization_sprint\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_4814ca7712537f79bed938d9a15\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_e58e58a3fd113bf4b336c90997b\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_3c1eb880f298e646d43736e911a\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_1e5e43bbd58c370c538dac0b17c\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_c1c6e1c8d7c7971e234a768419c\``); + await queryRunner.query(`ALTER TABLE \`task_view\` DROP FOREIGN KEY \`FK_ee94e92fccbbf8898221cb4eb53\``); + await queryRunner.query(`DROP INDEX \`IDX_4814ca7712537f79bed938d9a1\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_e58e58a3fd113bf4b336c90997\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_3c1eb880f298e646d43736e911\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_1e5e43bbd58c370c538dac0b17\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_d4c182a7adfffa1a57315e8bfc\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_cf779d00641c3bd276a5a7e4df\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_c1c6e1c8d7c7971e234a768419\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_ee94e92fccbbf8898221cb4eb5\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_7c4f8a4d5b859c23c42ab5f984\` ON \`task_view\``); + await queryRunner.query(`DROP INDEX \`IDX_38bcdf0455ac5ab5a925a015ab\` ON \`task_view\``); + await queryRunner.query(`DROP TABLE \`task_view\``); + } +} diff --git a/packages/core/src/database/migrations/1728479473369-AddStandardWorkHoursPerDayColumnToOrganizationTable.ts b/packages/core/src/database/migrations/1728479473369-AddStandardWorkHoursPerDayColumnToOrganizationTable.ts new file mode 100644 index 00000000000..56199a44fa5 --- /dev/null +++ b/packages/core/src/database/migrations/1728479473369-AddStandardWorkHoursPerDayColumnToOrganizationTable.ts @@ -0,0 +1,189 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class AddStandardWorkHoursPerDayColumnToOrganizationTable1728479473369 implements MigrationInterface { + name = 'AddStandardWorkHoursPerDayColumnToOrganizationTable1728479473369'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "organization" ADD "standardWorkHoursPerDay" integer DEFAULT '8'`); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "organization" DROP COLUMN "standardWorkHoursPerDay"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_47b6a97e09895a06606a4a8042"`); + await queryRunner.query(`DROP INDEX "IDX_7965db2b12872551b586f76dd7"`); + await queryRunner.query(`DROP INDEX "IDX_2360aa7a4b5ab99e026584f305"`); + await queryRunner.query(`DROP INDEX "IDX_15458cef74076623c270500053"`); + await queryRunner.query(`DROP INDEX "IDX_9ea70bf5c390b00e7bb96b86ed"`); + await queryRunner.query(`DROP INDEX "IDX_c75285bf286b17c7ca5537857b"`); + await queryRunner.query(`DROP INDEX "IDX_f37d866c3326eca5f579cef35c"`); + await queryRunner.query(`DROP INDEX "IDX_b03a8a28f6ebdb6df8f630216b"`); + await queryRunner.query(`DROP INDEX "IDX_6cc2b2052744e352834a4c9e78"`); + await queryRunner.query(`DROP INDEX "IDX_40460ab803bf6e5a62b75a35c5"`); + await queryRunner.query(`DROP INDEX "IDX_03e5eecc2328eb545ff748cbdd"`); + await queryRunner.query(`DROP INDEX "IDX_c21e615583a3ebbb0977452afb"`); + await queryRunner.query(`DROP INDEX "IDX_745a293c8b2c750bc421fa0633"`); + await queryRunner.query(`DROP INDEX "IDX_6de52b8f3de32abee3df2628a3"`); + await queryRunner.query(`DROP INDEX "IDX_b2091c1795f1d0d919b278ab23"`); + await queryRunner.query( + `CREATE TABLE "temporary_organization" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "name" varchar NOT NULL, "isDefault" boolean NOT NULL DEFAULT (0), "profile_link" varchar, "banner" varchar, "totalEmployees" integer, "short_description" varchar, "client_focus" varchar, "overview" varchar, "imageUrl" varchar(500), "currency" varchar NOT NULL, "valueDate" datetime, "defaultValueDateType" varchar CHECK( "defaultValueDateType" IN ('TODAY','END_OF_MONTH','START_OF_MONTH') ) DEFAULT ('TODAY'), "isActive" boolean DEFAULT (1), "defaultAlignmentType" varchar, "timeZone" varchar, "regionCode" varchar, "brandColor" varchar, "dateFormat" varchar, "officialName" varchar, "startWeekOn" varchar, "taxId" varchar, "numberFormat" varchar, "minimumProjectSize" varchar, "bonusType" varchar, "bonusPercentage" integer, "invitesAllowed" boolean DEFAULT (1), "show_income" boolean, "show_profits" boolean, "show_bonuses_paid" boolean, "show_total_hours" boolean, "show_minimum_project_size" boolean, "show_projects_count" boolean, "show_clients_count" boolean, "show_clients" boolean, "show_employees_count" boolean, "inviteExpiryPeriod" integer, "fiscalStartDate" datetime, "fiscalEndDate" datetime, "registrationDate" datetime, "futureDateAllowed" boolean, "allowManualTime" boolean NOT NULL DEFAULT (1), "allowModifyTime" boolean NOT NULL DEFAULT (1), "allowDeleteTime" boolean NOT NULL DEFAULT (1), "requireReason" boolean NOT NULL DEFAULT (0), "requireDescription" boolean NOT NULL DEFAULT (0), "requireProject" boolean NOT NULL DEFAULT (0), "requireTask" boolean NOT NULL DEFAULT (0), "requireClient" boolean NOT NULL DEFAULT (0), "timeFormat" integer NOT NULL DEFAULT (12), "separateInvoiceItemTaxAndDiscount" boolean, "website" varchar, "fiscalInformation" varchar, "currencyPosition" varchar NOT NULL DEFAULT ('LEFT'), "discountAfterTax" boolean, "defaultStartTime" varchar, "defaultEndTime" varchar, "defaultInvoiceEstimateTerms" varchar, "convertAcceptedEstimates" boolean, "daysUntilDue" integer, "contactId" varchar, "allowTrackInactivity" boolean NOT NULL DEFAULT (1), "inactivityTimeLimit" integer NOT NULL DEFAULT (10), "activityProofDuration" integer NOT NULL DEFAULT (1), "isRemoveIdleTime" boolean NOT NULL DEFAULT (0), "allowScreenshotCapture" boolean NOT NULL DEFAULT (1), "imageId" varchar, "upworkOrganizationId" varchar, "upworkOrganizationName" varchar, "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "randomScreenshot" boolean DEFAULT (0), "trackOnSleep" boolean DEFAULT (0), "screenshotFrequency" numeric NOT NULL DEFAULT (10), "enforced" boolean DEFAULT (0), "archivedAt" datetime, "standardWorkHoursPerDay" integer DEFAULT (8), CONSTRAINT "FK_47b6a97e09895a06606a4a80421" FOREIGN KEY ("imageId") REFERENCES "image_asset" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_7965db2b12872551b586f76dd79" FOREIGN KEY ("contactId") REFERENCES "contact" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_745a293c8b2c750bc421fa06332" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_organization"("id", "createdAt", "updatedAt", "tenantId", "name", "isDefault", "profile_link", "banner", "totalEmployees", "short_description", "client_focus", "overview", "imageUrl", "currency", "valueDate", "defaultValueDateType", "isActive", "defaultAlignmentType", "timeZone", "regionCode", "brandColor", "dateFormat", "officialName", "startWeekOn", "taxId", "numberFormat", "minimumProjectSize", "bonusType", "bonusPercentage", "invitesAllowed", "show_income", "show_profits", "show_bonuses_paid", "show_total_hours", "show_minimum_project_size", "show_projects_count", "show_clients_count", "show_clients", "show_employees_count", "inviteExpiryPeriod", "fiscalStartDate", "fiscalEndDate", "registrationDate", "futureDateAllowed", "allowManualTime", "allowModifyTime", "allowDeleteTime", "requireReason", "requireDescription", "requireProject", "requireTask", "requireClient", "timeFormat", "separateInvoiceItemTaxAndDiscount", "website", "fiscalInformation", "currencyPosition", "discountAfterTax", "defaultStartTime", "defaultEndTime", "defaultInvoiceEstimateTerms", "convertAcceptedEstimates", "daysUntilDue", "contactId", "allowTrackInactivity", "inactivityTimeLimit", "activityProofDuration", "isRemoveIdleTime", "allowScreenshotCapture", "imageId", "upworkOrganizationId", "upworkOrganizationName", "isArchived", "deletedAt", "randomScreenshot", "trackOnSleep", "screenshotFrequency", "enforced", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "name", "isDefault", "profile_link", "banner", "totalEmployees", "short_description", "client_focus", "overview", "imageUrl", "currency", "valueDate", "defaultValueDateType", "isActive", "defaultAlignmentType", "timeZone", "regionCode", "brandColor", "dateFormat", "officialName", "startWeekOn", "taxId", "numberFormat", "minimumProjectSize", "bonusType", "bonusPercentage", "invitesAllowed", "show_income", "show_profits", "show_bonuses_paid", "show_total_hours", "show_minimum_project_size", "show_projects_count", "show_clients_count", "show_clients", "show_employees_count", "inviteExpiryPeriod", "fiscalStartDate", "fiscalEndDate", "registrationDate", "futureDateAllowed", "allowManualTime", "allowModifyTime", "allowDeleteTime", "requireReason", "requireDescription", "requireProject", "requireTask", "requireClient", "timeFormat", "separateInvoiceItemTaxAndDiscount", "website", "fiscalInformation", "currencyPosition", "discountAfterTax", "defaultStartTime", "defaultEndTime", "defaultInvoiceEstimateTerms", "convertAcceptedEstimates", "daysUntilDue", "contactId", "allowTrackInactivity", "inactivityTimeLimit", "activityProofDuration", "isRemoveIdleTime", "allowScreenshotCapture", "imageId", "upworkOrganizationId", "upworkOrganizationName", "isArchived", "deletedAt", "randomScreenshot", "trackOnSleep", "screenshotFrequency", "enforced", "archivedAt" FROM "organization"` + ); + await queryRunner.query(`DROP TABLE "organization"`); + await queryRunner.query(`ALTER TABLE "temporary_organization" RENAME TO "organization"`); + await queryRunner.query(`CREATE INDEX "IDX_47b6a97e09895a06606a4a8042" ON "organization" ("imageId") `); + await queryRunner.query(`CREATE INDEX "IDX_7965db2b12872551b586f76dd7" ON "organization" ("contactId") `); + await queryRunner.query( + `CREATE INDEX "IDX_2360aa7a4b5ab99e026584f305" ON "organization" ("defaultValueDateType") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_15458cef74076623c270500053" ON "organization" ("currency") `); + await queryRunner.query(`CREATE INDEX "IDX_9ea70bf5c390b00e7bb96b86ed" ON "organization" ("overview") `); + await queryRunner.query(`CREATE INDEX "IDX_c75285bf286b17c7ca5537857b" ON "organization" ("client_focus") `); + await queryRunner.query( + `CREATE INDEX "IDX_f37d866c3326eca5f579cef35c" ON "organization" ("short_description") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_b03a8a28f6ebdb6df8f630216b" ON "organization" ("totalEmployees") `); + await queryRunner.query(`CREATE INDEX "IDX_6cc2b2052744e352834a4c9e78" ON "organization" ("banner") `); + await queryRunner.query(`CREATE INDEX "IDX_40460ab803bf6e5a62b75a35c5" ON "organization" ("profile_link") `); + await queryRunner.query(`CREATE INDEX "IDX_03e5eecc2328eb545ff748cbdd" ON "organization" ("isDefault") `); + await queryRunner.query(`CREATE INDEX "IDX_c21e615583a3ebbb0977452afb" ON "organization" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_745a293c8b2c750bc421fa0633" ON "organization" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_6de52b8f3de32abee3df2628a3" ON "organization" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_b2091c1795f1d0d919b278ab23" ON "organization" ("isArchived") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_b2091c1795f1d0d919b278ab23"`); + await queryRunner.query(`DROP INDEX "IDX_6de52b8f3de32abee3df2628a3"`); + await queryRunner.query(`DROP INDEX "IDX_745a293c8b2c750bc421fa0633"`); + await queryRunner.query(`DROP INDEX "IDX_c21e615583a3ebbb0977452afb"`); + await queryRunner.query(`DROP INDEX "IDX_03e5eecc2328eb545ff748cbdd"`); + await queryRunner.query(`DROP INDEX "IDX_40460ab803bf6e5a62b75a35c5"`); + await queryRunner.query(`DROP INDEX "IDX_6cc2b2052744e352834a4c9e78"`); + await queryRunner.query(`DROP INDEX "IDX_b03a8a28f6ebdb6df8f630216b"`); + await queryRunner.query(`DROP INDEX "IDX_f37d866c3326eca5f579cef35c"`); + await queryRunner.query(`DROP INDEX "IDX_c75285bf286b17c7ca5537857b"`); + await queryRunner.query(`DROP INDEX "IDX_9ea70bf5c390b00e7bb96b86ed"`); + await queryRunner.query(`DROP INDEX "IDX_15458cef74076623c270500053"`); + await queryRunner.query(`DROP INDEX "IDX_2360aa7a4b5ab99e026584f305"`); + await queryRunner.query(`DROP INDEX "IDX_7965db2b12872551b586f76dd7"`); + await queryRunner.query(`DROP INDEX "IDX_47b6a97e09895a06606a4a8042"`); + await queryRunner.query(`ALTER TABLE "organization" RENAME TO "temporary_organization"`); + await queryRunner.query( + `CREATE TABLE "organization" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "name" varchar NOT NULL, "isDefault" boolean NOT NULL DEFAULT (0), "profile_link" varchar, "banner" varchar, "totalEmployees" integer, "short_description" varchar, "client_focus" varchar, "overview" varchar, "imageUrl" varchar(500), "currency" varchar NOT NULL, "valueDate" datetime, "defaultValueDateType" varchar CHECK( "defaultValueDateType" IN ('TODAY','END_OF_MONTH','START_OF_MONTH') ) DEFAULT ('TODAY'), "isActive" boolean DEFAULT (1), "defaultAlignmentType" varchar, "timeZone" varchar, "regionCode" varchar, "brandColor" varchar, "dateFormat" varchar, "officialName" varchar, "startWeekOn" varchar, "taxId" varchar, "numberFormat" varchar, "minimumProjectSize" varchar, "bonusType" varchar, "bonusPercentage" integer, "invitesAllowed" boolean DEFAULT (1), "show_income" boolean, "show_profits" boolean, "show_bonuses_paid" boolean, "show_total_hours" boolean, "show_minimum_project_size" boolean, "show_projects_count" boolean, "show_clients_count" boolean, "show_clients" boolean, "show_employees_count" boolean, "inviteExpiryPeriod" integer, "fiscalStartDate" datetime, "fiscalEndDate" datetime, "registrationDate" datetime, "futureDateAllowed" boolean, "allowManualTime" boolean NOT NULL DEFAULT (1), "allowModifyTime" boolean NOT NULL DEFAULT (1), "allowDeleteTime" boolean NOT NULL DEFAULT (1), "requireReason" boolean NOT NULL DEFAULT (0), "requireDescription" boolean NOT NULL DEFAULT (0), "requireProject" boolean NOT NULL DEFAULT (0), "requireTask" boolean NOT NULL DEFAULT (0), "requireClient" boolean NOT NULL DEFAULT (0), "timeFormat" integer NOT NULL DEFAULT (12), "separateInvoiceItemTaxAndDiscount" boolean, "website" varchar, "fiscalInformation" varchar, "currencyPosition" varchar NOT NULL DEFAULT ('LEFT'), "discountAfterTax" boolean, "defaultStartTime" varchar, "defaultEndTime" varchar, "defaultInvoiceEstimateTerms" varchar, "convertAcceptedEstimates" boolean, "daysUntilDue" integer, "contactId" varchar, "allowTrackInactivity" boolean NOT NULL DEFAULT (1), "inactivityTimeLimit" integer NOT NULL DEFAULT (10), "activityProofDuration" integer NOT NULL DEFAULT (1), "isRemoveIdleTime" boolean NOT NULL DEFAULT (0), "allowScreenshotCapture" boolean NOT NULL DEFAULT (1), "imageId" varchar, "upworkOrganizationId" varchar, "upworkOrganizationName" varchar, "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "randomScreenshot" boolean DEFAULT (0), "trackOnSleep" boolean DEFAULT (0), "screenshotFrequency" numeric NOT NULL DEFAULT (10), "enforced" boolean DEFAULT (0), "archivedAt" datetime, CONSTRAINT "FK_47b6a97e09895a06606a4a80421" FOREIGN KEY ("imageId") REFERENCES "image_asset" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_7965db2b12872551b586f76dd79" FOREIGN KEY ("contactId") REFERENCES "contact" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_745a293c8b2c750bc421fa06332" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "organization"("id", "createdAt", "updatedAt", "tenantId", "name", "isDefault", "profile_link", "banner", "totalEmployees", "short_description", "client_focus", "overview", "imageUrl", "currency", "valueDate", "defaultValueDateType", "isActive", "defaultAlignmentType", "timeZone", "regionCode", "brandColor", "dateFormat", "officialName", "startWeekOn", "taxId", "numberFormat", "minimumProjectSize", "bonusType", "bonusPercentage", "invitesAllowed", "show_income", "show_profits", "show_bonuses_paid", "show_total_hours", "show_minimum_project_size", "show_projects_count", "show_clients_count", "show_clients", "show_employees_count", "inviteExpiryPeriod", "fiscalStartDate", "fiscalEndDate", "registrationDate", "futureDateAllowed", "allowManualTime", "allowModifyTime", "allowDeleteTime", "requireReason", "requireDescription", "requireProject", "requireTask", "requireClient", "timeFormat", "separateInvoiceItemTaxAndDiscount", "website", "fiscalInformation", "currencyPosition", "discountAfterTax", "defaultStartTime", "defaultEndTime", "defaultInvoiceEstimateTerms", "convertAcceptedEstimates", "daysUntilDue", "contactId", "allowTrackInactivity", "inactivityTimeLimit", "activityProofDuration", "isRemoveIdleTime", "allowScreenshotCapture", "imageId", "upworkOrganizationId", "upworkOrganizationName", "isArchived", "deletedAt", "randomScreenshot", "trackOnSleep", "screenshotFrequency", "enforced", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "name", "isDefault", "profile_link", "banner", "totalEmployees", "short_description", "client_focus", "overview", "imageUrl", "currency", "valueDate", "defaultValueDateType", "isActive", "defaultAlignmentType", "timeZone", "regionCode", "brandColor", "dateFormat", "officialName", "startWeekOn", "taxId", "numberFormat", "minimumProjectSize", "bonusType", "bonusPercentage", "invitesAllowed", "show_income", "show_profits", "show_bonuses_paid", "show_total_hours", "show_minimum_project_size", "show_projects_count", "show_clients_count", "show_clients", "show_employees_count", "inviteExpiryPeriod", "fiscalStartDate", "fiscalEndDate", "registrationDate", "futureDateAllowed", "allowManualTime", "allowModifyTime", "allowDeleteTime", "requireReason", "requireDescription", "requireProject", "requireTask", "requireClient", "timeFormat", "separateInvoiceItemTaxAndDiscount", "website", "fiscalInformation", "currencyPosition", "discountAfterTax", "defaultStartTime", "defaultEndTime", "defaultInvoiceEstimateTerms", "convertAcceptedEstimates", "daysUntilDue", "contactId", "allowTrackInactivity", "inactivityTimeLimit", "activityProofDuration", "isRemoveIdleTime", "allowScreenshotCapture", "imageId", "upworkOrganizationId", "upworkOrganizationName", "isArchived", "deletedAt", "randomScreenshot", "trackOnSleep", "screenshotFrequency", "enforced", "archivedAt" FROM "temporary_organization"` + ); + await queryRunner.query(`DROP TABLE "temporary_organization"`); + await queryRunner.query(`CREATE INDEX "IDX_b2091c1795f1d0d919b278ab23" ON "organization" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_6de52b8f3de32abee3df2628a3" ON "organization" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_745a293c8b2c750bc421fa0633" ON "organization" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c21e615583a3ebbb0977452afb" ON "organization" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_03e5eecc2328eb545ff748cbdd" ON "organization" ("isDefault") `); + await queryRunner.query(`CREATE INDEX "IDX_40460ab803bf6e5a62b75a35c5" ON "organization" ("profile_link") `); + await queryRunner.query(`CREATE INDEX "IDX_6cc2b2052744e352834a4c9e78" ON "organization" ("banner") `); + await queryRunner.query(`CREATE INDEX "IDX_b03a8a28f6ebdb6df8f630216b" ON "organization" ("totalEmployees") `); + await queryRunner.query( + `CREATE INDEX "IDX_f37d866c3326eca5f579cef35c" ON "organization" ("short_description") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_c75285bf286b17c7ca5537857b" ON "organization" ("client_focus") `); + await queryRunner.query(`CREATE INDEX "IDX_9ea70bf5c390b00e7bb96b86ed" ON "organization" ("overview") `); + await queryRunner.query(`CREATE INDEX "IDX_15458cef74076623c270500053" ON "organization" ("currency") `); + await queryRunner.query( + `CREATE INDEX "IDX_2360aa7a4b5ab99e026584f305" ON "organization" ("defaultValueDateType") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_7965db2b12872551b586f76dd7" ON "organization" ("contactId") `); + await queryRunner.query(`CREATE INDEX "IDX_47b6a97e09895a06606a4a8042" ON "organization" ("imageId") `); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`organization\` ADD \`standardWorkHoursPerDay\` int NULL DEFAULT '8'`); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`organization\` DROP COLUMN \`standardWorkHoursPerDay\``); + } +} diff --git a/packages/core/src/database/migrations/1728482634174-AddStandardWorkHoursPerDayColumnToTenantTable.ts b/packages/core/src/database/migrations/1728482634174-AddStandardWorkHoursPerDayColumnToTenantTable.ts new file mode 100644 index 00000000000..f11b59b0baf --- /dev/null +++ b/packages/core/src/database/migrations/1728482634174-AddStandardWorkHoursPerDayColumnToTenantTable.ts @@ -0,0 +1,137 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class AddStandardWorkHoursPerDayColumnToTenantTable1728482634174 implements MigrationInterface { + name = 'AddStandardWorkHoursPerDayColumnToTenantTable1728482634174'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tenant" ADD "standardWorkHoursPerDay" integer DEFAULT '8'`); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tenant" DROP COLUMN "standardWorkHoursPerDay"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_eeedffab85b3534a1068d9270f"`); + await queryRunner.query(`DROP INDEX "IDX_b8eb9f3e420aa846f30e291960"`); + await queryRunner.query(`DROP INDEX "IDX_56211336b5ff35fd944f225917"`); + await queryRunner.query(`DROP INDEX "IDX_d154d06dac0d0e0a5d9a083e25"`); + await queryRunner.query( + `CREATE TABLE "temporary_tenant" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "name" varchar NOT NULL, "logo" varchar, "imageId" varchar, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "archivedAt" datetime, "standardWorkHoursPerDay" integer DEFAULT (8), CONSTRAINT "FK_d154d06dac0d0e0a5d9a083e253" FOREIGN KEY ("imageId") REFERENCES "image_asset" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_tenant"("id", "createdAt", "updatedAt", "name", "logo", "imageId", "isActive", "isArchived", "deletedAt", "archivedAt") SELECT "id", "createdAt", "updatedAt", "name", "logo", "imageId", "isActive", "isArchived", "deletedAt", "archivedAt" FROM "tenant"` + ); + await queryRunner.query(`DROP TABLE "tenant"`); + await queryRunner.query(`ALTER TABLE "temporary_tenant" RENAME TO "tenant"`); + await queryRunner.query(`CREATE INDEX "IDX_eeedffab85b3534a1068d9270f" ON "tenant" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_b8eb9f3e420aa846f30e291960" ON "tenant" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_56211336b5ff35fd944f225917" ON "tenant" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_d154d06dac0d0e0a5d9a083e25" ON "tenant" ("imageId") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_d154d06dac0d0e0a5d9a083e25"`); + await queryRunner.query(`DROP INDEX "IDX_56211336b5ff35fd944f225917"`); + await queryRunner.query(`DROP INDEX "IDX_b8eb9f3e420aa846f30e291960"`); + await queryRunner.query(`DROP INDEX "IDX_eeedffab85b3534a1068d9270f"`); + await queryRunner.query(`ALTER TABLE "tenant" RENAME TO "temporary_tenant"`); + await queryRunner.query( + `CREATE TABLE "tenant" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "name" varchar NOT NULL, "logo" varchar, "imageId" varchar, "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "deletedAt" datetime, "archivedAt" datetime, CONSTRAINT "FK_d154d06dac0d0e0a5d9a083e253" FOREIGN KEY ("imageId") REFERENCES "image_asset" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "tenant"("id", "createdAt", "updatedAt", "name", "logo", "imageId", "isActive", "isArchived", "deletedAt", "archivedAt") SELECT "id", "createdAt", "updatedAt", "name", "logo", "imageId", "isActive", "isArchived", "deletedAt", "archivedAt" FROM "temporary_tenant"` + ); + await queryRunner.query(`DROP TABLE "temporary_tenant"`); + await queryRunner.query(`CREATE INDEX "IDX_d154d06dac0d0e0a5d9a083e25" ON "tenant" ("imageId") `); + await queryRunner.query(`CREATE INDEX "IDX_56211336b5ff35fd944f225917" ON "tenant" ("name") `); + await queryRunner.query(`CREATE INDEX "IDX_b8eb9f3e420aa846f30e291960" ON "tenant" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_eeedffab85b3534a1068d9270f" ON "tenant" ("isArchived") `); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`tenant\` ADD \`standardWorkHoursPerDay\` int NULL DEFAULT '8'`); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`tenant\` DROP COLUMN \`standardWorkHoursPerDay\``); + } +} diff --git a/packages/core/src/database/migrations/1728645657957-CreateApiCallLogTable.ts b/packages/core/src/database/migrations/1728645657957-CreateApiCallLogTable.ts new file mode 100644 index 00000000000..f55eb92dfa7 --- /dev/null +++ b/packages/core/src/database/migrations/1728645657957-CreateApiCallLogTable.ts @@ -0,0 +1,270 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class CreateApiCallLogTable1728645657957 implements MigrationInterface { + name = 'CreateApiCallLogTable1728645657957'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "api_call_log" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "correlationId" character varying NOT NULL, "url" character varying NOT NULL, "method" integer NOT NULL, "requestHeaders" jsonb NOT NULL, "requestBody" jsonb NOT NULL, "responseBody" jsonb NOT NULL, "statusCode" integer NOT NULL, "requestTime" TIMESTAMP NOT NULL, "responseTime" TIMESTAMP NOT NULL, "ipAddress" character varying, "protocol" character varying, "userAgent" character varying, "origin" character varying, "userId" uuid, CONSTRAINT "PK_ba8bfaffbb35aff7026eecae2a7" PRIMARY KEY ("id"))` + ); + await queryRunner.query(`CREATE INDEX "IDX_f3505a1756b04b59626d1bd836" ON "api_call_log" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_85c20063cd74c766533fd08389" ON "api_call_log" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_94c4d067f73d90faaad8c2d3db" ON "api_call_log" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_89292145eeceb7ff32dac0de83" ON "api_call_log" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_c74a2db6a95bb3a5b788e23a50" ON "api_call_log" ("correlationId") `); + await queryRunner.query(`CREATE INDEX "IDX_b484d5942747f0c19372ae8fcd" ON "api_call_log" ("url") `); + await queryRunner.query(`CREATE INDEX "IDX_0a62fc4546d596f9e9ce305ecb" ON "api_call_log" ("method") `); + await queryRunner.query(`CREATE INDEX "IDX_5820fe8a6385bfc0338c49a508" ON "api_call_log" ("statusCode") `); + await queryRunner.query(`CREATE INDEX "IDX_1d6cb060eba156d1e50f7ea4a0" ON "api_call_log" ("requestTime") `); + await queryRunner.query(`CREATE INDEX "IDX_66f2fd42fa8f00e11d6960cb39" ON "api_call_log" ("responseTime") `); + await queryRunner.query(`CREATE INDEX "IDX_085b00c43479478866d7a27ca9" ON "api_call_log" ("ipAddress") `); + await queryRunner.query(`CREATE INDEX "IDX_964d6a55608f67f7d92e9827db" ON "api_call_log" ("protocol") `); + await queryRunner.query(`CREATE INDEX "IDX_ada33b1685138be7798aea280b" ON "api_call_log" ("userId") `); + await queryRunner.query( + `ALTER TABLE "api_call_log" ADD CONSTRAINT "FK_94c4d067f73d90faaad8c2d3dbd" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "api_call_log" ADD CONSTRAINT "FK_89292145eeceb7ff32dac0de83b" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "api_call_log" ADD CONSTRAINT "FK_ada33b1685138be7798aea280b2" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "api_call_log" DROP CONSTRAINT "FK_ada33b1685138be7798aea280b2"`); + await queryRunner.query(`ALTER TABLE "api_call_log" DROP CONSTRAINT "FK_89292145eeceb7ff32dac0de83b"`); + await queryRunner.query(`ALTER TABLE "api_call_log" DROP CONSTRAINT "FK_94c4d067f73d90faaad8c2d3dbd"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ada33b1685138be7798aea280b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_964d6a55608f67f7d92e9827db"`); + await queryRunner.query(`DROP INDEX "public"."IDX_085b00c43479478866d7a27ca9"`); + await queryRunner.query(`DROP INDEX "public"."IDX_66f2fd42fa8f00e11d6960cb39"`); + await queryRunner.query(`DROP INDEX "public"."IDX_1d6cb060eba156d1e50f7ea4a0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5820fe8a6385bfc0338c49a508"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0a62fc4546d596f9e9ce305ecb"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b484d5942747f0c19372ae8fcd"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c74a2db6a95bb3a5b788e23a50"`); + await queryRunner.query(`DROP INDEX "public"."IDX_89292145eeceb7ff32dac0de83"`); + await queryRunner.query(`DROP INDEX "public"."IDX_94c4d067f73d90faaad8c2d3db"`); + await queryRunner.query(`DROP INDEX "public"."IDX_85c20063cd74c766533fd08389"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f3505a1756b04b59626d1bd836"`); + await queryRunner.query(`DROP TABLE "api_call_log"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "api_call_log" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "correlationId" varchar NOT NULL, "url" varchar NOT NULL, "method" integer NOT NULL, "requestHeaders" text NOT NULL, "requestBody" text NOT NULL, "responseBody" text NOT NULL, "statusCode" integer NOT NULL, "requestTime" datetime NOT NULL, "responseTime" datetime NOT NULL, "ipAddress" varchar, "protocol" varchar, "userAgent" varchar, "origin" varchar, "userId" varchar)` + ); + await queryRunner.query(`CREATE INDEX "IDX_f3505a1756b04b59626d1bd836" ON "api_call_log" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_85c20063cd74c766533fd08389" ON "api_call_log" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_94c4d067f73d90faaad8c2d3db" ON "api_call_log" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_89292145eeceb7ff32dac0de83" ON "api_call_log" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_c74a2db6a95bb3a5b788e23a50" ON "api_call_log" ("correlationId") `); + await queryRunner.query(`CREATE INDEX "IDX_b484d5942747f0c19372ae8fcd" ON "api_call_log" ("url") `); + await queryRunner.query(`CREATE INDEX "IDX_0a62fc4546d596f9e9ce305ecb" ON "api_call_log" ("method") `); + await queryRunner.query(`CREATE INDEX "IDX_5820fe8a6385bfc0338c49a508" ON "api_call_log" ("statusCode") `); + await queryRunner.query(`CREATE INDEX "IDX_1d6cb060eba156d1e50f7ea4a0" ON "api_call_log" ("requestTime") `); + await queryRunner.query(`CREATE INDEX "IDX_66f2fd42fa8f00e11d6960cb39" ON "api_call_log" ("responseTime") `); + await queryRunner.query(`CREATE INDEX "IDX_085b00c43479478866d7a27ca9" ON "api_call_log" ("ipAddress") `); + await queryRunner.query(`CREATE INDEX "IDX_964d6a55608f67f7d92e9827db" ON "api_call_log" ("protocol") `); + await queryRunner.query(`CREATE INDEX "IDX_ada33b1685138be7798aea280b" ON "api_call_log" ("userId") `); + await queryRunner.query(`DROP INDEX "IDX_f3505a1756b04b59626d1bd836"`); + await queryRunner.query(`DROP INDEX "IDX_85c20063cd74c766533fd08389"`); + await queryRunner.query(`DROP INDEX "IDX_94c4d067f73d90faaad8c2d3db"`); + await queryRunner.query(`DROP INDEX "IDX_89292145eeceb7ff32dac0de83"`); + await queryRunner.query(`DROP INDEX "IDX_c74a2db6a95bb3a5b788e23a50"`); + await queryRunner.query(`DROP INDEX "IDX_b484d5942747f0c19372ae8fcd"`); + await queryRunner.query(`DROP INDEX "IDX_0a62fc4546d596f9e9ce305ecb"`); + await queryRunner.query(`DROP INDEX "IDX_5820fe8a6385bfc0338c49a508"`); + await queryRunner.query(`DROP INDEX "IDX_1d6cb060eba156d1e50f7ea4a0"`); + await queryRunner.query(`DROP INDEX "IDX_66f2fd42fa8f00e11d6960cb39"`); + await queryRunner.query(`DROP INDEX "IDX_085b00c43479478866d7a27ca9"`); + await queryRunner.query(`DROP INDEX "IDX_964d6a55608f67f7d92e9827db"`); + await queryRunner.query(`DROP INDEX "IDX_ada33b1685138be7798aea280b"`); + await queryRunner.query( + `CREATE TABLE "temporary_api_call_log" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "correlationId" varchar NOT NULL, "url" varchar NOT NULL, "method" integer NOT NULL, "requestHeaders" text NOT NULL, "requestBody" text NOT NULL, "responseBody" text NOT NULL, "statusCode" integer NOT NULL, "requestTime" datetime NOT NULL, "responseTime" datetime NOT NULL, "ipAddress" varchar, "protocol" varchar, "userAgent" varchar, "origin" varchar, "userId" varchar, CONSTRAINT "FK_94c4d067f73d90faaad8c2d3dbd" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_89292145eeceb7ff32dac0de83b" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_ada33b1685138be7798aea280b2" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_api_call_log"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "correlationId", "url", "method", "requestHeaders", "requestBody", "responseBody", "statusCode", "requestTime", "responseTime", "ipAddress", "protocol", "userAgent", "origin", "userId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "correlationId", "url", "method", "requestHeaders", "requestBody", "responseBody", "statusCode", "requestTime", "responseTime", "ipAddress", "protocol", "userAgent", "origin", "userId" FROM "api_call_log"` + ); + await queryRunner.query(`DROP TABLE "api_call_log"`); + await queryRunner.query(`ALTER TABLE "temporary_api_call_log" RENAME TO "api_call_log"`); + await queryRunner.query(`CREATE INDEX "IDX_f3505a1756b04b59626d1bd836" ON "api_call_log" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_85c20063cd74c766533fd08389" ON "api_call_log" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_94c4d067f73d90faaad8c2d3db" ON "api_call_log" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_89292145eeceb7ff32dac0de83" ON "api_call_log" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_c74a2db6a95bb3a5b788e23a50" ON "api_call_log" ("correlationId") `); + await queryRunner.query(`CREATE INDEX "IDX_b484d5942747f0c19372ae8fcd" ON "api_call_log" ("url") `); + await queryRunner.query(`CREATE INDEX "IDX_0a62fc4546d596f9e9ce305ecb" ON "api_call_log" ("method") `); + await queryRunner.query(`CREATE INDEX "IDX_5820fe8a6385bfc0338c49a508" ON "api_call_log" ("statusCode") `); + await queryRunner.query(`CREATE INDEX "IDX_1d6cb060eba156d1e50f7ea4a0" ON "api_call_log" ("requestTime") `); + await queryRunner.query(`CREATE INDEX "IDX_66f2fd42fa8f00e11d6960cb39" ON "api_call_log" ("responseTime") `); + await queryRunner.query(`CREATE INDEX "IDX_085b00c43479478866d7a27ca9" ON "api_call_log" ("ipAddress") `); + await queryRunner.query(`CREATE INDEX "IDX_964d6a55608f67f7d92e9827db" ON "api_call_log" ("protocol") `); + await queryRunner.query(`CREATE INDEX "IDX_ada33b1685138be7798aea280b" ON "api_call_log" ("userId") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_ada33b1685138be7798aea280b"`); + await queryRunner.query(`DROP INDEX "IDX_964d6a55608f67f7d92e9827db"`); + await queryRunner.query(`DROP INDEX "IDX_085b00c43479478866d7a27ca9"`); + await queryRunner.query(`DROP INDEX "IDX_66f2fd42fa8f00e11d6960cb39"`); + await queryRunner.query(`DROP INDEX "IDX_1d6cb060eba156d1e50f7ea4a0"`); + await queryRunner.query(`DROP INDEX "IDX_5820fe8a6385bfc0338c49a508"`); + await queryRunner.query(`DROP INDEX "IDX_0a62fc4546d596f9e9ce305ecb"`); + await queryRunner.query(`DROP INDEX "IDX_b484d5942747f0c19372ae8fcd"`); + await queryRunner.query(`DROP INDEX "IDX_c74a2db6a95bb3a5b788e23a50"`); + await queryRunner.query(`DROP INDEX "IDX_89292145eeceb7ff32dac0de83"`); + await queryRunner.query(`DROP INDEX "IDX_94c4d067f73d90faaad8c2d3db"`); + await queryRunner.query(`DROP INDEX "IDX_85c20063cd74c766533fd08389"`); + await queryRunner.query(`DROP INDEX "IDX_f3505a1756b04b59626d1bd836"`); + await queryRunner.query(`ALTER TABLE "api_call_log" RENAME TO "temporary_api_call_log"`); + await queryRunner.query( + `CREATE TABLE "api_call_log" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "correlationId" varchar NOT NULL, "url" varchar NOT NULL, "method" integer NOT NULL, "requestHeaders" text NOT NULL, "requestBody" text NOT NULL, "responseBody" text NOT NULL, "statusCode" integer NOT NULL, "requestTime" datetime NOT NULL, "responseTime" datetime NOT NULL, "ipAddress" varchar, "protocol" varchar, "userAgent" varchar, "origin" varchar, "userId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "api_call_log"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "correlationId", "url", "method", "requestHeaders", "requestBody", "responseBody", "statusCode", "requestTime", "responseTime", "ipAddress", "protocol", "userAgent", "origin", "userId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "correlationId", "url", "method", "requestHeaders", "requestBody", "responseBody", "statusCode", "requestTime", "responseTime", "ipAddress", "protocol", "userAgent", "origin", "userId" FROM "temporary_api_call_log"` + ); + await queryRunner.query(`DROP TABLE "temporary_api_call_log"`); + await queryRunner.query(`CREATE INDEX "IDX_ada33b1685138be7798aea280b" ON "api_call_log" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_964d6a55608f67f7d92e9827db" ON "api_call_log" ("protocol") `); + await queryRunner.query(`CREATE INDEX "IDX_085b00c43479478866d7a27ca9" ON "api_call_log" ("ipAddress") `); + await queryRunner.query(`CREATE INDEX "IDX_66f2fd42fa8f00e11d6960cb39" ON "api_call_log" ("responseTime") `); + await queryRunner.query(`CREATE INDEX "IDX_1d6cb060eba156d1e50f7ea4a0" ON "api_call_log" ("requestTime") `); + await queryRunner.query(`CREATE INDEX "IDX_5820fe8a6385bfc0338c49a508" ON "api_call_log" ("statusCode") `); + await queryRunner.query(`CREATE INDEX "IDX_0a62fc4546d596f9e9ce305ecb" ON "api_call_log" ("method") `); + await queryRunner.query(`CREATE INDEX "IDX_b484d5942747f0c19372ae8fcd" ON "api_call_log" ("url") `); + await queryRunner.query(`CREATE INDEX "IDX_c74a2db6a95bb3a5b788e23a50" ON "api_call_log" ("correlationId") `); + await queryRunner.query(`CREATE INDEX "IDX_89292145eeceb7ff32dac0de83" ON "api_call_log" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_94c4d067f73d90faaad8c2d3db" ON "api_call_log" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_85c20063cd74c766533fd08389" ON "api_call_log" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_f3505a1756b04b59626d1bd836" ON "api_call_log" ("isActive") `); + await queryRunner.query(`DROP INDEX "IDX_ada33b1685138be7798aea280b"`); + await queryRunner.query(`DROP INDEX "IDX_964d6a55608f67f7d92e9827db"`); + await queryRunner.query(`DROP INDEX "IDX_085b00c43479478866d7a27ca9"`); + await queryRunner.query(`DROP INDEX "IDX_66f2fd42fa8f00e11d6960cb39"`); + await queryRunner.query(`DROP INDEX "IDX_1d6cb060eba156d1e50f7ea4a0"`); + await queryRunner.query(`DROP INDEX "IDX_5820fe8a6385bfc0338c49a508"`); + await queryRunner.query(`DROP INDEX "IDX_0a62fc4546d596f9e9ce305ecb"`); + await queryRunner.query(`DROP INDEX "IDX_b484d5942747f0c19372ae8fcd"`); + await queryRunner.query(`DROP INDEX "IDX_c74a2db6a95bb3a5b788e23a50"`); + await queryRunner.query(`DROP INDEX "IDX_89292145eeceb7ff32dac0de83"`); + await queryRunner.query(`DROP INDEX "IDX_94c4d067f73d90faaad8c2d3db"`); + await queryRunner.query(`DROP INDEX "IDX_85c20063cd74c766533fd08389"`); + await queryRunner.query(`DROP INDEX "IDX_f3505a1756b04b59626d1bd836"`); + await queryRunner.query(`DROP TABLE "api_call_log"`); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`api_call_log\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`correlationId\` varchar(255) NOT NULL, \`url\` varchar(255) NOT NULL, \`method\` int NOT NULL, \`requestHeaders\` json NOT NULL, \`requestBody\` json NOT NULL, \`responseBody\` json NOT NULL, \`statusCode\` int NOT NULL, \`requestTime\` datetime NOT NULL, \`responseTime\` datetime NOT NULL, \`ipAddress\` varchar(255) NULL, \`protocol\` varchar(255) NULL, \`userAgent\` varchar(255) NULL, \`origin\` varchar(255) NULL, \`userId\` varchar(255) NULL, INDEX \`IDX_f3505a1756b04b59626d1bd836\` (\`isActive\`), INDEX \`IDX_85c20063cd74c766533fd08389\` (\`isArchived\`), INDEX \`IDX_94c4d067f73d90faaad8c2d3db\` (\`tenantId\`), INDEX \`IDX_89292145eeceb7ff32dac0de83\` (\`organizationId\`), INDEX \`IDX_c74a2db6a95bb3a5b788e23a50\` (\`correlationId\`), INDEX \`IDX_b484d5942747f0c19372ae8fcd\` (\`url\`), INDEX \`IDX_0a62fc4546d596f9e9ce305ecb\` (\`method\`), INDEX \`IDX_5820fe8a6385bfc0338c49a508\` (\`statusCode\`), INDEX \`IDX_1d6cb060eba156d1e50f7ea4a0\` (\`requestTime\`), INDEX \`IDX_66f2fd42fa8f00e11d6960cb39\` (\`responseTime\`), INDEX \`IDX_085b00c43479478866d7a27ca9\` (\`ipAddress\`), INDEX \`IDX_964d6a55608f67f7d92e9827db\` (\`protocol\`), INDEX \`IDX_ada33b1685138be7798aea280b\` (\`userId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `ALTER TABLE \`api_call_log\` ADD CONSTRAINT \`FK_94c4d067f73d90faaad8c2d3dbd\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`api_call_log\` ADD CONSTRAINT \`FK_89292145eeceb7ff32dac0de83b\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`api_call_log\` ADD CONSTRAINT \`FK_ada33b1685138be7798aea280b2\` FOREIGN KEY (\`userId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`api_call_log\` DROP FOREIGN KEY \`FK_ada33b1685138be7798aea280b2\``); + await queryRunner.query(`ALTER TABLE \`api_call_log\` DROP FOREIGN KEY \`FK_89292145eeceb7ff32dac0de83b\``); + await queryRunner.query(`ALTER TABLE \`api_call_log\` DROP FOREIGN KEY \`FK_94c4d067f73d90faaad8c2d3dbd\``); + await queryRunner.query(`DROP INDEX \`IDX_ada33b1685138be7798aea280b\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_964d6a55608f67f7d92e9827db\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_085b00c43479478866d7a27ca9\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_66f2fd42fa8f00e11d6960cb39\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_1d6cb060eba156d1e50f7ea4a0\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_5820fe8a6385bfc0338c49a508\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_0a62fc4546d596f9e9ce305ecb\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_b484d5942747f0c19372ae8fcd\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_c74a2db6a95bb3a5b788e23a50\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_89292145eeceb7ff32dac0de83\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_94c4d067f73d90faaad8c2d3db\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_85c20063cd74c766533fd08389\` ON \`api_call_log\``); + await queryRunner.query(`DROP INDEX \`IDX_f3505a1756b04b59626d1bd836\` ON \`api_call_log\``); + await queryRunner.query(`DROP TABLE \`api_call_log\``); + } +} diff --git a/packages/core/src/database/migrations/1728798743598-CreateResourceLinkTable.ts b/packages/core/src/database/migrations/1728798743598-CreateResourceLinkTable.ts new file mode 100644 index 00000000000..bded25a05c1 --- /dev/null +++ b/packages/core/src/database/migrations/1728798743598-CreateResourceLinkTable.ts @@ -0,0 +1,216 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class CreateResourceLinkTable1728798743598 implements MigrationInterface { + name = 'CreateResourceLinkTable1728798743598'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + console.log(yellow(this.name + ' start running!')); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "resource_link" ("deletedAt" TIMESTAMP, "id" uuid NOT NULL DEFAULT gen_random_uuid(), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isActive" boolean DEFAULT true, "isArchived" boolean DEFAULT false, "archivedAt" TIMESTAMP, "tenantId" uuid, "organizationId" uuid, "entity" character varying NOT NULL, "entityId" character varying NOT NULL, "title" character varying NOT NULL, "url" text NOT NULL, "metaData" json, "creatorId" uuid, CONSTRAINT "PK_c4bd5617a9b97fff39d02de2456" PRIMARY KEY ("id"))` + ); + await queryRunner.query(`CREATE INDEX "IDX_841b729b80bc03ea38d16b8508" ON "resource_link" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_f9438f82f6e93bd6a87b8216af" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_95603855ae10050123e48a6688" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_44100d3eaf418ee67fa7a756f1" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_b73c278619bd8fb7f30f93182c" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_2ef674d18792e8864fd8d484ea" ON "resource_link" ("creatorId") `); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_f9438f82f6e93bd6a87b8216af9" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_95603855ae10050123e48a66881" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_2ef674d18792e8864fd8d484eac" FOREIGN KEY ("creatorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_2ef674d18792e8864fd8d484eac"`); + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_95603855ae10050123e48a66881"`); + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_f9438f82f6e93bd6a87b8216af9"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2ef674d18792e8864fd8d484ea"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b73c278619bd8fb7f30f93182c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_44100d3eaf418ee67fa7a756f1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_95603855ae10050123e48a6688"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f9438f82f6e93bd6a87b8216af"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4c25c2c9d7ebbd0c07edd824ff"`); + await queryRunner.query(`DROP INDEX "public"."IDX_841b729b80bc03ea38d16b8508"`); + await queryRunner.query(`DROP TABLE "resource_link"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "resource_link" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entity" varchar NOT NULL, "entityId" varchar NOT NULL, "title" varchar NOT NULL, "url" text NOT NULL, "metaData" text, "creatorId" varchar)` + ); + await queryRunner.query(`CREATE INDEX "IDX_841b729b80bc03ea38d16b8508" ON "resource_link" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_f9438f82f6e93bd6a87b8216af" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_95603855ae10050123e48a6688" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_44100d3eaf418ee67fa7a756f1" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_b73c278619bd8fb7f30f93182c" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_2ef674d18792e8864fd8d484ea" ON "resource_link" ("creatorId") `); + await queryRunner.query(`DROP INDEX "IDX_841b729b80bc03ea38d16b8508"`); + await queryRunner.query(`DROP INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff"`); + await queryRunner.query(`DROP INDEX "IDX_f9438f82f6e93bd6a87b8216af"`); + await queryRunner.query(`DROP INDEX "IDX_95603855ae10050123e48a6688"`); + await queryRunner.query(`DROP INDEX "IDX_44100d3eaf418ee67fa7a756f1"`); + await queryRunner.query(`DROP INDEX "IDX_b73c278619bd8fb7f30f93182c"`); + await queryRunner.query(`DROP INDEX "IDX_2ef674d18792e8864fd8d484ea"`); + await queryRunner.query( + `CREATE TABLE "temporary_resource_link" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entity" varchar NOT NULL, "entityId" varchar NOT NULL, "title" varchar NOT NULL, "url" text NOT NULL, "metaData" text, "creatorId" varchar, CONSTRAINT "FK_f9438f82f6e93bd6a87b8216af9" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_95603855ae10050123e48a66881" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_2ef674d18792e8864fd8d484eac" FOREIGN KEY ("creatorId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_resource_link"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId" FROM "resource_link"` + ); + await queryRunner.query(`DROP TABLE "resource_link"`); + await queryRunner.query(`ALTER TABLE "temporary_resource_link" RENAME TO "resource_link"`); + await queryRunner.query(`CREATE INDEX "IDX_841b729b80bc03ea38d16b8508" ON "resource_link" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_f9438f82f6e93bd6a87b8216af" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_95603855ae10050123e48a6688" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_44100d3eaf418ee67fa7a756f1" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_b73c278619bd8fb7f30f93182c" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_2ef674d18792e8864fd8d484ea" ON "resource_link" ("creatorId") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_2ef674d18792e8864fd8d484ea"`); + await queryRunner.query(`DROP INDEX "IDX_b73c278619bd8fb7f30f93182c"`); + await queryRunner.query(`DROP INDEX "IDX_44100d3eaf418ee67fa7a756f1"`); + await queryRunner.query(`DROP INDEX "IDX_95603855ae10050123e48a6688"`); + await queryRunner.query(`DROP INDEX "IDX_f9438f82f6e93bd6a87b8216af"`); + await queryRunner.query(`DROP INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff"`); + await queryRunner.query(`DROP INDEX "IDX_841b729b80bc03ea38d16b8508"`); + await queryRunner.query(`ALTER TABLE "resource_link" RENAME TO "temporary_resource_link"`); + await queryRunner.query( + `CREATE TABLE "resource_link" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entity" varchar NOT NULL, "entityId" varchar NOT NULL, "title" varchar NOT NULL, "url" text NOT NULL, "metaData" text, "creatorId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "resource_link"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId" FROM "temporary_resource_link"` + ); + await queryRunner.query(`DROP TABLE "temporary_resource_link"`); + await queryRunner.query(`CREATE INDEX "IDX_2ef674d18792e8864fd8d484ea" ON "resource_link" ("creatorId") `); + await queryRunner.query(`CREATE INDEX "IDX_b73c278619bd8fb7f30f93182c" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_44100d3eaf418ee67fa7a756f1" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_95603855ae10050123e48a6688" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_f9438f82f6e93bd6a87b8216af" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_841b729b80bc03ea38d16b8508" ON "resource_link" ("isActive") `); + await queryRunner.query(`DROP INDEX "IDX_2ef674d18792e8864fd8d484ea"`); + await queryRunner.query(`DROP INDEX "IDX_b73c278619bd8fb7f30f93182c"`); + await queryRunner.query(`DROP INDEX "IDX_44100d3eaf418ee67fa7a756f1"`); + await queryRunner.query(`DROP INDEX "IDX_95603855ae10050123e48a6688"`); + await queryRunner.query(`DROP INDEX "IDX_f9438f82f6e93bd6a87b8216af"`); + await queryRunner.query(`DROP INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff"`); + await queryRunner.query(`DROP INDEX "IDX_841b729b80bc03ea38d16b8508"`); + await queryRunner.query(`DROP TABLE "resource_link"`); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE \`resource_link\` (\`deletedAt\` datetime(6) NULL, \`id\` varchar(36) NOT NULL, \`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updatedAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`isActive\` tinyint NULL DEFAULT 1, \`isArchived\` tinyint NULL DEFAULT 0, \`archivedAt\` datetime NULL, \`tenantId\` varchar(255) NULL, \`organizationId\` varchar(255) NULL, \`entity\` varchar(255) NOT NULL, \`entityId\` varchar(255) NOT NULL, \`title\` varchar(255) NOT NULL, \`url\` text NOT NULL, \`metaData\` json NULL, \`creatorId\` varchar(255) NULL, INDEX \`IDX_841b729b80bc03ea38d16b8508\` (\`isActive\`), INDEX \`IDX_4c25c2c9d7ebbd0c07edd824ff\` (\`isArchived\`), INDEX \`IDX_f9438f82f6e93bd6a87b8216af\` (\`tenantId\`), INDEX \`IDX_95603855ae10050123e48a6688\` (\`organizationId\`), INDEX \`IDX_44100d3eaf418ee67fa7a756f1\` (\`entity\`), INDEX \`IDX_b73c278619bd8fb7f30f93182c\` (\`entityId\`), INDEX \`IDX_2ef674d18792e8864fd8d484ea\` (\`creatorId\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_f9438f82f6e93bd6a87b8216af9\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_95603855ae10050123e48a66881\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_2ef674d18792e8864fd8d484eac\` FOREIGN KEY (\`creatorId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_2ef674d18792e8864fd8d484eac\``); + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_95603855ae10050123e48a66881\``); + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_f9438f82f6e93bd6a87b8216af9\``); + await queryRunner.query(`DROP INDEX \`IDX_2ef674d18792e8864fd8d484ea\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_b73c278619bd8fb7f30f93182c\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_44100d3eaf418ee67fa7a756f1\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_95603855ae10050123e48a6688\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_f9438f82f6e93bd6a87b8216af\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_4c25c2c9d7ebbd0c07edd824ff\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_841b729b80bc03ea38d16b8508\` ON \`resource_link\``); + await queryRunner.query(`DROP TABLE \`resource_link\``); + } +} diff --git a/packages/core/src/database/migrations/1729861943822-AlterConstraintsForResourceLinkTable.ts b/packages/core/src/database/migrations/1729861943822-AlterConstraintsForResourceLinkTable.ts new file mode 100644 index 00000000000..7a9533ab4d5 --- /dev/null +++ b/packages/core/src/database/migrations/1729861943822-AlterConstraintsForResourceLinkTable.ts @@ -0,0 +1,276 @@ +import { Logger } from '@nestjs/common'; +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class AlterConstraintsForResourceLinkTable1729861943822 implements MigrationInterface { + name = 'AlterConstraintsForResourceLinkTable1729861943822'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + Logger.debug(yellow(this.name + ' start running!'), 'Migration'); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_2ef674d18792e8864fd8d484eac"`); + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_95603855ae10050123e48a66881"`); + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_f9438f82f6e93bd6a87b8216af9"`); + await queryRunner.query(`DROP INDEX "public"."IDX_841b729b80bc03ea38d16b8508"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4c25c2c9d7ebbd0c07edd824ff"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f9438f82f6e93bd6a87b8216af"`); + await queryRunner.query(`DROP INDEX "public"."IDX_95603855ae10050123e48a6688"`); + await queryRunner.query(`DROP INDEX "public"."IDX_44100d3eaf418ee67fa7a756f1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b73c278619bd8fb7f30f93182c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2ef674d18792e8864fd8d484ea"`); + await queryRunner.query(`CREATE INDEX "IDX_e891dad6f91b8eb04a47f42a06" ON "resource_link" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_2efdd5f6dc5d0c483edbc932ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_64d90b997156b7de382fd8a88f" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_b3caaf70dcd98d572c0fe09c59" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_ada8b0cf4463e653a756fc6db2" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_61dc38c01dfd2fe25cd934a0d1" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_df91a85b49f78544da67aa9d9a" ON "resource_link" ("creatorId") `); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_64d90b997156b7de382fd8a88f2" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_b3caaf70dcd98d572c0fe09c59f" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_df91a85b49f78544da67aa9d9ad" FOREIGN KEY ("creatorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_df91a85b49f78544da67aa9d9ad"`); + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_b3caaf70dcd98d572c0fe09c59f"`); + await queryRunner.query(`ALTER TABLE "resource_link" DROP CONSTRAINT "FK_64d90b997156b7de382fd8a88f2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_df91a85b49f78544da67aa9d9a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_61dc38c01dfd2fe25cd934a0d1"`); + await queryRunner.query(`DROP INDEX "public"."IDX_ada8b0cf4463e653a756fc6db2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b3caaf70dcd98d572c0fe09c59"`); + await queryRunner.query(`DROP INDEX "public"."IDX_64d90b997156b7de382fd8a88f"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2efdd5f6dc5d0c483edbc932ff"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e891dad6f91b8eb04a47f42a06"`); + await queryRunner.query(`CREATE INDEX "IDX_2ef674d18792e8864fd8d484ea" ON "resource_link" ("creatorId") `); + await queryRunner.query(`CREATE INDEX "IDX_b73c278619bd8fb7f30f93182c" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_44100d3eaf418ee67fa7a756f1" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_95603855ae10050123e48a6688" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_f9438f82f6e93bd6a87b8216af" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_841b729b80bc03ea38d16b8508" ON "resource_link" ("isActive") `); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_f9438f82f6e93bd6a87b8216af9" FOREIGN KEY ("tenantId") REFERENCES "tenant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_95603855ae10050123e48a66881" FOREIGN KEY ("organizationId") REFERENCES "organization"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE "resource_link" ADD CONSTRAINT "FK_2ef674d18792e8864fd8d484eac" FOREIGN KEY ("creatorId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_2ef674d18792e8864fd8d484ea"`); + await queryRunner.query(`DROP INDEX "IDX_b73c278619bd8fb7f30f93182c"`); + await queryRunner.query(`DROP INDEX "IDX_44100d3eaf418ee67fa7a756f1"`); + await queryRunner.query(`DROP INDEX "IDX_95603855ae10050123e48a6688"`); + await queryRunner.query(`DROP INDEX "IDX_f9438f82f6e93bd6a87b8216af"`); + await queryRunner.query(`DROP INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff"`); + await queryRunner.query(`DROP INDEX "IDX_841b729b80bc03ea38d16b8508"`); + await queryRunner.query( + `CREATE TABLE "temporary_resource_link" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entity" varchar NOT NULL, "entityId" varchar NOT NULL, "title" varchar NOT NULL, "url" text NOT NULL, "metaData" text, "creatorId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "temporary_resource_link"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId" FROM "resource_link"` + ); + await queryRunner.query(`DROP TABLE "resource_link"`); + await queryRunner.query(`ALTER TABLE "temporary_resource_link" RENAME TO "resource_link"`); + await queryRunner.query( + `CREATE TABLE "temporary_resource_link" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entity" varchar NOT NULL, "entityId" varchar NOT NULL, "title" varchar NOT NULL, "url" text NOT NULL, "metaData" text, "creatorId" varchar, CONSTRAINT "FK_64d90b997156b7de382fd8a88f2" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_b3caaf70dcd98d572c0fe09c59f" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_df91a85b49f78544da67aa9d9ad" FOREIGN KEY ("creatorId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_resource_link"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId" FROM "resource_link"` + ); + await queryRunner.query(`DROP TABLE "resource_link"`); + await queryRunner.query(`ALTER TABLE "temporary_resource_link" RENAME TO "resource_link"`); + await queryRunner.query(`CREATE INDEX "IDX_e891dad6f91b8eb04a47f42a06" ON "resource_link" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_2efdd5f6dc5d0c483edbc932ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_64d90b997156b7de382fd8a88f" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_b3caaf70dcd98d572c0fe09c59" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_ada8b0cf4463e653a756fc6db2" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_61dc38c01dfd2fe25cd934a0d1" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_df91a85b49f78544da67aa9d9a" ON "resource_link" ("creatorId") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_df91a85b49f78544da67aa9d9a"`); + await queryRunner.query(`DROP INDEX "IDX_61dc38c01dfd2fe25cd934a0d1"`); + await queryRunner.query(`DROP INDEX "IDX_ada8b0cf4463e653a756fc6db2"`); + await queryRunner.query(`DROP INDEX "IDX_b3caaf70dcd98d572c0fe09c59"`); + await queryRunner.query(`DROP INDEX "IDX_64d90b997156b7de382fd8a88f"`); + await queryRunner.query(`DROP INDEX "IDX_2efdd5f6dc5d0c483edbc932ff"`); + await queryRunner.query(`DROP INDEX "IDX_e891dad6f91b8eb04a47f42a06"`); + await queryRunner.query(`ALTER TABLE "resource_link" RENAME TO "temporary_resource_link"`); + await queryRunner.query( + `CREATE TABLE "resource_link" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entity" varchar NOT NULL, "entityId" varchar NOT NULL, "title" varchar NOT NULL, "url" text NOT NULL, "metaData" text, "creatorId" varchar)` + ); + await queryRunner.query( + `INSERT INTO "resource_link"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId" FROM "temporary_resource_link"` + ); + await queryRunner.query(`DROP TABLE "temporary_resource_link"`); + await queryRunner.query(`DROP INDEX "IDX_e891dad6f91b8eb04a47f42a06"`); + await queryRunner.query(`DROP INDEX "IDX_2ef674d18792e8864fd8d484ea"`); + await queryRunner.query(`ALTER TABLE "resource_link" RENAME TO "temporary_resource_link"`); + await queryRunner.query( + `CREATE TABLE "resource_link" ("deletedAt" datetime, "id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "isActive" boolean DEFAULT (1), "isArchived" boolean DEFAULT (0), "archivedAt" datetime, "tenantId" varchar, "organizationId" varchar, "entity" varchar NOT NULL, "entityId" varchar NOT NULL, "title" varchar NOT NULL, "url" text NOT NULL, "metaData" text, "creatorId" varchar, CONSTRAINT "FK_2ef674d18792e8864fd8d484eac" FOREIGN KEY ("creatorId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_95603855ae10050123e48a66881" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_f9438f82f6e93bd6a87b8216af9" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "resource_link"("deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId") SELECT "deletedAt", "id", "createdAt", "updatedAt", "isActive", "isArchived", "archivedAt", "tenantId", "organizationId", "entity", "entityId", "title", "url", "metaData", "creatorId" FROM "temporary_resource_link"` + ); + await queryRunner.query(`DROP TABLE "temporary_resource_link"`); + await queryRunner.query(`CREATE INDEX "IDX_841b729b80bc03ea38d16b8508" ON "resource_link" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4c25c2c9d7ebbd0c07edd824ff" ON "resource_link" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_f9438f82f6e93bd6a87b8216af" ON "resource_link" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_95603855ae10050123e48a6688" ON "resource_link" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_44100d3eaf418ee67fa7a756f1" ON "resource_link" ("entity") `); + await queryRunner.query(`CREATE INDEX "IDX_b73c278619bd8fb7f30f93182c" ON "resource_link" ("entityId") `); + await queryRunner.query(`CREATE INDEX "IDX_2ef674d18792e8864fd8d484ea" ON "resource_link" ("creatorId") `); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_2ef674d18792e8864fd8d484eac\``); + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_95603855ae10050123e48a66881\``); + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_f9438f82f6e93bd6a87b8216af9\``); + await queryRunner.query(`DROP INDEX \`IDX_4c25c2c9d7ebbd0c07edd824ff\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_f9438f82f6e93bd6a87b8216af\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_44100d3eaf418ee67fa7a756f1\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_2ef674d18792e8864fd8d484ea\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_841b729b80bc03ea38d16b8508\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_b73c278619bd8fb7f30f93182c\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_95603855ae10050123e48a6688\` ON \`resource_link\``); + await queryRunner.query(`CREATE INDEX \`IDX_e891dad6f91b8eb04a47f42a06\` ON \`resource_link\` (\`isActive\`)`); + await queryRunner.query( + `CREATE INDEX \`IDX_2efdd5f6dc5d0c483edbc932ff\` ON \`resource_link\` (\`isArchived\`)` + ); + await queryRunner.query(`CREATE INDEX \`IDX_64d90b997156b7de382fd8a88f\` ON \`resource_link\` (\`tenantId\`)`); + await queryRunner.query( + `CREATE INDEX \`IDX_b3caaf70dcd98d572c0fe09c59\` ON \`resource_link\` (\`organizationId\`)` + ); + await queryRunner.query(`CREATE INDEX \`IDX_ada8b0cf4463e653a756fc6db2\` ON \`resource_link\` (\`entity\`)`); + await queryRunner.query(`CREATE INDEX \`IDX_61dc38c01dfd2fe25cd934a0d1\` ON \`resource_link\` (\`entityId\`)`); + await queryRunner.query(`CREATE INDEX \`IDX_df91a85b49f78544da67aa9d9a\` ON \`resource_link\` (\`creatorId\`)`); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_64d90b997156b7de382fd8a88f2\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_b3caaf70dcd98d572c0fe09c59f\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_df91a85b49f78544da67aa9d9ad\` FOREIGN KEY (\`creatorId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_df91a85b49f78544da67aa9d9ad\``); + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_b3caaf70dcd98d572c0fe09c59f\``); + await queryRunner.query(`ALTER TABLE \`resource_link\` DROP FOREIGN KEY \`FK_64d90b997156b7de382fd8a88f2\``); + await queryRunner.query(`DROP INDEX \`IDX_df91a85b49f78544da67aa9d9a\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_61dc38c01dfd2fe25cd934a0d1\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_ada8b0cf4463e653a756fc6db2\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_b3caaf70dcd98d572c0fe09c59\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_64d90b997156b7de382fd8a88f\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_2efdd5f6dc5d0c483edbc932ff\` ON \`resource_link\``); + await queryRunner.query(`DROP INDEX \`IDX_e891dad6f91b8eb04a47f42a06\` ON \`resource_link\``); + await queryRunner.query( + `CREATE INDEX \`IDX_95603855ae10050123e48a6688\` ON \`resource_link\` (\`organizationId\`)` + ); + await queryRunner.query(`CREATE INDEX \`IDX_b73c278619bd8fb7f30f93182c\` ON \`resource_link\` (\`entityId\`)`); + await queryRunner.query(`CREATE INDEX \`IDX_841b729b80bc03ea38d16b8508\` ON \`resource_link\` (\`isActive\`)`); + await queryRunner.query(`CREATE INDEX \`IDX_2ef674d18792e8864fd8d484ea\` ON \`resource_link\` (\`creatorId\`)`); + await queryRunner.query(`CREATE INDEX \`IDX_44100d3eaf418ee67fa7a756f1\` ON \`resource_link\` (\`entity\`)`); + await queryRunner.query(`CREATE INDEX \`IDX_f9438f82f6e93bd6a87b8216af\` ON \`resource_link\` (\`tenantId\`)`); + await queryRunner.query( + `CREATE INDEX \`IDX_4c25c2c9d7ebbd0c07edd824ff\` ON \`resource_link\` (\`isArchived\`)` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_f9438f82f6e93bd6a87b8216af9\` FOREIGN KEY (\`tenantId\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_95603855ae10050123e48a66881\` FOREIGN KEY (\`organizationId\`) REFERENCES \`organization\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE` + ); + await queryRunner.query( + `ALTER TABLE \`resource_link\` ADD CONSTRAINT \`FK_2ef674d18792e8864fd8d484eac\` FOREIGN KEY (\`creatorId\`) REFERENCES \`user\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } +} diff --git a/packages/core/src/database/migrations/1729867054227-AddTimeTrackingPermissionsColumnsToEmployeeTable.ts b/packages/core/src/database/migrations/1729867054227-AddTimeTrackingPermissionsColumnsToEmployeeTable.ts new file mode 100644 index 00000000000..d24fe4f5013 --- /dev/null +++ b/packages/core/src/database/migrations/1729867054227-AddTimeTrackingPermissionsColumnsToEmployeeTable.ts @@ -0,0 +1,166 @@ +import { Logger } from '@nestjs/common'; +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { yellow } from 'chalk'; +import { DatabaseTypeEnum } from '@gauzy/config'; + +export class AddTimeTrackingPermissionsColumnsToEmployeeTable1729867054227 implements MigrationInterface { + name = 'AddTimeTrackingPermissionsColumnsToEmployeeTable1729867054227'; + + /** + * Up Migration + * + * @param queryRunner + */ + public async up(queryRunner: QueryRunner): Promise { + Logger.debug(yellow(this.name + ' start running!'), 'Migration'); + + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresUpQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlUpQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * Down Migration + * + * @param queryRunner + */ + public async down(queryRunner: QueryRunner): Promise { + switch (queryRunner.connection.options.type) { + case DatabaseTypeEnum.sqlite: + case DatabaseTypeEnum.betterSqlite3: + await this.sqliteDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.postgres: + await this.postgresDownQueryRunner(queryRunner); + break; + case DatabaseTypeEnum.mysql: + await this.mysqlDownQueryRunner(queryRunner); + break; + default: + throw Error(`Unsupported database: ${queryRunner.connection.options.type}`); + } + } + + /** + * PostgresDB Up Migration + * + * @param queryRunner + */ + public async postgresUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "employee" ADD "allowManualTime" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "employee" ADD "allowModifyTime" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "employee" ADD "allowDeleteTime" boolean NOT NULL DEFAULT false`); + } + + /** + * PostgresDB Down Migration + * + * @param queryRunner + */ + public async postgresDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "employee" DROP COLUMN "allowDeleteTime"`); + await queryRunner.query(`ALTER TABLE "employee" DROP COLUMN "allowModifyTime"`); + await queryRunner.query(`ALTER TABLE "employee" DROP COLUMN "allowManualTime"`); + } + + /** + * SqliteDB and BetterSQlite3DB Up Migration + * + * @param queryRunner + */ + public async sqliteUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_5e719204dcafa8d6b2ecdeda13"`); + await queryRunner.query(`DROP INDEX "IDX_1c0c1370ecd98040259625e17e"`); + await queryRunner.query(`DROP INDEX "IDX_f4b0d329c4a3cf79ffe9d56504"`); + await queryRunner.query(`DROP INDEX "IDX_96dfbcaa2990df01fe5bb39ccc"`); + await queryRunner.query(`DROP INDEX "IDX_c6a48286f3aa8ae903bee0d1e7"`); + await queryRunner.query(`DROP INDEX "IDX_4b3303a6b7eb92d237a4379734"`); + await queryRunner.query(`DROP INDEX "IDX_510cb87f5da169e57e694d1a5c"`); + await queryRunner.query(`DROP INDEX "IDX_175b7be641928a31521224daa8"`); + await queryRunner.query( + `CREATE TABLE "temporary_employee" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "valueDate" datetime, "isActive" boolean DEFAULT (1), "short_description" varchar(200), "description" varchar, "startedWorkOn" datetime, "endWork" datetime, "payPeriod" varchar, "billRateValue" integer, "billRateCurrency" varchar, "reWeeklyLimit" integer, "offerDate" datetime, "acceptDate" datetime, "rejectDate" datetime, "employeeLevel" varchar(500), "anonymousBonus" boolean, "averageIncome" numeric, "averageBonus" numeric, "totalWorkHours" numeric DEFAULT (0), "averageExpenses" numeric, "show_anonymous_bonus" boolean, "show_average_bonus" boolean, "show_average_expenses" boolean, "show_average_income" boolean, "show_billrate" boolean, "show_payperiod" boolean, "show_start_work_on" boolean, "isJobSearchActive" boolean, "linkedInUrl" varchar, "facebookUrl" varchar, "instagramUrl" varchar, "twitterUrl" varchar, "githubUrl" varchar, "gitlabUrl" varchar, "upworkUrl" varchar, "stackoverflowUrl" varchar, "isVerified" boolean, "isVetted" boolean, "totalJobs" numeric, "jobSuccess" numeric, "profile_link" varchar, "userId" varchar NOT NULL, "contactId" varchar, "organizationPositionId" varchar, "isTrackingEnabled" boolean DEFAULT (0), "deletedAt" datetime, "allowScreenshotCapture" boolean NOT NULL DEFAULT (1), "upworkId" varchar, "linkedInId" varchar, "isOnline" boolean DEFAULT (0), "isTrackingTime" boolean DEFAULT (0), "minimumBillingRate" integer, "isAway" boolean DEFAULT (0), "isArchived" boolean DEFAULT (0), "fix_relational_custom_fields" boolean, "archivedAt" datetime, "allowManualTime" boolean NOT NULL DEFAULT (0), "allowModifyTime" boolean NOT NULL DEFAULT (0), "allowDeleteTime" boolean NOT NULL DEFAULT (0), CONSTRAINT "REL_1c0c1370ecd98040259625e17e" UNIQUE ("contactId"), CONSTRAINT "REL_f4b0d329c4a3cf79ffe9d56504" UNIQUE ("userId"), CONSTRAINT "FK_5e719204dcafa8d6b2ecdeda130" FOREIGN KEY ("organizationPositionId") REFERENCES "organization_position" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_1c0c1370ecd98040259625e17e2" FOREIGN KEY ("contactId") REFERENCES "contact" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_f4b0d329c4a3cf79ffe9d565047" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_c6a48286f3aa8ae903bee0d1e72" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_4b3303a6b7eb92d237a4379734e" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_employee"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "valueDate", "isActive", "short_description", "description", "startedWorkOn", "endWork", "payPeriod", "billRateValue", "billRateCurrency", "reWeeklyLimit", "offerDate", "acceptDate", "rejectDate", "employeeLevel", "anonymousBonus", "averageIncome", "averageBonus", "totalWorkHours", "averageExpenses", "show_anonymous_bonus", "show_average_bonus", "show_average_expenses", "show_average_income", "show_billrate", "show_payperiod", "show_start_work_on", "isJobSearchActive", "linkedInUrl", "facebookUrl", "instagramUrl", "twitterUrl", "githubUrl", "gitlabUrl", "upworkUrl", "stackoverflowUrl", "isVerified", "isVetted", "totalJobs", "jobSuccess", "profile_link", "userId", "contactId", "organizationPositionId", "isTrackingEnabled", "deletedAt", "allowScreenshotCapture", "upworkId", "linkedInId", "isOnline", "isTrackingTime", "minimumBillingRate", "isAway", "isArchived", "fix_relational_custom_fields", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "valueDate", "isActive", "short_description", "description", "startedWorkOn", "endWork", "payPeriod", "billRateValue", "billRateCurrency", "reWeeklyLimit", "offerDate", "acceptDate", "rejectDate", "employeeLevel", "anonymousBonus", "averageIncome", "averageBonus", "totalWorkHours", "averageExpenses", "show_anonymous_bonus", "show_average_bonus", "show_average_expenses", "show_average_income", "show_billrate", "show_payperiod", "show_start_work_on", "isJobSearchActive", "linkedInUrl", "facebookUrl", "instagramUrl", "twitterUrl", "githubUrl", "gitlabUrl", "upworkUrl", "stackoverflowUrl", "isVerified", "isVetted", "totalJobs", "jobSuccess", "profile_link", "userId", "contactId", "organizationPositionId", "isTrackingEnabled", "deletedAt", "allowScreenshotCapture", "upworkId", "linkedInId", "isOnline", "isTrackingTime", "minimumBillingRate", "isAway", "isArchived", "fix_relational_custom_fields", "archivedAt" FROM "employee"` + ); + await queryRunner.query(`DROP TABLE "employee"`); + await queryRunner.query(`ALTER TABLE "temporary_employee" RENAME TO "employee"`); + await queryRunner.query( + `CREATE INDEX "IDX_5e719204dcafa8d6b2ecdeda13" ON "employee" ("organizationPositionId") ` + ); + await queryRunner.query(`CREATE INDEX "IDX_1c0c1370ecd98040259625e17e" ON "employee" ("contactId") `); + await queryRunner.query(`CREATE INDEX "IDX_f4b0d329c4a3cf79ffe9d56504" ON "employee" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_96dfbcaa2990df01fe5bb39ccc" ON "employee" ("profile_link") `); + await queryRunner.query(`CREATE INDEX "IDX_c6a48286f3aa8ae903bee0d1e7" ON "employee" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_4b3303a6b7eb92d237a4379734" ON "employee" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_510cb87f5da169e57e694d1a5c" ON "employee" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_175b7be641928a31521224daa8" ON "employee" ("isArchived") `); + } + + /** + * SqliteDB and BetterSQlite3DB Down Migration + * + * @param queryRunner + */ + public async sqliteDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_175b7be641928a31521224daa8"`); + await queryRunner.query(`DROP INDEX "IDX_510cb87f5da169e57e694d1a5c"`); + await queryRunner.query(`DROP INDEX "IDX_4b3303a6b7eb92d237a4379734"`); + await queryRunner.query(`DROP INDEX "IDX_c6a48286f3aa8ae903bee0d1e7"`); + await queryRunner.query(`DROP INDEX "IDX_96dfbcaa2990df01fe5bb39ccc"`); + await queryRunner.query(`DROP INDEX "IDX_f4b0d329c4a3cf79ffe9d56504"`); + await queryRunner.query(`DROP INDEX "IDX_1c0c1370ecd98040259625e17e"`); + await queryRunner.query(`DROP INDEX "IDX_5e719204dcafa8d6b2ecdeda13"`); + await queryRunner.query(`ALTER TABLE "employee" RENAME TO "temporary_employee"`); + await queryRunner.query( + `CREATE TABLE "employee" ("id" varchar PRIMARY KEY NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "tenantId" varchar, "organizationId" varchar, "valueDate" datetime, "isActive" boolean DEFAULT (1), "short_description" varchar(200), "description" varchar, "startedWorkOn" datetime, "endWork" datetime, "payPeriod" varchar, "billRateValue" integer, "billRateCurrency" varchar, "reWeeklyLimit" integer, "offerDate" datetime, "acceptDate" datetime, "rejectDate" datetime, "employeeLevel" varchar(500), "anonymousBonus" boolean, "averageIncome" numeric, "averageBonus" numeric, "totalWorkHours" numeric DEFAULT (0), "averageExpenses" numeric, "show_anonymous_bonus" boolean, "show_average_bonus" boolean, "show_average_expenses" boolean, "show_average_income" boolean, "show_billrate" boolean, "show_payperiod" boolean, "show_start_work_on" boolean, "isJobSearchActive" boolean, "linkedInUrl" varchar, "facebookUrl" varchar, "instagramUrl" varchar, "twitterUrl" varchar, "githubUrl" varchar, "gitlabUrl" varchar, "upworkUrl" varchar, "stackoverflowUrl" varchar, "isVerified" boolean, "isVetted" boolean, "totalJobs" numeric, "jobSuccess" numeric, "profile_link" varchar, "userId" varchar NOT NULL, "contactId" varchar, "organizationPositionId" varchar, "isTrackingEnabled" boolean DEFAULT (0), "deletedAt" datetime, "allowScreenshotCapture" boolean NOT NULL DEFAULT (1), "upworkId" varchar, "linkedInId" varchar, "isOnline" boolean DEFAULT (0), "isTrackingTime" boolean DEFAULT (0), "minimumBillingRate" integer, "isAway" boolean DEFAULT (0), "isArchived" boolean DEFAULT (0), "fix_relational_custom_fields" boolean, "archivedAt" datetime, CONSTRAINT "REL_1c0c1370ecd98040259625e17e" UNIQUE ("contactId"), CONSTRAINT "REL_f4b0d329c4a3cf79ffe9d56504" UNIQUE ("userId"), CONSTRAINT "FK_5e719204dcafa8d6b2ecdeda130" FOREIGN KEY ("organizationPositionId") REFERENCES "organization_position" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_1c0c1370ecd98040259625e17e2" FOREIGN KEY ("contactId") REFERENCES "contact" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_f4b0d329c4a3cf79ffe9d565047" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_c6a48286f3aa8ae903bee0d1e72" FOREIGN KEY ("organizationId") REFERENCES "organization" ("id") ON DELETE CASCADE ON UPDATE CASCADE, CONSTRAINT "FK_4b3303a6b7eb92d237a4379734e" FOREIGN KEY ("tenantId") REFERENCES "tenant" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "employee"("id", "createdAt", "updatedAt", "tenantId", "organizationId", "valueDate", "isActive", "short_description", "description", "startedWorkOn", "endWork", "payPeriod", "billRateValue", "billRateCurrency", "reWeeklyLimit", "offerDate", "acceptDate", "rejectDate", "employeeLevel", "anonymousBonus", "averageIncome", "averageBonus", "totalWorkHours", "averageExpenses", "show_anonymous_bonus", "show_average_bonus", "show_average_expenses", "show_average_income", "show_billrate", "show_payperiod", "show_start_work_on", "isJobSearchActive", "linkedInUrl", "facebookUrl", "instagramUrl", "twitterUrl", "githubUrl", "gitlabUrl", "upworkUrl", "stackoverflowUrl", "isVerified", "isVetted", "totalJobs", "jobSuccess", "profile_link", "userId", "contactId", "organizationPositionId", "isTrackingEnabled", "deletedAt", "allowScreenshotCapture", "upworkId", "linkedInId", "isOnline", "isTrackingTime", "minimumBillingRate", "isAway", "isArchived", "fix_relational_custom_fields", "archivedAt") SELECT "id", "createdAt", "updatedAt", "tenantId", "organizationId", "valueDate", "isActive", "short_description", "description", "startedWorkOn", "endWork", "payPeriod", "billRateValue", "billRateCurrency", "reWeeklyLimit", "offerDate", "acceptDate", "rejectDate", "employeeLevel", "anonymousBonus", "averageIncome", "averageBonus", "totalWorkHours", "averageExpenses", "show_anonymous_bonus", "show_average_bonus", "show_average_expenses", "show_average_income", "show_billrate", "show_payperiod", "show_start_work_on", "isJobSearchActive", "linkedInUrl", "facebookUrl", "instagramUrl", "twitterUrl", "githubUrl", "gitlabUrl", "upworkUrl", "stackoverflowUrl", "isVerified", "isVetted", "totalJobs", "jobSuccess", "profile_link", "userId", "contactId", "organizationPositionId", "isTrackingEnabled", "deletedAt", "allowScreenshotCapture", "upworkId", "linkedInId", "isOnline", "isTrackingTime", "minimumBillingRate", "isAway", "isArchived", "fix_relational_custom_fields", "archivedAt" FROM "temporary_employee"` + ); + await queryRunner.query(`DROP TABLE "temporary_employee"`); + await queryRunner.query(`CREATE INDEX "IDX_175b7be641928a31521224daa8" ON "employee" ("isArchived") `); + await queryRunner.query(`CREATE INDEX "IDX_510cb87f5da169e57e694d1a5c" ON "employee" ("isActive") `); + await queryRunner.query(`CREATE INDEX "IDX_4b3303a6b7eb92d237a4379734" ON "employee" ("tenantId") `); + await queryRunner.query(`CREATE INDEX "IDX_c6a48286f3aa8ae903bee0d1e7" ON "employee" ("organizationId") `); + await queryRunner.query(`CREATE INDEX "IDX_96dfbcaa2990df01fe5bb39ccc" ON "employee" ("profile_link") `); + await queryRunner.query(`CREATE INDEX "IDX_f4b0d329c4a3cf79ffe9d56504" ON "employee" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_1c0c1370ecd98040259625e17e" ON "employee" ("contactId") `); + await queryRunner.query( + `CREATE INDEX "IDX_5e719204dcafa8d6b2ecdeda13" ON "employee" ("organizationPositionId") ` + ); + } + + /** + * MySQL Up Migration + * + * @param queryRunner + */ + public async mysqlUpQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`employee\` ADD \`allowManualTime\` tinyint NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE \`employee\` ADD \`allowModifyTime\` tinyint NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE \`employee\` ADD \`allowDeleteTime\` tinyint NOT NULL DEFAULT 0`); + } + + /** + * MySQL Down Migration + * + * @param queryRunner + */ + public async mysqlDownQueryRunner(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE \`employee\` DROP COLUMN \`allowDeleteTime\``); + await queryRunner.query(`ALTER TABLE \`employee\` DROP COLUMN \`allowModifyTime\``); + await queryRunner.query(`ALTER TABLE \`employee\` DROP COLUMN \`allowManualTime\``); + } +} diff --git a/packages/core/src/email-send/email.service.ts b/packages/core/src/email-send/email.service.ts index bdf53319246..08a00027ae2 100644 --- a/packages/core/src/email-send/email.service.ts +++ b/packages/core/src/email-send/email.service.ts @@ -548,24 +548,36 @@ export class EmailService { } /** + * Sends a password reset request to the user via email. * - * @param user - * @param url - * @param languageCode - * @param organizationId - * @param originUrl + * This method sends a password reset email to the specified user using the provided reset link. + * It integrates with an email service to send the email and logs the email message to a record. + * The function checks whether the user's email domain is allowed before proceeding with the email. + * + * @param user - The user object containing the user's information, including their email. + * @param resetLink - The generated password reset link that will be sent to the user's email. + * @param languageCode - The language code to use for the email localization. + * @param originUrl - Optional URL that defines the origin of the reset link. If not provided, + * it defaults to the application's client base URL. + * @returns {Promise} - A promise that resolves once the email has been sent and the record has been created. */ - async requestPassword(user: IUser, resetLink: string, languageCode: LanguagesEnum, originUrl?: string) { - const integration = Object.assign({}, env.appIntegrationConfig); + async requestPassword( + user: IUser, + resetLink: string, + languageCode: LanguagesEnum, + originUrl?: string + ): Promise { + const { email, name, tenant } = user; + const integration = { ...env.appIntegrationConfig }; // Clone app integration config + + // Prepare email sending options const sendOptions = { template: EmailTemplateEnum.PASSWORD_RESET, - message: { - to: `${user.email}` - }, + message: { to: email }, locals: { ...integration, - userName: user.name, - tenantName: user.tenant.name, + userName: name, + tenantName: tenant?.name, // Tenant is optional locale: languageCode, generatedUrl: resetLink, host: originUrl || env.clientBaseUrl @@ -574,67 +586,59 @@ export class EmailService { const body = { templateName: sendOptions.template, - email: sendOptions.message.to, + email, languageCode, message: '' }; - const match = !!DISALLOW_EMAIL_SERVER_DOMAIN.find((server) => body.email.includes(server)); - if (!match) { - try { - const instance = await this.emailSendService.getInstance(); - const send = await instance.send(sendOptions); - body['message'] = send.originalMessage; - } catch (error) { - console.error(error); - } finally { - await this.createEmailRecord(body); - } + // Check if the email domain is disallowed + if (DISALLOW_EMAIL_SERVER_DOMAIN.some((server) => email.includes(server))) { + return; + } + + try { + // Retrieve the email service instance and send the email + const instance = await this.emailSendService.getInstance(); + const send = await instance.send(sendOptions); + + // Record the original message + body.message = send.originalMessage; + } catch (error) { + console.error('Failed to send password reset email:', error); + } finally { + // Create an email record + await this.createEmailRecord(body); } } /** + * Sends a multi-tenant password reset email to a user across multiple tenants. * - * @param email - * @param tenantUsersMap - * @param languageCode - * @param originUrl + * @param email The email of the user. + * @param tenants Array of tenants and user details with reset links. + * @param languageCode The language code for localization. + * @param originUrl The origin URL to be used for generating reset links. */ async multiTenantResetPassword( email: string, - tenants: { resetLink: string; tenant: ITenant; user: IUser }[], + tenants: { resetLink: string; tenant?: ITenant; user: IUser }[], languageCode: LanguagesEnum, originUrl: string ) { - const integration = Object.assign({}, env.appIntegrationConfig); + const integration = { ...env.appIntegrationConfig }; // Clone app integration config - /** */ - const items: { - resetLink: string; - tenantName: ITenant['name']; - tenantId: ITenant['id']; - userName: IUser['name']; - }[] = []; - - /** */ - for await (const { resetLink, tenant, user } of tenants) { - /** */ - const tenantId = tenant ? tenant.id : RequestContext.currentTenantId(); - - /** */ - items.push({ - tenantName: tenant ? tenant.name : user.name, - userName: user.name, - resetLink, - tenantId - }); - } + // Iterate over each tenant and user, constructing email items + const items = tenants.map(({ resetLink, tenant, user }) => ({ + tenantName: tenant?.name ?? 'Not Created', // If tenant is missing, use 'Not Created' or 'Not Yet' + userName: user.name, // + resetLink, + tenantId: tenant?.id ?? RequestContext.currentTenantId() // Fallback to current tenant ID if tenant is not available + })); + // Prepare email sending options const sendOptions = { template: EmailTemplateEnum.MULTI_TENANT_PASSWORD_RESET, - message: { - to: `${email}` - }, + message: { to: email }, locals: { ...integration, locale: languageCode, @@ -643,6 +647,7 @@ export class EmailService { } }; + // Prepare email record body const body = { templateName: sendOptions.template, email: sendOptions.message.to, @@ -650,19 +655,22 @@ export class EmailService { message: '' }; - const match = !!DISALLOW_EMAIL_SERVER_DOMAIN.find((server) => body.email.includes(server)); - if (!match) { - try { - // TODO : Which Organization to prefer while sending email - const instance = await this.emailSendService.getInstance(); - const send = await instance.send(sendOptions); + // Return early if the email domain is disallowed + if (DISALLOW_EMAIL_SERVER_DOMAIN.some((server) => email.includes(server))) { + return; + } - body['message'] = send.originalMessage; - } catch (error) { - console.error(error); - } finally { - await this.createEmailRecord(body); - } + try { + // Retrieve the email service instance and send the email + const instance = await this.emailSendService.getInstance(); + const send = await instance.send(sendOptions); + + // Record the original message + body.message = send.originalMessage; + } catch (error) { + console.error('Failed to send multi-tenant password reset email:', error); + } finally { + await this.createEmailRecord(body); } } diff --git a/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts b/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts index 0563daff967..e792b467823 100644 --- a/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts +++ b/packages/core/src/employee-recurring-expense/commands/handlers/employee-recurring-expense.create.handler.ts @@ -10,34 +10,35 @@ import { EmployeeRecurringExpenseCreateCommand } from '../employee-recurring-exp * The parentRecurringExpenseId is it's own id since this is a new expense. */ @CommandHandler(EmployeeRecurringExpenseCreateCommand) -export class EmployeeRecurringExpenseCreateHandler - implements ICommandHandler { - constructor( - private readonly employeeRecurringExpenseService: EmployeeRecurringExpenseService - ) {} +export class EmployeeRecurringExpenseCreateHandler implements ICommandHandler { + constructor(private readonly employeeRecurringExpenseService: EmployeeRecurringExpenseService) {} - public async execute( - command: EmployeeRecurringExpenseCreateCommand - ): Promise { + /** + * Executes the command to create a recurring expense for an employee. + * + * @param command - The command containing the input data for creating the recurring expense. + * @returns A promise that resolves with the created employee recurring expense. + * @throws BadRequestException if there is an error during the creation process. + */ + public async execute(command: EmployeeRecurringExpenseCreateCommand): Promise { try { const { input } = command; - /** - * If employee create self recurring expense - */ - if (!RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - )) { + + // If the user does not have permission to change the selected employee, set the current employee's ID + if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { input.employeeId = RequestContext.currentEmployeeId(); - } else { - input.employeeId = input.employeeId || null; } - const recurringExpense = await this.employeeRecurringExpenseService.create( - input - ); + + // Create the recurring expense + const recurringExpense = await this.employeeRecurringExpenseService.create(input); + + // Update the parent recurring expense to reference itself await this.employeeRecurringExpenseService.update(recurringExpense.id, { parentRecurringExpenseId: recurringExpense.id }); - return await this.employeeRecurringExpenseService.findOneByIdString(recurringExpense.id) + + // Return the newly created recurring expense + return await this.employeeRecurringExpenseService.findOneByIdString(recurringExpense.id); } catch (error) { throw new BadRequestException(error); } diff --git a/packages/core/src/employee/commands/handlers/employee.update.handler.ts b/packages/core/src/employee/commands/handlers/employee.update.handler.ts index 95cfa25641c..e43379d0624 100644 --- a/packages/core/src/employee/commands/handlers/employee.update.handler.ts +++ b/packages/core/src/employee/commands/handlers/employee.update.handler.ts @@ -7,10 +7,7 @@ import { RequestContext } from './../../../core/context'; @CommandHandler(EmployeeUpdateCommand) export class EmployeeUpdateHandler implements ICommandHandler { - - constructor( - private readonly _employeeService: EmployeeService, - ) { } + constructor(private readonly _employeeService: EmployeeService) {} public async execute(command: EmployeeUpdateCommand): Promise { const { id, input } = command; diff --git a/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts b/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts index ad8591e63e0..0a97159396d 100644 --- a/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts +++ b/packages/core/src/employee/commands/handlers/update-employee-total-worked-hours.handler.ts @@ -1,72 +1,126 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { InjectRepository } from '@nestjs/typeorm'; -import { DatabaseTypeEnum, getConfig } from '@gauzy/config'; +import { ConfigService, DatabaseTypeEnum } from '@gauzy/config'; +import { ID } from '@gauzy/contracts'; import { prepareSQLQuery as p } from './../../../database/database.helper'; -import { UpdateEmployeeTotalWorkedHoursCommand } from '../update-employee-total-worked-hours.command'; -import { EmployeeService } from '../../employee.service'; -import { TimeLog } from './../../../core/entities/internal'; +import { TimeLog, TimeSlot } from './../../../core/entities/internal'; import { RequestContext } from './../../../core/context'; +import { EmployeeService } from '../../employee.service'; +import { UpdateEmployeeTotalWorkedHoursCommand } from '../update-employee-total-worked-hours.command'; import { TypeOrmTimeLogRepository } from '../../../time-tracking/time-log/repository/type-orm-time-log.repository'; -import { MikroOrmTimeLogRepository } from '../../../time-tracking/time-log/repository/mikro-orm-time-log.repository'; - -const config = getConfig(); +import { TypeOrmTimeSlotRepository } from '../../../time-tracking/time-slot/repository/type-orm-time-slot.repository'; @CommandHandler(UpdateEmployeeTotalWorkedHoursCommand) export class UpdateEmployeeTotalWorkedHoursHandler implements ICommandHandler { constructor( - private readonly employeeService: EmployeeService, - - @InjectRepository(TimeLog) - readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - - readonly mikroOrmTimeLogRepository: MikroOrmTimeLogRepository, - ) { } + @InjectRepository(TimeLog) readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, + @InjectRepository(TimeSlot) readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, + private readonly _employeeService: EmployeeService, + private readonly _configService: ConfigService + ) {} /** + * Updates the total worked hours for an employee. * - * @param command + * @param command The command containing employee ID and worked hours. */ public async execute(command: UpdateEmployeeTotalWorkedHoursCommand) { const { employeeId, hours } = command; const tenantId = RequestContext.currentTenantId(); - let totalWorkHours: number; - if (hours) { - totalWorkHours = hours; - } else { - let sumQuery: string = this.getSumQuery(); - const query = this.typeOrmTimeLogRepository.createQueryBuilder(); - query.select(sumQuery, `duration`); - query.where({ employeeId, tenantId }); - const logs = await query.getRawOne(); - totalWorkHours = (logs.duration || 0) / 3600; - } + // Determine total work hours, calculate if not provided + const totalWorkHours = (await this.calculateTotalWorkHours(employeeId, tenantId)) || hours; + console.log('Updated Employee Total Worked Hours: %s', Math.floor(totalWorkHours)); - await this.employeeService.update(employeeId, { - totalWorkHours: parseInt(totalWorkHours + '', 10) + // Update employee's total worked hours + await this._employeeService.update(employeeId, { + totalWorkHours: Math.floor(totalWorkHours) // Use Math.floor for integer conversion }); } + /** + * Calculates the total work hours for an employee. + * @param employeeId The ID of the employee. + * @param tenantId The tenant ID. + * @returns The total work hours. + */ + private async calculateTotalWorkHours(employeeId: ID, tenantId: ID): Promise { + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeLogRepository.createQueryBuilder(); + query.innerJoin(`${query.alias}.timeSlots`, 'time_slot'); + + // Get the sum of durations between startedAt and stoppedAt + const sumQuery = this.getSumQuery(query.alias); + console.log('sum of durations between startedAt and stoppedAt', sumQuery); + + // Execute the query and get the duration + const result = await query + .select(sumQuery, 'duration') + .where({ + employeeId, + tenantId + }) + .getRawOne(); + + console.log(`get sum duration for specific employee: ${employeeId}`, +result.duration); + + // Convert duration from seconds to hours + return Number(+result.duration || 0) / 3600; + } + /** * Get the database-specific sum query for calculating time duration between "startedAt" and "stoppedAt". - * @returns The database-specific sum query. + * @param logQueryAlias The alias for the table in the query. + * @returns The database-specific sum query that returns a Number. */ - private getSumQuery(): string { + private getSumQuery(logQueryAlias: string): string { let sumQuery: string; - switch (config.dbConnectionOptions.type) { + const { dbConnectionOptions } = this._configService; + + switch (dbConnectionOptions.type) { case DatabaseTypeEnum.sqlite: case DatabaseTypeEnum.betterSqlite3: - sumQuery = 'SUM((julianday("stoppedAt") - julianday("startedAt")) * 86400)'; + sumQuery = ` + CAST( + SUM( + CASE + WHEN (julianday("${logQueryAlias}"."stoppedAt") - julianday("${logQueryAlias}"."startedAt")) * 86400 >= 0 + THEN (julianday("${logQueryAlias}"."stoppedAt") - julianday("${logQueryAlias}"."startedAt")) * 86400 + ELSE 0 + END + ) AS REAL + ) + `; break; case DatabaseTypeEnum.postgres: - sumQuery = 'SUM(extract(epoch from ("stoppedAt" - "startedAt")))'; + sumQuery = ` + CAST( + SUM( + CASE + WHEN extract(epoch from ("${logQueryAlias}"."stoppedAt" - "${logQueryAlias}"."startedAt")) >= 0 + THEN extract(epoch from ("${logQueryAlias}"."stoppedAt" - "${logQueryAlias}"."startedAt")) + ELSE 0 + END + ) AS DOUBLE PRECISION + ) + `; break; case DatabaseTypeEnum.mysql: - sumQuery = p('SUM(TIMESTAMPDIFF(SECOND, "startedAt", "stoppedAt"))'); + sumQuery = p(` + CAST( + SUM( + CASE + WHEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, \`${logQueryAlias}\`.\`stoppedAt\`) >= 0 + THEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, \`${logQueryAlias}\`.\`stoppedAt\`) + ELSE 0 + END + ) AS DECIMAL(10, 6) + ) + `); break; default: - throw Error(`cannot update employee total worked hours due to unsupported database type: ${config.dbConnectionOptions.type}`); + throw new Error(`Unsupported database type: ${dbConnectionOptions.type}`); } return sumQuery; diff --git a/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts b/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts index 0f5307b415c..d6efd1981f8 100644 --- a/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts +++ b/packages/core/src/employee/commands/update-employee-total-worked-hours.command.ts @@ -1,10 +1,8 @@ import { ICommand } from '@nestjs/cqrs'; +import { ID } from '@gauzy/contracts'; export class UpdateEmployeeTotalWorkedHoursCommand implements ICommand { static readonly type = '[Employee] Update Total Worked Hours'; - constructor( - public readonly employeeId: string, - public readonly hours?: number - ) {} + constructor(public readonly employeeId: ID, public readonly hours?: number) {} } diff --git a/packages/core/src/employee/default-employees.ts b/packages/core/src/employee/default-employees.ts index bad4803283f..351533542f9 100644 --- a/packages/core/src/employee/default-employees.ts +++ b/packages/core/src/employee/default-employees.ts @@ -182,6 +182,18 @@ export const DEFAULT_EVER_EMPLOYEES: any = [ preferredLanguage: LanguagesEnum.ENGLISH, preferredComponentLayout: ComponentLayoutStyleEnum.TABLE }, + { + email: 'rahulrathore576@gmail.com', + password: '123456', + firstName: 'Rahul', + lastName: 'R.', + imageUrl: 'assets/images/avatars/rahul.png', + startedWorkOn: '2020-09-10', + endWork: null, + employeeLevel: null, + preferredLanguage: LanguagesEnum.ENGLISH, + preferredComponentLayout: ComponentLayoutStyleEnum.TABLE + }, { email: 'julia@example-ever.co', password: '123456', diff --git a/packages/core/src/employee/dto/update-employee.dto.ts b/packages/core/src/employee/dto/update-employee.dto.ts index 5c9c2e3567a..8313cff2363 100644 --- a/packages/core/src/employee/dto/update-employee.dto.ts +++ b/packages/core/src/employee/dto/update-employee.dto.ts @@ -31,7 +31,10 @@ export class UpdateEmployeeDTO 'isTrackingEnabled', 'isTrackingTime', 'isJobSearchActive', - 'allowScreenshotCapture' + 'allowScreenshotCapture', + 'allowManualTime', + 'allowModifyTime', + 'allowDeleteTime' ] as const) ) implements IEmployeeUpdateInput {} diff --git a/packages/core/src/employee/employee.controller.ts b/packages/core/src/employee/employee.controller.ts index d869b782403..6ae93257561 100644 --- a/packages/core/src/employee/employee.controller.ts +++ b/packages/core/src/employee/employee.controller.ts @@ -289,14 +289,12 @@ export class EmployeeController extends CrudController { @Param('id', UUIDValidationPipe) id: ID, @Query() params: OptionParams ): Promise { - const currentEmployeeId = RequestContext.currentEmployeeId(); - // Check permissions to determine the correct ID to retrieve const searchCriteria = { where: { ...(RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) ? { id } - : { id: currentEmployeeId }) + : { id: RequestContext.currentEmployeeId() }) }, ...(params.relations ? { relations: params.relations } : {}), withDeleted: true diff --git a/packages/core/src/employee/employee.entity.ts b/packages/core/src/employee/employee.entity.ts index 1c2243481e1..043079d7729 100644 --- a/packages/core/src/employee/employee.entity.ts +++ b/packages/core/src/employee/employee.entity.ts @@ -35,7 +35,8 @@ import { IOrganizationProjectModule, ID, IFavorite, - IComment + IComment, + IOrganizationSprint } from '@gauzy/contracts'; import { ColumnIndex, @@ -67,6 +68,7 @@ import { OrganizationPosition, OrganizationProjectEmployee, OrganizationProjectModule, + OrganizationSprintEmployee, OrganizationTeamEmployee, RequestApprovalEmployee, Skill, @@ -374,6 +376,36 @@ export class Employee extends TenantOrganizationBaseEntity implements IEmployee, @MultiORMColumn({ default: true }) allowScreenshotCapture?: boolean; + /** + * Indicates whether manual time entry is allowed for time tracking + * for a specific employee. + */ + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() + @MultiORMColumn({ default: false }) + allowManualTime?: boolean; + + /** + * Indicates whether modification of time entries is allowed for time tracking + * for a specific employee. + */ + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() + @MultiORMColumn({ default: false }) + allowModifyTime?: boolean; + + /** + * Indicates whether deletion of time entries is allowed for time tracking + * for a specific employee. + */ + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() + @MultiORMColumn({ default: false }) + allowDeleteTime?: boolean; + /** Upwork ID */ @ApiPropertyOptional({ type: () => String }) @IsOptional() @@ -496,6 +528,12 @@ export class Employee extends TenantOrganizationBaseEntity implements IEmployee, }) projects?: IOrganizationProject[]; + // Employee Sprint + @MultiORMOneToMany(() => OrganizationSprintEmployee, (it) => it.employee, { + cascade: true + }) + sprints?: IOrganizationSprint[]; + /** * Estimations */ diff --git a/packages/core/src/employee/employee.module.ts b/packages/core/src/employee/employee.module.ts index 57b7f9939c3..f49ac696dd5 100644 --- a/packages/core/src/employee/employee.module.ts +++ b/packages/core/src/employee/employee.module.ts @@ -2,7 +2,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MikroOrmModule } from '@mikro-orm/nestjs'; -import { TimeLog } from './../core/entities/internal'; +import { TimeLog, TimeSlot } from './../core/entities/internal'; import { Employee } from './employee.entity'; import { UserModule } from './../user/user.module'; import { CommandHandlers } from './commands/handlers'; @@ -17,8 +17,8 @@ import { TypeOrmEmployeeRepository } from './repository/type-orm-employee.reposi @Module({ imports: [ - TypeOrmModule.forFeature([Employee, TimeLog]), - MikroOrmModule.forFeature([Employee, TimeLog]), + TypeOrmModule.forFeature([Employee, TimeLog, TimeSlot]), + MikroOrmModule.forFeature([Employee, TimeLog, TimeSlot]), forwardRef(() => EmailSendModule), forwardRef(() => UserOrganizationModule), forwardRef(() => RolePermissionModule), diff --git a/packages/core/src/employee/employee.service.ts b/packages/core/src/employee/employee.service.ts index 5fc7b8a1d3e..c716fa91f87 100644 --- a/packages/core/src/employee/employee.service.ts +++ b/packages/core/src/employee/employee.service.ts @@ -449,6 +449,9 @@ export class EmployeeService extends TenantAwareCrudService { isTrackingEnabled: true, deletedAt: true, allowScreenshotCapture: true, + allowManualTime: true, + allowModifyTime: true, + allowDeleteTime: true, isActive: true, isArchived: true, isAway: true, diff --git a/packages/core/src/expense/commands/handlers/expense.delete.handler.ts b/packages/core/src/expense/commands/handlers/expense.delete.handler.ts index 902baaea90b..e69dc6cce7d 100644 --- a/packages/core/src/expense/commands/handlers/expense.delete.handler.ts +++ b/packages/core/src/expense/commands/handlers/expense.delete.handler.ts @@ -2,7 +2,7 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { DeleteResult } from 'typeorm'; import { isNotEmpty } from '@gauzy/common'; -import { PermissionsEnum } from '@gauzy/contracts'; +import { ID, PermissionsEnum } from '@gauzy/contracts'; import { ExpenseService } from '../../expense.service'; import { EmployeeService } from '../../../employee/employee.service'; import { EmployeeStatisticsService } from '../../../employee-statistics'; @@ -10,53 +10,63 @@ import { ExpenseDeleteCommand } from '../expense.delete.command'; import { RequestContext } from './../../../core/context'; @CommandHandler(ExpenseDeleteCommand) -export class ExpenseDeleteHandler - implements ICommandHandler { +export class ExpenseDeleteHandler implements ICommandHandler { constructor( private readonly expenseService: ExpenseService, private readonly employeeService: EmployeeService, private readonly employeeStatisticsService: EmployeeStatisticsService ) {} + /** + * Executes the deletion of an expense and updates the employee's average expenses if applicable. + * + * @param command - The command containing the expense ID to delete and the optional employee ID. + * @returns A promise that resolves with the result of the delete operation. + * @throws BadRequestException if there is an error updating employee average expenses. + */ public async execute(command: ExpenseDeleteCommand): Promise { - const { expenseId } = command; + const { expenseId, employeeId } = command; + // Delete the expense by ID const result = await this.deleteExpense(expenseId); + try { - const { employeeId } = command; + // If employeeId exists, update the employee's average expenses if (isNotEmpty(employeeId)) { - let averageExpense = 0; - const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId( - employeeId - ); - averageExpense = this.expenseService.countStatistic( - stat.expenseStatistics - ); + const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId(employeeId); + const averageExpense = this.expenseService.countStatistic(stat.expenseStatistics); + await this.employeeService.create({ id: employeeId, averageExpenses: averageExpense }); } } catch (error) { - throw new BadRequestException(error); + console.error('Error while updating employee average expenses', error); + throw new BadRequestException('Error while updating employee average expenses'); } + return result; } - public async deleteExpense(expenseId: string): Promise { - try { - if (RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - )) { - return await this.expenseService.delete(expenseId); - } else { - return await this.expenseService.delete({ + /** + * Delete the expense based on user permissions + * + * @param expenseId - The ID of the expense to delete + * @returns Promise - The result of the delete operation + */ + public async deleteExpense(expenseId: ID): Promise { + const query = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) + ? { id: expenseId } + : { id: expenseId, employeeId: RequestContext.currentEmployeeId(), tenantId: RequestContext.currentTenantId() - }); - } + }; + + try { + return await this.expenseService.delete(query); } catch (error) { - throw new ForbiddenException(); + throw new ForbiddenException('You do not have permission to delete this expense.'); } } } diff --git a/packages/core/src/favorite/favorite.controller.ts b/packages/core/src/favorite/favorite.controller.ts index 576ef5cdfbf..8e4482cb577 100644 --- a/packages/core/src/favorite/favorite.controller.ts +++ b/packages/core/src/favorite/favorite.controller.ts @@ -39,6 +39,28 @@ export class FavoriteController extends CrudController { return await this.favoriteService.create(entity); } + /** + * @description Find favorites by employee + * @param {PaginationParams} params Filter criteria to find favorites + * @returns A promise that resolves to paginated list of favorites + * @memberof FavoriteController + */ + @ApiOperation({ summary: 'Find favorite entity records By current Employee' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Found favorite records', + type: Favorite + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Records not found' + }) + @Get('/employee') + @UseValidationPipe({ transform: true }) + async findFavoritesByEmployee(@Query() params: PaginationParams) { + return await this.favoriteService.findFavoritesByEmployee(params); + } + /** * @description Get favorites elements details * @param params - Favorite query params diff --git a/packages/core/src/favorite/favorite.entity.ts b/packages/core/src/favorite/favorite.entity.ts index 7d207403355..161086165c3 100644 --- a/packages/core/src/favorite/favorite.entity.ts +++ b/packages/core/src/favorite/favorite.entity.ts @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { EntityRepositoryType } from '@mikro-orm/core'; import { JoinColumn, RelationId } from 'typeorm'; import { IsEnum, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; -import { FavoriteEntityEnum, ID, IEmployee, IFavorite } from '@gauzy/contracts'; +import { BaseEntityEnum, ID, IEmployee, IFavorite } from '@gauzy/contracts'; import { Employee, TenantOrganizationBaseEntity } from '../core/entities/internal'; import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; import { MikroOrmFavoriteRepository } from './repository/mikro-orm-favorite.repository'; @@ -12,12 +12,12 @@ export class Favorite extends TenantOrganizationBaseEntity implements IFavorite [EntityRepositoryType]?: MikroOrmFavoriteRepository; // Indicate the entity type - @ApiProperty({ type: () => String, enum: FavoriteEntityEnum }) + @ApiProperty({ type: () => String, enum: BaseEntityEnum }) @IsNotEmpty() - @IsEnum(FavoriteEntityEnum) + @IsEnum(BaseEntityEnum) @ColumnIndex() @MultiORMColumn() - entity: FavoriteEntityEnum; + entity: BaseEntityEnum; // Indicate the ID of entity record marked as favorite @ApiProperty({ type: () => String }) diff --git a/packages/core/src/favorite/favorite.service.ts b/packages/core/src/favorite/favorite.service.ts index c9827849e75..f66d1eeb26c 100644 --- a/packages/core/src/favorite/favorite.service.ts +++ b/packages/core/src/favorite/favorite.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DeleteResult, FindOptionsWhere, In } from 'typeorm'; -import { FavoriteEntityEnum, ID, IFavorite, IFavoriteCreateInput } from '@gauzy/contracts'; +import { BaseEntityEnum, ID, IFavorite, IFavoriteCreateInput, IPagination } from '@gauzy/contracts'; import { PaginationParams, TenantAwareCrudService } from './../core/crud'; import { RequestContext } from '../core/context'; import { Favorite } from './favorite.entity'; @@ -20,6 +20,29 @@ export class FavoriteService extends TenantAwareCrudService { super(typeOrmFavoriteRepository, mikroOrmFavoriteRepository); } + /** + * @description Find favorites by employee + * @param {PaginationParams} options Filter criteria to find favorites + * @returns A promise that resolves to paginated list of favorites + * @memberof FavoriteService + */ + async findFavoritesByEmployee(options: PaginationParams): Promise> { + try { + const { where, relations = [], take, skip } = options; + + const employeeId = RequestContext.currentEmployeeId() || where.employeeId; + + return await super.findAll({ + where: { ...where, employeeId }, + ...(skip && { skip }), + ...(take && { take }), + ...(relations && { relations }) + }); + } catch (error) { + throw new BadRequestException(error); + } + } + /** * @description Mark entity element as favorite * @param {IFavoriteCreateInput} entity - Data to create favorite element @@ -86,10 +109,10 @@ export class FavoriteService extends TenantAwareCrudService { try { const { where } = options; const { entity } = where; - const favoriteType: FavoriteEntityEnum = entity as FavoriteEntityEnum; + const favoriteType: BaseEntityEnum = entity as BaseEntityEnum; // Find favorite elements with filtered params - const favorites = await this.findAll(options); + const favorites = await super.findAll(options); // Get related entity IDs const entityIds: ID[] = favorites.items.map((favorite) => favorite.entityId); diff --git a/packages/core/src/favorite/global-favorite-service.service.ts b/packages/core/src/favorite/global-favorite-service.service.ts index af9571f5e7f..3fbe49d1f95 100644 --- a/packages/core/src/favorite/global-favorite-service.service.ts +++ b/packages/core/src/favorite/global-favorite-service.service.ts @@ -1,12 +1,12 @@ import { BadRequestException, Injectable, InternalServerErrorException, OnModuleInit } from '@nestjs/common'; import { DiscoveryService, MetadataScanner, Reflector } from '@nestjs/core'; import { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper'; -import { FavoriteEntityEnum } from '@gauzy/contracts'; +import { BaseEntityEnum } from '@gauzy/contracts'; import { FAVORITABLE_TYPE } from '../core/decorators/is-favoritable'; @Injectable() export class GlobalFavoriteDiscoveryService implements OnModuleInit { - private readonly serviceMap = new Map(); + private readonly serviceMap = new Map(); constructor( private readonly discoveryService: DiscoveryService, @@ -39,16 +39,16 @@ export class GlobalFavoriteDiscoveryService implements OnModuleInit { } // Extract service favorite type - private extractTypeFromProvider(metatype: any): FavoriteEntityEnum | null { + private extractTypeFromProvider(metatype: any): BaseEntityEnum | null { return Reflect.getMetadata(FAVORITABLE_TYPE, metatype); } // Get "Favoritable" service - getService(type: FavoriteEntityEnum) { + getService(type: BaseEntityEnum) { return this.serviceMap.get(type); } - callMethod(type: FavoriteEntityEnum, methodName: string, ...args: any[]): any { + callMethod(type: BaseEntityEnum, methodName: string, ...args: any[]): any { const serviceWithMethods = this.serviceMap.get(type); if (!serviceWithMethods) { throw new BadRequestException(`Service for type ${type} not found`); diff --git a/packages/core/src/goal-kpi/goal-kpi.seed.ts b/packages/core/src/goal-kpi/goal-kpi.seed.ts index a3902e0a5c3..0203b46a097 100644 --- a/packages/core/src/goal-kpi/goal-kpi.seed.ts +++ b/packages/core/src/goal-kpi/goal-kpi.seed.ts @@ -27,12 +27,25 @@ export const createDefaultGoalKpi = async ( goalKpis.push(goalKpi); }); }); - return await insertRandomGoalKpi(dataSource, goalKpis); + // Insert in batches to prevent deadlocks + return await insertRandomGoalKpiInBatches(dataSource, goalKpis, 100); }; -const insertRandomGoalKpi = async ( +const insertRandomGoalKpiInBatches = async ( dataSource: DataSource, - goalKpis: GoalKPI[] + goalKpis: GoalKPI[], + batchSize: number ): Promise => { - return await dataSource.manager.save(goalKpis); + const insertedGoalKpis: GoalKPI[] = []; + + // Insert records in batches to avoid deadlocks + for (let i = 0; i < goalKpis.length; i += batchSize) { + const batch = goalKpis.slice(i, i + batchSize); + await dataSource.transaction(async (manager) => { + await manager.save(batch); + }); + insertedGoalKpis.push(...batch); + } + + return insertedGoalKpis; }; diff --git a/packages/core/src/income/commands/handlers/income.delete.handler.ts b/packages/core/src/income/commands/handlers/income.delete.handler.ts index becca870fe0..14d578b337e 100644 --- a/packages/core/src/income/commands/handlers/income.delete.handler.ts +++ b/packages/core/src/income/commands/handlers/income.delete.handler.ts @@ -10,58 +10,65 @@ import { IncomeDeleteCommand } from '../income.delete.command'; import { RequestContext } from './../../../core/context'; @CommandHandler(IncomeDeleteCommand) -export class IncomeDeleteHandler - implements ICommandHandler { +export class IncomeDeleteHandler implements ICommandHandler { constructor( private readonly incomeService: IncomeService, private readonly employeeService: EmployeeService, private readonly employeeStatisticsService: EmployeeStatisticsService ) {} + /** + * Deletes an income record and updates the employee's statistics if necessary. + * + * @param command - The command containing the income ID to delete and the optional employee ID. + * @returns A promise that resolves with the result of the delete operation. + * @throws BadRequestException if there is an error updating employee statistics. + */ public async execute(command: IncomeDeleteCommand): Promise { - const { incomeId } = command; + const { incomeId, employeeId } = command; + // Delete the income const result = await this.deleteIncome(incomeId); + try { - const { employeeId } = command; if (isNotEmpty(employeeId)) { - let averageIncome = 0; - let averageBonus = 0; - const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId( - employeeId - ); - averageIncome = this.incomeService.countStatistic( - stat.incomeStatistics - ); - averageBonus = this.incomeService.countStatistic( - stat.bonusStatistics - ); + // Fetch statistics and calculate averages + const stat = await this.employeeStatisticsService.getStatisticsByEmployeeId(employeeId); + const averageIncome = this.incomeService.countStatistic(stat.incomeStatistics); + const averageBonus = this.incomeService.countStatistic(stat.bonusStatistics); + + // Update employee with the calculated averages await this.employeeService.create({ id: employeeId, - averageIncome: averageIncome, - averageBonus: averageBonus + averageIncome, + averageBonus }); } } catch (error) { - throw new BadRequestException(error); + throw new BadRequestException('Error while updating employee statistics', error); } + return result; } + /** + * Deletes income by ID with permission check + * + * @param incomeId - The ID of the income to delete + * @returns Promise - The result of the delete operation + */ public async deleteIncome(incomeId: string): Promise { - try { - if (RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - )) { - return await this.incomeService.delete(incomeId); - } else { - return await this.incomeService.delete({ + const deleteQuery = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) + ? { id: incomeId } + : { id: incomeId, employeeId: RequestContext.currentEmployeeId(), tenantId: RequestContext.currentTenantId() - }); - } + }; + + try { + return await this.incomeService.delete(deleteQuery); } catch (error) { - throw new ForbiddenException(); + throw new ForbiddenException('You do not have permission to delete this income.'); } } } diff --git a/packages/core/src/organization-contact/commands/handlers/organization-contact-create.handler.ts b/packages/core/src/organization-contact/commands/handlers/organization-contact-create.handler.ts index 2edf43764a8..2d3e2634675 100644 --- a/packages/core/src/organization-contact/commands/handlers/organization-contact-create.handler.ts +++ b/packages/core/src/organization-contact/commands/handlers/organization-contact-create.handler.ts @@ -74,6 +74,7 @@ export class OrganizationContactCreateHandler implements ICommandHandler { - constructor( private readonly _organizationContactService: OrganizationContactService, - private readonly _contactService: ContactService, - ) { } + private readonly _contactService: ContactService + ) {} /** * Updates an organization contact based on a given command and retrieves the updated contact. @@ -34,7 +34,10 @@ export class OrganizationContactUpdateHandler implements ICommandHandler { + constructor(private readonly organizationProjectModuleService: OrganizationProjectModuleService) {} + + /** + * @description Executes the OrganizationProjectModuleCreateCommand + * @param {OrganizationProjectModuleCreateCommand} command The command containing the Module create data. + * @returns The created module. + * @memberof OrganizationProjectModuleCreateHandler + */ + public async execute(command: OrganizationProjectModuleCreateCommand): Promise { + const { input } = command; + return await this.organizationProjectModuleService.create(input); + } +} diff --git a/packages/core/src/organization-project-module/commands/handlers/organization-project-module-update.handler.ts b/packages/core/src/organization-project-module/commands/handlers/organization-project-module-update.handler.ts new file mode 100644 index 00000000000..cec63ecaa02 --- /dev/null +++ b/packages/core/src/organization-project-module/commands/handlers/organization-project-module-update.handler.ts @@ -0,0 +1,23 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { UpdateResult } from 'typeorm'; +import { IOrganizationProjectModule } from '@gauzy/contracts'; +import { OrganizationProjectModuleUpdateCommand } from '../organization-project-module-update.command'; +import { OrganizationProjectModuleService } from 'organization-project-module/organization-project-module.service'; + +@CommandHandler(OrganizationProjectModuleUpdateCommand) +export class OrganizationProjectModuleUpdateHandler implements ICommandHandler { + constructor(private readonly organizationProjectModuleService: OrganizationProjectModuleService) {} + + /** + * @description Executes the OrganizationProjectModuleUpdateCommand + * @param {OrganizationProjectModuleUpdateCommand} command The command containing the Module ID and update data. + * @returns The updated module. + * @memberof OrganizationProjectModuleUpdateHandler + */ + public async execute( + command: OrganizationProjectModuleUpdateCommand + ): Promise { + const { id, input } = command; + return await this.organizationProjectModuleService.update(id, input); + } +} diff --git a/packages/core/src/organization-project-module/commands/index.ts b/packages/core/src/organization-project-module/commands/index.ts new file mode 100644 index 00000000000..f2a860890fd --- /dev/null +++ b/packages/core/src/organization-project-module/commands/index.ts @@ -0,0 +1,2 @@ +export * from './organization-project-module-create.command'; +export * from './organization-project-module-update.command'; diff --git a/packages/core/src/organization-project-module/commands/organization-project-module-create.command.ts b/packages/core/src/organization-project-module/commands/organization-project-module-create.command.ts new file mode 100644 index 00000000000..7683ec90777 --- /dev/null +++ b/packages/core/src/organization-project-module/commands/organization-project-module-create.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { IOrganizationProjectModuleCreateInput } from '@gauzy/contracts'; + +export class OrganizationProjectModuleCreateCommand implements ICommand { + static readonly type = '[OrganizationProjectModule] Create Module'; + + constructor(public readonly input: IOrganizationProjectModuleCreateInput) {} +} diff --git a/packages/core/src/organization-project-module/commands/organization-project-module-update.command.ts b/packages/core/src/organization-project-module/commands/organization-project-module-update.command.ts new file mode 100644 index 00000000000..09ab1738fa5 --- /dev/null +++ b/packages/core/src/organization-project-module/commands/organization-project-module-update.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { ID, IOrganizationProjectModuleUpdateInput } from '@gauzy/contracts'; + +export class OrganizationProjectModuleUpdateCommand implements ICommand { + static readonly type = '[OrganizationProjectModule] Update Module'; + + constructor(public readonly id: ID, public readonly input: IOrganizationProjectModuleUpdateInput) {} +} diff --git a/packages/core/src/organization-project-module/organization-project-module.controller.ts b/packages/core/src/organization-project-module/organization-project-module.controller.ts index 570cb8ef904..816e430d38f 100644 --- a/packages/core/src/organization-project-module/organization-project-module.controller.ts +++ b/packages/core/src/organization-project-module/organization-project-module.controller.ts @@ -1,3 +1,4 @@ +import { CommandBus } from '@nestjs/cqrs'; import { Body, Controller, @@ -25,14 +26,18 @@ import { OrganizationProjectModuleFindInputDTO, UpdateOrganizationProjectModuleDTO } from './dto'; +import { OrganizationProjectModuleCreateCommand, OrganizationProjectModuleUpdateCommand } from './commands'; @ApiTags('Project Modules') @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ALL_ORG_EDIT) @Controller() export class OrganizationProjectModuleController extends CrudController { - constructor(private readonly projectModuleService: OrganizationProjectModuleService) { - super(projectModuleService); + constructor( + private readonly organizationProjectModuleService: OrganizationProjectModuleService, + private readonly commandBus: CommandBus + ) { + super(organizationProjectModuleService); } /** @@ -57,7 +62,7 @@ export class OrganizationProjectModuleController extends CrudController ): Promise> { - return await this.projectModuleService.getEmployeeProjectModules(params); + return await this.organizationProjectModuleService.getEmployeeProjectModules(params); } /** @@ -82,7 +87,7 @@ export class OrganizationProjectModuleController extends CrudController ): Promise> { - return await this.projectModuleService.findTeamProjectModules(params); + return await this.organizationProjectModuleService.findTeamProjectModules(params); } /** @@ -111,7 +116,7 @@ export class OrganizationProjectModuleController extends CrudController> { - return await this.projectModuleService.findByEmployee(employeeId, params); + return await this.organizationProjectModuleService.findByEmployee(employeeId, params); } @ApiOperation({ summary: 'Find all project modules.' }) @@ -129,7 +134,7 @@ export class OrganizationProjectModuleController extends CrudController ): Promise> { - return await this.projectModuleService.findAll(params); + return await this.organizationProjectModuleService.findAll(params); } @UseValidationPipe() @@ -148,7 +153,7 @@ export class OrganizationProjectModuleController extends CrudController ): Promise { - return this.projectModuleService.findOneByIdString(id, params); + return this.organizationProjectModuleService.findOneByIdString(id, params); } @ApiOperation({ summary: 'create a project module' }) @@ -165,7 +170,7 @@ export class OrganizationProjectModuleController extends CrudController { - return await this.projectModuleService.create(entity); + return await this.commandBus.execute(new OrganizationProjectModuleCreateCommand(entity)); } @ApiOperation({ summary: 'Update an existing project module' }) @@ -189,12 +194,12 @@ export class OrganizationProjectModuleController extends CrudController { - return await this.projectModuleService.update(id, entity); + return await this.commandBus.execute(new OrganizationProjectModuleUpdateCommand(id, entity)); } @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.PROJECT_MODULE_DELETE) @Delete(':id') async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { - return await this.projectModuleService.delete(id); + return await this.organizationProjectModuleService.delete(id); } } diff --git a/packages/core/src/organization-project-module/organization-project-module.entity.ts b/packages/core/src/organization-project-module/organization-project-module.entity.ts index 3a282cbcc97..429b71c1fb3 100644 --- a/packages/core/src/organization-project-module/organization-project-module.entity.ts +++ b/packages/core/src/organization-project-module/organization-project-module.entity.ts @@ -20,6 +20,7 @@ import { IOrganizationSprint, IOrganizationTeam, ITask, + ITaskView, IUser, ProjectModuleStatusEnum } from '@gauzy/contracts'; @@ -29,6 +30,7 @@ import { OrganizationSprint, OrganizationTeam, Task, + TaskView, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; @@ -184,6 +186,12 @@ export class OrganizationProjectModule extends TenantOrganizationBaseEntity impl @MultiORMOneToMany(() => OrganizationProjectModule, (module) => module.parent) children?: OrganizationProjectModule[]; + /** + * Project Module views + */ + @MultiORMOneToMany(() => TaskView, (module) => module.projectModule) + views?: ITaskView[]; + /* |-------------------------------------------------------------------------- | @ManyToMany diff --git a/packages/core/src/organization-project-module/organization-project-module.module.ts b/packages/core/src/organization-project-module/organization-project-module.module.ts index 49370c97c5b..588d9eeb46a 100644 --- a/packages/core/src/organization-project-module/organization-project-module.module.ts +++ b/packages/core/src/organization-project-module/organization-project-module.module.ts @@ -1,7 +1,9 @@ +import { CqrsModule } from '@nestjs/cqrs'; import { Module } from '@nestjs/common'; import { RouterModule } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { CommandHandlers } from './commands/handlers'; import { OrganizationProjectModuleService } from './organization-project-module.service'; import { OrganizationProjectModuleController } from './organization-project-module.controller'; import { OrganizationProjectModule } from './organization-project-module.entity'; @@ -19,10 +21,11 @@ import { RolePermissionModule } from '../role-permission/role-permission.module' TypeOrmModule.forFeature([OrganizationProjectModule]), MikroOrmModule.forFeature([OrganizationProjectModule]), MikroOrmModule, - RolePermissionModule + RolePermissionModule, + CqrsModule ], controllers: [OrganizationProjectModuleController], - providers: [OrganizationProjectModuleService, TypeOrmOrganizationProjectModuleRepository], + providers: [OrganizationProjectModuleService, TypeOrmOrganizationProjectModuleRepository, ...CommandHandlers], exports: [TypeOrmModule, OrganizationProjectModuleService, TypeOrmOrganizationProjectModuleRepository] }) export class OrganizationProjectModuleModule {} diff --git a/packages/core/src/organization-project-module/organization-project-module.service.ts b/packages/core/src/organization-project-module/organization-project-module.service.ts index 9f0fe19e327..6b7698de7c2 100644 --- a/packages/core/src/organization-project-module/organization-project-module.service.ts +++ b/packages/core/src/organization-project-module/organization-project-module.service.ts @@ -1,13 +1,17 @@ import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { Brackets, FindManyOptions, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; +import { Brackets, FindManyOptions, SelectQueryBuilder, UpdateResult, WhereExpressionBuilder } from 'typeorm'; import { + BaseEntityEnum, + ActorTypeEnum, ID, IOrganizationProjectModule, IOrganizationProjectModuleCreateInput, IOrganizationProjectModuleFindInput, + IOrganizationProjectModuleUpdateInput, IPagination, PermissionsEnum, - ProjectModuleStatusEnum + ProjectModuleStatusEnum, + ActionTypeEnum } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { isPostgres } from '@gauzy/config'; @@ -15,6 +19,7 @@ import { PaginationParams, TenantAwareCrudService } from './../core/crud'; import { RequestContext } from '../core/context'; import { OrganizationProjectModule } from './organization-project-module.entity'; import { prepareSQLQuery as p } from './../database/database.helper'; +import { ActivityLogService } from '../activity-log/activity-log.service'; import { TypeOrmOrganizationProjectModuleRepository } from './repository/type-orm-organization-project-module.repository'; import { MikroOrmOrganizationProjectModuleRepository } from './repository/mikro-orm-organization-project-module.repository'; @@ -22,18 +27,96 @@ import { MikroOrmOrganizationProjectModuleRepository } from './repository/mikro- export class OrganizationProjectModuleService extends TenantAwareCrudService { constructor( readonly typeOrmProjectModuleRepository: TypeOrmOrganizationProjectModuleRepository, - readonly mikroOrmProjectModuleRepository: MikroOrmOrganizationProjectModuleRepository + readonly mikroOrmProjectModuleRepository: MikroOrmOrganizationProjectModuleRepository, + private readonly activityLogService: ActivityLogService ) { super(typeOrmProjectModuleRepository, mikroOrmProjectModuleRepository); } - + /** + * @description Create project Module + * @param {IOrganizationProjectModuleCreateInput} entity Body Request data + * @returns A promise resolved to created project module + * @memberof OrganizationProjectModuleService + */ async create(entity: IOrganizationProjectModuleCreateInput): Promise { + const tenantId = RequestContext.currentTenantId() || entity.tenantId; + const creatorId = RequestContext.currentUserId(); + const { organizationId } = entity; + try { - const creatorId = RequestContext.currentUserId(); - return super.create({ + const projectModule = await super.create({ ...entity, creatorId }); + + // Generate the activity log + this.activityLogService.logActivity( + BaseEntityEnum.OrganizationProjectModule, + ActionTypeEnum.Created, + ActorTypeEnum.User, + projectModule.id, + projectModule.name, + projectModule, + organizationId, + tenantId + ); + + return projectModule; + } catch (error) { + throw new BadRequestException(error); + } + } + + /** + * @description Update Project Module + * @param {ID} id - The project module ID to be updated + * @param {IOrganizationProjectModuleUpdateInput} entity Body Request data + * @returns A promise resolved to updated project module Or Update Result + * @memberof OrganizationProjectModuleService + */ + async update( + id: ID, + entity: IOrganizationProjectModuleUpdateInput + ): Promise { + const tenantId = RequestContext.currentTenantId() || entity.tenantId; + + try { + // Retrieve existing module. + const existingProjectModule = await this.findOneByIdString(id, { + relations: { + members: true, + manager: true + } + }); + + if (!existingProjectModule) { + throw new BadRequestException('Module not found'); + } + + // Update module with new values + const updatedProjectModule = await super.create({ + ...entity, + id + }); + + // Generate the activity log + const { organizationId } = updatedProjectModule; + + this.activityLogService.logActivity( + BaseEntityEnum.OrganizationProjectModule, + ActionTypeEnum.Updated, + ActorTypeEnum.User, + updatedProjectModule.id, + updatedProjectModule.name, + updatedProjectModule, + organizationId, + tenantId, + existingProjectModule, + entity + ); + + // return updated Module + return updatedProjectModule; } catch (error) { throw new BadRequestException(error); } diff --git a/packages/core/src/organization-project/commands/handlers/organization-project-create.handler.ts b/packages/core/src/organization-project/commands/handlers/organization-project-create.handler.ts index e13b2a813a9..f86cc285f6a 100644 --- a/packages/core/src/organization-project/commands/handlers/organization-project-create.handler.ts +++ b/packages/core/src/organization-project/commands/handlers/organization-project-create.handler.ts @@ -1,4 +1,3 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { IOrganizationProject } from '@gauzy/contracts'; import { OrganizationProjectCreateCommand } from '../organization-project-create.command'; @@ -25,23 +24,17 @@ export class OrganizationProjectCreateHandler implements ICommandHandler { - try { - // Destructure the input data from the command - const { input } = command; + // Destructure the input data from the command + const { input } = command; - // Create the organization project using the input data - const project = await this._organizationProjectService.create(input); + // Create the organization project using the input data + const project = await this._organizationProjectService.create(input); - // Initialize associated entities for the created project - this.createAssociatedEntitiesForProject(project); + // Initialize associated entities for the created project + this.createAssociatedEntitiesForProject(project); - // Return the created organization project - return project; - } catch (error) { - // Handle errors and return an appropriate error response - console.error('Error during organization project creation:', error); - throw new HttpException(`Failed to create organization project: ${error.message}`, HttpStatus.BAD_REQUEST); - } + // Return the created organization project + return project; } /** diff --git a/packages/core/src/organization-project/commands/handlers/organization-project-edit-by-employee.handler.ts b/packages/core/src/organization-project/commands/handlers/organization-project-edit-by-employee.handler.ts index 410b50dd9a3..159da6782ab 100644 --- a/packages/core/src/organization-project/commands/handlers/organization-project-edit-by-employee.handler.ts +++ b/packages/core/src/organization-project/commands/handlers/organization-project-edit-by-employee.handler.ts @@ -17,6 +17,8 @@ export class OrganizationProjectEditByEmployeeHandler public async execute(command: OrganizationProjectEditByEmployeeCommand): Promise { // Extracts the input from the command and executes the command logic const { input } = command; + + // Update the organization project by an employee return await this.organizationProjectService.updateByEmployee(input); } } diff --git a/packages/core/src/organization-project/dto/organization-project.dto.ts b/packages/core/src/organization-project/dto/organization-project.dto.ts index 463883c80b4..d7a55b90377 100644 --- a/packages/core/src/organization-project/dto/organization-project.dto.ts +++ b/packages/core/src/organization-project/dto/organization-project.dto.ts @@ -1,6 +1,5 @@ -import { ApiPropertyOptional, IntersectionType, PartialType, PickType } from '@nestjs/swagger'; -import { IsArray, IsOptional } from 'class-validator'; -import { ID } from '@gauzy/contracts'; +import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; +import { MemberEntityBasedDTO } from '../../core/dto'; import { OrganizationProject } from './../organization-project.entity'; import { UpdateTaskModeDTO } from './update-task-mode.dto'; @@ -9,15 +8,5 @@ import { UpdateTaskModeDTO } from './update-task-mode.dto'; */ export class OrganizationProjectDTO extends IntersectionType( PickType(OrganizationProject, ['imageId', 'name', 'billing', 'budgetType'] as const), - PartialType(UpdateTaskModeDTO) -) { - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsArray() - memberIds?: ID[] = []; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsArray() - managerIds?: ID[] = []; -} + IntersectionType(PartialType(UpdateTaskModeDTO), MemberEntityBasedDTO) +) {} diff --git a/packages/core/src/organization-project/organization-project.entity.ts b/packages/core/src/organization-project/organization-project.entity.ts index 5b7af7a3a18..4fb74471801 100644 --- a/packages/core/src/organization-project/organization-project.entity.ts +++ b/packages/core/src/organization-project/organization-project.entity.ts @@ -23,6 +23,7 @@ import { ITaskSize, ITaskStatus, ITaskVersion, + ITaskView, ITimeLog, OrganizationProjectBudgetTypeEnum, ProjectBillingEnum, @@ -50,6 +51,7 @@ import { TaskSize, TaskStatus, TaskVersion, + TaskView, TenantOrganizationBaseEntity, TimeLog } from '../core/entities/internal'; @@ -422,6 +424,12 @@ export class OrganizationProject @MultiORMOneToMany(() => TaskVersion, (it) => it.project) versions?: ITaskVersion[]; + /** + * Project views Relationship + */ + @MultiORMOneToMany(() => TaskView, (it) => it.project) + views?: ITaskView[]; + /** * Organization modules Relationship */ diff --git a/packages/core/src/organization-project/organization-project.seed.ts b/packages/core/src/organization-project/organization-project.seed.ts index d3b2adda785..5ad4d2ae13c 100644 --- a/packages/core/src/organization-project/organization-project.seed.ts +++ b/packages/core/src/organization-project/organization-project.seed.ts @@ -52,7 +52,6 @@ export const createDefaultOrganizationProjects = async ( organizationId }); - // Define a mapping between Budget Types and their respective min and max values const budgetRanges: Record = { [OrganizationProjectBudgetTypeEnum.COST]: { min: 500, max: 5000 }, @@ -182,7 +181,6 @@ export const createRandomOrganizationProjects = async ( project.startDate = faker.date.past({ years: 5 }); project.endDate = faker.date.between({ from: project.startDate, to: new Date() }); - // If organizationContacts is not empty, assign a random organization contact if (organizationContacts.length > 0) { project.organizationContact = faker.helpers.arrayElement(organizationContacts); @@ -268,9 +266,26 @@ export async function seedProjectMembersCount(dataSource: DataSource, tenants: I for (const tenant of tenants) { const tenantId = tenant.id; - // Consolidated SQL to update membersCount for all projects of the current tenant - const query = replacePlaceholders( - p(` + let query: string; + + // Check if the database type is MySQL + if (dataSource.options.type === DatabaseTypeEnum.mysql) { + // Rewrite the query for MySQL without using the FROM clause + query = ` + UPDATE \`organization_project\` op + JOIN ( + SELECT \`organizationProjectId\`, COUNT(\`employeeId\`) AS count + FROM \`organization_project_employee\` + GROUP BY \`organizationProjectId\` + ) AS sub + ON op.id = sub.\`organizationProjectId\` + SET op.\`membersCount\` = sub.count + WHERE op.\`tenantId\` = ?; + `; + } else { + // Consolidated SQL to update membersCount for all projects of the current tenant + query = replacePlaceholders( + p(` UPDATE "organization_project" AS op SET "membersCount" = sub.count FROM ( @@ -281,9 +296,9 @@ export async function seedProjectMembersCount(dataSource: DataSource, tenants: I WHERE op.id = sub."organizationProjectId" AND op."tenantId" = $1; `), - dataSource.options.type as DatabaseTypeEnum - ); - + dataSource.options.type as DatabaseTypeEnum + ); + } // Execute the consolidated update query with the appropriate parameter await dataSource.manager.query(query, [tenantId]); console.log(`Updated membersCount for tenant ID: ${tenantId}`); diff --git a/packages/core/src/organization-project/organization-project.service.ts b/packages/core/src/organization-project/organization-project.service.ts index 06ca85814cd..e9499e49735 100644 --- a/packages/core/src/organization-project/organization-project.service.ts +++ b/packages/core/src/organization-project/organization-project.service.ts @@ -1,11 +1,9 @@ -import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { EventBus } from '@nestjs/cqrs'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { ILike, In, IsNull, SelectQueryBuilder } from 'typeorm'; import { ActionTypeEnum, - ActivityLogEntityEnum, + BaseEntityEnum, ActorTypeEnum, - FavoriteEntityEnum, ID, IEmployee, IOrganizationGithubRepository, @@ -23,9 +21,8 @@ import { PaginationParams, TenantAwareCrudService } from '../core/crud'; import { RequestContext } from '../core/context'; import { OrganizationProjectEmployee } from '../core/entities/internal'; import { FavoriteService } from '../core/decorators'; -import { ActivityLogEvent } from '../activity-log/events'; -import { generateActivityLogDescription } from '../activity-log/activity-log.helper'; import { RoleService } from '../role/role.service'; +import { ActivityLogService } from '../activity-log/activity-log.service'; import { OrganizationProject } from './organization-project.entity'; import { prepareSQLQuery as p } from './../database/database.helper'; import { EmployeeService } from '../employee/employee.service'; @@ -37,7 +34,7 @@ import { TypeOrmOrganizationProjectRepository } from './repository'; -@FavoriteService(FavoriteEntityEnum.OrganizationProject) +@FavoriteService(BaseEntityEnum.OrganizationProject) @Injectable() export class OrganizationProjectService extends TenantAwareCrudService { constructor( @@ -48,7 +45,7 @@ export class OrganizationProjectService extends TenantAwareCrudService [em.employee.id, em.assignedAt])); - // Use destructuring to directly extract 'id' from 'employee' - const projectMembers = employees.map(({ id: employeeId }) => { - // Check if the employee is a manager - const isManager = managerIdsSet.has(employeeId); + const members = employees.map(({ id: employeeId }) => { // If the employee is a manager, assign the existing manager with the latest assignedAt date - const assignedAt = - isManager && !existingManagersMap.has(employeeId) - ? new Date() - : existingManagersMap.get(employeeId); + const isManager = managerIdsSet.has(employeeId); + const assignedAt = new Date(); - // If the employee is a manager, assign the existing manager with the latest assignedAt date return new OrganizationProjectEmployee({ employeeId, organizationId, tenantId, isManager, - role: isManager ? managerRole : null, - assignedAt: assignedAt || null + assignedAt, + role: isManager ? managerRole : null }); }); - // Create the organization team with the prepared members + // Create the organization project with the prepared members const project = await super.create({ ...entity, - members: projectMembers, + members, tags, organizationId, tenantId }); - // Generate the activity log description - const description = generateActivityLogDescription( + // Generate the activity log + this._activityLogService.logActivity( + BaseEntityEnum.OrganizationProject, ActionTypeEnum.Created, - ActivityLogEntityEnum.OrganizationProject, - project.name - ); - - // Emit an event to log the activity - this._eventBus.publish( - new ActivityLogEvent({ - entity: ActivityLogEntityEnum.OrganizationProject, - entityId: project.id, - action: ActionTypeEnum.Created, - actorType: ActorTypeEnum.User, - description, - data: project, - organizationId, - tenantId - }) + ActorTypeEnum.User, + project.id, + project.name, + project, + organizationId, + tenantId ); + // Return the created project return project; } catch (error) { // Handle errors and return an appropriate error response @@ -187,30 +161,44 @@ export class OrganizationProjectService extends TenantAwareCrudService( + BaseEntityEnum.OrganizationProject, + ActionTypeEnum.Updated, + ActorTypeEnum.User, + updatedProject.id, + updatedProject.name, + updatedProject, + organizationId, + tenantId, + organizationProject, + input + ); + + return updatedProject; } catch (error) { // Handle errors and return an appropriate error response throw new HttpException(`Failed to update organization project: ${error.message}`, HttpStatus.BAD_REQUEST); @@ -283,8 +271,11 @@ export class OrganizationProjectService extends TenantAwareCrudService { try { + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; const { organizationId, addedProjectIds = [], removedProjectIds = [], member } = input; // Handle adding projects if (addedProjectIds.length > 0) { const projectsToAdd = await this.find({ - where: { - id: In(addedProjectIds), - organizationId - }, - relations: { - members: true - } + where: { id: In(addedProjectIds), organizationId, tenantId }, + relations: { members: true } }); - const updatedProjectsToAdd = projectsToAdd.map((project) => { - const existingMembers = project.members || []; - - // Verify if member already exists on project - const isMemberAlreadyInProject = existingMembers.some( - (existingMember) => existingMember.employeeId === member.employeeId - ); + const updatedProjectsToAdd = projectsToAdd + .filter((project: IOrganizationProject) => { + // Filter only projects where the member is not already assigned + return !project.members?.some(({ employeeId }) => employeeId === member.id); + }) + .map((project: IOrganizationProject) => { + // Create new member object for the projects where the member is not yet assigned + const newMember = new OrganizationProjectEmployee({ + employeeId: member.id, + organizationProjectId: project.id, + organizationId, + tenantId + }); - if (!isMemberAlreadyInProject) { + // Return the project with the new member added to the members array return { ...project, - members: [...existingMembers, { ...member, organizationProjectId: project.id }] + members: [...project.members, newMember] // Add new member while keeping existing members intact }; - } - - return project; // If member already assigned to project, no change needed - }); + }); - // save updated projects - await Promise.all(updatedProjectsToAdd.map(async (project) => await this.save(project))); + // Save updated projects + await Promise.all(updatedProjectsToAdd.map((project) => this.save(project))); } // Handle removing projects if (removedProjectIds.length > 0) { await this.typeOrmOrganizationProjectEmployeeRepository.delete({ organizationProjectId: In(removedProjectIds), - employeeId: member.employeeId + employeeId: member.id }); } return true; } catch (error) { - console.error('Error while updating project by member:', error); - throw new BadRequestException(error); + console.log('Error while updating project by employee:', error); + throw new HttpException({ message: 'Error while updating project by employee' }, HttpStatus.BAD_REQUEST); } } } diff --git a/packages/core/src/organization-sprint/commands/handlers/index.ts b/packages/core/src/organization-sprint/commands/handlers/index.ts index d1060d8f054..c51a2230c54 100644 --- a/packages/core/src/organization-sprint/commands/handlers/index.ts +++ b/packages/core/src/organization-sprint/commands/handlers/index.ts @@ -1,3 +1,4 @@ +import { OrganizationSprintCreateHandler } from './organization-sprint.create.handler'; import { OrganizationSprintUpdateHandler } from './organization-sprint.update.handler'; -export const CommandHandlers = [OrganizationSprintUpdateHandler]; +export const CommandHandlers = [OrganizationSprintCreateHandler, OrganizationSprintUpdateHandler]; diff --git a/packages/core/src/organization-sprint/commands/handlers/organization-sprint.create.handler.ts b/packages/core/src/organization-sprint/commands/handlers/organization-sprint.create.handler.ts new file mode 100644 index 00000000000..990dc163ccf --- /dev/null +++ b/packages/core/src/organization-sprint/commands/handlers/organization-sprint.create.handler.ts @@ -0,0 +1,24 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { OrganizationSprintCreateCommand } from '../organization-sprint.create.command'; +import { OrganizationSprintService } from '../../organization-sprint.service'; +import { IOrganizationSprint } from '@gauzy/contracts'; + +@CommandHandler(OrganizationSprintCreateCommand) +export class OrganizationSprintCreateHandler implements ICommandHandler { + constructor(private readonly _organizationSprintService: OrganizationSprintService) {} + + /** + * Executes the creation of an organization sprint + * @param {OrganizationSprintCreateCommand} command The command containing the input data for creating the organization sprint. + * @returns {Promise} - Returns a promise that resolves with the created organization sprint. + * @throws {BadRequestException} - Throws a BadRequestException if an error occurs during the creation process. + * @memberof OrganizationSprintCreateHandler + */ + public async execute(command: OrganizationSprintCreateCommand): Promise { + // Destructure the input data from command + const { input } = command; + + // Create and return organization sprint + return await this._organizationSprintService.create(input); + } +} diff --git a/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts b/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts index 8cdadc604e9..e343269756a 100644 --- a/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts +++ b/packages/core/src/organization-sprint/commands/handlers/organization-sprint.update.handler.ts @@ -1,28 +1,19 @@ -import { NotFoundException } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { IOrganizationSprint } from '@gauzy/contracts'; import { OrganizationSprintService } from '../../organization-sprint.service'; import { OrganizationSprintUpdateCommand } from '../organization-sprint.update.command'; @CommandHandler(OrganizationSprintUpdateCommand) -export class OrganizationSprintUpdateHandler - implements ICommandHandler { - constructor( - private readonly organizationSprintService: OrganizationSprintService - ) {} +export class OrganizationSprintUpdateHandler implements ICommandHandler { + constructor(private readonly organizationSprintService: OrganizationSprintService) {} - public async execute( - command: OrganizationSprintUpdateCommand - ): Promise { + public async execute(command: OrganizationSprintUpdateCommand): Promise { const { id, input } = command; - const record = await this.organizationSprintService.findOneByIdString(id); - if (!record) { - throw new NotFoundException(`The requested record was not found`); - } - //This will call save() with the id so that task[] also get saved accordingly - return this.organizationSprintService.create({ - id, - ...input - }); + + // Update the organization sprint using the provided input + await this.organizationSprintService.update(id, input); + + // Find the updated organization project by ID + return await this.organizationSprintService.findOneByIdString(id); } } diff --git a/packages/core/src/organization-sprint/commands/index.ts b/packages/core/src/organization-sprint/commands/index.ts index accc17486ac..e6b9832495d 100644 --- a/packages/core/src/organization-sprint/commands/index.ts +++ b/packages/core/src/organization-sprint/commands/index.ts @@ -1 +1,2 @@ -export * from './organization-sprint.update.command'; \ No newline at end of file +export * from './organization-sprint.create.command'; +export * from './organization-sprint.update.command'; diff --git a/packages/core/src/organization-sprint/commands/organization-sprint.create.command.ts b/packages/core/src/organization-sprint/commands/organization-sprint.create.command.ts new file mode 100644 index 00000000000..8908be03b12 --- /dev/null +++ b/packages/core/src/organization-sprint/commands/organization-sprint.create.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { IOrganizationSprintCreateInput } from '@gauzy/contracts'; + +export class OrganizationSprintCreateCommand implements ICommand { + static readonly type = '[OrganizationSprint] Create'; + + constructor(readonly input: IOrganizationSprintCreateInput) {} +} diff --git a/packages/core/src/organization-sprint/dto/create-organization-sprint.dto.ts b/packages/core/src/organization-sprint/dto/create-organization-sprint.dto.ts new file mode 100644 index 00000000000..3946a07c664 --- /dev/null +++ b/packages/core/src/organization-sprint/dto/create-organization-sprint.dto.ts @@ -0,0 +1,11 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { IOrganizationSprintCreateInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from './../../core/dto'; +import { OrganizationSprintDTO } from './organization-sprint.dto'; + +/** + * Create Organization Sprint DTO request validation + */ +export class CreateOrganizationSprintDTO + extends IntersectionType(OrganizationSprintDTO, TenantOrganizationBaseDTO) + implements IOrganizationSprintCreateInput {} diff --git a/packages/core/src/organization-sprint/dto/index.ts b/packages/core/src/organization-sprint/dto/index.ts new file mode 100644 index 00000000000..7253d6e420f --- /dev/null +++ b/packages/core/src/organization-sprint/dto/index.ts @@ -0,0 +1,3 @@ +export * from './organization-sprint.dto'; +export * from './create-organization-sprint.dto'; +export * from './update-organization-sprint.dto'; diff --git a/packages/core/src/organization-sprint/dto/organization-sprint.dto.ts b/packages/core/src/organization-sprint/dto/organization-sprint.dto.ts new file mode 100644 index 00000000000..347847b8d91 --- /dev/null +++ b/packages/core/src/organization-sprint/dto/organization-sprint.dto.ts @@ -0,0 +1,5 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { MemberEntityBasedDTO } from '../../core/dto'; +import { OrganizationSprint } from './../organization-sprint.entity'; + +export class OrganizationSprintDTO extends IntersectionType(OrganizationSprint, MemberEntityBasedDTO) {} diff --git a/packages/core/src/organization-sprint/dto/update-organization-sprint.dto.ts b/packages/core/src/organization-sprint/dto/update-organization-sprint.dto.ts new file mode 100644 index 00000000000..7b48df646d7 --- /dev/null +++ b/packages/core/src/organization-sprint/dto/update-organization-sprint.dto.ts @@ -0,0 +1,11 @@ +import { IntersectionType, PartialType } from '@nestjs/swagger'; +import { IOrganizationSprintUpdateInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../core/dto'; +import { OrganizationSprintDTO } from './organization-sprint.dto'; + +/** + * Update Organization Project DTO request validation + */ +export class UpdateOrganizationSprintDTO + extends IntersectionType(TenantOrganizationBaseDTO, PartialType(OrganizationSprintDTO)) + implements IOrganizationSprintUpdateInput {} diff --git a/packages/core/src/organization-sprint/organization-sprint-employee.entity.ts b/packages/core/src/organization-sprint/organization-sprint-employee.entity.ts new file mode 100644 index 00000000000..440dbdc9bb5 --- /dev/null +++ b/packages/core/src/organization-sprint/organization-sprint-employee.entity.ts @@ -0,0 +1,88 @@ +import { RelationId } from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsDateString, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; +import { ID, IEmployee, IOrganizationSprintEmployee, IRole } from '@gauzy/contracts'; +import { Employee, OrganizationSprint, Role, TenantOrganizationBaseEntity } from '../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { MikroOrmOrganizationSprintEmployeeRepository } from './repository/mikro-orm-organization-sprint-employee.repository'; + +@MultiORMEntity('organization_sprint_employee', { + mikroOrmRepository: () => MikroOrmOrganizationSprintEmployeeRepository +}) +export class OrganizationSprintEmployee extends TenantOrganizationBaseEntity implements IOrganizationSprintEmployee { + // Manager of the organization project + @ApiPropertyOptional({ type: () => Boolean, default: false }) + @IsOptional() + @IsBoolean() + @ColumnIndex() + @MultiORMColumn({ type: Boolean, nullable: true, default: false }) + isManager?: boolean; + + // Assigned At Manager of the organization project + @ApiPropertyOptional({ type: () => Date }) + @IsOptional() + @IsDateString() + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + assignedAt?: Date; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.members, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintEmployee) => it.organizationSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + organizationSprintId: ID; + + /** + * Employee + */ + @MultiORMManyToOne(() => Employee, (it) => it.sprints, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + employee!: IEmployee; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintEmployee) => it.employee) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + employeeId?: ID; + + /** + * Role + */ + @MultiORMManyToOne(() => Role, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + role!: IRole; + + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: OrganizationSprintEmployee) => it.role) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + roleId?: ID; +} diff --git a/packages/core/src/organization-sprint/organization-sprint-task-history.entity.ts b/packages/core/src/organization-sprint/organization-sprint-task-history.entity.ts new file mode 100644 index 00000000000..8bac37f4c47 --- /dev/null +++ b/packages/core/src/organization-sprint/organization-sprint-task-history.entity.ts @@ -0,0 +1,97 @@ +import { JoinColumn, RelationId } from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { ID, IOrganizationSprintTaskHistory, ITask, IUser } from '@gauzy/contracts'; +import { OrganizationSprint, Task, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { MikroOrmOrganizationSprintTaskRepository } from './repository/mikro-orm-organization-sprint-task.repository'; + +@MultiORMEntity('organization_sprint_task_history', { + mikroOrmRepository: () => MikroOrmOrganizationSprintTaskRepository +}) +export class OrganizationSprintTaskHistory + extends TenantOrganizationBaseEntity + implements IOrganizationSprintTaskHistory +{ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsString() + @ColumnIndex() + @MultiORMColumn({ type: 'text', nullable: true }) + reason?: string; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * Task + */ + @MultiORMManyToOne(() => Task, (it) => it.taskSprintHistories, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + task!: ITask; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTaskHistory) => it.task) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + taskId: ID; + + /** + * From OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.fromSprintTaskHistories, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + fromSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTaskHistory) => it.fromSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + fromSprintId: ID; + + /** + * To OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.toSprintTaskHistories, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + toSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTaskHistory) => it.toSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + toSprintId: ID; + + /** + * User moved issue + */ + @MultiORMManyToOne(() => User, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + @JoinColumn() + movedBy?: IUser; + + @RelationId((it: OrganizationSprintTaskHistory) => it.movedBy) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + movedById?: ID; +} diff --git a/packages/core/src/organization-sprint/organization-sprint-task.entity.ts b/packages/core/src/organization-sprint/organization-sprint-task.entity.ts new file mode 100644 index 00000000000..8723cd38088 --- /dev/null +++ b/packages/core/src/organization-sprint/organization-sprint-task.entity.ts @@ -0,0 +1,59 @@ +import { RelationId } from 'typeorm'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { ID, IOrganizationSprintTask, ITask } from '@gauzy/contracts'; +import { OrganizationSprint, Task, TenantOrganizationBaseEntity } from '../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { MikroOrmOrganizationSprintTaskRepository } from './repository/mikro-orm-organization-sprint-task.repository'; + +@MultiORMEntity('organization_sprint_task', { + mikroOrmRepository: () => MikroOrmOrganizationSprintTaskRepository +}) +export class OrganizationSprintTask extends TenantOrganizationBaseEntity implements IOrganizationSprintTask { + @ApiPropertyOptional({ type: () => Number }) + @IsOptional() + @IsNumber() + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + totalWorkedHours?: number; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * OrganizationSprint + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.taskSprints, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationSprint!: OrganizationSprint; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTask) => it.organizationSprint) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + organizationSprintId: ID; + + /** + * Task + */ + @MultiORMManyToOne(() => Task, (it) => it.taskSprints, { + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + task!: ITask; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @RelationId((it: OrganizationSprintTask) => it.task) + @ColumnIndex() + @MultiORMColumn({ relationId: true }) + taskId: ID; +} diff --git a/packages/core/src/organization-sprint/organization-sprint.controller.ts b/packages/core/src/organization-sprint/organization-sprint.controller.ts index 9423e3a7925..7774918ed54 100644 --- a/packages/core/src/organization-sprint/organization-sprint.controller.ts +++ b/packages/core/src/organization-sprint/organization-sprint.controller.ts @@ -8,24 +8,25 @@ import { Put, Query, UseGuards, - Post + Post, + Delete } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { - IOrganizationSprint, - IOrganizationSprintUpdateInput, - IPagination -} from '@gauzy/contracts'; -import { CrudController } from './../core/crud'; +import { DeleteResult } from 'typeorm'; +import { ID, IOrganizationSprint, IPagination, PermissionsEnum } from '@gauzy/contracts'; +import { CrudController, PaginationParams } from './../core/crud'; +import { Permissions } from './../shared/decorators'; +import { PermissionGuard, TenantPermissionGuard } from './../shared/guards'; import { OrganizationSprint } from './organization-sprint.entity'; import { OrganizationSprintService } from './organization-sprint.service'; -import { OrganizationSprintUpdateCommand } from './commands'; -import { TenantPermissionGuard } from './../shared/guards'; -import { ParseJsonPipe, UUIDValidationPipe } from './../shared/pipes'; +import { OrganizationSprintCreateCommand, OrganizationSprintUpdateCommand } from './commands'; +import { ParseJsonPipe, UseValidationPipe, UUIDValidationPipe } from './../shared/pipes'; +import { CreateOrganizationSprintDTO, UpdateOrganizationSprintDTO } from './dto'; @ApiTags('OrganizationSprint') -@UseGuards(TenantPermissionGuard) +@UseGuards(TenantPermissionGuard, PermissionGuard) +@Permissions(PermissionsEnum.ALL_ORG_EDIT) @Controller() export class OrganizationSprintController extends CrudController { constructor( @@ -35,12 +36,6 @@ export class OrganizationSprintController extends CrudController> { + @UseValidationPipe() + async findAll(@Query('data', ParseJsonPipe) data: any): Promise> { const { relations, findInput } = data; return this.organizationSprintService.findAll({ where: findInput, @@ -64,12 +59,21 @@ export class OrganizationSprintController extends CrudController + ): Promise { + return await this.organizationSprintService.findOneByIdString(id, params); + } + /** * CREATE organization sprint - * - * @param entity - * @param options - * @returns + * + * @param entity + * @param options + * @returns */ @ApiOperation({ summary: 'Create new record' }) @ApiResponse({ @@ -78,23 +82,22 @@ export class OrganizationSprintController extends CrudController { - return this.organizationSprintService.create(body); + async create(@Body() entity: CreateOrganizationSprintDTO): Promise { + return await this.commandBus.execute(new OrganizationSprintCreateCommand(entity)); } /** * UPDATE organization sprint by id - * - * @param id - * @param entity - * @returns + * + * @param id + * @param entity + * @returns */ @ApiOperation({ summary: 'Update an existing record' }) @ApiResponse({ @@ -107,17 +110,22 @@ export class OrganizationSprintController extends CrudController { - return this.commandBus.execute( - new OrganizationSprintUpdateCommand(id, body) - ); + return this.commandBus.execute(new OrganizationSprintUpdateCommand(id, entity)); + } + + @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_SPRINT_DELETE) + @Delete(':id') + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { + return await this.organizationSprintService.delete(id); } } diff --git a/packages/core/src/organization-sprint/organization-sprint.entity.ts b/packages/core/src/organization-sprint/organization-sprint.entity.ts index cb8aacaa473..df195e7720e 100644 --- a/packages/core/src/organization-sprint/organization-sprint.entity.ts +++ b/packages/core/src/organization-sprint/organization-sprint.entity.ts @@ -1,14 +1,32 @@ import { JoinColumn } from 'typeorm'; -import { IOrganizationProjectModule, IOrganizationSprint, SprintStartDayEnum } from '@gauzy/contracts'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDate, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; +import { IsDate, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { isMySQL, isPostgres } from '@gauzy/config'; +import { + ID, + IOrganizationProjectModule, + IOrganizationSprint, + IOrganizationSprintEmployee, + IOrganizationSprintTask, + IOrganizationSprintTaskHistory, + JsonData, + ITaskView, + OrganizationSprintStatusEnum, + SprintStartDayEnum +} from '@gauzy/contracts'; import { OrganizationProject, OrganizationProjectModule, + OrganizationSprintEmployee, + OrganizationSprintTask, + OrganizationSprintTaskHistory, Task, + TaskView, TenantOrganizationBaseEntity } from '../core/entities/internal'; import { + ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToMany, @@ -39,20 +57,35 @@ export class OrganizationSprint extends TenantOrganizationBaseEntity implements @ApiPropertyOptional({ type: () => Date }) @IsDate() @IsOptional() + @Type(() => Date) @MultiORMColumn({ nullable: true }) startDate?: Date; @ApiPropertyOptional({ type: () => Date }) @IsDate() @IsOptional() + @Type(() => Date) @MultiORMColumn({ nullable: true }) endDate?: Date; - @ApiProperty({ type: () => Number, enum: SprintStartDayEnum }) - @IsNumber() + @ApiPropertyOptional({ type: () => String, enum: OrganizationSprintStatusEnum }) + @IsNotEmpty() + @IsEnum(OrganizationSprintStatusEnum) + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + status?: OrganizationSprintStatusEnum; + + @ApiPropertyOptional({ type: () => Number, enum: SprintStartDayEnum }) + @IsOptional() + @IsEnum(SprintStartDayEnum) @MultiORMColumn({ nullable: true }) dayStart?: number; + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + sprintProgress?: JsonData; + /* |-------------------------------------------------------------------------- | @ManyToOne @@ -71,13 +104,13 @@ export class OrganizationSprint extends TenantOrganizationBaseEntity implements onDelete: 'CASCADE' }) @JoinColumn() - project?: OrganizationProject; + project: OrganizationProject; @ApiProperty({ type: () => String }) @IsString() @IsNotEmpty() @MultiORMColumn({ relationId: true }) - projectId: string; + projectId: ID; /* |-------------------------------------------------------------------------- @@ -85,11 +118,56 @@ export class OrganizationSprint extends TenantOrganizationBaseEntity implements |-------------------------------------------------------------------------- */ + /** + * OrganizationTeamEmployee + */ + @MultiORMOneToMany(() => OrganizationSprintEmployee, (it) => it.organizationSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + members?: IOrganizationSprintEmployee[]; + + /** + * Sprint Tasks (Many-To-Many sprint tasks) + */ + @MultiORMOneToMany(() => OrganizationSprintTask, (it) => it.organizationSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + taskSprints?: IOrganizationSprintTask[]; + + /** + * Tasks (Task active sprint) + */ @ApiProperty({ type: () => Task }) @MultiORMOneToMany(() => Task, (task) => task.organizationSprint) @JoinColumn() tasks?: Task[]; + /** + * Sprint views + */ + @MultiORMOneToMany(() => TaskView, (sprint) => sprint.organizationSprint) + views?: ITaskView[]; + + /** + * From OrganizationSprint histories + */ + @MultiORMOneToMany(() => OrganizationSprintTaskHistory, (it) => it.fromSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + fromSprintTaskHistories?: IOrganizationSprintTaskHistory[]; + + /** + * From OrganizationSprint histories + */ + @MultiORMOneToMany(() => OrganizationSprintTaskHistory, (it) => it.toSprint, { + /** If set to true then it means that related object can be allowed to be inserted or updated in the database. */ + cascade: true + }) + toSprintTaskHistories?: IOrganizationSprintTaskHistory[]; + /* |-------------------------------------------------------------------------- | @ManyToMany diff --git a/packages/core/src/organization-sprint/organization-sprint.module.ts b/packages/core/src/organization-sprint/organization-sprint.module.ts index 760039b6793..036ca3a9a32 100644 --- a/packages/core/src/organization-sprint/organization-sprint.module.ts +++ b/packages/core/src/organization-sprint/organization-sprint.module.ts @@ -3,23 +3,50 @@ import { Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { RouterModule } from '@nestjs/core'; import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { OrganizationSprintEmployee } from './organization-sprint-employee.entity'; +import { OrganizationSprintTaskHistory } from './organization-sprint-task-history.entity'; +import { RoleModule } from './../role/role.module'; +import { EmployeeModule } from './../employee/employee.module'; import { OrganizationSprintService } from './organization-sprint.service'; import { OrganizationSprintController } from './organization-sprint.controller'; import { OrganizationSprint } from './organization-sprint.entity'; import { Task } from '../tasks/task.entity'; import { CommandHandlers } from './commands/handlers'; import { RolePermissionModule } from '../role-permission/role-permission.module'; +import { TypeOrmOrganizationSprintRepository } from './repository/type-orm-organization-sprint.repository'; +import { TypeOrmOrganizationSprintEmployeeRepository } from './repository/type-orm-organization-sprint-employee.repository'; +import { TypeOrmOrganizationSprintTaskHistoryRepository } from './repository/type-orm-organization-sprint-task-history.repository'; @Module({ imports: [ RouterModule.register([{ path: '/organization-sprint', module: OrganizationSprintModule }]), - TypeOrmModule.forFeature([OrganizationSprint, Task]), - MikroOrmModule.forFeature([OrganizationSprint, Task]), + TypeOrmModule.forFeature([OrganizationSprint, Task, OrganizationSprintEmployee, OrganizationSprintTaskHistory]), + MikroOrmModule.forFeature([ + OrganizationSprint, + Task, + OrganizationSprintEmployee, + OrganizationSprintTaskHistory + ]), + RoleModule, + EmployeeModule, RolePermissionModule, CqrsModule ], controllers: [OrganizationSprintController], - providers: [OrganizationSprintService, ...CommandHandlers], - exports: [OrganizationSprintService] + providers: [ + OrganizationSprintService, + TypeOrmOrganizationSprintRepository, + TypeOrmOrganizationSprintEmployeeRepository, + TypeOrmOrganizationSprintTaskHistoryRepository, + ...CommandHandlers + ], + exports: [ + TypeOrmModule, + MikroOrmModule, + OrganizationSprintService, + TypeOrmOrganizationSprintRepository, + TypeOrmOrganizationSprintEmployeeRepository, + TypeOrmOrganizationSprintTaskHistoryRepository + ] }) -export class OrganizationSprintModule { } +export class OrganizationSprintModule {} diff --git a/packages/core/src/organization-sprint/organization-sprint.service.ts b/packages/core/src/organization-sprint/organization-sprint.service.ts index b768d597d4c..4b3600239cf 100644 --- a/packages/core/src/organization-sprint/organization-sprint.service.ts +++ b/packages/core/src/organization-sprint/organization-sprint.service.ts @@ -1,18 +1,296 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { OrganizationSprint } from './organization-sprint.entity'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { + BaseEntityEnum, + ActorTypeEnum, + ID, + IEmployee, + IOrganizationSprint, + IOrganizationSprintCreateInput, + IOrganizationSprintUpdateInput, + RolesEnum, + ActionTypeEnum +} from '@gauzy/contracts'; +import { isNotEmpty } from '@gauzy/common'; import { TenantAwareCrudService } from './../core/crud'; -import { TypeOrmOrganizationSprintRepository } from './repository/type-orm-organization-sprint.repository'; -import { MikroOrmOrganizationSprintRepository } from './repository/mikro-orm-organization-sprint.repository'; +import { RequestContext } from '../core/context'; +import { OrganizationSprintEmployee } from '../core/entities/internal'; +import { FavoriteService } from '../core/decorators'; +// import { prepareSQLQuery as p } from './../database/database.helper'; +import { RoleService } from '../role/role.service'; +import { EmployeeService } from '../employee/employee.service'; +import { ActivityLogService } from '../activity-log/activity-log.service'; +import { OrganizationSprint } from './organization-sprint.entity'; +import { TypeOrmEmployeeRepository } from '../employee/repository'; +import { + MikroOrmOrganizationSprintEmployeeRepository, + MikroOrmOrganizationSprintRepository, + TypeOrmOrganizationSprintEmployeeRepository, + TypeOrmOrganizationSprintRepository +} from './repository'; +@FavoriteService(BaseEntityEnum.OrganizationSprint) @Injectable() export class OrganizationSprintService extends TenantAwareCrudService { constructor( - @InjectRepository(OrganizationSprint) - typeOrmOrganizationSprintRepository: TypeOrmOrganizationSprintRepository, - - mikroOrmOrganizationSprintRepository: MikroOrmOrganizationSprintRepository + readonly typeOrmOrganizationSprintRepository: TypeOrmOrganizationSprintRepository, + readonly mikroOrmOrganizationSprintRepository: MikroOrmOrganizationSprintRepository, + readonly typeOrmOrganizationSprintEmployeeRepository: TypeOrmOrganizationSprintEmployeeRepository, + readonly mikroOrmOrganizationSprintEmployeeRepository: MikroOrmOrganizationSprintEmployeeRepository, + readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository, + private readonly _roleService: RoleService, + private readonly _employeeService: EmployeeService, + private readonly activityLogService: ActivityLogService ) { super(typeOrmOrganizationSprintRepository, mikroOrmOrganizationSprintRepository); } + + /** + * Creates an organization sprint based on the provided input. + * @param input - Input data for creating the organization sprint. + * @returns A Promise resolving to the created organization sprint. + * @throws BadRequestException if there is an error in the creation process. + */ + async create(input: IOrganizationSprintCreateInput): Promise { + const tenantId = RequestContext.currentTenantId() || input.tenantId; + const employeeId = RequestContext.currentEmployeeId(); + const currentRoleId = RequestContext.currentRoleId(); + + // Destructure the input data + const { memberIds = [], managerIds = [], organizationId, ...entity } = input; + + try { + // If the current employee creates the sprint, default add him as a manager + try { + // Check if the current role is EMPLOYEE + await this._roleService.findOneByIdString(currentRoleId, { where: { name: RolesEnum.EMPLOYEE } }); + + // Add the current employee to the managerIds if they have the EMPLOYEE role and are not already included. + if (!managerIds.includes(employeeId)) { + // If not included, add the employeeId to the managerIds array. + managerIds.push(employeeId); + } + } catch (error) {} + + // Combine memberIds and managerIds into a single array. + const employeeIds = [...memberIds, ...managerIds].filter(Boolean); + + // Retrieve a collection of employees based on specified criteria. + const employees = await this._employeeService.findActiveEmployeesByEmployeeIds( + employeeIds, + organizationId, + tenantId + ); + + // Find the manager role + const managerRole = await this._roleService.findOneByWhereOptions({ + name: RolesEnum.MANAGER + }); + + // Create a Set for faster membership checks + const managerIdsSet = new Set(managerIds); + + // Use destructuring to directly extract 'id' from 'employee' + const members = employees.map(({ id: employeeId }) => { + // If the employee is manager, assign the existing manager with the latest assignedAt date. + const isManager = managerIdsSet.has(employeeId); + const assignedAt = new Date(); + + return new OrganizationSprintEmployee({ + employeeId, + organizationId, + tenantId, + isManager, + assignedAt, + role: isManager ? managerRole : null + }); + }); + + // Create the organization sprint with the prepared members. + const sprint = await super.create({ + ...entity, + members, + organizationId, + tenantId + }); + + // Generate the activity log + this.activityLogService.logActivity( + BaseEntityEnum.OrganizationSprint, + ActionTypeEnum.Created, + ActorTypeEnum.User, + sprint.id, + sprint.name, + sprint, + organizationId, + tenantId + ); + + return sprint; + } catch (error) { + // Handle errors and return an appropriate error response + throw new HttpException(`Failed to create organization sprint: ${error.message}`, HttpStatus.BAD_REQUEST); + } + } + + /** + * Update an organization sprint. + * + * @param id - The ID of the organization sprint to be updated. + * @param input - The updated information for the organization sprint. + * @returns A Promise resolving to the updated organization sprint. + * @throws ForbiddenException if the user lacks permission or if certain conditions are not met. + * @throws BadRequestException if there's an error during the update process. + */ + async update(id: ID, input: IOrganizationSprintUpdateInput): Promise { + const tenantId = RequestContext.currentTenantId() || input.tenantId; + + // Destructure the input data + const { memberIds = [], managerIds = [], organizationId, projectId } = input; + + try { + // Search for existing Organization Sprint + const organizationSprint = await super.findOneByIdString(id, { + where: { organizationId, tenantId, projectId }, + relations: { + members: true, + modules: true + } + }); + + // Retrieve members and managers IDs + if (isNotEmpty(memberIds) || isNotEmpty(managerIds)) { + // Combine memberIds and managerIds into a single array + const employeeIds = [...memberIds, ...managerIds].filter(Boolean); + + // Retrieve a collection of employees based on specified criteria. + const sprintMembers = await this._employeeService.findActiveEmployeesByEmployeeIds( + employeeIds, + organizationId, + tenantId + ); + + // Update nested entity (Organization Sprint Members) + await this.updateOrganizationSprintMembers(id, organizationId, sprintMembers, managerIds, memberIds); + + // Update the organization sprint with the prepared members + const { id: organizationSprintId } = organizationSprint; + const updatedSprint = await super.create({ + ...input, + organizationId, + tenantId, + id: organizationSprintId + }); + + // Generate the activity log + this.activityLogService.logActivity( + BaseEntityEnum.OrganizationSprint, + ActionTypeEnum.Updated, + ActorTypeEnum.User, + updatedSprint.id, + updatedSprint.name, + updatedSprint, + organizationId, + tenantId, + organizationSprint, + input + ); + + // return updated sprint + return updatedSprint; + } + } catch (error) { + // Handle errors and return an appropriate error response + throw new HttpException(`Failed to update organization sprint: ${error.message}`, HttpStatus.BAD_REQUEST); + } + } + + /** + * Delete sprint members by IDs. + * + * @param memberIds - Array of member IDs to delete + * @returns A promise that resolves when all deletions are complete + */ + async deleteMemberByIds(memberIds: ID[]): Promise { + // Map member IDs to deletion promises + const deletePromises = memberIds.map((memberId) => + this.typeOrmOrganizationSprintEmployeeRepository.delete(memberId) + ); + + // Wait for all deletions to complete + await Promise.all(deletePromises); + } + + /** + * Updates an organization sprint by managing its members and their roles. + * + * @param organizationSprintId - ID of the organization sprint + * @param organizationId - ID of the organization + * @param employees - Array of employees to be assigned to the sprint + * @param managerIds - Array of employee IDs to be assigned as managers + * @param memberIds - Array of employee IDs to be assigned as members + * @returns Promise + */ + async updateOrganizationSprintMembers( + organizationSprintId: ID, + organizationId: ID, + employees: IEmployee[], + managerIds: ID[], + memberIds: ID[] + ): Promise { + const tenantId = RequestContext.currentTenantId(); + const membersToUpdate = new Set([...managerIds, ...memberIds].filter(Boolean)); + + // Find the manager role. + const managerRole = await this._roleService.findOneByWhereOptions({ + name: RolesEnum.MANAGER + }); + + // Fetch existing sprint members with their roles. + const sprintMembers = await this.typeOrmOrganizationSprintEmployeeRepository.find({ + where: { tenantId, organizationId, organizationSprintId } + }); + + // Create a map of existing members for quick lookup + const existingMemberMap = new Map(sprintMembers.map((member) => [member.employeeId, member])); + + // Separate members into removed, updated and new members + const removedMembers = sprintMembers.filter((member) => !membersToUpdate.has(member.employeeId)); + const updatedMembers = sprintMembers.filter((member) => membersToUpdate.has(member.employeeId)); + const newMembers = employees.filter((employee) => !existingMemberMap.has(employee.id)); + + // 1. Remove members who are no longer assigned to the sprint + if (removedMembers.length) { + await this.deleteMemberByIds(removedMembers.map((member) => member.id)); + } + + // 2. Update roles for existing members where necessary. + await Promise.all( + updatedMembers.map(async (member) => { + const isManager = managerIds.includes(member.employeeId); + const newRole = isManager ? managerRole : null; + + // Only update if the role has changed + if (newRole && newRole.id !== member.roleId) { + await this.typeOrmOrganizationSprintEmployeeRepository.update(member.id, { role: newRole }); + } + }) + ); + + // 3. Add new members to the sprint + if (newMembers.length) { + const newSprintMembers = newMembers.map( + (employee) => + new OrganizationSprintEmployee({ + organizationSprintId, + employeeId: employee.id, + tenantId, + organizationId, + isManager: managerIds.includes(employee.id), + roleId: managerIds.includes(employee.id) ? managerRole.id : null + }) + ); + + await this.typeOrmOrganizationSprintEmployeeRepository.save(newSprintMembers); + } + } } diff --git a/packages/core/src/organization-sprint/repository/index.ts b/packages/core/src/organization-sprint/repository/index.ts new file mode 100644 index 00000000000..e97a0699c62 --- /dev/null +++ b/packages/core/src/organization-sprint/repository/index.ts @@ -0,0 +1,8 @@ +export * from './mikro-orm-organization-sprint-employee.repository'; +export * from './mikro-orm-organization-sprint.repository'; +export * from './type-orm-organization-sprint.repository'; +export * from './type-orm-organization-sprint-employee.repository'; +export * from './type-orm-organization-sprint-task.repository'; +export * from './mikro-orm-organization-sprint-task.repository'; +export * from './type-orm-organization-sprint-task-history.repository'; +export * from './mikro-orm-organization-sprint-task-history.repository'; diff --git a/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-employee.repository.ts b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-employee.repository.ts new file mode 100644 index 00000000000..52bf29d08ea --- /dev/null +++ b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-employee.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { OrganizationSprintEmployee } from '../organization-sprint-employee.entity'; + +export class MikroOrmOrganizationSprintEmployeeRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task-history.repository.ts b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task-history.repository.ts new file mode 100644 index 00000000000..a06b99b37ad --- /dev/null +++ b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task-history.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { OrganizationSprintTaskHistory } from '../organization-sprint-task-history.entity'; + +export class MikroOrmOrganizationSprintTaskHistoryRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task.repository.ts b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task.repository.ts new file mode 100644 index 00000000000..779315bcad8 --- /dev/null +++ b/packages/core/src/organization-sprint/repository/mikro-orm-organization-sprint-task.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { OrganizationSprintTask } from '../organization-sprint-task.entity'; + +export class MikroOrmOrganizationSprintTaskRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-employee.repository.ts b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-employee.repository.ts new file mode 100644 index 00000000000..c8a0a01266a --- /dev/null +++ b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-employee.repository.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrganizationSprintEmployee } from '../organization-sprint-employee.entity'; + +@Injectable() +export class TypeOrmOrganizationSprintEmployeeRepository extends Repository { + constructor( + @InjectRepository(OrganizationSprintEmployee) readonly repository: Repository + ) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task-history.repository.ts b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task-history.repository.ts new file mode 100644 index 00000000000..f32ad19756a --- /dev/null +++ b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task-history.repository.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrganizationSprintTaskHistory } from '../organization-sprint-task-history.entity'; + +@Injectable() +export class TypeOrmOrganizationSprintTaskHistoryRepository extends Repository { + constructor( + @InjectRepository(OrganizationSprintTaskHistory) readonly repository: Repository + ) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task.repository.ts b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task.repository.ts new file mode 100644 index 00000000000..59efc27409f --- /dev/null +++ b/packages/core/src/organization-sprint/repository/type-orm-organization-sprint-task.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { OrganizationSprintTask } from '../organization-sprint-task.entity'; + +@Injectable() +export class TypeOrmOrganizationSprintTaskRepository extends Repository { + constructor(@InjectRepository(OrganizationSprintTask) readonly repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/organization-team-employee/organization-team-employee.service.ts b/packages/core/src/organization-team-employee/organization-team-employee.service.ts index d65073d5c51..97d504a8d9e 100644 --- a/packages/core/src/organization-team-employee/organization-team-employee.service.ts +++ b/packages/core/src/organization-team-employee/organization-team-employee.service.ts @@ -42,11 +42,11 @@ export class OrganizationTeamEmployeeService extends TenantAwareCrudService { const tenantId = RequestContext.currentTenantId(); - const membersToUpdate = [...managerIds, ...memberIds]; + const membersToUpdate = new Set([...managerIds, ...memberIds].filter(Boolean)); // Fetch existing team members with their roles const teamMembers = await this.typeOrmRepository.find({ @@ -58,25 +58,29 @@ export class OrganizationTeamEmployeeService extends TenantAwareCrudService [member.employeeId, member])); // Separate members to remove and to update - const removedMembers = teamMembers.filter((member) => !membersToUpdate.includes(member.employeeId)); - const updatedMembers = teamMembers.filter((member) => membersToUpdate.includes(member.employeeId)); + const removedMembers = teamMembers.filter((member) => !membersToUpdate.has(member.employeeId)); + const updatedMembers = teamMembers.filter((member) => membersToUpdate.has(member.employeeId)); // 1. Remove members who are no longer in the team if (removedMembers.length > 0) { - const removedMemberIds = removedMembers.map((member) => member.id); - await this.deleteMemberByIds(removedMemberIds); + await this.deleteMemberByIds(removedMembers.map((member) => member.id)); } // 2. Update role for existing members - for (const member of updatedMembers) { - const isManager = managerIds.includes(member.employeeId); - const newRole = isManager ? role : member.role?.id === role.id ? member.role : null; - - // Only update if the role is different - if (newRole && newRole.id !== member.roleId) { - await this.typeOrmRepository.update(member.id, { role: newRole }); - } - } + await Promise.all( + updatedMembers.map(async (member) => { + const isManager = managerIds.includes(member.employeeId); + const newRole = isManager ? role : null; + + // Only update if the role has changed + if (newRole?.id !== member.roleId) { + await this.typeOrmRepository.update(member.id, { + role: newRole, + isManager + }); + } + }) + ); // 3. Add new members to the team const newMembers = employees.filter((employee) => !existingMemberMap.has(employee.id)); diff --git a/packages/core/src/organization-team/dto/organization-team.dto.ts b/packages/core/src/organization-team/dto/organization-team.dto.ts index c2e2e95e226..40c1d43c199 100644 --- a/packages/core/src/organization-team/dto/organization-team.dto.ts +++ b/packages/core/src/organization-team/dto/organization-team.dto.ts @@ -1,14 +1,17 @@ import { ApiPropertyOptional, IntersectionType, PartialType, PickType } from '@nestjs/swagger'; import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; import { IOrganizationProject, IOrganizationTeam } from '@gauzy/contracts'; -import { TenantOrganizationBaseDTO } from './../../core/dto'; +import { MemberEntityBasedDTO, TenantOrganizationBaseDTO } from './../../core/dto'; import { RelationalTagDTO } from './../../tags/dto'; import { OrganizationTeam } from './../organization-team.entity'; import { OrganizationProject } from '../../organization-project/organization-project.entity'; export class OrganizationTeamDTO extends IntersectionType( - IntersectionType(TenantOrganizationBaseDTO, PartialType(RelationalTagDTO)), + IntersectionType( + TenantOrganizationBaseDTO, + IntersectionType(PartialType(RelationalTagDTO), MemberEntityBasedDTO) + ), PickType(OrganizationTeam, ['logo', 'prefix', 'imageId', 'shareProfileView', 'requirePlanToTrack']) ) implements Omit @@ -36,16 +39,6 @@ export class OrganizationTeamDTO @IsString() readonly teamSize?: string; - @ApiPropertyOptional({ type: () => String, isArray: true }) - @IsOptional() - @IsArray() - readonly memberIds?: string[] = []; - - @ApiPropertyOptional({ type: () => String, isArray: true }) - @IsOptional() - @IsArray() - readonly managerIds?: string[] = []; - @ApiPropertyOptional({ type: () => OrganizationProject, isArray: true }) @IsOptional() @IsArray() diff --git a/packages/core/src/organization-team/organization-team.entity.ts b/packages/core/src/organization-team/organization-team.entity.ts index ec315ebe5e7..af8b0198637 100644 --- a/packages/core/src/organization-team/organization-team.entity.ts +++ b/packages/core/src/organization-team/organization-team.entity.ts @@ -19,6 +19,7 @@ import { ITaskSize, ITaskStatus, ITaskVersion, + ITaskView, IUser } from '@gauzy/contracts'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; @@ -41,6 +42,7 @@ import { TaskSize, TaskStatus, TaskVersion, + TaskView, TenantOrganizationBaseEntity, User } from '../core/entities/internal'; @@ -256,6 +258,12 @@ export class OrganizationTeam extends TenantOrganizationBaseEntity implements IO @MultiORMOneToMany(() => TaskVersion, (it) => it.organizationTeam) versions?: ITaskVersion[]; + /** + * Team views + */ + @MultiORMOneToMany(() => TaskView, (it) => it.organizationTeam) + views?: ITaskView[]; + /** * Team Labels */ diff --git a/packages/core/src/organization-team/organization-team.service.ts b/packages/core/src/organization-team/organization-team.service.ts index bf1acd1541b..b21083eae4b 100644 --- a/packages/core/src/organization-team/organization-team.service.ts +++ b/packages/core/src/organization-team/organization-team.service.ts @@ -18,7 +18,7 @@ import { IOrganizationTeamEmployee, IOrganizationTeamStatisticInput, ITimerStatus, - FavoriteEntityEnum, + BaseEntityEnum, ID } from '@gauzy/contracts'; import { isNotEmpty, parseToBoolean } from '@gauzy/common'; @@ -41,7 +41,7 @@ import { MikroOrmOrganizationTeamRepository, TypeOrmOrganizationTeamRepository } import { OrganizationTeam } from './organization-team.entity'; import { MikroOrmOrganizationTeamEmployeeRepository } from '../organization-team-employee/repository/mikro-orm-organization-team-employee.repository'; -@FavoriteService(FavoriteEntityEnum.OrganizationTeam) +@FavoriteService(BaseEntityEnum.OrganizationTeam) @Injectable() export class OrganizationTeamService extends TenantAwareCrudService { constructor( @@ -172,8 +172,8 @@ export class OrganizationTeamService extends TenantAwareCrudService { @@ -354,23 +354,28 @@ export class OrganizationTeamService extends TenantAwareCrudService): Promise> { - if ('where' in options) { + if (options?.where) { const { where } = options; - if ('name' in where) { - options['where']['name'] = ILike(`%${where.name}%`); + + // Update name filter using ILike + if (where.name) { + options.where.name = ILike(`%${where.name}%`); } - if ('tags' in where) { - options['where']['tags'] = { + + // Update tags filter using In + if (Array.isArray(where.tags) && where.tags.length > 0) { + options.where.tags = { id: In(where.tags as []) }; } } + return await this.findAll(options); } diff --git a/packages/core/src/organization/commands/handlers/organization.create.handler.ts b/packages/core/src/organization/commands/handlers/organization.create.handler.ts index f4fa1a67de7..a47f919f08c 100644 --- a/packages/core/src/organization/commands/handlers/organization.create.handler.ts +++ b/packages/core/src/organization/commands/handlers/organization.create.handler.ts @@ -19,14 +19,13 @@ import { OrganizationTaskSettingCreateCommand } from '../../../organization-task @CommandHandler(OrganizationCreateCommand) export class OrganizationCreateHandler implements ICommandHandler { - constructor( private readonly commandBus: CommandBus, private readonly organizationService: OrganizationService, private readonly userOrganizationService: UserOrganizationService, private readonly userService: UserService, - private readonly contactService: ContactService, - ) { } + private readonly contactService: ContactService + ) {} /** * Asynchronously executes the process of creating a new organization, along with associated tasks such as @@ -51,8 +50,8 @@ export class OrganizationCreateHandler implements ICommandHandler { try { // 1. Create report for relative organization. - await this.commandBus.execute( - new ReportOrganizationCreateCommand(organization) - ); + await this.commandBus.execute(new ReportOrganizationCreateCommand(organization)); // 2. Create task statuses for relative organization. - await this.commandBus.execute( - new OrganizationStatusBulkCreateCommand(organization) - ); + await this.commandBus.execute(new OrganizationStatusBulkCreateCommand(organization)); // 3. Create task sizes for relative organization. - await this.commandBus.execute( - new OrganizationTaskSizeBulkCreateCommand(organization) - ); + await this.commandBus.execute(new OrganizationTaskSizeBulkCreateCommand(organization)); // 4. Create task priorities for relative organization. - await this.commandBus.execute( - new OrganizationTaskPriorityBulkCreateCommand(organization) - ); + await this.commandBus.execute(new OrganizationTaskPriorityBulkCreateCommand(organization)); // 5. Create issue types for relative organization. - await this.commandBus.execute( - new OrganizationIssueTypeBulkCreateCommand(organization) - ); + await this.commandBus.execute(new OrganizationIssueTypeBulkCreateCommand(organization)); // 6. Create task setting for relative organization. await this.commandBus.execute( new OrganizationTaskSettingCreateCommand({ diff --git a/packages/core/src/organization/commands/handlers/organization.update.handler.ts b/packages/core/src/organization/commands/handlers/organization.update.handler.ts index 653487c7cd3..ecc673f8e7f 100644 --- a/packages/core/src/organization/commands/handlers/organization.update.handler.ts +++ b/packages/core/src/organization/commands/handlers/organization.update.handler.ts @@ -1,18 +1,15 @@ +import { NotFoundException } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { IOrganization, IOrganizationUpdateInput } from '@gauzy/contracts'; +import { ID, IOrganization, IOrganizationUpdateInput } from '@gauzy/contracts'; import { RequestContext } from '../../../core/context'; import { OrganizationService } from '../../organization.service'; import { OrganizationUpdateCommand } from '../organization.update.command'; @CommandHandler(OrganizationUpdateCommand) export class OrganizationUpdateHandler implements ICommandHandler { - - constructor( - private readonly organizationService: OrganizationService - ) { } + constructor(private readonly organizationService: OrganizationService) {} /** - * * Executes the organization update operation. * * @param command This includes the organization's ID and the new data to be updated. @@ -30,37 +27,41 @@ export class OrganizationUpdateHandler implements ICommandHandler { + private async update(id: ID, input: IOrganizationUpdateInput): Promise { const organization: IOrganization = await this.organizationService.findOneByIdString(id); - if (organization) { - // - const tenantId = RequestContext.currentTenantId(); + if (!organization) { + throw new NotFoundException(`Organization with ID ${id} not found.`); + } + + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; - //if any organization set as default - if (input.isDefault) { - await this.organizationService.update({ tenantId }, { isDefault: false }); - } + // If any organization is set as default, update others to non-default + if (input.isDefault) { + await this.organizationService.update({ tenantId }, { isDefault: false }); + } - // Simplify boolean assignments - const request = { - ...input, - show_profits: !!input.show_profits, - show_bonuses_paid: !!input.show_bonuses_paid, - show_income: !!input.show_income, - show_total_hours: !!input.show_total_hours, - show_projects_count: input.show_projects_count !== false, - show_minimum_project_size: input.show_minimum_project_size !== false, - show_clients_count: input.show_clients_count !== false, - show_clients: input.show_clients !== false, - show_employees_count: input.show_employees_count !== false - }; + // Simplify boolean assignments and handle optional fields like standardWorkHoursPerDay + const updateData: Partial = { + ...input, + show_profits: !!input.show_profits, + show_bonuses_paid: !!input.show_bonuses_paid, + show_income: !!input.show_income, + show_total_hours: !!input.show_total_hours, + show_projects_count: input.show_projects_count !== false, + show_minimum_project_size: input.show_minimum_project_size !== false, + show_clients_count: input.show_clients_count !== false, + show_clients: input.show_clients !== false, + show_employees_count: input.show_employees_count !== false, + ...(input.standardWorkHoursPerDay !== undefined && { + standardWorkHoursPerDay: input.standardWorkHoursPerDay + }) + }; - // Creates a new organization or updates an existing one based on the provided data. - await this.organizationService.create({ ...request, id }); + // Creates a new organization or updates an existing one based on the provided data. + await this.organizationService.create({ ...updateData, id }); - // Retrieves an organization entity by its unique identifier. - return await this.organizationService.findOneByIdString(id); - } + // Return the updated organization entity + return await this.organizationService.findOneByIdString(id); } } diff --git a/packages/core/src/organization/commands/organization.create.command.ts b/packages/core/src/organization/commands/organization.create.command.ts index c6c1a609c0b..a9cd00b44ae 100644 --- a/packages/core/src/organization/commands/organization.create.command.ts +++ b/packages/core/src/organization/commands/organization.create.command.ts @@ -4,7 +4,5 @@ import { ICommand } from '@nestjs/cqrs'; export class OrganizationCreateCommand implements ICommand { static readonly type = '[Organization] Create'; - constructor( - public readonly input: IOrganizationCreateInput - ) {} + constructor(public readonly input: IOrganizationCreateInput) {} } diff --git a/packages/core/src/organization/dto/create-organization.dto.ts b/packages/core/src/organization/dto/create-organization.dto.ts index 5a29d7810dc..d9f7b83382a 100644 --- a/packages/core/src/organization/dto/create-organization.dto.ts +++ b/packages/core/src/organization/dto/create-organization.dto.ts @@ -1,29 +1,25 @@ -import { ApiProperty, IntersectionType, PickType } from "@nestjs/swagger"; -import { CurrenciesEnum, IOrganizationCreateInput } from "@gauzy/contracts"; -import { IsEnum, IsNotEmpty, IsString } from "class-validator"; -import { Organization } from "./../organization.entity"; -import { RelationalTagDTO } from "./../../tags/dto"; -import { OrganizationBonusesDTO } from "./organization-bonuses.dto"; -import { OrganizationSettingDTO } from "./organization-setting.dto"; +import { ApiProperty, IntersectionType, PickType } from '@nestjs/swagger'; +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { CurrenciesEnum, IOrganizationCreateInput } from '@gauzy/contracts'; +import { Organization } from './../organization.entity'; +import { OrganizationBonusesDTO } from './organization-bonuses.dto'; +import { OrganizationSettingDTO } from './organization-setting.dto'; +import { RelationalTagDTO } from './../../tags/dto'; /** * Organization Create DTO validation * */ -export class CreateOrganizationDTO extends IntersectionType( - IntersectionType(OrganizationBonusesDTO, PickType(Organization, [ - 'imageId', - 'upworkOrganizationId', - 'upworkOrganizationName' - ])), - IntersectionType(OrganizationSettingDTO, RelationalTagDTO) -) implements IOrganizationCreateInput { - - @ApiProperty({ required: true }) - @IsNotEmpty() - @IsString() - readonly name: string; - +export class CreateOrganizationDTO + extends IntersectionType( + OrganizationBonusesDTO, + OrganizationSettingDTO, + PickType(Organization, ['name', 'imageId', 'standardWorkHoursPerDay'] as const), + PickType(Organization, ['upworkOrganizationId', 'upworkOrganizationName'] as const), + RelationalTagDTO + ) + implements IOrganizationCreateInput +{ @ApiProperty({ enum: CurrenciesEnum, example: CurrenciesEnum.USD, diff --git a/packages/core/src/organization/dto/organization-bonuses.dto.ts b/packages/core/src/organization/dto/organization-bonuses.dto.ts index d54828986e7..19f5ca8acd9 100644 --- a/packages/core/src/organization/dto/organization-bonuses.dto.ts +++ b/packages/core/src/organization/dto/organization-bonuses.dto.ts @@ -1,27 +1,7 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsOptional, IsEnum, IsNumber, Min, Max, ValidateIf } from "class-validator"; -import { BonusTypeEnum, DEFAULT_PROFIT_BASED_BONUS } from "@gauzy/contracts"; +import { PickType } from '@nestjs/swagger'; +import { Organization } from '../organization.entity'; /** * Organization Bonuses DTO validation */ -export class OrganizationBonusesDTO { - - @ApiPropertyOptional({ - type: () => Number, - example: DEFAULT_PROFIT_BASED_BONUS - }) - @ValidateIf((it) => it.bonusType) - @IsNumber() - @Min(0) - @Max(100) - readonly bonusPercentage: number; - - @ApiPropertyOptional({ - enum: BonusTypeEnum, - example: BonusTypeEnum.PROFIT_BASED_BONUS - }) - @IsOptional() - @IsEnum(BonusTypeEnum) - readonly bonusType: BonusTypeEnum; -} \ No newline at end of file +export class OrganizationBonusesDTO extends PickType(Organization, ['bonusPercentage', 'bonusType'] as const) {} diff --git a/packages/core/src/organization/dto/organization-public-setting.dto.ts b/packages/core/src/organization/dto/organization-public-setting.dto.ts index c55718e2b5d..38dd0e7b742 100644 --- a/packages/core/src/organization/dto/organization-public-setting.dto.ts +++ b/packages/core/src/organization/dto/organization-public-setting.dto.ts @@ -1,53 +1,17 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsOptional, IsBoolean } from "class-validator"; +import { PickType } from '@nestjs/swagger'; +import { Organization } from '../organization.entity'; /** * Organization Public Setting DTO */ -export class OrganizationPublicSettingDTO { - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_income: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_profits: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_bonuses_paid: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_total_hours: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_minimum_project_size: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_projects_count: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_clients_count: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_clients: boolean; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - readonly show_employees_count: boolean; -} \ No newline at end of file +export class OrganizationPublicSettingDTO extends PickType(Organization, [ + 'show_income', + 'show_profits', + 'show_bonuses_paid', + 'show_total_hours', + 'show_minimum_project_size', + 'show_projects_count', + 'show_clients_count', + 'show_clients', + 'show_employees_count' +]) {} diff --git a/packages/core/src/organization/dto/organization-setting.dto.ts b/packages/core/src/organization/dto/organization-setting.dto.ts index f6ab5050d4f..705de81f7f5 100644 --- a/packages/core/src/organization/dto/organization-setting.dto.ts +++ b/packages/core/src/organization/dto/organization-setting.dto.ts @@ -1,43 +1,12 @@ -import { DefaultValueDateTypeEnum, DEFAULT_INVITE_EXPIRY_PERIOD, WeekDaysEnum } from "@gauzy/contracts"; -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform, TransformFnParams } from "class-transformer"; -import { IsOptional, IsEnum, IsString } from "class-validator"; +import { PickType } from '@nestjs/swagger'; +import { Organization } from '../organization.entity'; /** * Organization Setting DTO validation */ -export class OrganizationSettingDTO { - - @ApiPropertyOptional({ - enum: DefaultValueDateTypeEnum, - example: DefaultValueDateTypeEnum.TODAY, - required: true - }) - @IsOptional() - @IsEnum(DefaultValueDateTypeEnum) - readonly defaultValueDateType: DefaultValueDateTypeEnum; - - @ApiPropertyOptional({ - enum: WeekDaysEnum, - example: WeekDaysEnum.MONDAY - }) - @IsOptional() - @IsEnum(WeekDaysEnum) - readonly startWeekOn: WeekDaysEnum; - - @ApiPropertyOptional({ type: () => String }) - @IsOptional() - @IsString() - readonly regionCode: string; - - /** - * Default Organization Invite Expiry Period - */ - @ApiPropertyOptional({ - type: () => Number, - example: DEFAULT_INVITE_EXPIRY_PERIOD - }) - @IsOptional() - @Transform((params: TransformFnParams) => parseInt(params.value, 10)) - readonly inviteExpiryPeriod: number = DEFAULT_INVITE_EXPIRY_PERIOD; -} \ No newline at end of file +export class OrganizationSettingDTO extends PickType(Organization, [ + 'defaultValueDateType', + 'startWeekOn', + 'inviteExpiryPeriod', + 'regionCode' +] as const) {} diff --git a/packages/core/src/organization/dto/update-organization.dto.ts b/packages/core/src/organization/dto/update-organization.dto.ts index e5e0a175b67..9577952d17e 100644 --- a/packages/core/src/organization/dto/update-organization.dto.ts +++ b/packages/core/src/organization/dto/update-organization.dto.ts @@ -1,13 +1,12 @@ -import { IntersectionType } from "@nestjs/swagger"; -import { IOrganizationUpdateInput } from "@gauzy/contracts"; -import { CreateOrganizationDTO } from "./create-organization.dto"; -import { OrganizationPublicSettingDTO } from "./organization-public-setting.dto"; +import { IntersectionType } from '@nestjs/swagger'; +import { IOrganizationUpdateInput } from '@gauzy/contracts'; +import { CreateOrganizationDTO } from './create-organization.dto'; +import { OrganizationPublicSettingDTO } from './organization-public-setting.dto'; /** * Organization Update DTO validation * */ -export class UpdateOrganizationDTO extends IntersectionType( - CreateOrganizationDTO, - OrganizationPublicSettingDTO -) implements IOrganizationUpdateInput { } +export class UpdateOrganizationDTO + extends IntersectionType(CreateOrganizationDTO, OrganizationPublicSettingDTO) + implements IOrganizationUpdateInput {} diff --git a/packages/core/src/organization/organization.controller.ts b/packages/core/src/organization/organization.controller.ts index eb58e7b6db9..894f8f874be 100644 --- a/packages/core/src/organization/organization.controller.ts +++ b/packages/core/src/organization/organization.controller.ts @@ -1,20 +1,8 @@ -import { IOrganization, IPagination, PermissionsEnum } from '@gauzy/contracts'; -import { - Body, - Controller, - Get, - HttpCode, - HttpStatus, - Param, - Post, - UseGuards, - Put, - Query, - BadRequestException -} from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, UseGuards, Put, Query } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { FindOptionsWhere } from 'typeorm'; +import { ID, IOrganization, IPagination, PermissionsEnum } from '@gauzy/contracts'; import { CrudController } from './../core/crud'; import { UUIDValidationPipe, UseValidationPipe } from './../shared/pipes'; import { Permissions } from './../shared/decorators'; @@ -27,7 +15,7 @@ import { CreateOrganizationDTO, OrganizationFindOptionsDTO, UpdateOrganizationDT @ApiTags('Organization') @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ALL_ORG_EDIT) -@Controller() +@Controller('/organization') export class OrganizationController extends CrudController { constructor(private readonly organizationService: OrganizationService, private readonly commandBus: CommandBus) { super(organizationService); @@ -40,7 +28,7 @@ export class OrganizationController extends CrudController { * @returns */ @Permissions(PermissionsEnum.ALL_ORG_VIEW) - @Get('count') + @Get('/count') async getCount(@Query() options: FindOptionsWhere): Promise { return await this.organizationService.countBy(options); } @@ -48,11 +36,24 @@ export class OrganizationController extends CrudController { /** * GET organization pagination * - * @param options - * @returns + * Retrieve a paginated list of organizations within the tenant. + * + * @param options Query options for pagination and filtering + * @returns Paginated list of organizations */ + @ApiOperation({ summary: 'Retrieve paginated list of organizations within the tenant.' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successfully retrieved paginated list of organizations.', + type: Organization, + isArray: true + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'No organizations found.' + }) @Permissions(PermissionsEnum.ALL_ORG_VIEW) - @Get('pagination') + @Get('/pagination') @UseValidationPipe({ transform: true }) async pagination(@Query() options: OrganizationFindOptionsDTO): Promise> { return await this.organizationService.paginate(options); @@ -61,71 +62,79 @@ export class OrganizationController extends CrudController { /** * GET organizations by find many conditions * - * @param options - * @returns + * Find all organizations within the tenant, optionally applying filters. + * + * @param options Query options for filtering organizations + * @returns A list of organizations based on the applied filters */ @ApiOperation({ summary: 'Find all organizations within the tenant.' }) @ApiResponse({ status: HttpStatus.OK, - description: 'Found organizations', - type: Organization + description: 'Successfully retrieved organizations.', + type: Organization, + isArray: true }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: 'Record not found' + description: 'No organizations found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW) - @Get() + @Get('/') @UseValidationPipe({ transform: true }) async findAll(@Query() options: OrganizationFindOptionsDTO): Promise> { - try { - return await this.organizationService.findAll(options); - } catch (error) { - throw new BadRequestException(error); - } + return await this.organizationService.findAll(options); } /** * GET organization by id * - * @param id - * @param options - * @returns + * Find an organization by its ID within the tenant. + * + * @param id The unique ID of the organization + * @param options Query options for additional filtering + * @returns The organization that matches the ID */ - @ApiOperation({ summary: 'Find Organization by id within the tenant.' }) + @ApiOperation({ summary: 'Find Organization by ID within the tenant.' }) @ApiResponse({ status: HttpStatus.OK, - description: 'Found one record', + description: 'Successfully retrieved the organization.', type: Organization }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: 'Record not found' + description: 'No organization found with the provided ID.' + }) + @ApiParam({ + name: 'id', + description: 'The unique identifier (UUID) of the organization.' }) @Permissions() @Get(':id') @UseValidationPipe({ transform: true }) async findById( - @Param('id', UUIDValidationPipe) id: IOrganization['id'], + @Param('id', UUIDValidationPipe) id: ID, @Query() options: OrganizationFindOptionsDTO ): Promise { return await this.organizationService.findOneByIdString(id, options); } /** - * CREATE organization for specific tenant + * CREATE organization for a specific tenant * - * @param entity - * @returns + * Creates a new organization within the tenant. + * + * @param entity The DTO containing organization details + * @returns The newly created organization */ - @ApiOperation({ summary: 'Create new Organization' }) + @ApiOperation({ summary: 'Create a new Organization for a specific tenant' }) @ApiResponse({ status: HttpStatus.CREATED, - description: 'The Organization has been successfully created.' + description: 'The Organization has been successfully created.', + type: Organization }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the response body may contain clues as to what went wrong.' }) @HttpCode(HttpStatus.CREATED) @Post() @@ -137,24 +146,36 @@ export class OrganizationController extends CrudController { /** * UPDATE organization by id * - * @param id - * @param entity - * @returns + * Update an existing organization by its ID within the tenant. + * + * @param id The unique ID of the organization + * @param entity The DTO containing updated organization details + * @returns The updated organization */ - @ApiOperation({ summary: 'Update existing Organization' }) + @ApiOperation({ summary: 'Update an existing Organization' }) @ApiResponse({ status: HttpStatus.OK, - description: 'The Organization has been successfully updated.' + description: 'The Organization has been successfully updated.', + type: Organization }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the response body may contain clues as to what went wrong.' + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'No organization found with the provided ID.' + }) + @ApiParam({ + name: 'id', + type: String, + description: 'The unique identifier (UUID) of the organization.' }) @HttpCode(HttpStatus.OK) @Put(':id') @UseValidationPipe() async update( - @Param('id', UUIDValidationPipe) id: IOrganization['id'], + @Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateOrganizationDTO ): Promise { return await this.commandBus.execute(new OrganizationUpdateCommand(id, entity)); diff --git a/packages/core/src/organization/organization.entity.ts b/packages/core/src/organization/organization.entity.ts index f6e0a07195b..e108ad35f32 100644 --- a/packages/core/src/organization/organization.entity.ts +++ b/packages/core/src/organization/organization.entity.ts @@ -1,6 +1,7 @@ import { JoinColumn, JoinTable, RelationId } from 'typeorm'; -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; import { DefaultValueDateTypeEnum, IOrganization, @@ -21,7 +22,12 @@ import { IAccountingTemplate, IReportOrganization, IImageAsset, - ID + ID, + DEFAULT_PROFIT_BASED_BONUS, + BonusTypeEnum, + CurrenciesEnum, + DEFAULT_INVITE_EXPIRY_PERIOD, + DEFAULT_STANDARD_WORK_HOURS_PER_DAY } from '@gauzy/contracts'; import { AccountingTemplate, @@ -53,6 +59,9 @@ import { MikroOrmOrganizationRepository } from './repository/mikro-orm-organizat @MultiORMEntity('organization', { mikroOrmRepository: () => MikroOrmOrganizationRepository }) export class Organization extends TenantBaseEntity implements IOrganization { + @ApiProperty({ type: () => String, required: true }) + @IsNotEmpty() + @IsString() @ColumnIndex() @MultiORMColumn() name: string; @@ -88,6 +97,19 @@ export class Organization extends TenantBaseEntity implements IOrganization { @MultiORMColumn({ length: 500, nullable: true }) imageUrl?: string; + /** + * Currency + * + * The currency used by the organization, selected from the CurrenciesEnum. + */ + @ApiProperty({ + enum: CurrenciesEnum, + example: CurrenciesEnum.USD, + required: true, + description: 'The currency used by the organization, must be one of the CurrenciesEnum values.' + }) + @IsNotEmpty() + @IsEnum(CurrenciesEnum) @ColumnIndex() @MultiORMColumn() currency: string; @@ -95,6 +117,18 @@ export class Organization extends TenantBaseEntity implements IOrganization { @MultiORMColumn({ nullable: true }) valueDate?: Date; + /** + * Default Value Date Type + * + * The type of default value for a date field, which can be one of the values from DefaultValueDateTypeEnum. + */ + @ApiPropertyOptional({ + enum: DefaultValueDateTypeEnum, + example: DefaultValueDateTypeEnum.TODAY, + description: 'The default value date type, can be one of the values from DefaultValueDateTypeEnum.' + }) + @IsOptional() + @IsEnum(DefaultValueDateTypeEnum) @ColumnIndex() @MultiORMColumn({ type: 'simple-enum', @@ -110,6 +144,17 @@ export class Organization extends TenantBaseEntity implements IOrganization { @MultiORMColumn({ nullable: true }) timeZone?: string; + /** + * Region Code + * + * The code representing a specific region, such as a country or geographical area. + */ + @ApiPropertyOptional({ + type: () => String, + description: 'The code representing a specific region (e.g., country or geographical area).' + }) + @IsOptional() + @IsString() @MultiORMColumn({ nullable: true }) regionCode?: string; @@ -122,6 +167,18 @@ export class Organization extends TenantBaseEntity implements IOrganization { @MultiORMColumn({ nullable: true }) officialName?: string; + /** + * Start Week On + * + * Specifies which day the week starts on. The value must be one of the days from WeekDaysEnum. + */ + @ApiPropertyOptional({ + enum: WeekDaysEnum, + example: WeekDaysEnum.MONDAY, + description: 'Specifies which day the week starts on. Must be one of the WeekDaysEnum values.' + }) + @IsOptional() + @IsEnum(WeekDaysEnum) @MultiORMColumn({ nullable: true }) startWeekOn?: WeekDaysEnum; @@ -134,42 +191,107 @@ export class Organization extends TenantBaseEntity implements IOrganization { @MultiORMColumn({ nullable: true }) minimumProjectSize?: string; + /** + * Bonus Type + * + * The type of bonus, which can be one of the values from BonusTypeEnum. + */ + @ApiPropertyOptional({ + enum: BonusTypeEnum, + example: BonusTypeEnum.PROFIT_BASED_BONUS, + description: 'The type of bonus, can be one of the defined BonusTypeEnum values.' + }) + @IsOptional() + @IsEnum(BonusTypeEnum) @MultiORMColumn({ nullable: true }) - bonusType?: string; + bonusType?: BonusTypeEnum; + /** + * Bonus Percentage + * + * The percentage of profit-based bonus (between 0 and 100). + */ + @ApiPropertyOptional({ + type: () => Number, + example: DEFAULT_PROFIT_BASED_BONUS, + description: 'The percentage of profit-based bonus, must be between 0 and 100.' + }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) @MultiORMColumn({ nullable: true }) bonusPercentage?: number; @MultiORMColumn({ nullable: true, default: true }) invitesAllowed?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_income?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_profits?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_bonuses_paid?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_total_hours?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_minimum_project_size?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_projects_count?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_clients_count?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_clients?: boolean; + @ApiPropertyOptional({ type: () => Boolean }) + @IsOptional() + @IsBoolean() @MultiORMColumn({ nullable: true }) show_employees_count?: boolean; + /** + * Default Organization Invite Expiry Period + * + * The default period (in days) after which an organization invite expires. + */ + @ApiPropertyOptional({ + type: () => Number, + example: DEFAULT_INVITE_EXPIRY_PERIOD, + description: 'The default invite expiry period in days.' + }) + @IsOptional() + @Transform(({ value }: TransformFnParams) => parseInt(value, 10), { toClassOnly: true }) @MultiORMColumn({ nullable: true }) inviteExpiryPeriod?: number; @@ -328,6 +450,22 @@ export class Organization extends TenantBaseEntity implements IOrganization { @MultiORMColumn({ nullable: true, default: false }) enforced?: boolean; + /** + * Standard work hours per day for the organization. + */ + @ApiPropertyOptional({ + type: () => Number, + description: 'Standard work hours per day for the organization', + minimum: 1, + maximum: 24 + }) + @IsOptional() + @IsNumber() + @Max(24, { message: 'Standard work hours per day cannot exceed 24 hours' }) + @Min(1, { message: 'Standard work hours per day must be at least 1 hour' }) + @MultiORMColumn({ nullable: true, default: DEFAULT_STANDARD_WORK_HOURS_PER_DAY }) + standardWorkHoursPerDay?: number; + /* |-------------------------------------------------------------------------- | @ManyToOne @@ -432,7 +570,7 @@ export class Organization extends TenantBaseEntity implements IOrganization { | @ManyToMany |-------------------------------------------------------------------------- */ - // Tags + // Organization Tags @MultiORMManyToMany(() => Tag, (it) => it.organizations, { onUpdate: 'CASCADE', onDelete: 'CASCADE', @@ -444,8 +582,11 @@ export class Organization extends TenantBaseEntity implements IOrganization { @JoinTable({ name: 'tag_organization' }) - tags: ITag[]; + tags?: ITag[]; + /** + * Organization Skills + */ @MultiORMManyToMany(() => Skill, (skill) => skill.organizations, { onUpdate: 'CASCADE', onDelete: 'CASCADE' diff --git a/packages/core/src/organization/organization.module.ts b/packages/core/src/organization/organization.module.ts index 77b167d65ca..9e68f67acbe 100644 --- a/packages/core/src/organization/organization.module.ts +++ b/packages/core/src/organization/organization.module.ts @@ -1,6 +1,5 @@ import { CqrsModule } from '@nestjs/cqrs'; import { forwardRef, Module } from '@nestjs/common'; -import { RouterModule } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { UserOrganizationModule } from '../user-organization/user-organization.module'; @@ -15,9 +14,6 @@ import { TypeOrmOrganizationRepository } from './repository'; @Module({ imports: [ - RouterModule.register([ - { path: '/organization', module: OrganizationModule } - ]), TypeOrmModule.forFeature([Organization]), MikroOrmModule.forFeature([Organization]), forwardRef(() => RolePermissionModule), @@ -30,4 +26,4 @@ import { TypeOrmOrganizationRepository } from './repository'; providers: [OrganizationService, TypeOrmOrganizationRepository, ...CommandHandlers], exports: [TypeOrmModule, MikroOrmModule, OrganizationService, TypeOrmOrganizationRepository] }) -export class OrganizationModule { } +export class OrganizationModule {} diff --git a/packages/core/src/password-reset/commands/handlers/index.ts b/packages/core/src/password-reset/commands/handlers/index.ts index 270ddf2f487..17c4359a2d0 100644 --- a/packages/core/src/password-reset/commands/handlers/index.ts +++ b/packages/core/src/password-reset/commands/handlers/index.ts @@ -1,7 +1,4 @@ import { PasswordResetCreateHandler } from './password-reset.create.handler'; import { PasswordResetGetHandler } from './password-reset.get.handler'; -export const CommandHandlers = [ - PasswordResetCreateHandler, - PasswordResetGetHandler -]; +export const CommandHandlers = [PasswordResetCreateHandler, PasswordResetGetHandler]; diff --git a/packages/core/src/password-reset/commands/handlers/password-reset.create.handler.ts b/packages/core/src/password-reset/commands/handlers/password-reset.create.handler.ts index a70f45acccb..f74014488bf 100644 --- a/packages/core/src/password-reset/commands/handlers/password-reset.create.handler.ts +++ b/packages/core/src/password-reset/commands/handlers/password-reset.create.handler.ts @@ -5,10 +5,7 @@ import { PasswordResetService } from './../../password-reset.service'; @CommandHandler(PasswordResetCreateCommand) export class PasswordResetCreateHandler implements ICommandHandler { - - constructor( - private readonly _passwordResetService: PasswordResetService - ) { } + constructor(private readonly _passwordResetService: PasswordResetService) {} /** * Execute a command to create a password reset request. diff --git a/packages/core/src/password-reset/commands/handlers/password-reset.get.handler.ts b/packages/core/src/password-reset/commands/handlers/password-reset.get.handler.ts index 30ea4c1ccd4..c1daf647c86 100644 --- a/packages/core/src/password-reset/commands/handlers/password-reset.get.handler.ts +++ b/packages/core/src/password-reset/commands/handlers/password-reset.get.handler.ts @@ -1,34 +1,38 @@ -import { NotFoundException } from '@nestjs/common'; +import { HttpException, HttpStatus } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { IPasswordReset } from '@gauzy/contracts'; import { PasswordResetGetCommand } from './../password-reset.get.command'; import { PasswordResetService } from './../../password-reset.service'; -import { IPasswordReset } from '@gauzy/contracts'; @CommandHandler(PasswordResetGetCommand) -export class PasswordResetGetHandler - implements ICommandHandler { - - constructor( - private readonly _passwordResetService : PasswordResetService - ) {} - - public async execute( - command: PasswordResetGetCommand - ): Promise { - const { input } = command; - const { token } = input; +export class PasswordResetGetHandler implements ICommandHandler { + constructor(private readonly _passwordResetService: PasswordResetService) {} + /** + * Executes the PasswordResetGetCommand to retrieve a password reset entry. + * + * This method searches for a password reset entry based on the provided token and returns the latest entry (sorted by createdAt in descending order). + * + * - If the token is found, the corresponding password reset entry is returned. + * - If no matching token is found, a NotFoundException is thrown. + * + * @param {PasswordResetGetCommand} command - The command containing the input data, specifically the token for the password reset request. + * @returns {Promise} - A promise that resolves to the matching password reset entry. + * @throws {NotFoundException} - If no password reset entry is found for the provided token. + */ + public async execute(command: PasswordResetGetCommand): Promise { try { + // Extract the token from the command input + const { token } = command.input; + + // Find the password reset entry using the token return await this._passwordResetService.findOneByOptions({ - where: { - token - }, - order: { - createdAt: 'DESC' - } + where: { token, isActive: true, isArchived: false }, + order: { createdAt: 'DESC' } }); } catch (error) { - throw new NotFoundException(error); + // Handle errors and return an appropriate error response + throw new HttpException(`Forgot password request failed!`, HttpStatus.BAD_REQUEST); } } } diff --git a/packages/core/src/password-reset/commands/index.ts b/packages/core/src/password-reset/commands/index.ts index 4673bc4c275..cbfbfcd9179 100644 --- a/packages/core/src/password-reset/commands/index.ts +++ b/packages/core/src/password-reset/commands/index.ts @@ -1,2 +1,2 @@ export * from './password-reset.create.command'; -export * from './password-reset.get.command'; \ No newline at end of file +export * from './password-reset.get.command'; diff --git a/packages/core/src/password-reset/commands/password-reset.create.command.ts b/packages/core/src/password-reset/commands/password-reset.create.command.ts index 873cc5e6554..669beb50f90 100644 --- a/packages/core/src/password-reset/commands/password-reset.create.command.ts +++ b/packages/core/src/password-reset/commands/password-reset.create.command.ts @@ -1,10 +1,8 @@ -import { IPasswordReset } from '@gauzy/contracts'; import { ICommand } from '@nestjs/cqrs'; +import { IPasswordReset } from '@gauzy/contracts'; export class PasswordResetCreateCommand implements ICommand { static readonly type = '[Password Reset] Create'; - constructor( - public readonly input: IPasswordReset - ) {} + constructor(public readonly input: IPasswordReset) {} } diff --git a/packages/core/src/password-reset/commands/password-reset.get.command.ts b/packages/core/src/password-reset/commands/password-reset.get.command.ts index 102188d48eb..8e8f65de8af 100644 --- a/packages/core/src/password-reset/commands/password-reset.get.command.ts +++ b/packages/core/src/password-reset/commands/password-reset.get.command.ts @@ -1,10 +1,8 @@ -import { IPasswordResetFindInput } from '@gauzy/contracts'; import { ICommand } from '@nestjs/cqrs'; +import { IPasswordResetFindInput } from '@gauzy/contracts'; export class PasswordResetGetCommand implements ICommand { static readonly type = '[Password Reset] Get'; - constructor( - public readonly input: IPasswordResetFindInput - ) {} + constructor(public readonly input: IPasswordResetFindInput) {} } diff --git a/packages/core/src/password-reset/dto/change-password-request.dto.ts b/packages/core/src/password-reset/dto/change-password-request.dto.ts index f84867eee28..e213fd03e59 100644 --- a/packages/core/src/password-reset/dto/change-password-request.dto.ts +++ b/packages/core/src/password-reset/dto/change-password-request.dto.ts @@ -1,33 +1,26 @@ -import { IChangePasswordRequest } from "@gauzy/contracts"; -import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsString, MinLength } from "class-validator"; -import { Match } from "./../../shared/validators"; +import { IChangePasswordRequest } from '@gauzy/contracts'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { Match } from './../../shared/validators'; /** * Change Password Request DTO validation */ export class ChangePasswordRequestDTO implements IChangePasswordRequest { + @ApiProperty({ type: () => String }) + @IsNotEmpty({ message: 'Authorization token is invalid or missing.' }) + @IsString({ message: 'Authorization token must be string.' }) + readonly token: string; - @ApiProperty({ type: () => String }) - @IsNotEmpty({ - message: 'Authorization token is invalid or missing.' - }) - @IsString({ - message: 'Authorization token must be string.' - }) - readonly token: string; + @ApiProperty({ type: () => String }) + @IsNotEmpty({ message: 'Password should not be empty' }) + @MinLength(4, { message: 'Password should be at least 4 characters long.' }) + readonly password: string; - @ApiProperty({ type: () => String }) - @IsNotEmpty({ message: "Password should not be empty" }) - @MinLength(4, { - message: 'Password should be at least 4 characters long.' - }) - readonly password: string; - - @ApiProperty({ type: () => String }) - @IsNotEmpty({ message: "Confirm password should not be empty" }) - @Match(ChangePasswordRequestDTO, (it) => it.password, { - message: 'The password and confirmation password must match.' - }) - readonly confirmPassword: string; -} \ No newline at end of file + @ApiProperty({ type: () => String }) + @IsNotEmpty({ message: 'Confirm password should not be empty' }) + @Match(ChangePasswordRequestDTO, (it) => it.password, { + message: 'The password and confirmation password must match.' + }) + readonly confirmPassword: string; +} diff --git a/packages/core/src/password-reset/dto/index.ts b/packages/core/src/password-reset/dto/index.ts index caff018c545..cd010b57d02 100644 --- a/packages/core/src/password-reset/dto/index.ts +++ b/packages/core/src/password-reset/dto/index.ts @@ -1,2 +1,2 @@ export { ChangePasswordRequestDTO } from './change-password-request.dto'; -export { ResetPasswordRequestDTO } from './reset-password-request.dto'; \ No newline at end of file +export { ResetPasswordRequestDTO } from './reset-password-request.dto'; diff --git a/packages/core/src/password-reset/dto/reset-password-request.dto.ts b/packages/core/src/password-reset/dto/reset-password-request.dto.ts index ffdc15e6355..8459a64be29 100644 --- a/packages/core/src/password-reset/dto/reset-password-request.dto.ts +++ b/packages/core/src/password-reset/dto/reset-password-request.dto.ts @@ -1,7 +1,7 @@ -import { IResetPasswordRequest } from "@gauzy/contracts"; -import { UserEmailDTO } from "./../../user/dto"; +import { IResetPasswordRequest } from '@gauzy/contracts'; +import { UserEmailDTO } from './../../user/dto'; /** * Reset Password Request DTO validation */ -export class ResetPasswordRequestDTO extends UserEmailDTO implements IResetPasswordRequest {} \ No newline at end of file +export class ResetPasswordRequestDTO extends UserEmailDTO implements IResetPasswordRequest {} diff --git a/packages/core/src/password-reset/password-reset.entity.ts b/packages/core/src/password-reset/password-reset.entity.ts index d85602035ce..51dcb713224 100644 --- a/packages/core/src/password-reset/password-reset.entity.ts +++ b/packages/core/src/password-reset/password-reset.entity.ts @@ -9,8 +9,11 @@ import { MikroOrmPasswordResetRepository } from './repository/mikro-orm-password @MultiORMEntity('password_reset', { mikroOrmRepository: () => MikroOrmPasswordResetRepository }) export class PasswordReset extends TenantBaseEntity implements IPasswordReset { - - /** */ + /** + * The `email` column stores the user's email address. + * + * @example "user@example.com" + */ @ApiProperty({ type: () => String }) @IsNotEmpty() @IsEmail() @@ -18,25 +21,34 @@ export class PasswordReset extends TenantBaseEntity implements IPasswordReset { @MultiORMColumn() email: string; - /** */ + /** + * Token field to store a long string (text). + * + */ @ApiProperty({ type: () => String }) @IsNotEmpty() @IsString() @ColumnIndex() - @MultiORMColumn() + @MultiORMColumn({ type: 'text' }) token: string; - /** Additional virtual columns */ + /** + * Virtual column to indicate if the token or record is expired. + * + * This field is not stored in the database but is computed dynamically. + * + * @example false + */ @VirtualMultiOrmColumn() expired?: boolean; /** - * Called after entity is loaded. - */ + * Called after entity is loaded to check if the entity is expired. + */ @AfterLoad() afterLoadEntity?() { - const createdAt = moment(this.createdAt, 'YYYY-MM-DD HH:mm:ss'); - const expiredAt = moment(moment(), 'YYYY-MM-DD HH:mm:ss'); - this.expired = expiredAt.diff(createdAt, 'minutes') > 10; + // Calculate the difference between current time and createdAt in minutes + const expiredAt = moment(); + this.expired = expiredAt.diff(moment(this.createdAt), 'minutes') > 10; } } diff --git a/packages/core/src/password-reset/password-reset.module.ts b/packages/core/src/password-reset/password-reset.module.ts index 7a0dd5a9a49..dc726a948f0 100644 --- a/packages/core/src/password-reset/password-reset.module.ts +++ b/packages/core/src/password-reset/password-reset.module.ts @@ -4,19 +4,11 @@ import { MikroOrmModule } from '@mikro-orm/nestjs'; import { CommandHandlers } from './commands/handlers'; import { PasswordReset } from './password-reset.entity'; import { PasswordResetService } from './password-reset.service'; +import { TypeOrmPasswordResetRepository } from './repository/type-orm-password-reset.repository'; @Module({ - imports: [ - TypeOrmModule.forFeature([PasswordReset]), - MikroOrmModule.forFeature([PasswordReset]) - ], - providers: [ - PasswordResetService, - ...CommandHandlers - ], - exports: [ - TypeOrmModule, - PasswordResetService - ] + imports: [TypeOrmModule.forFeature([PasswordReset]), MikroOrmModule.forFeature([PasswordReset])], + providers: [PasswordResetService, TypeOrmPasswordResetRepository, ...CommandHandlers], + exports: [TypeOrmModule, MikroOrmModule, PasswordResetService, TypeOrmPasswordResetRepository] }) -export class PasswordResetModule { } +export class PasswordResetModule {} diff --git a/packages/core/src/password-reset/password-reset.service.ts b/packages/core/src/password-reset/password-reset.service.ts index 5228ff3e46d..e58672afa7b 100644 --- a/packages/core/src/password-reset/password-reset.service.ts +++ b/packages/core/src/password-reset/password-reset.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { CrudService } from './../core/crud'; import { PasswordReset } from './password-reset.entity'; import { TypeOrmPasswordResetRepository } from './repository/type-orm-password-reset.repository'; @@ -8,9 +7,7 @@ import { MikroOrmPasswordResetRepository } from './repository/mikro-orm-password @Injectable() export class PasswordResetService extends CrudService { constructor( - @InjectRepository(PasswordReset) typeOrmPasswordResetRepository: TypeOrmPasswordResetRepository, - mikroOrmPasswordResetRepository: MikroOrmPasswordResetRepository ) { super(typeOrmPasswordResetRepository, mikroOrmPasswordResetRepository); diff --git a/packages/core/src/password-reset/repository/mikro-orm-password-reset.repository.ts b/packages/core/src/password-reset/repository/mikro-orm-password-reset.repository.ts index 32a053cfc3e..6ece8179d3e 100644 --- a/packages/core/src/password-reset/repository/mikro-orm-password-reset.repository.ts +++ b/packages/core/src/password-reset/repository/mikro-orm-password-reset.repository.ts @@ -1,4 +1,4 @@ import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; import { PasswordReset } from '../password-reset.entity'; -export class MikroOrmPasswordResetRepository extends MikroOrmBaseEntityRepository { } +export class MikroOrmPasswordResetRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/password-reset/repository/type-orm-password-reset.repository.ts b/packages/core/src/password-reset/repository/type-orm-password-reset.repository.ts index bd85c7d638b..55819a0ebc8 100644 --- a/packages/core/src/password-reset/repository/type-orm-password-reset.repository.ts +++ b/packages/core/src/password-reset/repository/type-orm-password-reset.repository.ts @@ -5,7 +5,7 @@ import { PasswordReset } from '../password-reset.entity'; @Injectable() export class TypeOrmPasswordResetRepository extends Repository { - constructor(@InjectRepository(PasswordReset) readonly repository: Repository) { - super(repository.target, repository.manager, repository.queryRunner); - } + constructor(@InjectRepository(PasswordReset) readonly repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } } diff --git a/packages/core/src/resource-link/commands/handlers/index.ts b/packages/core/src/resource-link/commands/handlers/index.ts new file mode 100644 index 00000000000..282b11f7a12 --- /dev/null +++ b/packages/core/src/resource-link/commands/handlers/index.ts @@ -0,0 +1,4 @@ +import { ResourceLinkCreateHandler } from './resource-link.create.handler'; +import { ResourceLinkUpdateHandler } from './resource-link.update.handler'; + +export const CommandHandlers = [ResourceLinkCreateHandler, ResourceLinkUpdateHandler]; diff --git a/packages/core/src/resource-link/commands/handlers/resource-link.create.handler.ts b/packages/core/src/resource-link/commands/handlers/resource-link.create.handler.ts new file mode 100644 index 00000000000..e4c3aabd7ea --- /dev/null +++ b/packages/core/src/resource-link/commands/handlers/resource-link.create.handler.ts @@ -0,0 +1,14 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { IResourceLink } from '@gauzy/contracts'; +import { ResourceLinkService } from '../../resource-link.service'; +import { ResourceLinkCreateCommand } from '../resource-link.create.command'; + +@CommandHandler(ResourceLinkCreateCommand) +export class ResourceLinkCreateHandler implements ICommandHandler { + constructor(private readonly resourceLinkService: ResourceLinkService) {} + + public async execute(command: ResourceLinkCreateCommand): Promise { + const { input } = command; + return await this.resourceLinkService.create(input); + } +} diff --git a/packages/core/src/resource-link/commands/handlers/resource-link.update.handler.ts b/packages/core/src/resource-link/commands/handlers/resource-link.update.handler.ts new file mode 100644 index 00000000000..c2b1707f448 --- /dev/null +++ b/packages/core/src/resource-link/commands/handlers/resource-link.update.handler.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { UpdateResult } from 'typeorm'; +import { IResourceLink } from '@gauzy/contracts'; +import { ResourceLinkService } from '../../resource-link.service'; +import { ResourceLinkUpdateCommand } from '../resource-link.update.command'; + +@CommandHandler(ResourceLinkUpdateCommand) +export class ResourceLinkUpdateHandler implements ICommandHandler { + constructor(private readonly resourceLinkService: ResourceLinkService) {} + + public async execute(command: ResourceLinkUpdateCommand): Promise { + const { id, input } = command; + return await this.resourceLinkService.update(id, input); + } +} diff --git a/packages/core/src/resource-link/commands/index.ts b/packages/core/src/resource-link/commands/index.ts new file mode 100644 index 00000000000..8c0bbe36f36 --- /dev/null +++ b/packages/core/src/resource-link/commands/index.ts @@ -0,0 +1,2 @@ +export * from './resource-link.create.command'; +export * from './resource-link.update.command'; diff --git a/packages/core/src/resource-link/commands/resource-link.create.command.ts b/packages/core/src/resource-link/commands/resource-link.create.command.ts new file mode 100644 index 00000000000..f30eb3a595f --- /dev/null +++ b/packages/core/src/resource-link/commands/resource-link.create.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { IResourceLinkCreateInput } from '@gauzy/contracts'; + +export class ResourceLinkCreateCommand implements ICommand { + static readonly type = '[Resource Link] Create'; + + constructor(public readonly input: IResourceLinkCreateInput) {} +} diff --git a/packages/core/src/resource-link/commands/resource-link.update.command.ts b/packages/core/src/resource-link/commands/resource-link.update.command.ts new file mode 100644 index 00000000000..0d985a44a4f --- /dev/null +++ b/packages/core/src/resource-link/commands/resource-link.update.command.ts @@ -0,0 +1,8 @@ +import { ICommand } from '@nestjs/cqrs'; +import { IResourceLinkUpdateInput, ID } from '@gauzy/contracts'; + +export class ResourceLinkUpdateCommand implements ICommand { + static readonly type = '[Resource Link] Update'; + + constructor(public readonly id: ID, public readonly input: IResourceLinkUpdateInput) {} +} diff --git a/packages/core/src/resource-link/dto/create-resource-link.dto.ts b/packages/core/src/resource-link/dto/create-resource-link.dto.ts new file mode 100644 index 00000000000..3204a536d92 --- /dev/null +++ b/packages/core/src/resource-link/dto/create-resource-link.dto.ts @@ -0,0 +1,11 @@ +import { IntersectionType, OmitType } from '@nestjs/swagger'; +import { IResourceLinkCreateInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../core/dto'; +import { ResourceLink } from '../resource-link.entity'; + +/** + * Create ResourceLink data validation request DTO + */ +export class CreateResourceLinkDTO + extends IntersectionType(TenantOrganizationBaseDTO, OmitType(ResourceLink, ['creatorId', 'creator'])) + implements IResourceLinkCreateInput {} diff --git a/packages/core/src/resource-link/dto/index.ts b/packages/core/src/resource-link/dto/index.ts new file mode 100644 index 00000000000..f0d590a8a8f --- /dev/null +++ b/packages/core/src/resource-link/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-resource-link.dto'; +export * from './update-resource-link.dto'; diff --git a/packages/core/src/resource-link/dto/update-resource-link.dto.ts b/packages/core/src/resource-link/dto/update-resource-link.dto.ts new file mode 100644 index 00000000000..616d9c9e740 --- /dev/null +++ b/packages/core/src/resource-link/dto/update-resource-link.dto.ts @@ -0,0 +1,8 @@ +import { PartialType } from '@nestjs/swagger'; +import { IResourceLinkUpdateInput } from '@gauzy/contracts'; +import { CreateResourceLinkDTO } from './create-resource-link.dto'; + +/** + * Create ResourceLink data validation request DTO + */ +export class UpdateResourceLinkDTO extends PartialType(CreateResourceLinkDTO) implements IResourceLinkUpdateInput {} diff --git a/packages/core/src/resource-link/repository/index.ts b/packages/core/src/resource-link/repository/index.ts new file mode 100644 index 00000000000..7768402a970 --- /dev/null +++ b/packages/core/src/resource-link/repository/index.ts @@ -0,0 +1,2 @@ +export * from './mikro-orm-resource-link.repository'; +export * from './type-orm-resource-link.repository'; diff --git a/packages/core/src/resource-link/repository/mikro-orm-resource-link.repository.ts b/packages/core/src/resource-link/repository/mikro-orm-resource-link.repository.ts new file mode 100644 index 00000000000..d55eeb05c7a --- /dev/null +++ b/packages/core/src/resource-link/repository/mikro-orm-resource-link.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../core/repository/mikro-orm-base-entity.repository'; +import { ResourceLink } from '../resource-link.entity'; + +export class MikroOrmResourceLinkRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/resource-link/repository/type-orm-resource-link.repository.ts b/packages/core/src/resource-link/repository/type-orm-resource-link.repository.ts new file mode 100644 index 00000000000..d7673989321 --- /dev/null +++ b/packages/core/src/resource-link/repository/type-orm-resource-link.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ResourceLink } from '../resource-link.entity'; + +@Injectable() +export class TypeOrmResourceLinkRepository extends Repository { + constructor(@InjectRepository(ResourceLink) readonly repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/resource-link/resource-link.controller.ts b/packages/core/src/resource-link/resource-link.controller.ts new file mode 100644 index 00000000000..8c8c4fa6ad8 --- /dev/null +++ b/packages/core/src/resource-link/resource-link.controller.ts @@ -0,0 +1,122 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CommandBus } from '@nestjs/cqrs'; +import { DeleteResult } from 'typeorm'; +import { IResourceLink, IResourceLinkUpdateInput, ID, IPagination } from '@gauzy/contracts'; +import { UUIDValidationPipe, UseValidationPipe } from './../shared/pipes'; +import { PermissionGuard, TenantPermissionGuard } from '../shared/guards'; +import { CrudController, OptionParams, PaginationParams } from './../core/crud'; +import { ResourceLink } from './resource-link.entity'; +import { ResourceLinkService } from './resource-link.service'; +import { ResourceLinkCreateCommand, ResourceLinkUpdateCommand } from './commands'; +import { CreateResourceLinkDTO, UpdateResourceLinkDTO } from './dto'; + +@ApiTags('Resource Links') +@UseGuards(TenantPermissionGuard, PermissionGuard) +@Controller() +export class ResourceLinkController extends CrudController { + constructor(private readonly resourceLinkService: ResourceLinkService, private readonly commandBus: CommandBus) { + super(resourceLinkService); + } + + @ApiOperation({ + summary: 'Find all resource links filtered by type.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Found resource links', + type: ResourceLink + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @Get() + @UseValidationPipe() + async findAll(@Query() params: PaginationParams): Promise> { + return await this.resourceLinkService.findAll(params); + } + + @ApiOperation({ summary: 'Find by id' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Found one record' /*, type: T*/ + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @Get(':id') + async findById( + @Param('id', UUIDValidationPipe) id: ID, + @Query() params: OptionParams + ): Promise { + return this.resourceLinkService.findOneByIdString(id, params); + } + + @ApiOperation({ summary: 'Create a resource link' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully created.' + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Post() + @UseValidationPipe({ whitelist: true }) + async create(@Body() entity: CreateResourceLinkDTO): Promise { + return await this.commandBus.execute(new ResourceLinkCreateCommand(entity)); + } + + @ApiOperation({ summary: 'Update an existing resource link' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully edited.' + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Put(':id') + @UseValidationPipe({ whitelist: true }) + async update( + @Param('id', UUIDValidationPipe) id: ID, + @Body() entity: UpdateResourceLinkDTO + ): Promise { + return await this.commandBus.execute(new ResourceLinkUpdateCommand(id, entity)); + } + + @ApiOperation({ summary: 'Delete resource' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'The record has been successfully deleted' + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Delete('/:id') + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { + return await this.resourceLinkService.delete(id); + } +} diff --git a/packages/core/src/resource-link/resource-link.entity.ts b/packages/core/src/resource-link/resource-link.entity.ts new file mode 100644 index 00000000000..4409e0aefcb --- /dev/null +++ b/packages/core/src/resource-link/resource-link.entity.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { EntityRepositoryType } from '@mikro-orm/core'; +import { JoinColumn, RelationId } from 'typeorm'; +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUrl, IsUUID } from 'class-validator'; +import { BaseEntityEnum, ID, IResourceLink, IURLMetaData, IUser } from '@gauzy/contracts'; +import { isBetterSqlite3, isSqlite } from '@gauzy/config'; +import { TenantOrganizationBaseEntity, User } from '../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../core/decorators/entity'; +import { MikroOrmResourceLinkRepository } from './repository/mikro-orm-resource-link.repository'; + +@MultiORMEntity('resource_link', { mikroOrmRepository: () => MikroOrmResourceLinkRepository }) +export class ResourceLink extends TenantOrganizationBaseEntity implements IResourceLink { + [EntityRepositoryType]?: MikroOrmResourceLinkRepository; + + @ApiProperty({ enum: BaseEntityEnum }) + @IsNotEmpty() + @IsEnum(BaseEntityEnum) + @ColumnIndex() + @MultiORMColumn() + entity: BaseEntityEnum; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUUID() + @ColumnIndex() + @MultiORMColumn() + entityId: ID; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsString() + @MultiORMColumn() + title: string; + + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsUrl() + @MultiORMColumn({ type: 'text' }) + url: string; + + @ApiPropertyOptional({ type: () => (isSqlite() || isBetterSqlite3() ? String : Object) }) + @IsOptional() + @MultiORMColumn({ nullable: true, type: isSqlite() || isBetterSqlite3() ? 'text' : 'json' }) + metaData?: string | IURLMetaData; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + /** + * User Author of the Resource Link + */ + @MultiORMManyToOne(() => User, { + /** Indicates if relation column value can be nullable or not. */ + nullable: true, + + /** Database cascade action on delete. */ + onDelete: 'CASCADE' + }) + @JoinColumn() + creator?: IUser; + + @RelationId((it: ResourceLink) => it.creator) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + creatorId?: ID; +} diff --git a/packages/core/src/resource-link/resource-link.module.ts b/packages/core/src/resource-link/resource-link.module.ts new file mode 100644 index 00000000000..8bc48a28f55 --- /dev/null +++ b/packages/core/src/resource-link/resource-link.module.ts @@ -0,0 +1,27 @@ +import { CqrsModule } from '@nestjs/cqrs'; +import { Module } from '@nestjs/common'; +import { RouterModule } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { RolePermissionModule } from '../role-permission/role-permission.module'; +import { UserModule } from '../user/user.module'; +import { CommandHandlers } from './commands/handlers'; +import { ResourceLink } from './resource-link.entity'; +import { ResourceLinkService } from './resource-link.service'; +import { ResourceLinkController } from './resource-link.controller'; +import { TypeOrmResourceLinkRepository } from './repository/type-orm-resource-link.repository'; + +@Module({ + imports: [ + RouterModule.register([{ path: '/resource-link', module: ResourceLinkModule }]), + TypeOrmModule.forFeature([ResourceLink]), + MikroOrmModule.forFeature([ResourceLink]), + RolePermissionModule, + UserModule, + CqrsModule + ], + providers: [ResourceLinkService, TypeOrmResourceLinkRepository, ...CommandHandlers], + controllers: [ResourceLinkController], + exports: [ResourceLinkService, TypeOrmModule, TypeOrmResourceLinkRepository] +}) +export class ResourceLinkModule {} diff --git a/packages/core/src/resource-link/resource-link.service.ts b/packages/core/src/resource-link/resource-link.service.ts new file mode 100644 index 00000000000..a4f43eb9164 --- /dev/null +++ b/packages/core/src/resource-link/resource-link.service.ts @@ -0,0 +1,116 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { UpdateResult } from 'typeorm'; +import { + IResourceLink, + IResourceLinkCreateInput, + IResourceLinkUpdateInput, + ID, + BaseEntityEnum, + ActorTypeEnum, + ActionTypeEnum +} from '@gauzy/contracts'; +import { TenantAwareCrudService } from './../core/crud'; +import { RequestContext } from '../core/context'; +import { UserService } from '../user/user.service'; +import { ActivityLogService } from '../activity-log/activity-log.service'; +import { ResourceLink } from './resource-link.entity'; +import { TypeOrmResourceLinkRepository } from './repository/type-orm-resource-link.repository'; +import { MikroOrmResourceLinkRepository } from './repository/mikro-orm-resource-link.repository'; + +@Injectable() +export class ResourceLinkService extends TenantAwareCrudService { + constructor( + readonly typeOrmResourceLinkRepository: TypeOrmResourceLinkRepository, + readonly mikroOrmResourceLinkRepository: MikroOrmResourceLinkRepository, + private readonly userService: UserService, + private readonly activityLogService: ActivityLogService + ) { + super(typeOrmResourceLinkRepository, mikroOrmResourceLinkRepository); + } + + /** + * @description Create Resource Link + * @param {IResourceLinkCreateInput} input - Data to creating resource link + * @returns A promise that resolves to the created resource link + * @memberof ResourceLinkService + */ + async create(input: IResourceLinkCreateInput): Promise { + try { + const userId = RequestContext.currentUserId(); + const tenantId = RequestContext.currentTenantId(); + const { ...entity } = input; + + // Employee existence validation + const user = await this.userService.findOneByIdString(userId); + if (!user) { + throw new NotFoundException('User not found'); + } + + // return created resource link + const resourceLink = await super.create({ + ...entity, + tenantId, + creatorId: user.id + }); + + // Generate the activity log + this.activityLogService.logActivity( + BaseEntityEnum.ResourceLink, + ActionTypeEnum.Created, + ActorTypeEnum.User, + resourceLink.id, + resourceLink.title, + resourceLink, + resourceLink.organizationId, + tenantId + ); + + return resourceLink; + } catch (error) { + console.log(error); // Debug Logging + throw new BadRequestException('Resource Link creation failed', error); + } + } + + /** + * @description Update Resource Link + * @param {IResourceLinkUpdateInput} input - Data to update Resource Link + * @returns A promise that resolves to the updated resource link OR Update result + * @memberof ResourceLinkService + */ + async update(id: ID, input: IResourceLinkUpdateInput): Promise { + try { + const resourceLink = await this.findOneByIdString(id); + + if (!resourceLink) { + throw new BadRequestException('Resource Link not found'); + } + + const updatedResourceLink = await super.create({ + ...input, + id + }); + + // Generate the activity log + const { organizationId, tenantId } = updatedResourceLink; + this.activityLogService.logActivity( + BaseEntityEnum.ResourceLink, + ActionTypeEnum.Updated, + ActorTypeEnum.User, + resourceLink.id, + `${resourceLink.title} for ${resourceLink.entity}`, + updatedResourceLink, + organizationId, + tenantId, + resourceLink, + input + ); + + // return updated Resource Link + return updatedResourceLink; + } catch (error) { + console.log(error); // Debug Logging + throw new BadRequestException('Resource Link update failed', error); + } + } +} diff --git a/packages/core/src/resource-link/resource-link.subscriber.ts b/packages/core/src/resource-link/resource-link.subscriber.ts new file mode 100644 index 00000000000..f8cbcfbee26 --- /dev/null +++ b/packages/core/src/resource-link/resource-link.subscriber.ts @@ -0,0 +1,57 @@ +import { EventSubscriber } from 'typeorm'; +import { isBetterSqlite3, isSqlite } from '@gauzy/config'; +import { BaseEntityEventSubscriber } from '../core/entities/subscribers/base-entity-event.subscriber'; +import { MultiOrmEntityManager } from '../core/entities/subscribers/entity-event-subscriber.types'; +import { ResourceLink } from './resource-link.entity'; + +@EventSubscriber() +export class ResourceLinkSubscriber extends BaseEntityEventSubscriber { + /** + * Indicates that this subscriber only listen to ResourceLink events. + */ + listenTo() { + return ResourceLink; + } + + /** + * Called before an ResourceLink entity is inserted or created in the database. + * This method prepares the entity for insertion, particularly by serializing the metaData property to a JSON string + * for SQLite databases. + * + * @param entity The ResourceLink entity that is about to be created. + * @returns {Promise} A promise that resolves when the pre-creation processing is complete. + */ + async beforeEntityCreate(entity: ResourceLink): Promise { + try { + // Check if the database is SQLite and the entity's metaData is a JavaScript object + if (isSqlite() || isBetterSqlite3()) { + entity.metaData = JSON.stringify(entity.metaData); + } + } catch (error) { + // In case of error during JSON serialization, reset metaData to an empty object + entity.metaData = JSON.stringify({}); + } + } + + /** + * Handles the parsing of JSON data after the ResourceLink entity is loaded from the database. + * This function ensures that if the database is SQLite, the `metaData` field, stored as a JSON string, + * is parsed back into a JavaScript object. + * + * @param {ResourceLink} entity - The ResourceLink entity that has been loaded from the database. + * @param {MultiOrmEntityManager} [em] - The optional EntityManager instance, if provided. + * @returns {Promise} A promise that resolves once the after-load processing is complete. + */ + async afterEntityLoad(entity: ResourceLink, em?: MultiOrmEntityManager): Promise { + try { + // Check if the database is SQLite and if `metaData` is a non-null string + if ((isSqlite() || isBetterSqlite3()) && entity.metaData && typeof entity.metaData === 'string') { + entity.metaData = JSON.parse(entity.metaData); + } + } catch (error) { + // Log the error and reset the data to an empty object if JSON parsing fails + console.error('Error parsing JSON data in afterEntityLoad:', error); + entity.metaData = {}; + } + } +} diff --git a/packages/core/src/role-permission/default-role-permissions.ts b/packages/core/src/role-permission/default-role-permissions.ts index 2d99d7c127d..d4783d9190e 100644 --- a/packages/core/src/role-permission/default-role-permissions.ts +++ b/packages/core/src/role-permission/default-role-permissions.ts @@ -93,8 +93,10 @@ export const DEFAULT_ROLE_PERMISSIONS = [ PermissionsEnum.VIEW_SALES_PIPELINES, PermissionsEnum.EDIT_SALES_PIPELINES, PermissionsEnum.CAN_APPROVE_TIMESHEET, + PermissionsEnum.ORG_SPRINT_ADD, PermissionsEnum.ORG_SPRINT_EDIT, PermissionsEnum.ORG_SPRINT_VIEW, + PermissionsEnum.ORG_SPRINT_DELETE, PermissionsEnum.ORG_PROJECT_ADD, PermissionsEnum.ORG_PROJECT_VIEW, PermissionsEnum.ORG_PROJECT_EDIT, @@ -173,7 +175,10 @@ export const DEFAULT_ROLE_PERMISSIONS = [ PermissionsEnum.ALLOW_MODIFY_TIME, PermissionsEnum.ALLOW_MANUAL_TIME, PermissionsEnum.DELETE_SCREENSHOTS, - PermissionsEnum.ORG_MEMBER_LAST_LOG_VIEW + PermissionsEnum.ORG_MEMBER_LAST_LOG_VIEW, + /** API Call Log */ + PermissionsEnum.API_CALL_LOG_READ, + PermissionsEnum.API_CALL_LOG_DELETE ] }, { @@ -267,8 +272,10 @@ export const DEFAULT_ROLE_PERMISSIONS = [ PermissionsEnum.VIEW_SALES_PIPELINES, PermissionsEnum.EDIT_SALES_PIPELINES, PermissionsEnum.CAN_APPROVE_TIMESHEET, + PermissionsEnum.ORG_SPRINT_ADD, PermissionsEnum.ORG_SPRINT_EDIT, PermissionsEnum.ORG_SPRINT_VIEW, + PermissionsEnum.ORG_SPRINT_DELETE, PermissionsEnum.ORG_PROJECT_ADD, PermissionsEnum.ORG_PROJECT_VIEW, PermissionsEnum.ORG_PROJECT_EDIT, @@ -352,7 +359,10 @@ export const DEFAULT_ROLE_PERMISSIONS = [ PermissionsEnum.ALLOW_MODIFY_TIME, PermissionsEnum.ALLOW_MANUAL_TIME, PermissionsEnum.DELETE_SCREENSHOTS, - PermissionsEnum.ORG_MEMBER_LAST_LOG_VIEW + PermissionsEnum.ORG_MEMBER_LAST_LOG_VIEW, + /** API Call Log */ + PermissionsEnum.API_CALL_LOG_READ, + PermissionsEnum.API_CALL_LOG_DELETE ] }, { diff --git a/packages/core/src/shared/dto/filters-query.dto.ts b/packages/core/src/shared/dto/filters-query.dto.ts index 462480029c5..481038d6663 100644 --- a/packages/core/src/shared/dto/filters-query.dto.ts +++ b/packages/core/src/shared/dto/filters-query.dto.ts @@ -1,28 +1,45 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsEnum, IsOptional } from "class-validator"; -import { ITimeLogFilters, TimeLogSourceEnum, TimeLogType } from "@gauzy/contracts"; -import { IsBetweenActivty } from "./../../shared/validators"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsEnum, IsOptional } from 'class-validator'; +import { ITimeLogFilters, TimeLogSourceEnum, TimeLogType } from '@gauzy/contracts'; +import { IsBetweenActivity } from './../../shared/validators'; /** - * Get filters common request DTO validation + * Data Transfer Object for filtering time logs based on source, log type, and activity level. + * This DTO provides optional filters to refine time log queries. */ export class FiltersQueryDTO implements ITimeLogFilters { + /** + * Filters time logs by their source. + * Can include multiple sources like Desktop, Web, or Mobile. + * If not provided, no filtering by source will be applied. + */ + @ApiPropertyOptional({ enum: TimeLogSourceEnum }) + @IsOptional() + @IsEnum(TimeLogSourceEnum, { each: true }) + source: TimeLogSourceEnum[]; - @ApiPropertyOptional({ enum: TimeLogSourceEnum }) - @IsOptional() - @IsEnum(TimeLogSourceEnum, { each: true }) - readonly source: TimeLogSourceEnum[]; + /** + * Filters time logs by their log type (Manual, Tracked, etc.). + * Multiple log types can be specified. + * If not provided, no filtering by log type will be applied. + */ + @ApiPropertyOptional({ enum: TimeLogType }) + @IsOptional() + @IsEnum(TimeLogType, { each: true }) + logType: TimeLogType[]; - @ApiPropertyOptional({ enum: TimeLogType }) - @IsOptional() - @IsEnum(TimeLogType, { each: true }) - readonly logType: TimeLogType[]; - - @ApiPropertyOptional({ type: () => 'object' }) - @IsOptional() - @IsBetweenActivty(FiltersQueryDTO, (it) => it.activityLevel) - readonly activityLevel: { - start: number; - end: number; - }; + /** + * Filters time logs by activity level, specifying a range between `start` and `end`. + * This filter limits logs to a specific activity range (e.g., from 10% to 90% activity). + * If not provided, no filtering by activity level will be applied. + */ + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsBetweenActivity(FiltersQueryDTO, (it) => it.activityLevel) + @Type(() => Object) + activityLevel: { + start: number; + end: number; + }; } diff --git a/packages/core/src/shared/dto/relations-query.dto.ts b/packages/core/src/shared/dto/relations-query.dto.ts index bbb1b1a5a68..fec66747dbf 100644 --- a/packages/core/src/shared/dto/relations-query.dto.ts +++ b/packages/core/src/shared/dto/relations-query.dto.ts @@ -1,16 +1,15 @@ -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { Transform, TransformFnParams } from "class-transformer"; -import { IsArray, IsOptional } from "class-validator"; -import { IBaseRelationsEntityModel } from "@gauzy/contracts"; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { IsArray, IsOptional } from 'class-validator'; +import { IBaseRelationsEntityModel } from '@gauzy/contracts'; /** * Get relations request DTO validation */ export class RelationsQueryDTO implements IBaseRelationsEntityModel { - - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - @Transform(({ value }: TransformFnParams) => (value) ? value.map((element: string) => element.trim()) : []) - readonly relations: string[] = []; + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + @Transform(({ value }: TransformFnParams) => (value ? value.map((element: string) => element.trim()) : [])) + readonly relations: string[] = []; } diff --git a/packages/core/src/shared/dto/selectors-query.dto.ts b/packages/core/src/shared/dto/selectors-query.dto.ts index 7af3cf1e25e..078276a92e2 100644 --- a/packages/core/src/shared/dto/selectors-query.dto.ts +++ b/packages/core/src/shared/dto/selectors-query.dto.ts @@ -1,43 +1,46 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsArray } from 'class-validator'; -import { ITimeLogFilters } from '@gauzy/contracts'; +import { ID, ITimeLogFilters } from '@gauzy/contracts'; import { DateRangeQueryDTO } from './date-range-query.dto'; /** - * Get selectors common request DTO validation. - * Extends DateRangeQueryDTO to include date range filters. + * Data Transfer Object for filtering time logs by various selectors. + * Extends DateRangeQueryDTO to include date range filters alongside employee, project, task, and team selectors. */ export class SelectorsQueryDTO extends DateRangeQueryDTO implements ITimeLogFilters { + /** + * An array of employee IDs to filter the time logs by specific employees. + * If not provided, no filtering by employee will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + employeeIds: ID[]; - /** - * An array of employee IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly employeeIds: string[]; + /** + * An array of project IDs to filter the time logs by specific projects. + * If not provided, no filtering by project will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + projectIds: ID[]; - /** - * An array of project IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly projectIds: string[]; + /** + * An array of task IDs to filter the time logs by specific tasks. + * If not provided, no filtering by task will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + taskIds: ID[]; - /** - * An array of task IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly taskIds: string[]; - - /** - * An array of team IDs for filtering time logs. - */ - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly teamIds: string[]; + /** + * An array of team IDs to filter the time logs by specific teams. + * If not provided, no filtering by team will be applied. + */ + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + teamIds: ID[]; } diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index c4d762575ab..21313c475b5 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -1,6 +1,6 @@ export * from './shared.module'; -export * from './pipes'; -export * from './handlers'; export * from './decorators'; -export * from './guards'; export * from './dto'; +export * from './guards'; +export * from './handlers'; +export * from './pipes'; diff --git a/packages/core/src/shared/pipes/actor-type-transform.pipe.ts b/packages/core/src/shared/pipes/actor-type-transform.pipe.ts new file mode 100644 index 00000000000..fbfec6a00d8 --- /dev/null +++ b/packages/core/src/shared/pipes/actor-type-transform.pipe.ts @@ -0,0 +1,28 @@ +import { ValueTransformer } from 'typeorm'; +import { ActorTypeEnum } from '@gauzy/contracts'; + +/** + * ActorTypeTransformerPipe handles the conversion between the enum string values + * (used in the application) and the integer values (stored in the database). + */ +export class ActorTypeTransformerPipe implements ValueTransformer { + /** + * Converts the enum string value to its integer representation when writing to the database. + * + * @param value - The `ActorTypeEnum` value ('System' or 'User'). + * @returns The corresponding integer value to be stored in the database (0 for System, 1 for User). + */ + to(value: ActorTypeEnum): number { + return value === ActorTypeEnum.User ? 1 : 0; // 1 for 'User', 0 for 'System' (default) + } + + /** + * Converts the integer value to its corresponding `ActorTypeEnum` string when reading from the database. + * + * @param value - The integer value (0 or 1) from the database. + * @returns The corresponding `ActorTypeEnum` ('System' for 0, 'User' for 1). + */ + from(value: number): ActorTypeEnum { + return value === 1 ? ActorTypeEnum.User : ActorTypeEnum.System; + } +} diff --git a/packages/core/src/shared/pipes/column-numeric-transformer.pipe.ts b/packages/core/src/shared/pipes/column-numeric-transformer.pipe.ts index f13503b54d8..366afb302a6 100644 --- a/packages/core/src/shared/pipes/column-numeric-transformer.pipe.ts +++ b/packages/core/src/shared/pipes/column-numeric-transformer.pipe.ts @@ -1,32 +1,30 @@ -import { ValueTransformer } from "typeorm"; -import { isNullOrUndefined } from "@gauzy/common"; +import { ValueTransformer } from 'typeorm'; +import { isNotNullOrUndefined } from '@gauzy/common'; /** * Convert Non-integer numbers string to integer + * * From https://github.com/typeorm/typeorm/issues/873#issuecomment-502294597 */ export class ColumnNumericTransformerPipe implements ValueTransformer { - /** - * Transforms a number to the database value. - * - * @param data - The input number. - * @returns The transformed number or null. - */ - to(data?: number | null): number | null { - return isNullOrUndefined(data) ? null : data; - } + /** + * Converts a number for storage in the database. + * If the value is not defined, it returns null. + * + * @param value - The number to convert. + * @returns The number itself, or null if undefined. + */ + to(value: number): number | null { + return isNotNullOrUndefined(value) ? value : null; // Return the number for storage + } - /** - * Transforms a string to the entity property value. - * - * @param data - The input string. - * @returns The transformed number or null. - */ - from(data?: string | null): number | null { - if (!isNullOrUndefined(data)) { - const parsedValue = parseFloat(data); - return isNaN(parsedValue) ? null : parsedValue; - } - return null; - } + /** + * Transforms a string to the entity property value. + * + * @param value - The input string. + * @returns The transformed number or null if the input is invalid. + */ + from(value?: string | null): number | null { + return isNotNullOrUndefined(value) ? parseFloat(value) : null; // Convert string to number + } } diff --git a/packages/core/src/shared/pipes/http-method-transformer.pipe.ts b/packages/core/src/shared/pipes/http-method-transformer.pipe.ts new file mode 100644 index 00000000000..6a5bb292051 --- /dev/null +++ b/packages/core/src/shared/pipes/http-method-transformer.pipe.ts @@ -0,0 +1,57 @@ +import { ValueTransformer } from 'typeorm'; +import { RequestMethod, RequestMethodEnum } from '@gauzy/contracts'; + +/** + * HttpMethodTransformerPipe handles the conversion between enum integer (stored in DB) + * and the corresponding HTTP method string (e.g., 'GET', 'POST', etc.). + */ +export class HttpMethodTransformerPipe implements ValueTransformer { + /** + * A map of HTTP method strings to their corresponding enum values. + */ + private static methodMap = new Map([ + ['GET', RequestMethod.GET], + ['POST', RequestMethod.POST], + ['PUT', RequestMethod.PUT], + ['DELETE', RequestMethod.DELETE], + ['PATCH', RequestMethod.PATCH], + ['OPTIONS', RequestMethod.OPTIONS], + ['HEAD', RequestMethod.HEAD], + ['SEARCH', RequestMethod.SEARCH] + ]); + + /** + * A map of enum values to their corresponding HTTP method strings. + */ + private static reverseMethodMap = new Map([ + [RequestMethod.GET, 'GET'], + [RequestMethod.POST, 'POST'], + [RequestMethod.PUT, 'PUT'], + [RequestMethod.DELETE, 'DELETE'], + [RequestMethod.PATCH, 'PATCH'], + [RequestMethod.OPTIONS, 'OPTIONS'], + [RequestMethod.HEAD, 'HEAD'], + [RequestMethod.SEARCH, 'SEARCH'], + [RequestMethod.ALL, 'ALL'] + ]); + + /** + * Converts the HTTP method string to the corresponding enum value when writing to the database. + * + * @param value - The HTTP method string (e.g., 'GET', 'POST'). + * @returns The corresponding RequestMethod enum value. + */ + to(value: string): RequestMethod { + return HttpMethodTransformerPipe.methodMap.get(value.toUpperCase()) || RequestMethod.ALL; + } + + /** + * Converts the enum value to the corresponding HTTP method string when reading from the database. + * + * @param value - The enum value (e.g., RequestMethod.GET). + * @returns The corresponding HTTP method string. + */ + from(value: RequestMethod): string { + return HttpMethodTransformerPipe.reverseMethodMap.get(value) || RequestMethodEnum.ALL; + } +} diff --git a/packages/core/src/shared/pipes/index.ts b/packages/core/src/shared/pipes/index.ts index 0e562f4a66d..3a87928e060 100644 --- a/packages/core/src/shared/pipes/index.ts +++ b/packages/core/src/shared/pipes/index.ts @@ -1,6 +1,8 @@ -export * from './uuid-validation.pipe'; -export * from './parse-json.pipe'; +export * from './abstract-validation.pipe'; +export * from './actor-type-transform.pipe'; export * from './bulk-body-load-transform.pipe'; export * from './column-numeric-transformer.pipe'; -export * from './abstract-validation.pipe'; -export * from './use-validation-pipe.pipe'; +export * from './http-method-transformer.pipe'; +export * from './parse-json.pipe'; +export * from './use-validation.pipe'; +export * from './uuid-validation.pipe'; diff --git a/packages/core/src/shared/pipes/use-validation-pipe.pipe.ts b/packages/core/src/shared/pipes/use-validation-pipe.pipe.ts deleted file mode 100644 index 697977d7569..00000000000 --- a/packages/core/src/shared/pipes/use-validation-pipe.pipe.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { UsePipes, ValidationPipe, ValidationPipeOptions } from '@nestjs/common'; - -export function UseValidationPipe(options?: Partial) { - return UsePipes(new ValidationPipe(options)); -} diff --git a/packages/core/src/shared/pipes/use-validation.pipe.ts b/packages/core/src/shared/pipes/use-validation.pipe.ts new file mode 100644 index 00000000000..b07f271e8f5 --- /dev/null +++ b/packages/core/src/shared/pipes/use-validation.pipe.ts @@ -0,0 +1,15 @@ +import { UsePipes, ValidationPipe, ValidationPipeOptions } from '@nestjs/common'; + +/** + * Creates and applies a custom validation pipe with optional configuration. + * + * This function is a helper for applying NestJS's `ValidationPipe` with custom options + * to a route or controller. It wraps the `UsePipes` decorator and makes it easier to + * customize validation behavior. + * + * @param options - Optional `ValidationPipeOptions` to customize the validation behavior. + * @returns A decorator that applies the `ValidationPipe` with the given options. + */ +export function UseValidationPipe(options?: Partial) { + return UsePipes(new ValidationPipe(options ?? {})); +} diff --git a/packages/core/src/shared/validators/is-between-activity.decorator.ts b/packages/core/src/shared/validators/is-between-activity.decorator.ts index 5d79fd81c62..8e7b01c2828 100644 --- a/packages/core/src/shared/validators/is-between-activity.decorator.ts +++ b/packages/core/src/shared/validators/is-between-activity.decorator.ts @@ -1,11 +1,11 @@ -import { ClassConstructor } from "class-transformer"; +import { ClassConstructor } from 'class-transformer'; import { - ValidationArguments, - ValidationOptions, - ValidatorConstraint, - ValidatorConstraintInterface, - registerDecorator -} from "class-validator"; + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + registerDecorator +} from 'class-validator'; /** * IsBetweenActivity custom decorator. @@ -13,16 +13,20 @@ import { * @param validationOptions - Validation options. * @returns {PropertyDecorator} - Decorator function. */ -export const IsBetweenActivty = (type: ClassConstructor, property: (o: T) => any, validationOptions?: ValidationOptions): PropertyDecorator => { - return (object: any, propertyName: string) => { - registerDecorator({ - target: object.constructor, - propertyName, - options: validationOptions, - constraints: [property], - validator: BetweenActivtyConstraint, - }); - }; +export const IsBetweenActivity = ( + type: ClassConstructor, + property: (o: T) => any, + validationOptions?: ValidationOptions +): PropertyDecorator => { + return (object: any, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [property], + validator: BetweenActivityConstraint + }); + }; }; /** @@ -31,32 +35,35 @@ export const IsBetweenActivty = (type: ClassConstructor, property: (o: T) * @param validationOptions * @returns */ -@ValidatorConstraint({ name: "IsBetweenActivty", async: false }) -export class BetweenActivtyConstraint implements ValidatorConstraintInterface { - /** - * Validate if the start and end values in the activityLevel object are between 0 and 100 (inclusive). - * - * @param activityLevel - The object containing start and end properties to be validated. - * @param args - Validation arguments. - * @returns {boolean} - Returns `true` if both start and end values are between 0 and 100 (inclusive); otherwise, `false`. - */ - validate(activityLevel: { - start: number; - end: number; - }, args: ValidationArguments): boolean { - const { start, end } = activityLevel; +@ValidatorConstraint({ name: 'IsBetweenActivity', async: false }) +export class BetweenActivityConstraint implements ValidatorConstraintInterface { + /** + * Validate if the start and end values in the activityLevel object are between 0 and 100 (inclusive). + * + * @param activityLevel - The object containing start and end properties to be validated. + * @param args - Validation arguments. + * @returns {boolean} - Returns `true` if both start and end values are between 0 and 100 (inclusive); otherwise, `false`. + */ + validate( + activityLevel: { + start: number; + end: number; + }, + args: ValidationArguments + ): boolean { + const { start, end } = activityLevel; - // Check if start and end values are within the range [0, 100] - return (start >= 0) && (end <= 100); - } + // Check if start and end values are within the range [0, 100] + return start >= 0 && end <= 100; + } - /** - * Get the default error message for the IsBetweenActivty constraint. - * - * @param args - Validation arguments. - * @returns {string} - The default error message. - */ - defaultMessage(args: ValidationArguments): string { - return "Start & End must be between 0 and 100"; - } + /** + * Get the default error message for the IsBetweenActivity constraint. + * + * @param args - Validation arguments. + * @returns {string} - The default error message. + */ + defaultMessage(args: ValidationArguments): string { + return 'Start & End must be between 0 and 100'; + } } diff --git a/packages/core/src/tasks/commands/handlers/automation-task.sync.handler.ts b/packages/core/src/tasks/commands/handlers/automation-task.sync.handler.ts index c3b465bd13e..05c4ee02e59 100644 --- a/packages/core/src/tasks/commands/handlers/automation-task.sync.handler.ts +++ b/packages/core/src/tasks/commands/handlers/automation-task.sync.handler.ts @@ -1,11 +1,21 @@ import { ICommandHandler, CommandHandler } from '@nestjs/cqrs'; import { InjectRepository } from '@nestjs/typeorm'; import * as chalk from 'chalk'; -import { ID, IIntegrationMap, ITask, ITaskCreateInput, ITaskUpdateInput } from '@gauzy/contracts'; +import { + ActionTypeEnum, + ActorTypeEnum, + BaseEntityEnum, + ID, + IIntegrationMap, + ITask, + ITaskCreateInput, + ITaskUpdateInput +} from '@gauzy/contracts'; import { RequestContext } from '../../../core/context'; import { IntegrationMap, TaskStatus } from '../../../core/entities/internal'; import { AutomationTaskSyncCommand } from './../automation-task.sync.command'; import { TaskService } from './../../task.service'; +import { ActivityLogService } from '../../../activity-log/activity-log.service'; import { Task } from './../../task.entity'; import { TypeOrmIntegrationMapRepository } from '../../../integration-map/repository/type-orm-integration-map.repository'; import { TypeOrmTaskStatusRepository } from '../../statuses/repository/type-orm-task-status.repository'; @@ -23,7 +33,9 @@ export class AutomationTaskSyncHandler implements ICommandHandler( + BaseEntityEnum.Task, + ActionTypeEnum.Created, + ActorTypeEnum.System, + createdTask.id, + createdTask.title, + createdTask, + organizationId, + tenantId + ); + + // Return the created Task return createdTask; } catch (error) { // Handle and log errors, and return a rejected promise or throw an exception. - console.log(chalk.red(`Error automation syncing a task with payload: %s`, error.message), entity); + console.log(chalk.red(`Error while creating task using Automation Task: %s`, error.message), entity); } } @@ -146,7 +173,7 @@ export class AutomationTaskSyncHandler implements ICommandHandler { + async updateTask(id: ID, entity: ITaskUpdateInput): Promise { try { // Find the existing task by its ID const existingTask = await this._taskService.findOneByIdString(id); @@ -159,10 +186,27 @@ export class AutomationTaskSyncHandler implements ICommandHandler( + BaseEntityEnum.Task, + ActionTypeEnum.Updated, + ActorTypeEnum.System, + updatedTask.id, + updatedTask.title, + updatedTask, + organizationId, + tenantId, + existingTask, + entity + ); + + // Return the updated Task return updatedTask; } catch (error) { // Handle and log errors, and return a rejected promise or throw an exception. - console.log(chalk.red(`Error automation syncing a task with payload: %s`, error), entity); + console.log(chalk.red(`Error while updating task using Automation Task: %s`), error.message); } } } diff --git a/packages/core/src/tasks/commands/handlers/task-create.handler.ts b/packages/core/src/tasks/commands/handlers/task-create.handler.ts index 91386d37ae3..494f14a5d11 100644 --- a/packages/core/src/tasks/commands/handlers/task-create.handler.ts +++ b/packages/core/src/tasks/commands/handlers/task-create.handler.ts @@ -1,6 +1,6 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; import { HttpException, HttpStatus, Logger } from '@nestjs/common'; -import { ITask } from '@gauzy/contracts'; +import { BaseEntityEnum, ActorTypeEnum, ITask, ActionTypeEnum } from '@gauzy/contracts'; import { EventBus } from '../../../event-bus'; import { TaskEvent } from '../../../event-bus/events'; import { BaseEntityEventTypeEnum } from '../../../event-bus/base-entity-event'; @@ -8,6 +8,8 @@ import { RequestContext } from './../../../core/context'; import { OrganizationProjectService } from './../../../organization-project/organization-project.service'; import { TaskCreateCommand } from './../task-create.command'; import { TaskService } from '../../task.service'; +import { Task } from './../../task.entity'; +import { ActivityLogService } from '../../../activity-log/activity-log.service'; @CommandHandler(TaskCreateCommand) export class TaskCreateHandler implements ICommandHandler { @@ -16,7 +18,8 @@ export class TaskCreateHandler implements ICommandHandler { constructor( private readonly _eventBus: EventBus, private readonly _taskService: TaskService, - private readonly _organizationProjectService: OrganizationProjectService + private readonly _organizationProjectService: OrganizationProjectService, + private readonly activityLogService: ActivityLogService ) {} /** @@ -29,46 +32,60 @@ export class TaskCreateHandler implements ICommandHandler { try { // Destructure input and triggered event flag from the command const { input, triggeredEvent } = command; - let { organizationId, project } = input; + const { organizationId } = input; // Retrieve current tenant ID from request context or use input tenant ID - const tenantId = RequestContext.currentTenantId() || input.tenantId; + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; - // If input contains project ID, fetch project details - if (input.projectId) { - const { projectId } = input; - project = await this._organizationProjectService.findOneByIdString(projectId); - } + // Check if projectId is provided, if not use the provided project object from the input. + // If neither is provided, set project to null. + const project = input.projectId + ? await this._organizationProjectService.findOneByIdString(input.projectId) + : input.project || null; + + // Check if project exists and extract the project prefix (first 3 characters of the project name) + const projectPrefix = project?.name?.substring(0, 3) ?? null; - // Determine project ID and task prefix based on project existence - const projectId = project ? project.id : null; - const taskPrefix = project ? project.name.substring(0, 3) : null; + // Log or throw an exception if both projectId and project are not provided (optional) + if (!project) { + this.logger.warn('No projectId or project provided. Proceeding without project information.'); + } - // Retrieve the maximum task number for the specified project + // Retrieve the maximum task number for the specified project, or handle null projectId if no project const maxNumber = await this._taskService.getMaxTaskNumberByProject({ tenantId, organizationId, - projectId + projectId: project?.id ?? null // If no project is provided, this will pass null for projectId }); - // Create the task with incremented number, prefix, and other details - const createdTask = await this._taskService.create({ - ...input, - number: maxNumber + 1, - prefix: taskPrefix, - tenantId, - organizationId + // Create the task with incremented number, project prefix, and other task details + const task = await this._taskService.create({ + ...input, // Spread the input properties + number: maxNumber + 1, // Increment the task number + prefix: projectPrefix, // Use the project prefix, or null if no project + tenantId, // Pass the tenant ID + organizationId // Pass the organization ID }); // Publish a task created event if triggeredEvent flag is set if (triggeredEvent) { - // Publish the task created event const ctx = RequestContext.currentRequestContext(); // Get current request context; - const event = new TaskEvent(ctx, createdTask, BaseEntityEventTypeEnum.CREATED, input); - this._eventBus.publish(event); // Publish the event using EventBus + this._eventBus.publish(new TaskEvent(ctx, task, BaseEntityEventTypeEnum.CREATED, input)); // Publish the event using EventBus } - return createdTask; // Return the created task + // Generate the activity log + this.activityLogService.logActivity( + BaseEntityEnum.Task, + ActionTypeEnum.Created, + ActorTypeEnum.User, // TODO : Since we have Github Integration, make sure we can also store "System" for actor + task.id, + task.title, + task, + organizationId, + tenantId + ); + + return task; // Return the created task } catch (error) { // Handle errors during task creation this.logger.error(`Error while creating task: ${error.message}`, error.message); diff --git a/packages/core/src/tasks/commands/handlers/task-update.handler.ts b/packages/core/src/tasks/commands/handlers/task-update.handler.ts index fb0c8396997..0464095287a 100644 --- a/packages/core/src/tasks/commands/handlers/task-update.handler.ts +++ b/packages/core/src/tasks/commands/handlers/task-update.handler.ts @@ -1,5 +1,5 @@ -import { HttpException, HttpStatus, Logger } from '@nestjs/common'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { HttpException, HttpStatus, Logger } from '@nestjs/common'; import { ID, ITask, ITaskUpdateInput } from '@gauzy/contracts'; import { TaskEvent } from '../../../event-bus/events'; import { EventBus } from '../../../event-bus/event-bus'; @@ -38,31 +38,8 @@ export class TaskUpdateHandler implements ICommandHandler { */ public async update(id: ID, input: ITaskUpdateInput, triggeredEvent: boolean): Promise { try { - const tenantId = RequestContext.currentTenantId() || input.tenantId; - const task = await this._taskService.findOneByIdString(id); - - if (input.projectId && input.projectId !== task.projectId) { - const { organizationId, projectId } = task; - - // Get the maximum task number for the project - const maxNumber = await this._taskService.getMaxTaskNumberByProject({ - tenantId, - organizationId, - projectId - }); - - // Update the task with the new project and task number - await this._taskService.update(id, { - projectId, - number: maxNumber + 1 - }); - } - // Update the task with the provided data - const updatedTask = await this._taskService.create({ - ...input, - id - }); + const updatedTask = await this._taskService.update(id, input); // The "2 Way Sync Triggered Event" for Synchronization if (triggeredEvent) { diff --git a/packages/core/src/tasks/daily-plan/daily-plan.service.ts b/packages/core/src/tasks/daily-plan/daily-plan.service.ts index 5fabc553623..82e832fe348 100644 --- a/packages/core/src/tasks/daily-plan/daily-plan.service.ts +++ b/packages/core/src/tasks/daily-plan/daily-plan.service.ts @@ -41,7 +41,7 @@ export class DailyPlanService extends TenantAwareCrudService { async createDailyPlan(partialEntity: IDailyPlanCreateInput): Promise { try { const tenantId = RequestContext.currentTenantId(); - const { employeeId, organizationId, taskId } = partialEntity; + const { employeeId, organizationId, organizationTeamId, taskId } = partialEntity; const dailyPlanDate = new Date(partialEntity.date).toISOString().split('T')[0]; @@ -56,6 +56,7 @@ export class DailyPlanService extends TenantAwareCrudService { query.setFindOptions({ relations: { tasks: true } }); query.where('"dailyPlan"."tenantId" = :tenantId', { tenantId }); query.andWhere('"dailyPlan"."organizationId" = :organizationId', { organizationId }); + query.andWhere('"dailyPlan"."organizationTeamId" = :organizationTeamId', { organizationTeamId }); query.andWhere(p(`DATE("dailyPlan"."date") = :dailyPlanDate`), { dailyPlanDate: `${dailyPlanDate}` }); query.andWhere('"dailyPlan"."employeeId" = :employeeId', { employeeId }); let dailyPlan = await query.getOne(); @@ -317,7 +318,7 @@ export class DailyPlanService extends TenantAwareCrudService { async removeTaskFromManyPlans(taskId: ID, input: IDailyPlansTasksUpdateInput): Promise { try { const tenantId = RequestContext.currentTenantId(); - const { employeeId, plansIds, organizationId } = input; + const { employeeId, plansIds, organizationId, organizationTeamId } = input; const currentDate = new Date().toISOString().split('T')[0]; // Initial query @@ -331,6 +332,7 @@ export class DailyPlanService extends TenantAwareCrudService { // Conditions query.where(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + query.andWhere(p(`"${query.alias}"."organizationTeamId" = :organizationTeamId`), { organizationTeamId }); query.andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }); // Find condition must include only today and future plans. We cannot delete tasks from past plans diff --git a/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts b/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts index dc1708ed196..09ae3060c76 100644 --- a/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts +++ b/packages/core/src/tasks/estimation/commands/handlers/task-estimation-calculate.handler.ts @@ -5,9 +5,7 @@ import { TaskEstimationService } from '../../task-estimation.service'; import { TaskService } from '../../../task.service'; @CommandHandler(TaskEstimationCalculateCommand) -export class TaskEstimationCalculateHandler - implements ICommandHandler -{ +export class TaskEstimationCalculateHandler implements ICommandHandler { constructor( private readonly _taskEstimationService: TaskEstimationService, private readonly _taskService: TaskService @@ -19,18 +17,13 @@ export class TaskEstimationCalculateHandler const taskEstimations = await this._taskEstimationService.findAll({ where: { - taskId, - }, + taskId + } }); - const totalEstimation = taskEstimations.items.reduce( - (sum, current) => sum + current.estimate, - 0 - ); - const averageEstimation = Math.ceil( - totalEstimation / taskEstimations.items.length - ); + const totalEstimation = taskEstimations.items.reduce((sum, current) => sum + current.estimate, 0); + const averageEstimation = Math.ceil(totalEstimation / taskEstimations.items.length); await this._taskService.update(taskId, { - estimate: averageEstimation, + estimate: averageEstimation }); } catch (error) { console.log('Error while creating task estimation', error?.message); diff --git a/packages/core/src/tasks/issue-type/default-global-issue-types.ts b/packages/core/src/tasks/issue-type/default-global-issue-types.ts index 7fb3ffa05b6..9d6ab63c4a7 100644 --- a/packages/core/src/tasks/issue-type/default-global-issue-types.ts +++ b/packages/core/src/tasks/issue-type/default-global-issue-types.ts @@ -1,9 +1,9 @@ -import { IIssueType } from '@gauzy/contracts'; +import { IIssueType, TaskTypeEnum } from '@gauzy/contracts'; export const DEFAULT_GLOBAL_ISSUE_TYPES: IIssueType[] = [ { name: 'Bug', - value: 'bug', + value: TaskTypeEnum.BUG, description: 'A "bug type issue" typically refers to a specific type of technical issue that occurs in software development', icon: 'task-issue-types/bug.svg', @@ -13,7 +13,7 @@ export const DEFAULT_GLOBAL_ISSUE_TYPES: IIssueType[] = [ }, { name: 'Story', - value: 'story', + value: TaskTypeEnum.STORY, description: 'A "story (or user story) type issue" typically refers to an issue related to a user story in software development.', icon: 'task-issue-types/note.svg', @@ -23,7 +23,7 @@ export const DEFAULT_GLOBAL_ISSUE_TYPES: IIssueType[] = [ }, { name: 'Task', - value: 'task', + value: TaskTypeEnum.TASK, description: 'A "task type issue" typically refers to an issue related to a specific task within a project.', icon: 'task-issue-types/task-square.svg', color: '#5483BA', @@ -32,7 +32,7 @@ export const DEFAULT_GLOBAL_ISSUE_TYPES: IIssueType[] = [ }, { name: 'Epic', - value: 'epic', + value: TaskTypeEnum.EPIC, description: 'An "epic type issue" typically refers to an issue related to an Epic in software development.', icon: 'task-issue-types/category.svg', color: '#8154BA', diff --git a/packages/core/src/tasks/issue-type/issue-type.entity.ts b/packages/core/src/tasks/issue-type/issue-type.entity.ts index 72f5e9270ac..8f84f52ab1e 100644 --- a/packages/core/src/tasks/issue-type/issue-type.entity.ts +++ b/packages/core/src/tasks/issue-type/issue-type.entity.ts @@ -27,6 +27,7 @@ export class IssueType extends TenantOrganizationBaseEntity implements IIssueTyp name: string; @ApiProperty({ type: () => String }) + @IsString() @ColumnIndex() @MultiORMColumn() value: string; diff --git a/packages/core/src/tasks/task.controller.ts b/packages/core/src/tasks/task.controller.ts index 01f8d54c953..6696377cde1 100644 --- a/packages/core/src/tasks/task.controller.ts +++ b/packages/core/src/tasks/task.controller.ts @@ -14,7 +14,7 @@ import { import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CommandBus } from '@nestjs/cqrs'; import { DeleteResult } from 'typeorm'; -import { PermissionsEnum, ITask, IPagination, IEmployee, IOrganizationTeam } from '@gauzy/contracts'; +import { PermissionsEnum, ITask, IPagination, ID } from '@gauzy/contracts'; import { UUIDValidationPipe, UseValidationPipe } from './../shared/pipes'; import { PermissionGuard, TenantPermissionGuard } from './../shared/guards'; import { Permissions } from './../shared/decorators'; @@ -28,7 +28,7 @@ import { CreateTaskDTO, GetTaskByIdDTO, TaskMaxNumberQueryDTO, UpdateTaskDTO } f @ApiTags('Tasks') @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ALL_ORG_EDIT) -@Controller() +@Controller('/tasks') export class TaskController extends CrudController { constructor(private readonly taskService: TaskService, private readonly commandBus: CommandBus) { super(taskService); @@ -37,258 +37,260 @@ export class TaskController extends CrudController { /** * GET task count * - * @param options - * @returns + * @param options The filter options for counting tasks. + * @returns The total number of tasks. */ @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('count') + @Get('/count') @UseValidationPipe() + @ApiOperation({ summary: 'Get the total count of tasks.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Task count retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) async getCount(@Query() options: CountQueryDTO): Promise { - return await this.taskService.countBy(options); + return this.taskService.countBy(options); } /** * GET tasks by pagination * - * @param params - * @returns + * @param params The pagination and filter parameters. + * @returns A paginated list of tasks. */ @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('pagination') + @Get('/pagination') @UseValidationPipe({ transform: true }) + @ApiOperation({ summary: 'Get tasks by pagination.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) async pagination(@Query() params: PaginationParams): Promise> { - return await this.taskService.pagination(params); + return this.taskService.pagination(params); } /** * GET maximum task number * - * @param options - * @returns + * @param options The query options to filter the tasks by project. + * @returns The maximum task number for a given project. */ - @ApiOperation({ summary: 'Find maximum task number.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found maximum task number', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get the maximum task number by project.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Maximum task number retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('max-number') + @Get('/max-number') @UseValidationPipe() async getMaxTaskNumberByProject(@Query() options: TaskMaxNumberQueryDTO): Promise { - return await this.taskService.getMaxTaskNumberByProject(options); + return this.taskService.getMaxTaskNumberByProject(options); } /** * GET my tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving tasks. + * @returns A paginated list of tasks assigned to the current user. */ - @ApiOperation({ summary: 'Find my tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get tasks assigned to the current user.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('me') + @Get('/me') @UseValidationPipe({ transform: true }) async findMyTasks(@Query() params: PaginationParams): Promise> { - return await this.taskService.getMyTasks(params); + return this.taskService.getMyTasks(params); } /** * GET employee tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving employee tasks. + * @returns A paginated list of tasks assigned to the specified employee. */ - @ApiOperation({ summary: 'Find employee tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get tasks assigned to a specific employee.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('employee') + @Get('/employee') @UseValidationPipe({ transform: true }) async findEmployeeTask(@Query() params: PaginationParams): Promise> { - return await this.taskService.getEmployeeTasks(params); + return this.taskService.getEmployeeTasks(params); } /** * GET my team tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving team tasks. + * @returns A paginated list of tasks assigned to the current user's team. */ - @ApiOperation({ summary: 'Find my team tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: "Get tasks assigned to the current user's team." }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('team') + @Get('/team') @UseValidationPipe({ transform: true }) async findTeamTasks(@Query() params: PaginationParams): Promise> { - return await this.taskService.findTeamTasks(params); + return this.taskService.findTeamTasks(params); } /** * GET module tasks * - * @param params - * @returns + * @param params The filter and pagination options for retrieving module tasks. + * @returns A paginated list of tasks by module. */ - @ApiOperation({ summary: 'Find module tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Records not found' - }) + @ApiOperation({ summary: 'Get tasks by module.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('module') + @Get('/module') @UseValidationPipe({ transform: true }) async findModuleTasks(@Query() params: PaginationParams): Promise> { - return await this.taskService.findModuleTasks(params); + return this.taskService.findModuleTasks(params); } - @ApiOperation({ summary: 'Find by id' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found one record' /*, type: T*/ - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + /** + * GET view tasks + * + * @param params The filter options for retrieving view tasks. + * @returns A paginated list of tasks by view filters. + */ + @ApiOperation({ summary: 'Get tasks by view query filter.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) + @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) + @Get('/view/:id') + @UseValidationPipe({ transform: true }) + async findTasksByViewQuery(@Param('id', UUIDValidationPipe) viewId: ID): Promise> { + return this.taskService.findTasksByViewQuery(viewId); + } + + /** + * GET task by ID + * + * @param id The ID of the task. + * @param params The options for task retrieval. + * @returns The task with the specified ID. + */ + @ApiOperation({ summary: 'Get task by ID.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Task retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Task not found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get(':id') - async findById(@Param('id', UUIDValidationPipe) id: Task['id'], @Query() params: GetTaskByIdDTO): Promise { + @Get('/:id') + async findById(@Param('id', UUIDValidationPipe) id: ID, @Query() params: GetTaskByIdDTO): Promise { return this.taskService.findById(id, params); } /** * GET tasks by employee * - * @param employeeId - * @param findInput - * @returns + * @param employeeId The ID of the employee. + * @param params The pagination and filter parameters for tasks. + * @returns A list of tasks assigned to the specified employee. */ - @ApiOperation({ - summary: 'Find Employee Task.' - }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found Employee Task', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + @ApiOperation({ summary: 'Get tasks assigned to a specific employee.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No records found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get('employee/:id') + @Get('/employee/:id') @UseValidationPipe() async getAllTasksByEmployee( - @Param('id') employeeId: IEmployee['id'], + @Param('id') employeeId: ID, @Query() params: PaginationParams ): Promise { - return await this.taskService.getAllTasksByEmployee(employeeId, params); + return this.taskService.getAllTasksByEmployee(employeeId, params); } - @ApiOperation({ summary: 'Find all tasks.' }) - @ApiResponse({ - status: HttpStatus.OK, - description: 'Found tasks', - type: Task - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + /** + * GET all tasks + * + * @param params The pagination and filter parameters for retrieving tasks. + * @returns A paginated list of all tasks. + */ + @ApiOperation({ summary: 'Get all tasks.' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Tasks retrieved successfully.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'No tasks found.' }) @Permissions(PermissionsEnum.ALL_ORG_VIEW, PermissionsEnum.ORG_TASK_VIEW) - @Get() + @Get('/') @UseValidationPipe() async findAll(@Query() params: PaginationParams): Promise> { - return await this.taskService.findAll(params); + return this.taskService.findAll(params); } - @ApiOperation({ summary: 'create a task' }) + /** + * POST create a task + * + * @param entity The data for creating the task. + * @returns The created task. + */ + @ApiOperation({ summary: 'Create a new task.' }) @ApiResponse({ status: HttpStatus.CREATED, - description: 'The record has been successfully created.' - }) - @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'The task has been successfully created.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) @HttpCode(HttpStatus.ACCEPTED) @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_ADD) - @Post() + @Post('/') @UseValidationPipe({ whitelist: true }) async create(@Body() entity: CreateTaskDTO): Promise { - return await this.commandBus.execute(new TaskCreateCommand(entity)); + return this.commandBus.execute(new TaskCreateCommand(entity)); } - @ApiOperation({ summary: 'Update an existing task' }) - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'The record has been successfully edited.' - }) - @ApiResponse({ - status: HttpStatus.NOT_FOUND, - description: 'Record not found' - }) + /** + * PUT update an existing task + * + * @param id The ID of the task to update. + * @param entity The data for updating the task. + * @returns The updated task. + */ + @ApiOperation({ summary: 'Update an existing task.' }) @ApiResponse({ - status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + status: HttpStatus.OK, + description: 'The task has been successfully updated.' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Task not found.' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input.' }) @HttpCode(HttpStatus.ACCEPTED) @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_EDIT) - @Put(':id') + @Put('/:id') @UseValidationPipe({ whitelist: true }) - async update(@Param('id', UUIDValidationPipe) id: ITask['id'], @Body() entity: UpdateTaskDTO): Promise { - return await this.commandBus.execute(new TaskUpdateCommand(id, entity)); + async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateTaskDTO): Promise { + return this.commandBus.execute(new TaskUpdateCommand(id, entity)); } + /** + * DELETE task by ID + * + * @param id The ID of the task to delete. + * @returns The result of the deletion. + */ @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_DELETE) - @Delete(':id') - async delete(@Param('id', UUIDValidationPipe) id: ITask['id']): Promise { - return await this.taskService.delete(id); + @Delete('/:id') + @ApiOperation({ summary: 'Delete a task by ID.' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The task has been successfully deleted.' + }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: 'Task not found.' }) + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { + return this.taskService.delete(id); } + /** + * DELETE employee from team tasks + * + * Unassign an employee from tasks associated with a specific organization team. + * + * @param employeeId The ID of the employee to be unassigned from tasks. + * @param organizationTeamId The ID of the organization team from which to unassign the employee. + * @returns A Promise that resolves with the result of the no assignment. + */ @HttpCode(HttpStatus.OK) @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ORG_TASK_EDIT) - @Delete('employee/:employeeId') + @Delete('/employee/:employeeId') @UseValidationPipe({ whitelist: true }) async deleteEmployeeFromTasks( - @Param('employeeId', UUIDValidationPipe) employeeId: IEmployee['id'], - @Query('organizationTeamId', UUIDValidationPipe) - organizationTeamId: IOrganizationTeam['id'] - ) { - return await this.taskService.unassignEmployeeFromTeamTasks(employeeId, organizationTeamId); + @Param('employeeId', UUIDValidationPipe) employeeId: ID, + @Query('organizationTeamId', UUIDValidationPipe) organizationTeamId: ID + ): Promise { + return this.taskService.unassignEmployeeFromTeamTasks(employeeId, organizationTeamId); } } diff --git a/packages/core/src/tasks/task.entity.ts b/packages/core/src/tasks/task.entity.ts index f631b891d99..94b4d5445a7 100644 --- a/packages/core/src/tasks/task.entity.ts +++ b/packages/core/src/tasks/task.entity.ts @@ -11,6 +11,7 @@ import { IOrganizationProject, IOrganizationProjectModule, IOrganizationSprint, + IOrganizationSprintTaskHistory, IOrganizationTeam, ITag, ITask, @@ -32,6 +33,8 @@ import { OrganizationProject, OrganizationProjectModule, OrganizationSprint, + OrganizationSprintTask, + OrganizationSprintTaskHistory, OrganizationTeam, OrganizationTeamEmployee, Tag, @@ -353,6 +356,22 @@ export class Task extends TenantOrganizationBaseEntity implements ITask { @JoinColumn() linkedIssues?: TaskLinkedIssue[]; + /* + * Task Sprint + */ + @MultiORMOneToMany(() => OrganizationSprintTask, (it) => it.task, { + cascade: true + }) + taskSprints?: IOrganizationSprint[]; + + /* + * Sprint Task Histories + */ + @MultiORMOneToMany(() => OrganizationSprintTaskHistory, (it) => it.task, { + cascade: true + }) + taskSprintHistories?: IOrganizationSprintTaskHistory[]; + /* |-------------------------------------------------------------------------- | @ManyToMany diff --git a/packages/core/src/tasks/task.module.ts b/packages/core/src/tasks/task.module.ts index 119fe2c861d..490a09dd5ad 100644 --- a/packages/core/src/tasks/task.module.ts +++ b/packages/core/src/tasks/task.module.ts @@ -1,16 +1,17 @@ import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { RouterModule } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { EventBusModule } from '../event-bus/event-bus.module'; import { IntegrationMap, TaskStatus } from '../core/entities/internal'; -import { OrganizationProjectModule } from './../organization-project/organization-project.module'; import { CommandHandlers } from './commands/handlers'; import { RolePermissionModule } from '../role-permission/role-permission.module'; import { UserModule } from './../user/user.module'; import { RoleModule } from './../role/role.module'; import { EmployeeModule } from './../employee/employee.module'; +import { OrganizationProjectModule } from './../organization-project/organization-project.module'; +import { OrganizationSprintModule } from './../organization-sprint/organization-sprint.module'; +import { TaskViewModule } from './views/view.module'; import { Task } from './task.entity'; import { TaskService } from './task.service'; import { TaskController } from './task.controller'; @@ -18,7 +19,6 @@ import { TypeOrmTaskRepository } from './repository'; @Module({ imports: [ - RouterModule.register([{ path: '/tasks', module: TaskModule }]), TypeOrmModule.forFeature([Task, TaskStatus, IntegrationMap]), MikroOrmModule.forFeature([Task, TaskStatus, IntegrationMap]), RolePermissionModule, @@ -26,6 +26,8 @@ import { TypeOrmTaskRepository } from './repository'; RoleModule, EmployeeModule, OrganizationProjectModule, + OrganizationSprintModule, + TaskViewModule, CqrsModule, EventBusModule ], diff --git a/packages/core/src/tasks/task.service.ts b/packages/core/src/tasks/task.service.ts index 8fedfe19feb..61cca691699 100644 --- a/packages/core/src/tasks/task.service.ts +++ b/packages/core/src/tasks/task.service.ts @@ -1,12 +1,37 @@ import { Injectable, BadRequestException, HttpStatus, HttpException } from '@nestjs/common'; -import { IsNull, SelectQueryBuilder, Brackets, WhereExpressionBuilder, Raw, In } from 'typeorm'; +import { + IsNull, + SelectQueryBuilder, + Brackets, + WhereExpressionBuilder, + Raw, + In, + FindOptionsWhere, + FindManyOptions, + Between +} from 'typeorm'; import { isBoolean, isUUID } from 'class-validator'; -import { IEmployee, IGetTaskOptions, IPagination, ITask, PermissionsEnum } from '@gauzy/contracts'; +import { + BaseEntityEnum, + ActorTypeEnum, + ID, + IEmployee, + IGetTaskOptions, + IGetTasksByViewFilters, + IPagination, + ITask, + ITaskUpdateInput, + PermissionsEnum, + ActionTypeEnum +} from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; -import { isPostgres } from '@gauzy/config'; +import { isPostgres, isSqlite } from '@gauzy/config'; import { PaginationParams, TenantAwareCrudService } from './../core/crud'; import { RequestContext } from '../core/context'; +import { TaskViewService } from './views/view.service'; +import { ActivityLogService } from '../activity-log/activity-log.service'; import { Task } from './task.entity'; +import { TypeOrmOrganizationSprintTaskHistoryRepository } from './../organization-sprint/repository/type-orm-organization-sprint-task-history.repository'; import { GetTaskByIdDTO } from './dto'; import { prepareSQLQuery as p } from './../database/database.helper'; import { TypeOrmTaskRepository } from './repository/type-orm-task.repository'; @@ -16,52 +41,134 @@ import { MikroOrmTaskRepository } from './repository/mikro-orm-task.repository'; export class TaskService extends TenantAwareCrudService { constructor( readonly typeOrmTaskRepository: TypeOrmTaskRepository, - readonly mikroOrmTaskRepository: MikroOrmTaskRepository + readonly mikroOrmTaskRepository: MikroOrmTaskRepository, + readonly typeOrmOrganizationSprintTaskHistoryRepository: TypeOrmOrganizationSprintTaskHistoryRepository, + private readonly taskViewService: TaskViewService, + private readonly activityLogService: ActivityLogService ) { super(typeOrmTaskRepository, mikroOrmTaskRepository); } /** + * Update task, if already exist * - * @param id - * @param relations - * @returns + * @param id - The ID of the task to update + * @param input - The data to update the task with + * @returns The updated task + */ + async update(id: ID, input: Partial): Promise { + try { + const tenantId = RequestContext.currentTenantId() || input.tenantId; + const userId = RequestContext.currentUserId(); + const { organizationSprintId } = input; + const task = await this.findOneByIdString(id); + + if (input.projectId && input.projectId !== task.projectId) { + const { organizationId, projectId } = task; + + // Get the maximum task number for the project + const maxNumber = await this.getMaxTaskNumberByProject({ + tenantId, + organizationId, + projectId + }); + + // Update the task with the new project and task number + await super.update(id, { + projectId, + number: maxNumber + 1 + }); + } + + // Update the task with the provided data + const updatedTask = await super.create({ + ...input, + id + }); + + // Register Task Sprint moving history + if (organizationSprintId && organizationSprintId !== task.organizationSprintId) { + await this.typeOrmOrganizationSprintTaskHistoryRepository.save({ + fromSprintId: task.organizationSprintId, + toSprintId: organizationSprintId, + taskId: updatedTask.id, + movedById: userId, + reason: input.taskSprintMoveReason, + organizationId: input.organizationId, + tenantId + }); + } + + // Generate the activity log + const { organizationId } = updatedTask; + this.activityLogService.logActivity( + BaseEntityEnum.Task, + ActionTypeEnum.Updated, + ActorTypeEnum.User, // TODO : Since we have Github Integration, make sure we can also store "System" for actor + updatedTask.id, + updatedTask.title, + updatedTask, + organizationId, + tenantId, + task, + input + ); + + // Return the updated Task + return updatedTask; + } catch (error) { + console.error(`Error while updating task: ${error.message}`, error.message); + throw new HttpException({ message: error?.message, error }, HttpStatus.BAD_REQUEST); + } + } + + /** + * Retrieves a task by its ID and includes optional related data. + * + * @param id The unique identifier of the task. + * @param params Additional parameters for fetching task details, including related entities. + * @returns A Promise that resolves to the task entity. */ - async findById(id: ITask['id'], params: GetTaskByIdDTO): Promise { + async findById(id: ID, params: GetTaskByIdDTO): Promise { const task = await this.findOneByIdString(id, params); - if (params.includeRootEpic) { + // Include the root epic if requested + if (params.includeRootEpic && task) { task.rootEpic = await this.findParentUntilEpic(task.id); } return task; } - async findParentUntilEpic(issueId: string): Promise { - // Define the recursive SQL query + /** + * Recursively searches for the parent epic of a given task (issue) using a SQL recursive query. + * + * @param issueId The ID of the task (issue) to start the search from. + * @returns A Promise that resolves to the epic task if found, otherwise null. + */ + async findParentUntilEpic(issueId: ID): Promise { + // Define the recursive SQL query to find the parent epic const query = p(` - WITH RECURSIVE IssueHierarchy AS (SELECT * + WITH RECURSIVE IssueHierarchy AS ( + SELECT * FROM task WHERE id = $1 UNION ALL SELECT i.* FROM task i - INNER JOIN IssueHierarchy ih ON i.id = ih."parentId") + INNER JOIN IssueHierarchy ih ON i.id = ih."parentId" + ) SELECT * - FROM IssueHierarchy - WHERE "issueType" = 'Epic' + FROM IssueHierarchy + WHERE "issueType" = 'Epic' LIMIT 1; `); // Execute the raw SQL query with the issueId parameter const result = await this.typeOrmRepository.query(query, [issueId]); - // Check if any epic was found and return it, or return null - if (result.length > 0) { - return result[0]; - } else { - return null; - } + // Return the first epic task found or null if no epic is found + return result.length > 0 ? result[0] : null; } /** @@ -338,87 +445,94 @@ export class TaskService extends TenantAwareCrudService { } /** - * GET tasks by pagination + * GET tasks by pagination with filtering options. * - * @param options - * @returns + * @param options The pagination and filtering parameters. + * @returns A Promise that resolves to a paginated list of tasks. */ public async pagination(options: PaginationParams): Promise> { - if ('where' in options) { + // Define the like operator based on the database type + const likeOperator = isPostgres() ? 'ILIKE' : 'LIKE'; + + // Check if there are any filters in the options + if (options?.where) { const { where } = options; - const likeOperator = isPostgres() ? 'ILIKE' : 'LIKE'; - if ('title' in where) { - const { title } = where; - options['where']['title'] = Raw((alias) => `${alias} ${likeOperator} '%${title}%'`); + + // Apply filters for task title with like operator + if (where.title) { + options.where.title = Raw((alias) => `${alias} ${likeOperator} '%${where.title}%'`); } - if ('prefix' in where) { - const { prefix } = where; - options['where']['prefix'] = Raw((alias) => `${alias} ${likeOperator} '%${prefix}%'`); + + // Apply filters for task prefix with like operator + if (where.prefix) { + options.where.prefix = Raw((alias) => `${alias} ${likeOperator} '%${where.prefix}%'`); } - if ('isDraft' in where) { - const { isDraft } = where; - if (!isBoolean(isDraft)) { - options.where.isDraft = IsNull(); - } + + // Apply filters for isDraft, setting null if not a boolean + if (where.isDraft !== undefined && !isBoolean(where.isDraft)) { + options.where.isDraft = IsNull(); } - if ('organizationSprintId' in where) { - const { organizationSprintId } = where; - if (!isUUID(organizationSprintId)) { - options['where']['organizationSprintId'] = IsNull(); - } + + // Apply filters for organizationSprintId, setting null if not a valid UUID + if (where.organizationSprintId && !isUUID(where.organizationSprintId)) { + options.where.organizationSprintId = IsNull(); } - if ('teams' in where) { - const { teams } = where; + + // Apply filters for teams, ensuring it uses In for array comparison + if (where.teams) { options.where.teams = { - id: In(teams as string[]) + id: In(where.teams as string[]) }; } } + + // Call the base paginate method return await super.paginate(options); } /** * GET maximum task number by project filter * - * @param options + * @param options The filtering options including tenant, organization, and project details. + * @returns A Promise that resolves to the maximum task number for the given project. */ - public async getMaxTaskNumberByProject(options: IGetTaskOptions) { + public async getMaxTaskNumberByProject(options: IGetTaskOptions): Promise { try { - // Extract necessary options + // Extract tenantId from context or options const tenantId = RequestContext.currentTenantId() || options.tenantId; const { organizationId, projectId } = options; + // Create a query builder for the Task entity const query = this.typeOrmRepository.createQueryBuilder(this.tableName); // Build the query to get the maximum task number query.select(p(`COALESCE(MAX("${query.alias}"."number"), 0)`), 'maxTaskNumber'); - // Filter by organization and tenant + // Apply filters for organization and tenant query.andWhere( new Brackets((qb: WhereExpressionBuilder) => { - qb.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { - organizationId - }); - qb.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { - tenantId - }); + qb.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + qb.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); }) ); - // Filter by project (if provided) + // Apply project filter if provided, otherwise check for null if (isNotEmpty(projectId)) { - query.andWhere(p(`"${query.alias}"."projectId" = :projectId`), { - projectId - }); + query.andWhere(p(`"${query.alias}"."projectId" = :projectId`), { projectId }); } else { query.andWhere(p(`"${query.alias}"."projectId" IS NULL`)); } - // Execute the query and get the maximum task number - const { maxTaskNumber } = await query.getRawOne(); + // Execute the query and parse the result to a number + const result = await query.getRawOne(); + const maxTaskNumber = parseInt(result.maxTaskNumber, 10); + console.log('get max task number', maxTaskNumber); + return maxTaskNumber; } catch (error) { - throw new HttpException({ message: error?.message, error }, HttpStatus.BAD_REQUEST); + // Log the error and throw a detailed exception + console.log(`Error fetching max task number: ${error.message}`, error.stack); + throw new HttpException({ message: 'Failed to get the max task number', error }, HttpStatus.BAD_REQUEST); } } @@ -523,7 +637,7 @@ export class TaskService extends TenantAwareCrudService { projectId, members } = where; - const tenantId = RequestContext.currentTenantId() || where?.tenantId; + const tenantId = RequestContext.currentTenantId() || where.tenantId; const likeOperator = isPostgres() ? 'ILIKE' : 'LIKE'; // Initialize the query @@ -539,7 +653,7 @@ export class TaskService extends TenantAwareCrudService { }); } - // Filter by project_module_task with a subquery + // Filter by project_module_task with a sub query query.andWhere((qb: SelectQueryBuilder) => { const subQuery = qb .subQuery() @@ -563,7 +677,7 @@ export class TaskService extends TenantAwareCrudService { subQuery.andWhere(p(`"pmt"."organizationProjectModuleId" IN (:...modules)`), { modules }); } - return p(`"project_module_tasks"."taskId" IN `) + subQuery.distinct(true).getQuery(); + return p(`"task_modules"."taskId" IN `) + subQuery.distinct(true).getQuery(); }); // Add organization and tenant filters @@ -613,4 +727,110 @@ export class TaskService extends TenantAwareCrudService { throw new BadRequestException(error); } } + + /** + * @description Get tasks by views query + * @param {ID} viewId - View ID + * @returns {Promise>} A Promise resolved to paginated found tasks and total matching query filters + * @memberof TaskService + */ + async findTasksByViewQuery(viewId: ID): Promise> { + const tenantId = RequestContext.currentTenantId(); + try { + // Retrieve Task View by ID for getting their pre-defined query params + const taskView = await this.taskViewService.findOneByWhereOptions({ id: viewId, tenantId }); + if (!taskView) { + throw new HttpException('View not found', HttpStatus.NOT_FOUND); + } + + // Extract `queryParams` from the view + const queryParams = taskView.queryParams; + let viewFilters: IGetTasksByViewFilters = {}; + + try { + viewFilters = isSqlite() + ? (JSON.parse(queryParams as string) as IGetTasksByViewFilters) + : (queryParams as IGetTasksByViewFilters) || {}; + } catch (error) { + throw new HttpException('Invalid query parameters in task view', HttpStatus.BAD_REQUEST); + } + + // Extract filters + const { + projects = [], + teams = [], + members = [], + modules = [], + sprints = [], + statusIds = [], + statuses = [], + priorityIds = [], + priorities = [], + sizeIds = [], + sizes = [], + tags = [], + types = [], + creators = [], + startDates = [], + dueDates = [], + organizationId, + relations = [] + } = viewFilters; + + // Calculate min and max dates only if arrays are not empty + const getMinMaxDates = (dates: Date[]) => + dates.length + ? [ + new Date( + Math.min( + ...dates + .filter((date) => !Number.isNaN(new Date(date).getTime())) + .map((date) => new Date(date).getTime()) + ) + ), + new Date( + Math.max( + ...dates + .filter((date) => !Number.isNaN(new Date(date).getTime())) + .map((date) => new Date(date).getTime()) + ) + ) + ] + : [undefined, undefined]; + + const [minStartDate, maxStartDate] = getMinMaxDates(startDates); + const [minDueDate, maxDueDate] = getMinMaxDates(dueDates); + + // Build the 'where' condition + const where: FindOptionsWhere = { + ...(projects.length && { projectId: In(projects) }), + ...(teams.length && { teams: { id: In(teams) } }), + ...(members.length && { members: { id: In(members) } }), + ...(modules.length && { modules: { id: In(modules) } }), + ...(sprints.length && { organizationSprintId: In(sprints) }), + ...(statusIds.length && { taskStatusId: In(statusIds) }), + ...(statuses.length && { status: In(statuses) }), + ...(priorityIds.length && { taskPriorityId: In(priorityIds) }), + ...(priorities.length && { priority: In(priorities) }), + ...(sizeIds.length && { taskSizeId: In(sizeIds) }), + ...(sizes.length && { size: In(sizes) }), + ...(tags.length && { tags: { id: In(tags) } }), + ...(types.length && { issueType: In(types) }), + ...(creators.length && { creatorId: In(creators) }), + ...(minStartDate && maxStartDate && { startDate: Between(minStartDate, maxStartDate) }), + ...(minDueDate && maxDueDate && { dueDate: Between(minDueDate, maxDueDate) }), + organizationId: taskView.organizationId || organizationId, + tenantId + }; + + // Define find options + const findOptions: FindManyOptions = { where, ...(relations && { relations }) }; + + // Retrieve tasks using base class method + return await super.findAll(findOptions); + } catch (error) { + console.error(`Error while retrieve view tasks: ${error.message}`, error.message); + throw new HttpException({ message: error?.message, error }, HttpStatus.BAD_REQUEST); + } + } } diff --git a/packages/core/src/tasks/views/commands/handlers/index.ts b/packages/core/src/tasks/views/commands/handlers/index.ts new file mode 100644 index 00000000000..c49b8c2c720 --- /dev/null +++ b/packages/core/src/tasks/views/commands/handlers/index.ts @@ -0,0 +1,4 @@ +import { TaskViewCreateHandler } from './task-view-create.handler'; +import { TaskViewUpdateHandler } from './task-view-update.handler'; + +export const CommandHandlers = [TaskViewCreateHandler, TaskViewUpdateHandler]; diff --git a/packages/core/src/tasks/views/commands/handlers/task-view-create.handler.ts b/packages/core/src/tasks/views/commands/handlers/task-view-create.handler.ts new file mode 100644 index 00000000000..9326110e806 --- /dev/null +++ b/packages/core/src/tasks/views/commands/handlers/task-view-create.handler.ts @@ -0,0 +1,14 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { ITaskView } from '@gauzy/contracts'; +import { TaskViewService } from '../../view.service'; +import { TaskViewCreateCommand } from '../task-view-create.command'; + +@CommandHandler(TaskViewCreateCommand) +export class TaskViewCreateHandler implements ICommandHandler { + constructor(private readonly taskViewService: TaskViewService) {} + + public async execute(command: TaskViewCreateCommand): Promise { + const { input } = command; + return await this.taskViewService.create(input); + } +} diff --git a/packages/core/src/tasks/views/commands/handlers/task-view-update.handler.ts b/packages/core/src/tasks/views/commands/handlers/task-view-update.handler.ts new file mode 100644 index 00000000000..ae6a7386e3a --- /dev/null +++ b/packages/core/src/tasks/views/commands/handlers/task-view-update.handler.ts @@ -0,0 +1,15 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { ITaskView } from '@gauzy/contracts'; +import { TaskViewUpdateCommand } from '../task-view-update.command'; +import { TaskViewService } from '../../view.service'; + +@CommandHandler(TaskViewUpdateCommand) +export class TaskViewUpdateHandler implements ICommandHandler { + constructor(private readonly taskViewService: TaskViewService) {} + + public async execute(command: TaskViewUpdateCommand): Promise { + const { id, input } = command; + + return await this.taskViewService.update(id, input); + } +} diff --git a/packages/core/src/tasks/views/commands/index.ts b/packages/core/src/tasks/views/commands/index.ts new file mode 100644 index 00000000000..50d8cf56461 --- /dev/null +++ b/packages/core/src/tasks/views/commands/index.ts @@ -0,0 +1,2 @@ +export * from './task-view-create.command'; +export * from './task-view-update.command'; diff --git a/packages/core/src/tasks/views/commands/task-view-create.command.ts b/packages/core/src/tasks/views/commands/task-view-create.command.ts new file mode 100644 index 00000000000..94fdef29826 --- /dev/null +++ b/packages/core/src/tasks/views/commands/task-view-create.command.ts @@ -0,0 +1,8 @@ +import { ITaskViewCreateInput } from '@gauzy/contracts'; +import { ICommand } from '@nestjs/cqrs'; + +export class TaskViewCreateCommand implements ICommand { + static readonly type = '[Task View] Create'; + + constructor(public readonly input: ITaskViewCreateInput) {} +} diff --git a/packages/core/src/tasks/views/commands/task-view-update.command.ts b/packages/core/src/tasks/views/commands/task-view-update.command.ts new file mode 100644 index 00000000000..0be76d86857 --- /dev/null +++ b/packages/core/src/tasks/views/commands/task-view-update.command.ts @@ -0,0 +1,8 @@ +import { ID, ITaskViewUpdateInput } from '@gauzy/contracts'; +import { ICommand } from '@nestjs/cqrs'; + +export class TaskViewUpdateCommand implements ICommand { + static readonly type = '[Task View] Update'; + + constructor(public readonly id: ID, public readonly input: ITaskViewUpdateInput) {} +} diff --git a/packages/core/src/tasks/views/dto/create-view.dto.ts b/packages/core/src/tasks/views/dto/create-view.dto.ts new file mode 100644 index 00000000000..91793314244 --- /dev/null +++ b/packages/core/src/tasks/views/dto/create-view.dto.ts @@ -0,0 +1,8 @@ +import { IntersectionType, PartialType } from '@nestjs/swagger'; +import { ITaskViewCreateInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../../core/dto'; +import { TaskView } from '../view.entity'; + +export class CreateViewDTO + extends IntersectionType(PartialType(TenantOrganizationBaseDTO), TaskView) + implements ITaskViewCreateInput {} diff --git a/packages/core/src/tasks/views/dto/index.ts b/packages/core/src/tasks/views/dto/index.ts new file mode 100644 index 00000000000..cf545f96723 --- /dev/null +++ b/packages/core/src/tasks/views/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-view.dto'; +export * from './update-view.dto'; diff --git a/packages/core/src/tasks/views/dto/update-view.dto.ts b/packages/core/src/tasks/views/dto/update-view.dto.ts new file mode 100644 index 00000000000..e1c6f33ea6d --- /dev/null +++ b/packages/core/src/tasks/views/dto/update-view.dto.ts @@ -0,0 +1,8 @@ +import { IntersectionType, PartialType } from '@nestjs/swagger'; +import { TenantOrganizationBaseDTO } from '../../../core/dto'; +import { TaskView } from '../view.entity'; +import { ITaskViewUpdateInput } from '@gauzy/contracts'; + +export class UpdateViewDTO + extends IntersectionType(PartialType(TenantOrganizationBaseDTO), PartialType(TaskView)) + implements ITaskViewUpdateInput {} diff --git a/packages/core/src/tasks/views/repository/index.ts b/packages/core/src/tasks/views/repository/index.ts new file mode 100644 index 00000000000..cde16c5dd1d --- /dev/null +++ b/packages/core/src/tasks/views/repository/index.ts @@ -0,0 +1,2 @@ +export * from './mikro-orm-task-view.repository'; +export * from './type-orm-task-view.repository'; diff --git a/packages/core/src/tasks/views/repository/mikro-orm-task-view.repository.ts b/packages/core/src/tasks/views/repository/mikro-orm-task-view.repository.ts new file mode 100644 index 00000000000..538e929a389 --- /dev/null +++ b/packages/core/src/tasks/views/repository/mikro-orm-task-view.repository.ts @@ -0,0 +1,4 @@ +import { MikroOrmBaseEntityRepository } from '../../../core/repository/mikro-orm-base-entity.repository'; +import { TaskView } from '../view.entity'; + +export class MikroOrmTaskViewRepository extends MikroOrmBaseEntityRepository {} diff --git a/packages/core/src/tasks/views/repository/type-orm-task-view.repository.ts b/packages/core/src/tasks/views/repository/type-orm-task-view.repository.ts new file mode 100644 index 00000000000..94c4f5e42a3 --- /dev/null +++ b/packages/core/src/tasks/views/repository/type-orm-task-view.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TaskView } from '../view.entity'; + +@Injectable() +export class TypeOrmTaskViewRepository extends Repository { + constructor(@InjectRepository(TaskView) readonly repository: Repository) { + super(repository.target, repository.manager, repository.queryRunner); + } +} diff --git a/packages/core/src/tasks/views/view.controller.ts b/packages/core/src/tasks/views/view.controller.ts new file mode 100644 index 00000000000..fc109e3dbc4 --- /dev/null +++ b/packages/core/src/tasks/views/view.controller.ts @@ -0,0 +1,119 @@ +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Post, + Put, + Query, + UseGuards +} from '@nestjs/common'; +import { CommandBus } from '@nestjs/cqrs'; +import { DeleteResult } from 'typeorm'; +import { ID, IPagination, ITaskView } from '@gauzy/contracts'; +import { UUIDValidationPipe, UseValidationPipe } from '../../shared/pipes'; +import { PermissionGuard, TenantPermissionGuard } from '../../shared/guards'; +import { CrudController, OptionParams, PaginationParams } from '../../core/crud'; +import { TaskView } from './view.entity'; +import { TaskViewService } from './view.service'; +import { CreateViewDTO, UpdateViewDTO } from './dto'; +import { TaskViewCreateCommand, TaskViewUpdateCommand } from './commands'; + +@ApiTags('Task views') +@UseGuards(TenantPermissionGuard, PermissionGuard) +@Controller() +export class TaskViewController extends CrudController { + constructor(private readonly taskViewService: TaskViewService, private readonly commandBus: CommandBus) { + super(taskViewService); + } + + @ApiOperation({ + summary: 'Find all views.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Found views', + type: TaskView + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @Get() + @UseValidationPipe() + async findAll(@Query() params: PaginationParams): Promise> { + return await this.taskViewService.findAll(params); + } + + @ApiOperation({ summary: 'Find by id' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Found one record' /*, type: T*/ + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @Get(':id') + async findById( + @Param('id', UUIDValidationPipe) id: ID, + @Query() params: OptionParams + ): Promise { + return this.taskViewService.findOneByIdString(id, params); + } + + @ApiOperation({ summary: 'Create view' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully created.' + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Post() + @UseValidationPipe() + async create(@Body() entity: CreateViewDTO): Promise { + return await this.commandBus.execute(new TaskViewCreateCommand(entity)); + } + + @ApiOperation({ summary: 'Update an existing view' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'The record has been successfully edited.' + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Invalid input, The response body may contain clues as to what went wrong' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Put(':id') + @UseValidationPipe() + async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateViewDTO): Promise { + return await this.commandBus.execute(new TaskViewUpdateCommand(id, entity)); + } + + @ApiOperation({ summary: 'Delete view' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'The record has been successfully deleted' + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Record not found' + }) + @HttpCode(HttpStatus.ACCEPTED) + @Delete('/:id') + async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { + return await this.taskViewService.delete(id); + } +} diff --git a/packages/core/src/tasks/views/view.entity.ts b/packages/core/src/tasks/views/view.entity.ts new file mode 100644 index 00000000000..6cbee6c6fb4 --- /dev/null +++ b/packages/core/src/tasks/views/view.entity.ts @@ -0,0 +1,174 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { RelationId } from 'typeorm'; +import { + ID, + IOrganizationProject, + IOrganizationProjectModule, + IOrganizationSprint, + IOrganizationTeam, + ITaskView, + JsonData, + VisibilityLevelEnum +} from '@gauzy/contracts'; +import { isMySQL, isPostgres } from '@gauzy/config'; +import { IsBoolean, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'; +import { + OrganizationProject, + OrganizationProjectModule, + OrganizationSprint, + OrganizationTeam, + TenantOrganizationBaseEntity +} from '../../core/entities/internal'; +import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from '../../core/decorators/entity'; +import { MikroOrmTaskViewRepository } from './repository/mikro-orm-task-view.repository'; + +@MultiORMEntity('task_view', { mikroOrmRepository: () => MikroOrmTaskViewRepository }) +export class TaskView extends TenantOrganizationBaseEntity implements ITaskView { + @ApiProperty({ type: () => String }) + @IsNotEmpty() + @IsString() + @ColumnIndex() + @MultiORMColumn() + name: string; + + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsString() + @MultiORMColumn({ nullable: true, type: 'text' }) + description?: string; + + @ApiPropertyOptional({ type: () => String, enum: VisibilityLevelEnum }) + @IsOptional() + @IsEnum(VisibilityLevelEnum) + @ColumnIndex() + @MultiORMColumn({ nullable: true }) + visibilityLevel?: VisibilityLevelEnum; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + queryParams?: JsonData; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + filterOptions?: JsonData; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + displayOptions?: JsonData; + + @ApiPropertyOptional({ type: () => Object }) + @IsOptional() + @IsObject() + @MultiORMColumn({ type: isPostgres() ? 'jsonb' : isMySQL() ? 'json' : 'text', nullable: true }) + properties?: Record; + + @ApiPropertyOptional({ type: () => Boolean, default: false }) + @IsOptional() + @IsBoolean() + @MultiORMColumn({ default: false, update: false }) + isLocked?: boolean; + + /* + |-------------------------------------------------------------------------- + | @ManyToOne + |-------------------------------------------------------------------------- + */ + + /** + * Organization Project Relationship + */ + @MultiORMManyToOne(() => OrganizationProject, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + project?: IOrganizationProject; + + /** + * Organization Project ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.project) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + projectId?: ID; + + /** + * Organization Team Relationship + */ + @MultiORMManyToOne(() => OrganizationTeam, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationTeam?: IOrganizationTeam; + + /** + * Organization Team ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.organizationTeam) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + organizationTeamId?: ID; + + /** + * Organization Project Module Relationship + */ + @MultiORMManyToOne(() => OrganizationProjectModule, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + projectModule?: IOrganizationProjectModule; + + /** + * Organization Project Module ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.projectModule) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + projectModuleId?: ID; + + /** + * Organization Sprint Relationship + */ + @MultiORMManyToOne(() => OrganizationSprint, (it) => it.views, { + /** Indicates if the relation column value can be nullable or not. */ + nullable: true, + + /** Defines the database cascade action on delete. */ + onDelete: 'CASCADE' + }) + organizationSprint?: IOrganizationSprint; + + /** + * Organization Sprint ID + */ + @ApiPropertyOptional({ type: () => String }) + @IsOptional() + @IsUUID() + @RelationId((it: TaskView) => it.organizationSprint) + @ColumnIndex() + @MultiORMColumn({ nullable: true, relationId: true }) + organizationSprintId?: ID; +} diff --git a/packages/core/src/tasks/views/view.module.ts b/packages/core/src/tasks/views/view.module.ts new file mode 100644 index 00000000000..2d67b2f5692 --- /dev/null +++ b/packages/core/src/tasks/views/view.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { RouterModule } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { RolePermissionModule } from '../../role-permission/role-permission.module'; +import { TaskView } from './view.entity'; +import { CommandHandlers } from './commands/handlers'; +import { TaskViewService } from './view.service'; +import { TaskViewController } from './view.controller'; +import { TypeOrmTaskViewRepository } from './repository/type-orm-task-view.repository'; + +@Module({ + imports: [ + RouterModule.register([{ path: '/task-views', module: TaskViewModule }]), + TypeOrmModule.forFeature([TaskView]), + MikroOrmModule.forFeature([TaskView]), + RolePermissionModule, + CqrsModule + ], + providers: [TaskViewService, TypeOrmTaskViewRepository, ...CommandHandlers], + controllers: [TaskViewController], + exports: [TaskViewService, TypeOrmTaskViewRepository] +}) +export class TaskViewModule {} diff --git a/packages/core/src/tasks/views/view.service.ts b/packages/core/src/tasks/views/view.service.ts new file mode 100644 index 00000000000..76428ebb86e --- /dev/null +++ b/packages/core/src/tasks/views/view.service.ts @@ -0,0 +1,115 @@ +import { HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + ActionTypeEnum, + ActorTypeEnum, + BaseEntityEnum, + ID, + ITaskView, + ITaskViewCreateInput, + ITaskViewUpdateInput +} from '@gauzy/contracts'; +import { FavoriteService } from '../../core/decorators'; +import { TenantAwareCrudService } from '../../core/crud'; +import { RequestContext } from '../../core/context'; +import { ActivityLogService } from '../../activity-log/activity-log.service'; +import { TaskView } from './view.entity'; +import { TypeOrmTaskViewRepository } from './repository/type-orm-task-view.repository'; +import { MikroOrmTaskViewRepository } from './repository/mikro-orm-task-view.repository'; + +@FavoriteService(BaseEntityEnum.TaskView) +@Injectable() +export class TaskViewService extends TenantAwareCrudService { + constructor( + @InjectRepository(TaskView) + typeOrmTaskViewRepository: TypeOrmTaskViewRepository, + + mikroOrmTaskViewRepository: MikroOrmTaskViewRepository, + + private readonly activityLogService: ActivityLogService + ) { + super(typeOrmTaskViewRepository, mikroOrmTaskViewRepository); + } + + /** + * @description Creates a Task View based on provided input + * @param {ITaskViewCreateInput} entity - Input data for creating the task view + * @returns A promise resolving to the created Task View + * @throws BadRequestException if there is an error in the creation process. + * @memberof TaskViewService + */ + async create(entity: ITaskViewCreateInput): Promise { + const tenantId = RequestContext.currentTenantId() || entity.tenantId; + const { organizationId } = entity; + try { + const taskView = await super.create({ ...entity, tenantId }); + + // Generate the activity log + this.activityLogService.logActivity( + BaseEntityEnum.TaskView, + ActionTypeEnum.Created, + ActorTypeEnum.User, + taskView.id, + taskView.name, + taskView, + organizationId, + tenantId + ); + + // return the created task view + return taskView; + } catch (error) { + // Handle errors and return an appropriate error response + throw new HttpException(`Failed to create view : ${error.message}`, HttpStatus.BAD_REQUEST); + } + } + + /** + * @description Update a Task View + * @param {ID} id - The ID of the Task View to be updated + * @param {ITaskViewUpdateInput} input - The updated information for the Task View + * @throws NotFoundException if there's an error if requested update view was not found. + * @throws BadRequest if there's an error during the update process. + * @returns {Promise} A Promise resolving to the updated Task View + * @memberof TaskViewService + */ + async update(id: ID, input: ITaskViewUpdateInput): Promise { + const tenantId = RequestContext.currentTenantId() || input.tenantId; + + try { + // Retrieve existing view. + const existingTaskView = await this.findOneByIdString(id); + + if (!existingTaskView) { + throw new NotFoundException('View not found'); + } + + const updatedTaskView = await super.create({ + ...input, + tenantId, + id + }); + + // Generate the activity log + const { organizationId } = updatedTaskView; + this.activityLogService.logActivity( + BaseEntityEnum.TaskView, + ActionTypeEnum.Updated, + ActorTypeEnum.User, + updatedTaskView.id, + updatedTaskView.name, + updatedTaskView, + organizationId, + tenantId, + existingTaskView, + input + ); + + // return updated view + return updatedTaskView; + } catch (error) { + // Handle errors and return an appropriate error response + throw new HttpException(`Failed to update view: ${error.message}`, HttpStatus.BAD_REQUEST); + } + } +} diff --git a/packages/core/src/tenant/tenant.entity.ts b/packages/core/src/tenant/tenant.entity.ts index dc3709b240a..67548bb3628 100644 --- a/packages/core/src/tenant/tenant.entity.ts +++ b/packages/core/src/tenant/tenant.entity.ts @@ -1,26 +1,27 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { JoinColumn, RelationId } from 'typeorm'; -import { IsOptional, IsUUID } from 'class-validator'; +import { IsNumber, IsOptional, IsUUID, Max, Min } from 'class-validator'; import { ITenant, IOrganization, IRolePermission, IFeatureOrganization, + DEFAULT_STANDARD_WORK_HOURS_PER_DAY, + ID, IImageAsset } from '@gauzy/contracts'; +import { BaseEntity, FeatureOrganization, ImageAsset, Organization, RolePermission } from '../core/entities/internal'; import { - BaseEntity, - FeatureOrganization, - ImageAsset, - Organization, - RolePermission -} from '../core/entities/internal'; -import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne, MultiORMOneToMany } from './../core/decorators/entity'; + ColumnIndex, + MultiORMColumn, + MultiORMEntity, + MultiORMManyToOne, + MultiORMOneToMany +} from './../core/decorators/entity'; import { MikroOrmTenantRepository } from './repository/mikro-orm-tenant.repository'; @MultiORMEntity('tenant', { mikroOrmRepository: () => MikroOrmTenantRepository }) export class Tenant extends BaseEntity implements ITenant { - @ApiProperty({ type: () => String }) @ColumnIndex() @MultiORMColumn() @@ -30,6 +31,22 @@ export class Tenant extends BaseEntity implements ITenant { @MultiORMColumn({ nullable: true }) logo?: string; + /** + * Standard work hours per day for the tenant. + */ + @ApiPropertyOptional({ + type: () => Number, + description: 'Standard work hours per day for the tenant', + minimum: 1, + maximum: 24 + }) + @IsOptional() + @IsNumber() + @Max(24, { message: 'Standard work hours per day cannot exceed 24 hours' }) + @Min(1, { message: 'Standard work hours per day must be at least 1 hour' }) + @MultiORMColumn({ nullable: true, default: DEFAULT_STANDARD_WORK_HOURS_PER_DAY }) + standardWorkHoursPerDay?: number; + /* |-------------------------------------------------------------------------- | @ManyToOne @@ -50,7 +67,7 @@ export class Tenant extends BaseEntity implements ITenant { eager: true }) @JoinColumn() - image?: ImageAsset; + image?: IImageAsset; @ApiPropertyOptional({ type: () => String }) @IsOptional() @@ -58,7 +75,7 @@ export class Tenant extends BaseEntity implements ITenant { @RelationId((it: Tenant) => it.image) @ColumnIndex() @MultiORMColumn({ nullable: true, relationId: true }) - imageId?: IImageAsset['id']; + imageId?: ID; /* |-------------------------------------------------------------------------- @@ -80,7 +97,7 @@ export class Tenant extends BaseEntity implements ITenant { * Array of feature organizations associated with the entity. */ @MultiORMOneToMany(() => FeatureOrganization, (it) => it.tenant, { - cascade: true, + cascade: true }) featureOrganizations?: IFeatureOrganization[]; } diff --git a/packages/core/src/time-tracking/activity/activity.controller.ts b/packages/core/src/time-tracking/activity/activity.controller.ts index f1ad29869dc..5b8da83247d 100644 --- a/packages/core/src/time-tracking/activity/activity.controller.ts +++ b/packages/core/src/time-tracking/activity/activity.controller.ts @@ -1,12 +1,13 @@ import { Controller, UseGuards, HttpStatus, Get, Query, Post, Body } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { IGetActivitiesInput, IBulkActivitiesInput, ReportGroupFilterEnum, PermissionsEnum } from '@gauzy/contracts'; +import { IGetActivitiesInput, ReportGroupFilterEnum, PermissionsEnum, IActivity } from '@gauzy/contracts'; import { PermissionGuard, TenantPermissionGuard } from './../../shared/guards'; import { Permissions } from './../../shared/decorators'; import { UseValidationPipe } from '../../shared/pipes'; import { ActivityService } from './activity.service'; import { ActivityMapService } from './activity.map.service'; -import { ActivityQueryDTO } from './dto/query'; +import { BulkActivityInputDTO } from './dto/bulk-activities-input.dto'; +import { ActivityQueryDTO } from './dto'; @ApiTags('Activity') @UseGuards(TenantPermissionGuard, PermissionGuard) @@ -16,41 +17,77 @@ export class ActivityController { constructor( private readonly activityService: ActivityService, private readonly activityMapService: ActivityMapService - ) { } + ) {} - @ApiOperation({ summary: 'Get Activities' }) + /** + * Retrieves a paginated list of activities based on the provided query parameters. + * + * @param options - The query parameters for fetching activities, including pagination options. + * @returns A promise resolving to a paginated list of activities. + */ + @ApiOperation({ + summary: 'Retrieve paginated activities', + description: 'Fetches a paginated list of activities based on filters like date, employee, and project.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successfully retrieved activities' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request parameters may contain errors' }) - @Get() + @Get('/') @UseValidationPipe({ transform: true, whitelist: true }) - async getActivities(@Query() options: ActivityQueryDTO) { - const defaultParams: Partial = { - page: 0, - limit: 30 - }; + async getActivities(@Query() options: ActivityQueryDTO): Promise { + const defaultParams: Partial = { page: 0, limit: 30 }; options = Object.assign({}, defaultParams, options); return await this.activityService.getActivities(options); } - @ApiOperation({ summary: 'Get Daily Activities' }) + /** + * Retrieves daily activities based on the provided query parameters. + * + * @param options - The query parameters for fetching daily activities. + * @returns A promise resolving to a list of daily activities. + */ + @ApiOperation({ + summary: 'Retrieve daily activities', + description: 'Fetches a list of daily activities filtered by parameters such as date, employee, and project.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successfully retrieved daily activities' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request parameters may contain errors' }) - @Get('daily') + @Get('/daily') @UseValidationPipe({ transform: true, whitelist: true }) async getDailyActivities(@Query() options: ActivityQueryDTO) { return await this.activityService.getDailyActivities(options); } - @ApiOperation({ summary: 'Get Daily Activities' }) + /** + * Retrieves a report of daily activities based on the provided query parameters. + * + * @param options - The query parameters for fetching the daily activities report, including grouping options. + * @returns A promise resolving to a grouped report of daily activities. + */ + @ApiOperation({ + summary: 'Retrieve daily activities report', + description: 'Fetches a report of daily activities grouped by parameters like date, employee, or project.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Successfully retrieved the daily activities report' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request parameters may contain errors' }) - @Get('report') + @Get('/report') @UseValidationPipe({ transform: true, whitelist: true }) async getDailyActivitiesReport(@Query() options: ActivityQueryDTO) { let activities = await this.activityService.getDailyActivitiesReport(options); @@ -64,13 +101,27 @@ export class ActivityController { return activities; } - @ApiOperation({ summary: 'Save bulk Activities' }) + /** + * Saves multiple activities in bulk. + * + * @param entities - The list of activities to be saved in bulk. + * @returns A promise resolving when the bulk save is complete. + */ + @ApiOperation({ + summary: 'Bulk save activities', + description: 'Saves multiple activities in one request. Useful for bulk data insertion.' + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'The activities have been successfully saved' + }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, the request body may contain errors' }) - @Post('bulk') - async bulkSaveActivities(@Body() entities: IBulkActivitiesInput) { + @Post('/bulk') + @UseValidationPipe() + async bulkSaveActivities(@Body() entities: BulkActivityInputDTO) { return await this.activityService.bulkSave(entities); } } diff --git a/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts b/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts index 13aed0dab7f..dac26cd393c 100644 --- a/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts +++ b/packages/core/src/time-tracking/activity/commands/handlers/bulk-activities-save.handler.ts @@ -1,85 +1,70 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; import { IActivity, PermissionsEnum } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { Activity } from '../../activity.entity'; import { BulkActivitiesSaveCommand } from '../bulk-activities-save.command'; import { RequestContext } from '../../../../core/context'; -import { Employee } from './../../../../core/entities/internal'; import { TypeOrmActivityRepository } from '../../repository/type-orm-activity.repository'; import { TypeOrmEmployeeRepository } from '../../../../employee/repository/type-orm-employee.repository'; @CommandHandler(BulkActivitiesSaveCommand) export class BulkActivitiesSaveHandler implements ICommandHandler { - constructor( - @InjectRepository(Activity) private readonly typeOrmActivityRepository: TypeOrmActivityRepository, - - @InjectRepository(Employee) private readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository - ) { } + ) {} /** + * Executes the bulk save operation for activities. * - * @param command - * @returns + * @param command - The command containing the input data for saving multiple activities. + * @returns A promise that resolves with the saved activities. + * @throws BadRequestException if there is an error during the save process. */ public async execute(command: BulkActivitiesSaveCommand): Promise { const { input } = command; - let { employeeId, organizationId, activities = [] } = input; + let { employeeId, organizationId, activities = [], projectId } = input; const user = RequestContext.currentUser(); - const tenantId = RequestContext.currentTenantId(); + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; - /** - * Check logged user does not have employee selection permission - */ - if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { - try { - let employee = await this.typeOrmEmployeeRepository.findOneByOrFail({ - userId: user.id, - tenantId - }); - employeeId = employee.id; - organizationId = employee.organizationId; - } catch (error) { - console.log(`Error while finding logged in employee for (${user.name}) create bulk activities`, error); - } - } else if (isEmpty(employeeId) && RequestContext.currentEmployeeId()) { + // Check if the logged user has permission to change the selected employee + const hasChangeEmployeePermission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE); + + // Assign current employeeId if the user doesn't have permission or if employeeId is not provided + if (!hasChangeEmployeePermission || (isEmpty(employeeId) && RequestContext.currentEmployeeId())) { employeeId = RequestContext.currentEmployeeId(); } - /* - * If organization not found in request then assign current logged user organization - */ - if (isEmpty(organizationId)) { - let employee = await this.typeOrmEmployeeRepository.findOneBy({ - id: employeeId - }); + // Assign the current user's organizationId if it's not provided + if (isEmpty(organizationId) && employeeId) { + const employee = await this.typeOrmEmployeeRepository.findOneBy({ id: employeeId }); organizationId = employee ? employee.organizationId : null; } - console.log(`Empty bulk App & URL's activities for employee (${user.name}): ${employeeId}`, activities.filter( - (activity: IActivity) => Object.keys(activity).length === 0 - )); + // Log empty activities and filter out any invalid ones + console.log( + `Empty bulk App & URL's activities for employee (${user.name}): ${employeeId}`, + activities.filter((activity: IActivity) => Object.keys(activity).length === 0) + ); - activities = activities.filter( - (activity: IActivity) => Object.keys(activity).length !== 0 - ).map((activity: IActivity) => new Activity({ - ...activity, - ...(input.projectId ? { projectId: input.projectId } : {}), - employeeId, - organizationId, - tenantId, - })); + activities = activities + .filter((activity: IActivity) => Object.keys(activity).length !== 0) + .map( + (activity: IActivity) => + new Activity({ + ...activity, + ...(projectId ? { projectId } : {}), + employeeId, + organizationId, + tenantId + }) + ); - console.log(`Activities should be insert into database for employee (${user.name})`, { activities }); + // Log the activities that will be inserted into the database + console.log(`Activities should be inserted into database for employee (${user.name})`, { activities }); - if (isNotEmpty(activities)) { - return await this.typeOrmActivityRepository.save(activities); - } else { - return []; - } + // Save activities if they exist, otherwise return an empty array + return isNotEmpty(activities) ? await this.typeOrmActivityRepository.save(activities) : []; } } diff --git a/packages/core/src/time-tracking/activity/dto/activity-query.dto.ts b/packages/core/src/time-tracking/activity/dto/activity-query.dto.ts new file mode 100644 index 00000000000..3c9e0a6e031 --- /dev/null +++ b/packages/core/src/time-tracking/activity/dto/activity-query.dto.ts @@ -0,0 +1,27 @@ +import { IGetActivitiesInput, ReportGroupFilterEnum } from '@gauzy/contracts'; +import { ApiPropertyOptional, IntersectionType } from '@nestjs/swagger'; +import { IsArray, IsEnum, IsOptional } from 'class-validator'; +import { FiltersQueryDTO, SelectorsQueryDTO } from '../../../shared/dto'; + +/** + * Get activities request DTO validation + */ +export class ActivityQueryDTO + extends IntersectionType(FiltersQueryDTO, SelectorsQueryDTO) + implements IGetActivitiesInput +{ + @ApiPropertyOptional({ type: () => Array, enum: ReportGroupFilterEnum }) + @IsOptional() + @IsEnum(ReportGroupFilterEnum) + readonly groupBy: ReportGroupFilterEnum; + + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + readonly types: string[]; + + @ApiPropertyOptional({ type: () => Array, isArray: true }) + @IsOptional() + @IsArray() + readonly titles: string[]; +} diff --git a/packages/core/src/time-tracking/activity/dto/bulk-activities-input.dto.ts b/packages/core/src/time-tracking/activity/dto/bulk-activities-input.dto.ts new file mode 100644 index 00000000000..420146363ff --- /dev/null +++ b/packages/core/src/time-tracking/activity/dto/bulk-activities-input.dto.ts @@ -0,0 +1,14 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { IActivity, IBulkActivitiesInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../../core/dto'; +import { EmployeeFeatureDTO } from '../../../employee/dto'; + +/** + * Get activities request DTO validation + */ +export class BulkActivityInputDTO + extends IntersectionType(TenantOrganizationBaseDTO, EmployeeFeatureDTO) + implements IBulkActivitiesInput +{ + readonly activities: IActivity[]; +} diff --git a/packages/core/src/time-tracking/activity/dto/index.ts b/packages/core/src/time-tracking/activity/dto/index.ts new file mode 100644 index 00000000000..aa4563922b0 --- /dev/null +++ b/packages/core/src/time-tracking/activity/dto/index.ts @@ -0,0 +1,2 @@ +export * from './bulk-activities-input.dto'; +export * from './activity-query.dto'; diff --git a/packages/core/src/time-tracking/activity/dto/query/activity-query.dto.ts b/packages/core/src/time-tracking/activity/dto/query/activity-query.dto.ts deleted file mode 100644 index 710afaea9bc..00000000000 --- a/packages/core/src/time-tracking/activity/dto/query/activity-query.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IGetActivitiesInput, ReportGroupFilterEnum } from "@gauzy/contracts"; -import { IntersectionType } from "@nestjs/swagger"; -import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsArray, IsEnum, IsOptional } from "class-validator"; -import { FiltersQueryDTO, SelectorsQueryDTO } from "../../../../shared/dto"; - -/** - * Get activities request DTO validation - */ -export class ActivityQueryDTO extends IntersectionType( - FiltersQueryDTO, - SelectorsQueryDTO -) implements IGetActivitiesInput { - - @ApiPropertyOptional({ type: () => Array, enum: ReportGroupFilterEnum }) - @IsOptional() - @IsEnum(ReportGroupFilterEnum) - readonly groupBy: ReportGroupFilterEnum; - - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly types: string[]; - - @ApiPropertyOptional({ type: () => Array, isArray: true }) - @IsOptional() - @IsArray() - readonly titles: string[]; -} \ No newline at end of file diff --git a/packages/core/src/time-tracking/activity/dto/query/index.ts b/packages/core/src/time-tracking/activity/dto/query/index.ts deleted file mode 100644 index 72efc3ae5cf..00000000000 --- a/packages/core/src/time-tracking/activity/dto/query/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ActivityQueryDTO } from './activity-query.dto'; \ No newline at end of file diff --git a/packages/core/src/time-tracking/screenshot/dto/delete-screenshot.dto.ts b/packages/core/src/time-tracking/screenshot/dto/delete-screenshot.dto.ts new file mode 100644 index 00000000000..3f8203b0c3e --- /dev/null +++ b/packages/core/src/time-tracking/screenshot/dto/delete-screenshot.dto.ts @@ -0,0 +1,9 @@ +import { IDeleteScreenshot } from '@gauzy/contracts'; +import { ForceDeleteBaseDTO } from '../../../core/dto'; +import { Screenshot } from '../screenshot.entity'; + +/** + * Data Transfer Object (DTO) for deleting screenshots with the `forceDelete` flag. + * This DTO extends the `ForceDeleteBaseDTO` to include the `forceDelete` flag. + */ +export class DeleteScreenshotDTO extends ForceDeleteBaseDTO implements IDeleteScreenshot {} diff --git a/packages/core/src/time-tracking/screenshot/screenshot.helper.ts b/packages/core/src/time-tracking/screenshot/screenshot-file-storage.helper.ts similarity index 76% rename from packages/core/src/time-tracking/screenshot/screenshot.helper.ts rename to packages/core/src/time-tracking/screenshot/screenshot-file-storage.helper.ts index 609eae545c2..f4973f388b8 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.helper.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot-file-storage.helper.ts @@ -15,6 +15,10 @@ export function createFileStorage() { // Generate unique sub directories based on the current tenant and employee IDs const subDirectory = getSubDirectory(); + console.log( + `--------------------screenshot full path: ${path.join(baseDirectory, subDirectory)}--------------------` + ); + return new FileStorage().storage({ dest: () => path.join(baseDirectory, subDirectory), prefix: 'screenshots' @@ -25,7 +29,7 @@ export function createFileStorage() { * Gets the base directory for storing screenshots based on the current date. * @returns The base directory path */ -function getBaseDirectory(): string { +export function getBaseDirectory(): string { return path.join('screenshots', moment().format('YYYY/MM/DD')); } @@ -33,9 +37,12 @@ function getBaseDirectory(): string { * Generates a unique sub-directory based on the current tenant and employee IDs. * @returns The sub-directory path */ -function getSubDirectory(): string { +export function getSubDirectory(): string { + const user = RequestContext.currentUser(); + // Retrieve the tenant ID from the current context or a random UUID - const tenantId = RequestContext.currentTenantId() || uuid(); - const employeeId = RequestContext.currentEmployeeId() || uuid(); + const tenantId = user?.tenantId || uuid(); + const employeeId = user?.employeeId || uuid(); + return path.join(tenantId, employeeId); } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.controller.ts b/packages/core/src/time-tracking/screenshot/screenshot.controller.ts index 23341b79e44..ece578066df 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.controller.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.controller.ts @@ -4,7 +4,7 @@ import { isUUID } from 'class-validator'; import * as path from 'path'; import * as fs from 'fs'; import * as Jimp from 'jimp'; -import { IScreenshot, PermissionsEnum, UploadedFile } from '@gauzy/contracts'; +import { ID, IScreenshot, PermissionsEnum, UploadedFile } from '@gauzy/contracts'; import { EventBus } from '../../event-bus/event-bus'; import { ScreenshotEvent } from '../../event-bus/events/screenshot.event'; import { BaseEntityEventTypeEnum } from '../../event-bus/base-entity-event'; @@ -15,16 +15,18 @@ import { LazyFileInterceptor } from './../../core/interceptors'; import { Permissions } from './../../shared/decorators'; import { PermissionGuard, TenantPermissionGuard } from './../../shared/guards'; import { UUIDValidationPipe, UseValidationPipe } from './../../shared/pipes'; -import { DeleteQueryDTO } from './../../shared/dto'; +import { DeleteScreenshotDTO } from './dto/delete-screenshot.dto'; import { Screenshot } from './screenshot.entity'; import { ScreenshotService } from './screenshot.service'; -import { createFileStorage } from './screenshot.helper'; +import { createFileStorage } from './screenshot-file-storage.helper'; @ApiTags('Screenshot') @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.TIME_TRACKER) @Controller() export class ScreenshotController { + private logging: boolean = true; + constructor(private readonly _screenshotService: ScreenshotService, private readonly _eventBus: EventBus) {} /** @@ -62,7 +64,7 @@ export class ScreenshotController { return; } - console.log('Screenshot request input:', input); + if (this.logging) console.log('Screenshot request input:', input); // Extract user information from the request context const user = RequestContext.currentUser(); @@ -114,7 +116,7 @@ export class ScreenshotController { // Upload the thumbnail data to the file storage provider const thumb = await provider.putFile(data, fullPath); - console.log(`Screenshot thumb created for employee (${user.name})`, thumb); + if (this.logging) console.log(`Screenshot thumb created for employee (${user.name})`, thumb); // Populate entity properties for the screenshot const entity = new Screenshot({ @@ -130,7 +132,7 @@ export class ScreenshotController { // Create the screenshot entity in the database const screenshot = await this._screenshotService.create(entity); - console.log(`Screenshot created for employee (${user.name})`, screenshot); + if (this.logging) console.log(`Screenshot created for employee (${user.name})`, screenshot); // Publish the screenshot created event const ctx = RequestContext.currentRequestContext(); // Get current request context; @@ -145,29 +147,35 @@ export class ScreenshotController { } /** + * Deletes a screenshot record by its ID. + * + * This endpoint allows authorized users to delete a screenshot record by providing its ID. + * Additional query options can be provided to customize the delete operation. * - * @param screenshotId - * @param options - * @returns + * @param id - The UUID of the screenshot to delete. + * @param options - Additional query options for deletion (e.g., soft delete or force delete). + * @returns A Promise that resolves with the details of the deleted screenshot. */ @ApiOperation({ - summary: 'Delete record' + summary: 'Delete a screenshot by ID', + description: 'Deletes a screenshot record from the system based on the provided ID.' }) @ApiResponse({ status: HttpStatus.OK, - description: 'The record has been successfully deleted' + description: 'The screenshot has been successfully deleted.' }) @ApiResponse({ status: HttpStatus.NOT_FOUND, - description: 'Record not found' + description: 'Screenshot record not found.' + }) + @ApiResponse({ + status: HttpStatus.FORBIDDEN, + description: 'User does not have permission to delete screenshots.' }) @Permissions(PermissionsEnum.DELETE_SCREENSHOTS) @Delete(':id') @UseValidationPipe() - async delete( - @Param('id', UUIDValidationPipe) screenshotId: IScreenshot['id'], - @Query() options: DeleteQueryDTO - ): Promise { - return await this._screenshotService.deleteScreenshot(screenshotId, options); + async delete(@Param('id', UUIDValidationPipe) id: ID, @Query() options: DeleteScreenshotDTO): Promise { + return await this._screenshotService.deleteScreenshot(id, options); } } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.entity.ts b/packages/core/src/time-tracking/screenshot/screenshot.entity.ts index 91d91755d36..1be4fd67ce0 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.entity.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.entity.ts @@ -2,9 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { RelationId, JoinColumn } from 'typeorm'; import { IsString, IsOptional, IsDateString, IsUUID, IsNotEmpty, IsEnum, IsBoolean } from 'class-validator'; import { Exclude } from 'class-transformer'; -import { FileStorageProvider, FileStorageProviderEnum, IScreenshot, ITimeSlot, IUser } from '@gauzy/contracts'; +import { FileStorageProvider, FileStorageProviderEnum, ID, IScreenshot, ITimeSlot, IUser } from '@gauzy/contracts'; import { isBetterSqlite3, isSqlite } from '@gauzy/config'; -import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne, VirtualMultiOrmColumn } from '../../core/decorators/entity'; +import { + ColumnIndex, + MultiORMColumn, + MultiORMEntity, + MultiORMManyToOne, + VirtualMultiOrmColumn +} from '../../core/decorators/entity'; import { TenantOrganizationBaseEntity, TimeSlot, User } from './../../core/entities/internal'; import { MikroOrmScreenshotRepository } from './repository/mikro-orm-screenshot.repository'; @@ -115,7 +121,7 @@ export class Screenshot extends TenantOrganizationBaseEntity implements IScreens @RelationId((it: Screenshot) => it.timeSlot) @ColumnIndex() @MultiORMColumn({ nullable: true, relationId: true }) - timeSlotId?: ITimeSlot['id']; + timeSlotId?: ID; /** * User @@ -136,5 +142,5 @@ export class Screenshot extends TenantOrganizationBaseEntity implements IScreens @RelationId((it: Screenshot) => it.user) @ColumnIndex() @MultiORMColumn({ nullable: true, relationId: true }) - userId?: IUser['id']; + userId?: ID; } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.service.ts b/packages/core/src/time-tracking/screenshot/screenshot.service.ts index c69fe7765de..84d245440d5 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.service.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.service.ts @@ -1,8 +1,8 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; -import { ID, IScreenshot, PermissionsEnum } from '@gauzy/contracts'; +import { ID, IDeleteScreenshot, IScreenshot, PermissionsEnum } from '@gauzy/contracts'; import { RequestContext } from './../../core/context'; import { TenantAwareCrudService } from './../../core/crud'; +import { prepareSQLQuery as p } from '../../database/database.helper'; import { Screenshot } from './screenshot.entity'; import { MikroOrmScreenshotRepository, TypeOrmScreenshotRepository } from './repository'; @@ -23,33 +23,50 @@ export class ScreenshotService extends TenantAwareCrudService { * @returns The deleted screenshot * @throws ForbiddenException if the screenshot cannot be found or deleted */ - async deleteScreenshot(id: ID, options?: FindOptionsWhere): Promise { + async deleteScreenshot(id: ID, options?: IDeleteScreenshot): Promise { try { - const tenantId = RequestContext.currentTenantId() || options?.tenantId; - const { organizationId } = options || {}; - - const query = this.typeOrmRepository.createQueryBuilder(this.tableName); - query.setFindOptions({ - where: { - ...(options ? options : {}), - id, - tenantId, - organizationId - } - }); - - if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { + const tenantId = RequestContext.currentTenantId() ?? options.tenantId; + const { organizationId, forceDelete } = options; + + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + + // Create a query builder for the Screenshot entity + const query = this.typeOrmRepository.createQueryBuilder(); + + // Add the WHERE clause to the query + query + .where(p(`"${query.alias}"."id" = :id`), { id }) + .andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }) + .andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + + // Restrict by employeeId if the user doesn't have permission + if (!hasChangeSelectedEmployeePermission) { + // Get the current employee ID from the request context const employeeId = RequestContext.currentEmployeeId(); + + // Join the timeSlot table and filter by employeeId, tenantId, and organizationId query.leftJoin( `${query.alias}.timeSlot`, 'time_slot', - 'time_slot.employeeId = :employeeId AND time_slot.tenantId = :tenantId', - { employeeId, tenantId } + 'time_slot.employeeId = :employeeId AND time_slot.tenantId = :tenantId AND time_slot.organizationId = :organizationId', + { + employeeId, + tenantId, + organizationId + } ); } + // Find the screenshot const screenshot = await query.getOneOrFail(); - return await this.typeOrmRepository.remove(screenshot); + + // Handle force delete or soft delete based on the flag + return forceDelete + ? await this.typeOrmRepository.remove(screenshot) + : await this.typeOrmRepository.softRemove(screenshot); } catch (error) { throw new ForbiddenException('You do not have permission to delete this screenshot.'); } diff --git a/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts b/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts index 1054380df8d..dddb8fd8f84 100644 --- a/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts +++ b/packages/core/src/time-tracking/screenshot/screenshot.subscriber.ts @@ -163,7 +163,7 @@ export class ScreenshotSubscriber extends BaseEntityEventSubscriber return; // Early exit if the entity is not a Screenshot } const { id: entityId, storageProvider, file, thumb } = entity; - console.log(`BEFORE SCREENSHOT ENTITY WITH ID ${entityId} REMOVED`); + console.log(`AFTER SCREENSHOT ENTITY WITH ID ${entityId} REMOVED`); console.log('ScreenshotSubscriber: Deleting files...', file, thumb); // Initialize the file storage instance with the provided storage provider. @@ -176,4 +176,24 @@ export class ScreenshotSubscriber extends BaseEntityEventSubscriber console.error(`ScreenshotSubscriber: Error deleting files for entity ID ${entity?.id}:`, error.message); } } + + /** + * Called after entity is soft removed from the database. + * This method handles the removal of associated files (both the main file and its thumbnail) from the storage system. + * + * @param entity The entity that was soft removed. + * @returns {Promise} A promise that resolves when the file soft removal operations are complete. + */ + async afterEntitySoftRemove(entity: Screenshot): Promise { + try { + if (!(entity instanceof Screenshot)) { + return; // Early exit if the entity is not a Screenshot + } + const { id: entityId, file, thumb } = entity; + console.log(`AFTER SCREENSHOT ENTITY WITH ID ${entityId} SOFT REMOVED`); + console.log('ScreenshotSubscriber: Soft removing files...', file, thumb); + } catch (error) { + console.error(`ScreenshotSubscriber: Error soft removing entity ID ${entity?.id}:`, error.message); + } + } } diff --git a/packages/core/src/time-tracking/statistic/statistic.helper.ts b/packages/core/src/time-tracking/statistic/statistic.helper.ts index 727d49228a3..be17f5a8ed6 100644 --- a/packages/core/src/time-tracking/statistic/statistic.helper.ts +++ b/packages/core/src/time-tracking/statistic/statistic.helper.ts @@ -38,14 +38,43 @@ export const getDurationQueryString = (dbType: string, logQueryAlias: string, sl switch (dbType) { case DatabaseTypeEnum.sqlite: case DatabaseTypeEnum.betterSqlite3: - return `COALESCE(ROUND(SUM((julianday(COALESCE("${logQueryAlias}"."stoppedAt", datetime('now'))) - julianday("${logQueryAlias}"."startedAt")) * 86400) / COUNT("${slotQueryAlias}"."id")), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN (julianday(COALESCE("${logQueryAlias}"."stoppedAt", datetime('now'))) - + julianday("${logQueryAlias}"."startedAt")) * 86400 >= 0 + THEN (julianday(COALESCE("${logQueryAlias}"."stoppedAt", datetime('now'))) - + julianday("${logQueryAlias}"."startedAt")) * 86400 + ELSE 0 + END + ) / COUNT("${slotQueryAlias}"."id") + ), 0 + )`; case DatabaseTypeEnum.postgres: - return `COALESCE(ROUND(SUM(extract(epoch from (COALESCE("${logQueryAlias}"."stoppedAt", NOW()) - "${logQueryAlias}"."startedAt"))) / COUNT("${slotQueryAlias}"."id")), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN extract(epoch from (COALESCE("${logQueryAlias}"."stoppedAt", NOW()) - "${logQueryAlias}"."startedAt")) >= 0 + THEN extract(epoch from (COALESCE("${logQueryAlias}"."stoppedAt", NOW()) - "${logQueryAlias}"."startedAt")) + ELSE 0 + END + ) / COUNT("${slotQueryAlias}"."id") + ), 0 + )`; case DatabaseTypeEnum.mysql: - // Directly return the SQL string for MySQL, as MikroORM allows raw SQL. - return p( - `COALESCE(ROUND(SUM(TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, COALESCE(\`${logQueryAlias}\`.\`stoppedAt\`, NOW()))) / COUNT(\`${slotQueryAlias}\`.\`id\`)), 0)` - ); + return p(`COALESCE( + ROUND( + SUM( + CASE + WHEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, COALESCE(\`${logQueryAlias}\`.\`stoppedAt\`, NOW())) >= 0 + THEN TIMESTAMPDIFF(SECOND, \`${logQueryAlias}\`.\`startedAt\`, COALESCE(\`${logQueryAlias}\`.\`stoppedAt\`, NOW())) + ELSE 0 + END + ) / COUNT(\`${slotQueryAlias}\`.\`id\`) + ), 0 + )`); default: throw new Error(`Unsupported database type: ${dbType}`); } @@ -63,11 +92,43 @@ export const getTotalDurationQueryString = (dbType: string, queryAlias: string): switch (dbType) { case DatabaseTypeEnum.sqlite: case DatabaseTypeEnum.betterSqlite3: - return `COALESCE(ROUND(SUM((julianday(COALESCE("${queryAlias}"."stoppedAt", datetime('now'))) - julianday("${queryAlias}"."startedAt")) * 86400)), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN (julianday(COALESCE("${queryAlias}"."stoppedAt", datetime('now'))) - + julianday("${queryAlias}"."startedAt")) * 86400 >= 0 + THEN (julianday(COALESCE("${queryAlias}"."stoppedAt", datetime('now'))) - + julianday("${queryAlias}"."startedAt")) * 86400 + ELSE 0 + END + ) + ), 0 + )`; case DatabaseTypeEnum.postgres: - return `COALESCE(ROUND(SUM(extract(epoch from (COALESCE("${queryAlias}"."stoppedAt", NOW()) - "${queryAlias}"."startedAt")))), 0)`; + return `COALESCE( + ROUND( + SUM( + CASE + WHEN extract(epoch from (COALESCE("${queryAlias}"."stoppedAt", NOW()) - "${queryAlias}"."startedAt")) >= 0 + THEN extract(epoch from (COALESCE("${queryAlias}"."stoppedAt", NOW()) - "${queryAlias}"."startedAt")) + ELSE 0 + END + ) + ), 0 + )`; case DatabaseTypeEnum.mysql: - return `COALESCE(ROUND(SUM(TIMESTAMPDIFF(SECOND, \`${queryAlias}\`.\`startedAt\`, COALESCE(\`${queryAlias}\`.\`stoppedAt\`, NOW())))), 0)`; + return p(`COALESCE( + ROUND( + SUM( + CASE + WHEN TIMESTAMPDIFF(SECOND, \`${queryAlias}\`.\`startedAt\`, COALESCE(\`${queryAlias}\`.\`stoppedAt\`, NOW())) >= 0 + THEN TIMESTAMPDIFF(SECOND, \`${queryAlias}\`.\`startedAt\`, COALESCE(\`${queryAlias}\`.\`stoppedAt\`, NOW())) + ELSE 0 + END + ) + ), 0 + )`); default: throw Error(`Unsupported database type: ${dbType}`); } diff --git a/packages/core/src/time-tracking/statistic/statistic.service.ts b/packages/core/src/time-tracking/statistic/statistic.service.ts index 6d6b41ba09f..9db343e3b96 100644 --- a/packages/core/src/time-tracking/statistic/statistic.service.ts +++ b/packages/core/src/time-tracking/statistic/statistic.service.ts @@ -20,7 +20,9 @@ import { IGetManualTimesStatistics, IManualTimesStatistics, TimeLogType, - ITimeLog + ITimeLog, + IWeeklyStatisticsActivities, + ITodayStatisticsActivities } from '@gauzy/contracts'; import { ArraySum, isNotEmpty } from '@gauzy/common'; import { @@ -44,7 +46,7 @@ import { TimeLog, TimeSlot } from './../../core/entities/internal'; import { MultiORMEnum, getDateRangeFormat, getORMType } from './../../core/utils'; import { TypeOrmTimeSlotRepository } from '../../time-tracking/time-slot/repository/type-orm-time-slot.repository'; import { TypeOrmEmployeeRepository } from '../../employee/repository/type-orm-employee.repository'; -import { TypeOrmActivityRepository } from '../activity/repository'; +import { TypeOrmActivityRepository } from '../activity/repository/type-orm-activity.repository'; import { MikroOrmTimeLogRepository, TypeOrmTimeLogRepository } from '../time-log/repository'; // Get the type of the Object-Relational Mapping (ORM) used in the application. @@ -99,233 +101,316 @@ export class StatisticService { } /** - * GET Time Tracking Dashboard Counts Statistics + * Retrieves time tracking dashboard count statistics, including the total number of employees worked, + * projects worked, weekly activities, and today's activities based on the given request. * - * @param request - * @returns + * This function executes multiple asynchronous operations concurrently to fetch the necessary statistics + * and constructs a comprehensive response object with aggregated data. + * + * @param {IGetCountsStatistics} request - The request object containing filters and parameters to fetch + * the counts statistics, such as organizationId, date ranges, employeeIds, projectIds, and other filtering criteria. + * + * @returns {Promise} - Returns a promise that resolves with the counts statistics object, + * containing total employees count, projects count, weekly activity, weekly duration, today's activity, and today's duration. */ async getCounts(request: IGetCountsStatistics): Promise { - const { organizationId, startDate, endDate, todayStart, todayEnd } = request; - let { employeeIds = [], projectIds = [], teamIds = [] } = request; + // Retrieve statistics counts concurrently + const [employeesCount, projectsCount, weekActivities, todayActivities] = await Promise.all([ + this.getEmployeeWorkedCounts(request), + this.getProjectWorkedCounts(request), + this.getWeeklyStatisticsActivities(request), + this.getTodayStatisticsActivities(request) + ]); + + // Construct and return the response object + return { + employeesCount, + projectsCount, + weekActivities: parseFloat(weekActivities.overall.toFixed(2)), + weekDuration: weekActivities.duration, + todayActivities: parseFloat(todayActivities.overall.toFixed(2)), + todayDuration: todayActivities.duration + }; + } - const user = RequestContext.currentUser(); - const tenantId = RequestContext.currentTenantId() || request.tenantId; + /** + * Get average activity and total duration of the work for the week. + * + * @param request - The request object containing filters and parameters + * @returns {Promise} - The weekly activity statistics + */ + async getWeeklyStatisticsActivities(request: IGetCountsStatistics): Promise { + let { + organizationId, + startDate, + endDate, + employeeIds = [], + projectIds = [], + teamIds = [], + activityLevel, + logType, + source, + onlyMe: isOnlyMeSelected // Determine if the request specifies to retrieve data for the current user only + } = request; - const { start, end } = getDateRangeFormat( - moment.utc(startDate || moment().startOf('week')), - moment.utc(endDate || moment().endOf('week')) - ); + // Retrieves the database type from the configuration service. + const dbType = this.configService.dbConnectionOptions.type; + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the current tenant ID // Check if the current user has the permission to change the selected employee const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( PermissionsEnum.CHANGE_SELECTED_EMPLOYEE ); - // Determine if the request specifies to retrieve data for the current user only - const isOnlyMeSelected: boolean = request.onlyMe; - - /** - * Set employeeIds based on user conditions and permissions - */ - if ((user.employeeId && isOnlyMeSelected) || (!hasChangeSelectedEmployeePermission && user.employeeId)) { + // Set employeeIds based on user conditions and permissions + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { employeeIds = [user.employeeId]; } - /** - * GET statistics counts - */ - const employeesCount = await this.getEmployeeWorkedCounts({ - ...request, - employeeIds - }); - const projectsCount = await this.getProjectWorkedCounts({ - ...request, - employeeIds - }); - - // Retrieves the database type from the configuration service. - const dbType = this.configService.dbConnectionOptions.type; - - /* - * Get average activity and total duration of the work for the week. - */ let weekActivities = { overall: 0, duration: 0 }; - const weekQuery = this.typeOrmTimeSlotRepository.createQueryBuilder(); - weekQuery - .innerJoin(`${weekQuery.alias}.timeLogs`, 'timeLogs') - .select(getDurationQueryString(dbType, 'timeLogs', weekQuery.alias), `week_duration`) - .addSelect(p(`COALESCE(SUM("${weekQuery.alias}"."overall"), 0)`), `overall`) - .addSelect(p(`COALESCE(SUM("${weekQuery.alias}"."duration"), 0)`), `duration`) - .addSelect(p(`COUNT("${weekQuery.alias}"."id")`), `time_slot_count`); - - weekQuery - .andWhere(`${weekQuery.alias}.tenantId = :tenantId`, { tenantId }) - .andWhere(`${weekQuery.alias}.organizationId = :organizationId`, { organizationId }) - .andWhere(`timeLogs.tenantId = :tenantId`, { tenantId }) - .andWhere(`timeLogs.organizationId = :organizationId`, { organizationId }); - - weekQuery - .andWhere(p(`"${weekQuery.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { + // Define the start and end dates + const { start, end } = getDateRangeFormat( + moment.utc(startDate || moment().startOf('week')), + moment.utc(endDate || moment().endOf('week')) + ); + + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + query + .innerJoin(`${query.alias}.timeLogs`, 'time_log') + .select([ + getDurationQueryString(dbType, 'time_log', query.alias) + ' AS week_duration', + p(`COALESCE(SUM("${query.alias}"."overall"), 0)`) + ' AS overall', + p(`COALESCE(SUM("${query.alias}"."duration"), 0)`) + ' AS duration', + p(`COUNT("${query.alias}"."id")`) + ' AS time_slot_count' + ]); + + // Base where conditions + query + .where(`${query.alias}.tenantId = :tenantId`, { tenantId }) + .andWhere(`${query.alias}.organizationId = :organizationId`, { organizationId }) + .andWhere(`time_log.tenantId = :tenantId`, { tenantId }) + .andWhere(`time_log.organizationId = :organizationId`, { organizationId }); + + query + .andWhere(`${query.alias}.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }) - .andWhere(p(`"timeLogs"."startedAt" BETWEEN :startDate AND :endDate`), { startDate: start, endDate: end }); + .andWhere(`time_log.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }) + .andWhere(`time_log.stoppedAt >= time_log.startedAt`); + // Applying optional filters conditionally to avoid unnecessary execution if (isNotEmpty(employeeIds)) { - weekQuery.andWhere(p(`"${weekQuery.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); - weekQuery.andWhere(p(`"timeLogs"."employeeId" IN (:...employeeIds)`), { employeeIds }); + query.andWhere(`${query.alias}.employeeId IN (:...employeeIds)`, { employeeIds }); + query.andWhere(`time_log.employeeId IN (:...employeeIds)`, { employeeIds }); } + // Filter by project if (isNotEmpty(projectIds)) { - weekQuery.andWhere(p(`"timeLogs"."projectId" IN (:...projectIds)`), { projectIds }); + query.andWhere(`time_log.projectId IN (:...projectIds)`, { projectIds }); } - if (isNotEmpty(request.activityLevel)) { - /** - * Activity Level should be 0-100% - * So, we have convert it into 10 minutes TimeSlot by multiply by 6 - */ - const { activityLevel } = request; - const startLevel = activityLevel.start * 6; - const endLevel = activityLevel.end * 6; + // Filter by team + if (isNotEmpty(teamIds)) { + query.andWhere(`time_log.organizationTeamId IN (:...teamIds)`, { teamIds }); + } + + // Filter by activity level + if (isNotEmpty(activityLevel)) { + const startLevel = activityLevel.start * 6; // Start level for activity level in seconds + const endLevel = activityLevel.end * 6; // End level for activity level in seconds - weekQuery.andWhere(p(`"${weekQuery.alias}"."overall" BETWEEN :startLevel AND :endLevel`), { + query.andWhere(`${query.alias}.overall BETWEEN :startLevel AND :endLevel`, { startLevel, endLevel }); } - if (isNotEmpty(request.logType)) { - const { logType } = request; - weekQuery.andWhere(p(`"timeLogs"."logType" IN (:...logType)`), { logType }); + // Filter by log type + if (isNotEmpty(logType)) { + query.andWhere(`time_log.logType IN (:...logType)`, { logType }); } - if (isNotEmpty(request.source)) { - const { source } = request; - weekQuery.andWhere(p(`"timeLogs"."source" IN (:...source)`), { source }); + // Filter by source + if (isNotEmpty(source)) { + query.andWhere(`time_log.source IN (:...source)`, { source }); } - if (isNotEmpty(teamIds)) { - weekQuery.andWhere(p(`"timeLogs"."organizationTeamId" IN (:...teamIds)`), { teamIds }); - } + // Group by time_log.id to get the total duration and overall for each time slot + const weekTimeStatistics = await query.groupBy(p(`"time_log"."id"`)).getRawMany(); + console.log('weekly time statistics activity', JSON.stringify(weekTimeStatistics)); - weekQuery.groupBy(p(`"timeLogs"."id"`)); - const weekTimeStatistics = await weekQuery.getRawMany(); + // Initialize variables to accumulate values + let totalWeekDuration = 0; + let totalOverall = 0; + let totalDuration = 0; - const weekDuration = reduce(pluck(weekTimeStatistics, 'week_duration'), ArraySum, 0); - const weekPercentage = - (reduce(pluck(weekTimeStatistics, 'overall'), ArraySum, 0) * 100) / - reduce(pluck(weekTimeStatistics, 'duration'), ArraySum, 0); + // Iterate over the weekTimeStatistics array once to calculate all values + for (const stat of weekTimeStatistics) { + totalWeekDuration += Number(stat.week_duration) || 0; + totalOverall += Number(stat.overall) || 0; + totalDuration += Number(stat.duration) || 0; + } + + // Calculate the week percentage, avoiding division by zero + const weekPercentage = totalDuration > 0 ? (totalOverall * 100) / totalDuration : 0; - weekActivities['duration'] = weekDuration; + // Assign the calculated values to weekActivities + weekActivities['duration'] = totalWeekDuration; weekActivities['overall'] = weekPercentage; - /* - * Get average activity and total duration of the work for today. + return weekActivities; + } + + /** + * Get average activity and total duration of the work for today. + * + * @param request - The request object containing filters and parameters + * @returns {Promise} - Today's activity statistics + */ + async getTodayStatisticsActivities(request: IGetCountsStatistics): Promise { + // Destructure the necessary properties from the request with default values + let { + organizationId, + todayStart, + todayEnd, + employeeIds = [], + projectIds = [], + teamIds = [], + activityLevel, + onlyMe: isOnlyMeSelected, // Determine if the request specifies to retrieve data for the current user only + logType, + source + } = request || {}; + + // Retrieves the database type from the configuration service. + const dbType = this.configService.dbConnectionOptions.type; + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the current tenant ID + + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + + /** + * Set employeeIds based on user conditions and permissions */ + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { + employeeIds = [user.employeeId]; + } + + // Get average activity and total duration of the work for today. let todayActivities = { overall: 0, duration: 0 }; + // Get date range for today const { start: startToday, end: endToday } = getDateRangeFormat( moment.utc(todayStart || moment().startOf('day')), moment.utc(todayEnd || moment().endOf('day')) ); - const todayQuery = this.typeOrmTimeSlotRepository.createQueryBuilder(); - todayQuery - .innerJoin(`${todayQuery.alias}.timeLogs`, 'timeLogs') - .select(getDurationQueryString(dbType, 'timeLogs', todayQuery.alias), `today_duration`) - .addSelect(p(`COALESCE(SUM("${todayQuery.alias}"."overall"), 0)`), `overall`) - .addSelect(p(`COALESCE(SUM("${todayQuery.alias}"."duration"), 0)`), `duration`) - .addSelect(p(`COUNT("${todayQuery.alias}"."id")`), `time_slot_count`); - - todayQuery - .andWhere(`${todayQuery.alias}.tenantId = :tenantId`, { tenantId }) - .andWhere(`${todayQuery.alias}.organizationId = :organizationId`, { organizationId }) - .andWhere(`timeLogs.tenantId = :tenantId`, { tenantId }) - .andWhere(`timeLogs.organizationId = :organizationId`, { organizationId }); - - todayQuery - .andWhere(p(`"timeLogs"."startedAt" BETWEEN :startDate AND :endDate`), { + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + + // Define the base select statements and joins + query + .innerJoin(`${query.alias}.timeLogs`, 'time_log') + .select([ + getDurationQueryString(dbType, 'time_log', query.alias) + ' AS today_duration', + p(`COALESCE(SUM("${query.alias}"."overall"), 0)`) + ' AS overall', + p(`COALESCE(SUM("${query.alias}"."duration"), 0)`) + ' AS duration', + p(`COUNT("${query.alias}"."id")`) + ' AS time_slot_count' + ]); + + // Base where conditions + query + .andWhere(`${query.alias}.tenantId = :tenantId`, { tenantId }) + .andWhere(`${query.alias}.organizationId = :organizationId`, { organizationId }) + .andWhere(`time_log.tenantId = :tenantId`, { tenantId }) + .andWhere(`time_log.organizationId = :organizationId`, { organizationId }); + + query + .andWhere(p(`"${query.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { startDate: startToday, endDate: endToday }) - .andWhere(p(`"${todayQuery.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { + .andWhere(p(`"time_log"."startedAt" BETWEEN :startDate AND :endDate`), { startDate: startToday, endDate: endToday - }); + }) + .andWhere(`time_log.stoppedAt >= time_log.startedAt`); + // Optional filters if (isNotEmpty(employeeIds)) { - todayQuery.andWhere(p(`"timeLogs"."employeeId" IN (:...employeeIds)`), { employeeIds }); - todayQuery.andWhere(p(`"${todayQuery.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); + query + .andWhere(p(`"${query.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }) + .andWhere(p(`"time_log"."employeeId" IN (:...employeeIds)`), { employeeIds }); + } + + if (isNotEmpty(teamIds)) { + query.andWhere(p(`"time_log"."organizationTeamId" IN (:...teamIds)`), { teamIds }); } if (isNotEmpty(projectIds)) { - todayQuery.andWhere(p(`"timeLogs"."projectId" IN (:...projectIds)`), { projectIds }); + query.andWhere(p(`"time_log"."projectId" IN (:...projectIds)`), { projectIds }); } - if (isNotEmpty(request.activityLevel)) { + if (isNotEmpty(activityLevel)) { /** * Activity Level should be 0-100% - * So, we have convert it into 10 minutes TimeSlot by multiply by 6 + * So, we have to convert it into a 10-minute TimeSlot by multiplying by 6 */ - const { activityLevel } = request; const startLevel = activityLevel.start * 6; const endLevel = activityLevel.end * 6; - todayQuery.andWhere(p(`"${todayQuery.alias}"."overall" BETWEEN :startLevel AND :endLevel`), { + query.andWhere(p(`"${query.alias}"."overall" BETWEEN :startLevel AND :endLevel`), { startLevel, endLevel }); } - if (isNotEmpty(request.logType)) { - const { logType } = request; - todayQuery.andWhere(p(`"timeLogs"."logType" IN (:...logType)`), { logType }); + if (isNotEmpty(logType)) { + query.andWhere(p(`"time_log"."logType" IN (:...logType)`), { logType }); } - if (isNotEmpty(request.source)) { - const { source } = request; - todayQuery.andWhere(p(`"timeLogs"."source" IN (:...source)`), { source }); + if (isNotEmpty(source)) { + query.andWhere(p(`"time_log"."source" IN (:...source)`), { source }); } - if (isNotEmpty(teamIds)) { - todayQuery.andWhere(p(`"timeLogs"."organizationTeamId" IN (:...teamIds)`), { teamIds }); - } + const todayTimeStatistics = await query.groupBy(p(`"time_log"."id"`)).getRawMany(); + console.log('today time statistics activity', JSON.stringify(todayTimeStatistics)); + + // Initialize variables to accumulate values + let totalTodayDuration = 0; + let totalOverall = 0; + let totalDuration = 0; - todayQuery.groupBy(p(`"timeLogs"."id"`)); - const todayTimeStatistics = await todayQuery.getRawMany(); + // Iterate over the todayTimeStatistics array once to calculate all values + for (const stat of todayTimeStatistics) { + totalTodayDuration += Number(stat.today_duration) || 0; + totalOverall += Number(stat.overall) || 0; + totalDuration += Number(stat.duration) || 0; + } - const todayDuration = reduce(pluck(todayTimeStatistics, 'today_duration'), ArraySum, 0); - const todayPercentage = - (reduce(pluck(todayTimeStatistics, 'overall'), ArraySum, 0) * 100) / - reduce(pluck(todayTimeStatistics, 'duration'), ArraySum, 0); + // Calculate today's percentage, avoiding division by zero + const todayPercentage = totalDuration > 0 ? (totalOverall * 100) / totalDuration : 0; - todayActivities['duration'] = todayDuration; + // Assign the calculated values to todayActivities + todayActivities['duration'] = totalTodayDuration; todayActivities['overall'] = todayPercentage; - return { - employeesCount, - projectsCount, - weekActivities: parseFloat(parseFloat(weekActivities.overall + '').toFixed(2)), - weekDuration: weekActivities.duration, - todayActivities: parseFloat(parseFloat(todayActivities.overall + '').toFixed(2)), - todayDuration: todayActivities.duration - }; + return todayActivities; } - /** - * GET Time Tracking Dashboard Worked Members Statistics - * - * @param request - * @returns - */ /** * GET Time Tracking Dashboard Worked Members Statistics * @@ -333,12 +418,24 @@ export class StatisticService { * @returns */ async getMembers(request: IGetMembersStatistics): Promise { - const { organizationId, startDate, endDate, todayStart, todayEnd } = request; - let { employeeIds = [], projectIds = [], teamIds = [] } = request; + // Destructure properties from the request with default values where necessary + let { + organizationId, + startDate, + endDate, + todayStart, + todayEnd, + employeeIds = [], + projectIds = [], + teamIds = [] + } = request || {}; - const user = RequestContext.currentUser(); - const tenantId = RequestContext.currentTenantId() || request.tenantId; + // Retrieves the database type from the configuration service. + const dbType = this.configService.dbConnectionOptions.type; + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the current tenant ID + // Get the start and end date for the weekly statistics const { start: weeklyStart, end: weeklyEnd } = getDateRangeFormat( moment.utc(startDate || moment().startOf('week')), moment.utc(endDate || moment().endOf('week')) @@ -349,23 +446,23 @@ export class StatisticService { PermissionsEnum.CHANGE_SELECTED_EMPLOYEE ); - // Retrieves the database type from the configuration service. - const dbType = this.configService.dbConnectionOptions.type; - - /** - * Set employeeIds based on user conditions and permissions - */ + // Set employeeIds based on user conditions and permissions if (user.employeeId || (!hasChangeSelectedEmployeePermission && user.employeeId)) { employeeIds = [user.employeeId]; } + // Create a query builder for the Employee entity const query = this.typeOrmEmployeeRepository.createQueryBuilder(); + let employees: IMembersStatistics[] = await query .select(p(`"${query.alias}".id`)) // Builds a SELECT statement for the "user_name" column based on the database type. .addSelect(p(`${concateUserNameExpression(dbType)}`), 'user_name') .addSelect(p(`"user"."imageUrl"`), 'user_image_url') .addSelect(getTotalDurationQueryString(dbType, 'timeLogs'), `duration`) + // Add isOnline and isAway from the employee table + .addSelect(p(`"${query.alias}"."isOnline"`), 'isOnline') + .addSelect(p(`"${query.alias}"."isAway"`), 'isAway') .innerJoin(`${query.alias}.user`, 'user') .innerJoin(`${query.alias}.timeLogs`, 'timeLogs') .innerJoin(`timeLogs.timeSlots`, 'time_slot') @@ -409,10 +506,13 @@ export class StatisticService { } }) ) - .addGroupBy(p(`"${query.alias}"."id"`)) + .groupBy(p(`"${query.alias}"."id"`)) + .addGroupBy(p(`"${query.alias}"."isOnline"`)) + .addGroupBy(p(`"${query.alias}"."isAway"`)) .addGroupBy(p(`"user"."id"`)) .orderBy('duration', 'DESC') .getRawMany(); + if (employees.length > 0) { const employeeIds = pluck(employees, 'id'); @@ -1826,46 +1926,48 @@ export class StatisticService { } /** - * Get employees count who worked in this week. + * Get the count of employees who worked this week. * * @param request - * @returns + * @returns The count of unique employees */ private async getEmployeeWorkedCounts(request: IGetCountsStatistics) { const query = this.typeOrmTimeLogRepository.createQueryBuilder('time_log'); - query.select(p(`"${query.alias}"."employeeId"`), 'employeeId'); - query.innerJoin(`${query.alias}.employee`, 'employee'); - query.innerJoin(`${query.alias}.timeSlots`, 'time_slot'); - query.andWhere( - new Brackets((where: WhereExpressionBuilder) => { - this.getFilterQuery(query, where, request); - }) - ); - query.groupBy(p(`"${query.alias}"."employeeId"`)); - const employees = await query.getRawMany(); - return employees.length; + query + .select('COUNT(DISTINCT time_log.employeeId)', 'count') + .innerJoin('time_log.employee', 'employee') + .innerJoin('time_log.timeSlots', 'time_slot') + .andWhere( + new Brackets((where: WhereExpressionBuilder) => { + this.getFilterQuery(query, where, request); + }) + ); + const result = await query.getRawOne(); + const count = parseInt(result.count, 10); + return count; } /** - * Get projects count who worked in this week. + * Get the count of projects worked on this week. * * @param request - * @returns + * @returns The count of unique projects */ private async getProjectWorkedCounts(request: IGetCountsStatistics) { const query = this.typeOrmTimeLogRepository.createQueryBuilder('time_log'); - query.select(p(`"${query.alias}"."projectId"`), 'projectId'); - query.innerJoin(`${query.alias}.employee`, 'employee'); - query.innerJoin(`${query.alias}.project`, 'project'); - query.innerJoin(`${query.alias}.timeSlots`, 'time_slot'); - query.andWhere( - new Brackets((where: WhereExpressionBuilder) => { - this.getFilterQuery(query, where, request); - }) - ); - query.groupBy(p(`"${query.alias}"."projectId"`)); - const projects = await query.getRawMany(); - return projects.length; + query + .select('COUNT(DISTINCT time_log.projectId)', 'count') + .innerJoin('time_log.employee', 'employee') + .innerJoin('time_log.project', 'project') + .innerJoin('time_log.timeSlots', 'time_slot') + .andWhere( + new Brackets((where: WhereExpressionBuilder) => { + this.getFilterQuery(query, where, request); + }) + ); + const result = await query.getRawOne(); + const count = parseInt(result.count, 10); + return count; } /** @@ -1881,9 +1983,33 @@ export class StatisticService { qb: WhereExpressionBuilder, request: IGetCountsStatistics ): WhereExpressionBuilder { - const { organizationId, startDate, endDate, employeeIds = [], projectIds = [], teamIds = [] } = request; - const tenantId = RequestContext.currentTenantId() || request.tenantId; + let { + organizationId, + startDate, + endDate, + employeeIds = [], + projectIds = [], + teamIds = [], + activityLevel, + logType, + source, + onlyMe: isOnlyMeSelected // Determine if the request specifies to retrieve data for the current user only + } = request; + + const user = RequestContext.currentUser(); // Retrieve the current user + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the current tenant ID + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + + // Set employeeIds based on user conditions and permissions + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { + employeeIds = [user.employeeId]; + } + + // Use consistent date range formatting const { start, end } = getDateRangeFormat( moment.utc(startDate || moment().startOf('week')), moment.utc(endDate || moment().endOf('week')) @@ -1894,33 +2020,38 @@ export class StatisticService { qb.andWhere(`${query.alias}.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }); qb.andWhere(`time_slot.startedAt BETWEEN :startDate AND :endDate`, { startDate: start, endDate: end }); - if (isNotEmpty(request.activityLevel)) { - const { start: startLevel, end: endLevel } = request.activityLevel; + // Apply activity level filter only if provided + if (isNotEmpty(activityLevel)) { + const startLevel = activityLevel.start * 6; // Start level for activity level in seconds + const endLevel = activityLevel.end * 6; // End level for activity level in seconds + qb.andWhere(`time_slot.overall BETWEEN :startLevel AND :endLevel`, { - startLevel: startLevel * 6, - endLevel: endLevel * 6 + startLevel, + endLevel }); } - if (isNotEmpty(request.logType)) { - qb.andWhere(`${query.alias}.logType IN (:...logType)`, { logType: request.logType }); + // Apply log type filter if present + if (isNotEmpty(logType)) { + qb.andWhere(`${query.alias}.logType IN (:...logType)`, { logType }); } - if (isNotEmpty(request.source)) { - qb.andWhere(`${query.alias}.source IN (:...source)`, { source: request.source }); + // Apply source filter if present + if (isNotEmpty(source)) { + qb.andWhere(`${query.alias}.source IN (:...source)`, { source }); } + // Apply employee filter, optimizing joins if (isNotEmpty(employeeIds)) { - qb.andWhere(`${query.alias}.employeeId IN (:...employeeIds)`, { employeeIds }).andWhere( - `time_slot.employeeId IN (:...employeeIds)`, - { employeeIds } - ); + qb.andWhere(`${query.alias}.employeeId IN (:...employeeIds)`, { employeeIds }); } + // Apply project filter if (isNotEmpty(projectIds)) { qb.andWhere(`${query.alias}.projectId IN (:...projectIds)`, { projectIds }); } + // Apply team filter if (isNotEmpty(teamIds)) { qb.andWhere(`${query.alias}.organizationTeamId IN (:...teamIds)`, { teamIds }); } diff --git a/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts b/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts index 814aec9c4ee..e82380a2d13 100644 --- a/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts +++ b/packages/core/src/time-tracking/time-log/commands/delete-time-span.command.ts @@ -8,6 +8,7 @@ export class DeleteTimeSpanCommand implements ICommand { constructor( public readonly newTime: IDateRange, public readonly timeLog: TimeLog, - public readonly timeSlot: ITimeSlot + public readonly timeSlot: ITimeSlot, + public readonly forceDelete: boolean = false ) {} } diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts index 675436b2e55..a845f763f1e 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/delete-time-span.handler.ts @@ -1,10 +1,8 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; -import * as _ from 'underscore'; -import { ITimeLog } from '@gauzy/contracts'; +import { omit } from 'underscore'; +import { ID, ITimeLog, ITimeSlot } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { moment } from '../../../../core/moment-extend'; -import { TimeSlot } from './../../../../core/entities/internal'; import { TimesheetRecalculateCommand } from './../../../timesheet/commands'; import { TimeLog } from './../../time-log.entity'; import { DeleteTimeSpanCommand } from '../delete-time-span.command'; @@ -18,79 +16,46 @@ import { TypeOrmTimeSlotRepository } from '../../../time-slot/repository/type-or @CommandHandler(DeleteTimeSpanCommand) export class DeleteTimeSpanHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, + readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, + private readonly _commandBus: CommandBus, + private readonly _timeSlotService: TimeSlotService + ) {} - @InjectRepository(TimeSlot) - private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - - private readonly commandBus: CommandBus, - private readonly timeSlotService: TimeSlotService - ) { } - + /** + * Execute delete time span logic + * + * @param command - The command containing newTime, timeLog, and timeSlot + * @returns Promise + */ public async execute(command: DeleteTimeSpanCommand) { - const { newTime, timeLog, timeSlot } = command; + const { newTime, timeLog, timeSlot, forceDelete } = command; const { id } = timeLog; const { start, end } = newTime; - const refreshTimeLog = await this.typeOrmTimeLogRepository.findOne({ - where: { - id: id - }, - relations: { - timeSlots: true - } + // Retrieve the time log with the specified ID + const log = await this.typeOrmTimeLogRepository.findOne({ + where: { id }, + relations: { timeSlots: true } }); + const { startedAt, stoppedAt, employeeId, organizationId } = log; - const { startedAt, stoppedAt, employeeId, organizationId, timesheetId } = refreshTimeLog; - - const newTimeRange = moment.range(start, end); - const dbTimeRange = moment.range(startedAt, stoppedAt); + const newTimeRange = moment.range(start, end); // Calculate the new time rang + const dbTimeRange = moment.range(startedAt, stoppedAt); // Calculate the database time range - console.log({ newTimeRange, dbTimeRange }); /* * Check is overlapping time or not. */ if (!newTimeRange.overlaps(dbTimeRange, { adjacent: false })) { console.log('Not Overlapping', newTimeRange, dbTimeRange); - /** - * If TimeSlot Not Overlapping the TimeLog - * Still we have to remove that TimeSlot with screenshots/activities - */ - if (employeeId && start && end) { - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog: refreshTimeLog, - timeSlotsIds - }, true) - ); - await this.commandBus.execute( - new TimesheetRecalculateCommand(timesheetId) - ); - } + + // Handle non-overlapping time ranges + await this.handleNonOverlappingTimeRange(log, timeSlot, employeeId, organizationId, forceDelete); } - if ( - moment(startedAt).isBetween( - moment(start), - moment(end), - null, - '[]' - ) - ) { - if ( - moment(stoppedAt).isBetween( - moment(start), - moment(end), - null, - '[]' - ) - ) { + if (moment(startedAt).isBetween(moment(start), moment(end), null, '[]')) { + if (moment(stoppedAt).isBetween(moment(start), moment(end), null, '[]')) { /* * Delete time log because overlap entire time. * New Start time New Stop time @@ -98,14 +63,9 @@ export class DeleteTimeSpanHandler implements ICommandHandler 0) { - try { - console.log('Update startedAt time.'); - let updatedTimeLog: ITimeLog = await this.commandBus.execute( - new TimeLogUpdateCommand( - { - startedAt: end - }, - refreshTimeLog, - true - ) - ); - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog: updatedTimeLog, - timeSlotsIds - }, true) - ); - /* - * Delete TimeLog if remaining timeSlots are 0 - */ - updatedTimeLog = await this.typeOrmTimeLogRepository.findOne({ - where: { - id: updatedTimeLog.id - }, - relations: { - timeSlots: true - } - }); - if (isEmpty(updatedTimeLog.timeSlots)) { - await this.commandBus.execute( - new TimeLogDeleteCommand(updatedTimeLog, true) - ); - } - } catch (error) { - console.log('Error while, updating startedAt time', error); - } - } else { - console.log('Delete startedAt time log.'); - try { - /* - * Delete if remaining duration 0 seconds - */ - await this.commandBus.execute( - new TimeLogDeleteCommand(refreshTimeLog, true) - ); - } catch (error) { - console.log('Error while, deleting time log for startedAt time', error); - } - } } } else { - if ( - moment(timeLog.stoppedAt).isBetween( - moment(start), - moment(end), - null, - '[]' - ) - ) { + if (moment(timeLog.stoppedAt).isBetween(moment(start), moment(end), null, '[]')) { /* * Update stopped time * New Start time New Stop time @@ -188,64 +95,17 @@ export class DeleteTimeSpanHandler implements ICommandHandler 0) { - console.log('Update stoppedAt time.'); - try { - let updatedTimeLog: ITimeLog = await this.commandBus.execute( - new TimeLogUpdateCommand( - { - stoppedAt: start - }, - timeLog, - true - ) - ); - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog: updatedTimeLog, - timeSlotsIds - }, true) - ); - - /* - * Delete TimeLog if remaining timeSlots are 0 - */ - updatedTimeLog = await this.typeOrmTimeLogRepository.findOne({ - where: { - id: updatedTimeLog.id - }, - relations: { - timeSlots: true - } - }); - if (isEmpty(updatedTimeLog.timeSlots)) { - await this.commandBus.execute( - new TimeLogDeleteCommand(updatedTimeLog, true) - ); - } - } catch (error) { - console.log('Error while, updating stoppedAt time', error); - } - } else { - console.log('Delete stoppedAt time log.'); - try { - /* - * Delete if remaining duration 0 seconds - */ - await this.commandBus.execute( - new TimeLogDeleteCommand(refreshTimeLog, true) - ); - } catch (error) { - console.log('Error while, deleting time log for stoppedAt time', error); - } - } } else { /* * Split database time in two entries. @@ -255,98 +115,345 @@ export class DeleteTimeSpanHandler implements ICommandHandler 0) { - try { - timeLog.stoppedAt = start; - await this.typeOrmTimeLogRepository.save(timeLog); - } catch (error) { - console.error(`Error while updating old timelog`, error); - } - } else { - /* - * Delete if remaining duration 0 seconds - */ - try { - await this.commandBus.execute( - new TimeLogDeleteCommand(refreshTimeLog, true) - ); - } catch (error) { - console.error(`Error while deleting old timelog`, error); - } - } - const timeSlotsIds = [timeSlot.id]; - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ + } + } + + return true; + } + + /** + * Handles non-overlapping time ranges by deleting the time log and associated time slots, + * and recalculating the timesheet. + * + * @param timeLog - The time log associated with the non-overlapping time range. + * @param timeSlot - The time slot to be deleted. + * @param employeeId - The ID of the employee associated with the time log. + * @param organizationId - The ID of the organization. + * @param forceDelete - A flag indicating whether to perform a hard delete. + */ + private async handleNonOverlappingTimeRange( + timeLog: ITimeLog, + timeSlot: ITimeSlot, + employeeId: ID, + organizationId: ID, + forceDelete: boolean = false + ): Promise { + // Delete the associated time slots + const timeSlotsIds = [timeSlot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete, + true + ) + ); + + // Recalculate the timesheet + await this._commandBus.execute(new TimesheetRecalculateCommand(timeLog.timesheetId)); + } + + /** + * Updates the start time or deletes the time log if remaining duration is 0. + * + * @param log - The time log to update or delete. + * @param slot - The related time slot. + * @param organizationId - The organization ID. + * @param employeeId - The employee ID. + * @param end - The new end time. + * @param stoppedAt - The current stopped time of the log. + */ + private async updateStartTimeOrDelete( + log: ITimeLog, + slot: ITimeSlot, + organizationId: ID, + employeeId: ID, + end: Date, + stoppedAt: Date, + forceDelete: boolean = false + ): Promise { + const stoppedAtMoment = moment(stoppedAt); // Get the stopped at moment + const endMoment = moment(end); // Get the end moment + const remainingDuration = stoppedAtMoment.diff(endMoment, 'seconds'); // Calculate the remaining duration + + // If there is remaining duration + if (remainingDuration > 0) { + // Update the start time if there is remaining duration + try { + console.log(`update startedAt time to ${end}`); + // Update the started at time + let timeLog: ITimeLog = await this._commandBus.execute( + new TimeLogUpdateCommand({ startedAt: end }, log, true, forceDelete) + ); + + // Delete the associated time slots + const timeSlotsIds = [slot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { organizationId, employeeId, timeLog, timeSlotsIds - }, true) - ); - } catch (error) { - console.error(`Error while split time entires: ${remainingDuration}`); + }, + forceDelete, + true + ) + ); + + // Check if there are any remaining time slots + timeLog = await this.typeOrmTimeLogRepository.findOne({ + where: { id: timeLog.id }, + relations: { timeSlots: true } + }); + + // If no remaining time slots, delete the time log + if (isEmpty(timeLog.timeSlots)) { + // Delete TimeLog if remaining timeSlots are 0 + await this.deleteTimeLog(timeLog, forceDelete); } + } catch (error) { + console.log('Error while updating startedAt time', error); + } + } else { + // Delete the time log if remaining duration is 0 + console.log('Remaining duration is 0, so we are deleting the time log during update startedAt time'); + await this.deleteTimeLog(log, forceDelete); + } + } - const newLog = timeLogClone; - newLog.startedAt = end; + /** + * Updates the stoppedAt time for a given time log, or deletes it if the remaining duration is 0. + * + * @param log - The time log to update or delete. + * @param slot - The related time slot. + * @param organizationId - The organization ID. + * @param employeeId - The employee ID. + * @param start - The new start time. + * @param startedAt - The original start time of the time log. + * @param end - The new end time for the time log. + */ + private async updateStopTimeOrDelete( + log: ITimeLog, + slot: ITimeSlot, + organizationId: ID, + employeeId: ID, + start: Date, + startedAt: Date, + end: Date, + forceDelete: boolean = false + ): Promise { + const startedAtMoment = moment(startedAt); // Get the started at moment + const endMoment = moment(end); // Get the end moment + const remainingDuration = endMoment.diff(startedAtMoment, 'seconds'); // Calculate the remaining duration - const newLogRemainingDuration = moment(newLog.stoppedAt).diff( - moment(newLog.startedAt), - 'seconds' + // If there is remaining duration + if (remainingDuration > 0) { + // Update the stoppedAt time if there is remaining duration + try { + console.log(`update stoppedAt time to ${start}`); + + // Update the stoppedAt time + let timeLog: ITimeLog = await this._commandBus.execute( + new TimeLogUpdateCommand({ stoppedAt: start }, log, true, forceDelete) ); - /* - * Insert if remaining duration is more 0 seconds - */ - if (newLogRemainingDuration > 0) { - try { - await this.typeOrmTimeLogRepository.save(newLog); - } catch (error) { - console.log('Error while creating new log', error, newLog); - } - try { - const timeSlots = await this.syncTimeSlots(newLog); - console.log('Sync TimeSlots for new log', { timeSlots }, { newLog }); - if (isNotEmpty(timeSlots)) { - let timeLogs: ITimeLog[] = []; - timeLogs = timeLogs.concat(newLog); - - for await (const timeSlot of timeSlots) { - timeSlot.timeLogs = timeLogs; - } - - try { - await this.typeOrmTimeSlotRepository.save(timeSlots); - } catch (error) { - console.log('Error while creating new TimeSlot & TimeLog entires', error, timeSlots) - } - } - } catch (error) { - console.log('Error while syncing TimeSlot & TimeLog', error) + // Delete the associated time slots + const timeSlotsIds = [slot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete, + true + ) + ); + + // Check if there are any remaining time slots + timeLog = await this.typeOrmTimeLogRepository.findOne({ + where: { id: timeLog.id }, + relations: { timeSlots: true } + }); + + // If no remaining time slots, delete the time log + if (isEmpty(timeLog.timeSlots)) { + await this.deleteTimeLog(timeLog, forceDelete); + } + } catch (error) { + console.log('Error while updating stoppedAt time', error); + } + } else { + console.log('Remaining duration is 0, so we are deleting the time log during update stoppedAt time'); + await this.deleteTimeLog(log, forceDelete); + } + } + + /** + * Handles splitting a time log into two entries and processing the associated time slots. + * + * @param timeLog - The original time log to split. + * @param timeSlot - The related time slot. + * @param organizationId - The organization ID. + * @param employeeId - The employee ID. + * @param start - The new start time. + * @param end - The new end time. + * @param startedAt - The original start time of the time log. + */ + private async handleTimeLogSplitting( + timeLog: ITimeLog, + timeSlot: ITimeSlot, + organizationId: ID, + employeeId: ID, + start: Date, + end: Date, + startedAt: Date, + forceDelete: boolean = false + ): Promise { + const startedAtMoment = moment(startedAt); // Get the started at moment + const startMoment = moment(start); // Get the start moment + const remainingDuration = startMoment.diff(startedAtMoment, 'seconds'); // Calculate the remaining duration + + // If there is remaining duration + if (remainingDuration > 0) { + try { + timeLog.stoppedAt = start; + await this.typeOrmTimeLogRepository.save(timeLog); + } catch (error) { + console.error(`Error while updating stoppedAt time for ID: ${timeLog.id}`, error); + } + } else { + // Delete the old time log if remaining duration is 0 + await this.deleteTimeLog(timeLog, forceDelete); + } + + try { + // Delete the associated time slots + const timeSlotsIds = [timeSlot.id]; + + // Bulk delete the time slots + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete, + true + ) + ); + } catch (error) { + console.error(`Error while splitting time entries: ${remainingDuration}`, error); + } + + // Handle the creation of the new time log + await this.createAndSyncNewTimeLog(timeLog, end); + } + + /** + * Creates and syncs the new time log if the duration is greater than 0. + * + * @param timeLog - The original time log (will be cloned). + * @param end - The new start time for the new log. + */ + private async createAndSyncNewTimeLog(timeLog: ITimeLog, end: Date): Promise { + const clone: TimeLog = omit(timeLog, ['createdAt', 'updatedAt', 'id']); + const newLog = clone; + newLog.startedAt = end; + + // Calculate the remaining duration of the new log + const newLogRemainingDuration = moment(newLog.stoppedAt).diff(moment(newLog.startedAt), 'seconds'); + + // If there is remaining duration + if (newLogRemainingDuration > 0) { + try { + await this.typeOrmTimeLogRepository.save(newLog); + } catch (error) { + console.log('Error while creating new log', error, newLog); + } + + try { + // Sync time slots for the new time log + const slots = await this.syncTimeSlots(newLog); + console.log('sync time slots for new log', { slots }, { newLog }); + + // Assign the new log to time slots and save + if (isNotEmpty(slots)) { + // Assign the new log to time slots and save + for await (const ts of slots) { + ts.timeLogs = [newLog]; } + + await this.typeOrmTimeSlotRepository.save(slots); } + } catch (error) { + console.error('Error while creating or syncing new log and time slots', error); } } - return true; } - private async syncTimeSlots(timeLog: ITimeLog) { + /** + * Deletes a time log if it overlaps the entire time range. + * + * @param timeLog - The log to delete. + * @param forceDelete - Whether to hard delete (default: false). + * @returns Promise - Resolves when deletion is complete. + */ + + private async deleteTimeLog(timeLog: ITimeLog, forceDelete: boolean = false): Promise { + try { + // Execute the TimeLogDeleteCommand to delete the time log + await this._commandBus.execute(new TimeLogDeleteCommand(timeLog, forceDelete)); + } catch (error) { + // Log any errors that occur during deletion + console.log(`Error while, delete time log because overlap entire time for ID: ${timeLog.id}`, error); + } + } + + /** + * Synchronizes time slots for the provided time log. + * + * This method calculates the start and end intervals based on the `startedAt` and `stoppedAt` + * values from the provided time log. It then retrieves the corresponding time slots for the + * specified employee and organization within that time range. The time slot synchronization + * is triggered with the `syncSlots` flag set to true. + * + * @param timeLog - The time log containing the data used to synchronize time slots (start, stop, employeeId, organizationId). + * @returns A promise that resolves to the retrieved time slots within the specified range for the employee and organization. + */ + private async syncTimeSlots(timeLog: ITimeLog): Promise { const { startedAt, stoppedAt, employeeId, organizationId } = timeLog; - const { start, end } = getStartEndIntervals( - moment(startedAt), - moment(stoppedAt) - ); - return await this.timeSlotService.getTimeSlots({ + + // Calculate start and end intervals based on the time log's start and stop times + const { start, end } = getStartEndIntervals(moment(startedAt), moment(stoppedAt)); + + // Retrieve and return the corresponding time slots within the interval for the given employee and organization + return await this._timeSlotService.getTimeSlots({ startDate: moment(start).toDate(), endDate: moment(end).toDate(), organizationId, diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts index b096e26aad2..7f0a266208d 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-client.handler.ts @@ -84,12 +84,13 @@ export class GetTimeLogGroupByClientHandler implements ICommandHandler 0 ? timeLogs[0].employee : null; - const task = timeLogs.length > 0 ? timeLogs[0].task : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; - + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration + })); return { - description, - task, + tasks, employee, sum, activity: parseFloat(avgActivity.toFixed(2)) diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts index 717ee653396..3cd576e9b40 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-date.handler.ts @@ -32,17 +32,8 @@ export class GetTimeLogGroupByDateHandler implements ICommandHandler 0 ? byProjectLogs[0].project : null; - // Extract client information using optional chaining - const client = - byProjectLogs.length > 0 - ? byProjectLogs[0].organizationContact - : project - ? project.organizationContact - : null; - return { project, - client, employeeLogs: this.getGroupByEmployee(byProjectLogs) }; }) @@ -77,14 +68,18 @@ export class GetTimeLogGroupByDateHandler implements ICommandHandler 0 ? timeLogs[0].employee : null; - const task = timeLogs.length > 0 ? timeLogs[0].task : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; + + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration, + client: log.organizationContact ? log.organizationContact : null + })); return { - description, employee, sum, - task, + tasks, activity: parseFloat(parseFloat(avgActivity + '').toFixed(2)) }; }) diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts index 7f044275c7e..cebaa71ea49 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-employee.handler.ts @@ -66,20 +66,20 @@ export class GetTimeLogGroupByEmployeeHandler implements ICommandHandler 0 ? timeLogs[0].project : null; - const task = timeLogs.length > 0 ? timeLogs[0].task : null; - const client = - timeLogs.length > 0 - ? timeLogs[0].organizationContact + + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration, + client: log.organizationContact + ? log.organizationContact : project ? project.organizationContact - : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; - + : null + })); return { - description, - task, + tasks, project, - client, sum, activity: parseFloat(parseFloat(avgActivity + '').toFixed(2)) }; diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts index 92623b07e9a..e87bc705912 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/get-time-log-group-by-project.handler.ts @@ -30,14 +30,6 @@ export class GetTimeLogGroupByProjectHandler implements ICommandHandler 0 ? byProjectLogs[0].project : null; - // Extract client information using optional chaining - const client = - byProjectLogs.length > 0 - ? byProjectLogs[0].organizationContact - : project - ? project.organizationContact - : null; - // Group projectLogs by date const byDate = chain(byProjectLogs) .groupBy((log: ITimeLog) => moment.utc(log.startedAt).tz(timeZone).format('YYYY-MM-DD')) @@ -49,7 +41,6 @@ export class GetTimeLogGroupByProjectHandler implements ICommandHandler 0 ? timeLogs[0].task : null; + const employee = timeLogs.length > 0 ? timeLogs[0].employee : null; - const description = timeLogs.length > 0 ? timeLogs[0].description : null; + const tasks = timeLogs.map((log) => ({ + task: log.task, + description: log.description, + duration: log.duration, + client: log.organizationContact ? log.organizationContact : null + })); return { - description, - task, + tasks, employee, sum, activity: parseFloat(parseFloat(avgActivity + '').toFixed(2)) diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts index e68f9459333..11b0edd4036 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-create.handler.ts @@ -2,10 +2,7 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; import * as moment from 'moment'; import { TimeLogType, TimeLogSourceEnum, ID, ITimeSlot, ITimesheet } from '@gauzy/contracts'; import { TimeSlotService } from '../../../time-slot/time-slot.service'; -import { - TimesheetFirstOrCreateCommand, - TimesheetRecalculateCommand -} from '../../../timesheet/commands'; +import { TimesheetFirstOrCreateCommand, TimesheetRecalculateCommand } from '../../../timesheet/commands'; import { UpdateEmployeeTotalWorkedHoursCommand } from '../../../../employee/commands'; import { RequestContext } from '../../../../core/context'; import { TimeLogService } from '../../time-log.service'; @@ -20,7 +17,7 @@ export class TimeLogCreateHandler implements ICommandHandler ({ + const standardizedInputSlots = inputSlots.map((slot) => ({ ...slot, employeeId, organizationId, tenantId })); - return generatedSlots.map(blankSlot => { - const matchingSlot = standardizedInputSlots.find(slot => + return generatedSlots.map((blankSlot) => { + const matchingSlot = standardizedInputSlots.find((slot) => moment(slot.startedAt).isSame(blankSlot.startedAt) ); return matchingSlot ? { ...matchingSlot } : blankSlot; @@ -187,9 +176,7 @@ export class TimeLogCreateHandler implements ICommandHandler { if (timesheet?.id) { - await this._commandBus.execute( - new TimesheetRecalculateCommand(timesheet.id) - ); + await this._commandBus.execute(new TimesheetRecalculateCommand(timesheet.id)); } } @@ -199,8 +186,6 @@ export class TimeLogCreateHandler implements ICommandHandler { - await this._commandBus.execute( - new UpdateEmployeeTotalWorkedHoursCommand(employeeId) - ); + await this._commandBus.execute(new UpdateEmployeeTotalWorkedHoursCommand(employeeId)); } } diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts index 892e450cda1..495ace497e4 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-delete.handler.ts @@ -1,93 +1,153 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; import { In, DeleteResult, UpdateResult } from 'typeorm'; -import { chain, pluck } from 'underscore'; -import { TimeLog } from './../../time-log.entity'; +import { pluck } from 'underscore'; +import { ID } from '@gauzy/contracts'; import { TimesheetRecalculateCommand } from './../../../timesheet/commands/timesheet-recalculate.command'; -import { TimeLogDeleteCommand } from '../time-log-delete.command'; import { UpdateEmployeeTotalWorkedHoursCommand } from '../../../../employee/commands'; import { TimeSlotBulkDeleteCommand } from './../../../time-slot/commands'; +import { TimeLogDeleteCommand } from '../time-log-delete.command'; +import { TimeLog } from './../../time-log.entity'; import { TypeOrmTimeLogRepository } from '../../repository/type-orm-time-log.repository'; import { MikroOrmTimeLogRepository } from '../..//repository/mikro-orm-time-log.repository'; @CommandHandler(TimeLogDeleteCommand) export class TimeLogDeleteHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - readonly mikroOrmTimeLogRepository: MikroOrmTimeLogRepository, + private readonly _commandBus: CommandBus + ) {} - private readonly commandBus: CommandBus - ) { } + /** + * Executes the TimeLogDeleteCommand to handle both soft and hard deletions of time logs, + * and ensures that associated time slots are deleted. It also recalculates relevant + * timesheet and employee worked hours based on the deleted time logs. + * + * This method performs the following operations: + * 1. Fetches the time logs based on the provided IDs. + * 2. Deletes associated time slots for each time log sequentially. + * 3. Soft deletes the time logs (or hard deletes them if `forceDelete` is true). + * 4. Recalculates timesheet and employee worked hours for the affected time logs. + * + * @param command - The TimeLogDeleteCommand containing the IDs or TimeLog objects to delete, along with the `forceDelete` flag. + * @returns A promise that resolves to a DeleteResult (for hard delete) or UpdateResult (for soft delete). + */ + public async execute(command: TimeLogDeleteCommand): Promise { + const { ids, forceDelete = false } = command; - public async execute( - command: TimeLogDeleteCommand - ): Promise { - const { ids, forceDelete } = command; + // Step 1: Fetch time logs based on the provided IDs + const timeLogs = await this.fetchTimeLogs(ids); - let timeLogs: TimeLog[]; + // Step 2: Delete associated time slots for each time log sequentially + await this.deleteTimeSlotsForLogs(timeLogs, forceDelete); + + // Step 3: Perform soft delete or hard delete based on the `forceDelete` flag + const updateResult = await this.deleteTimeLogs(timeLogs, forceDelete); + + // Step 4: Recalculate timesheet and employee worked hours after deletion + await this.recalculateTimesheetAndEmployeeHours(timeLogs); + + return updateResult; + } + + /** + * Fetches time logs based on provided IDs or time log objects. + * + * @param ids - A string, array of strings, or TimeLog object(s). + * @returns A promise that resolves to an array of TimeLogs. + */ + private async fetchTimeLogs(ids: ID | ID[] | TimeLog | TimeLog[]): Promise { if (typeof ids === 'string') { - timeLogs = await this.typeOrmTimeLogRepository.findBy({ id: ids }); - } else if (ids instanceof Array && typeof ids[0] === 'string') { - timeLogs = await this.typeOrmTimeLogRepository.findBy({ - id: In(ids as string[]) - }); - } else if (ids instanceof TimeLog) { - timeLogs = [ids]; + // Fetch single time log by ID + return this.typeOrmTimeLogRepository.findBy({ id: ids }); + } else if (Array.isArray(ids)) { + if (typeof ids[0] === 'string') { + // Fetch multiple time logs by IDs + return this.typeOrmTimeLogRepository.findBy({ id: In(ids as ID[]) }); + } + // Return the array of TimeLog objects + return ids as TimeLog[]; } else { - timeLogs = ids as TimeLog[]; + // Return single TimeLog object wrapped in an array + return [ids as TimeLog]; } - console.log('TimeLog will be delete:', timeLogs); + } + /** + * Deletes associated time slots for each time log sequentially. + * + * @param timeLogs - An array of time logs whose associated time slots will be deleted. + */ + private async deleteTimeSlotsForLogs(timeLogs: TimeLog[], forceDelete = false): Promise { + // Loop through each time log and delete its associated time slots for await (const timeLog of timeLogs) { const { employeeId, organizationId, timeSlots } = timeLog; const timeSlotsIds = pluck(timeSlots, 'id'); - await this.commandBus.execute( - new TimeSlotBulkDeleteCommand({ - organizationId, - employeeId, - timeLog, - timeSlotsIds - }) + + // Delete time slots sequentially + await this._commandBus.execute( + new TimeSlotBulkDeleteCommand( + { + organizationId, + employeeId, + timeLog, + timeSlotsIds + }, + forceDelete + ) ); } + } + + /** + * Deletes the provided time logs, either soft or hard depending on the `forceDelete` flag. + * + * If `forceDelete` is true, the time logs are permanently deleted. Otherwise, they are soft deleted. + * The method uses the TypeORM repository to perform the appropriate operation. + * + * @param timeLogs - An array of time logs to be deleted or soft deleted. + * @param forceDelete - A boolean flag indicating whether to force delete (hard delete) the time logs. + * Defaults to `false`, meaning soft delete is performed by default. + * @returns A promise that resolves to a DeleteResult (for hard delete) or UpdateResult (for soft delete). + */ + private async deleteTimeLogs(timeLogs: TimeLog[], forceDelete = false): Promise { + const logIds = timeLogs.map((log) => log.id); // Extract ids using map for simplicity + console.log('deleting time logs', logIds, forceDelete); - let deleteResult: DeleteResult | UpdateResult; if (forceDelete) { - deleteResult = await this.typeOrmTimeLogRepository.delete({ - id: In(pluck(timeLogs, 'id')) - }); - } else { - deleteResult = await this.typeOrmTimeLogRepository.softDelete({ - id: In(pluck(timeLogs, 'id')) - }); + // Hard delete (permanent deletion) + return await this.typeOrmTimeLogRepository.delete({ id: In(logIds) }); } + // Soft delete (mark records as deleted) + return await this.typeOrmTimeLogRepository.softDelete({ id: In(logIds) }); + } + + /** + * Recalculates timesheet and employee worked hours for the deleted time logs. + * + * @param timeLogs - An array of time logs for which the recalculations will be made. + */ + private async recalculateTimesheetAndEmployeeHours(timeLogs: TimeLog[]): Promise { try { - /** - * Timesheet Recalculate Command - */ - const timesheetIds = chain(timeLogs).pluck('timesheetId').uniq().value(); - for await (const timesheetId of timesheetIds) { - await this.commandBus.execute( - new TimesheetRecalculateCommand(timesheetId) - ); - } + const timesheetIds = [...new Set(timeLogs.map((log) => log.timesheetId))]; + const employeeIds = [...new Set(timeLogs.map((log) => log.employeeId))]; - /** - * Employee Worked Hours Recalculate Command - */ - const employeeIds = chain(timeLogs).pluck('employeeId').uniq().value(); - for await (const employeeId of employeeIds) { - await this.commandBus.execute( - new UpdateEmployeeTotalWorkedHoursCommand(employeeId) - ); - } + // Recalculate timesheets + await Promise.all( + timesheetIds.map((timesheetId: ID) => + this._commandBus.execute(new TimesheetRecalculateCommand(timesheetId)) + ) + ); + + // Recalculate employee worked hours + await Promise.all( + employeeIds.map((employeeId: ID) => + this._commandBus.execute(new UpdateEmployeeTotalWorkedHoursCommand(employeeId)) + ) + ); } catch (error) { - console.log('TimeLogDeleteHandler', { error }); + console.error('Error while recalculating timesheet and employee worked hours:', error); } - return deleteResult; } } diff --git a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts index f26a1c8d480..2d3d596030d 100644 --- a/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts +++ b/packages/core/src/time-tracking/time-log/commands/handlers/time-log-update.handler.ts @@ -1,185 +1,266 @@ import { ICommandHandler, CommandBus, CommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; -import { SelectQueryBuilder } from 'typeorm'; import * as moment from 'moment'; -import { ITimeLog, ITimesheet, TimeLogSourceEnum } from '@gauzy/contracts'; -import { TimeLog } from './../../time-log.entity'; -import { TimeLogUpdateCommand } from '../time-log-update.command'; -import { - TimesheetFirstOrCreateCommand, - TimesheetRecalculateCommand -} from './../../../timesheet/commands'; +import { ID, ITimeLog, ITimeSlot, ITimesheet, TimeLogSourceEnum } from '@gauzy/contracts'; +import { isEmpty } from '@gauzy/common'; +import { TimesheetFirstOrCreateCommand, TimesheetRecalculateCommand } from './../../../timesheet/commands'; import { TimeSlotService } from '../../../time-slot/time-slot.service'; import { UpdateEmployeeTotalWorkedHoursCommand } from '../../../../employee/commands'; import { RequestContext } from './../../../../core/context'; -import { TimeSlot } from './../../../../core/entities/internal'; import { prepareSQLQuery as p } from './../../../../database/database.helper'; +import { TimeLog } from './../../time-log.entity'; +import { TimeLogUpdateCommand } from '../time-log-update.command'; import { TypeOrmTimeLogRepository } from '../../repository/type-orm-time-log.repository'; import { TypeOrmTimeSlotRepository } from '../../../time-slot/repository/type-orm-time-slot.repository'; @CommandHandler(TimeLogUpdateCommand) export class TimeLogUpdateHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) private readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus, private readonly timeSlotService: TimeSlotService - ) { } + ) {} - public async execute(command: TimeLogUpdateCommand): Promise { - const { id, input, manualTimeSlot } = command; + /** + * Updates a time log, manages associated time slots, and recalculates timesheet and employee hours. + * + * This method retrieves the time log, updates its details, and handles time slot conflicts if the start or stop time is modified. + * It creates new time slots if necessary, saves the updated time log, and recalculates the timesheet and employee hours. + * + * @param command - The command containing the time log update data, including options for force delete and manual time slots. + * @returns A promise that resolves to the updated `TimeLog`. + */ - let timeLog: ITimeLog; - if (id instanceof TimeLog) { - timeLog = id; - } else { - timeLog = await this.typeOrmTimeLogRepository.findOneBy({ id }); - } + public async execute(command: TimeLogUpdateCommand): Promise { + // Extract input parameters from the command + const { id, input, manualTimeSlot, forceDelete = false } = command; + console.log('Executing TimeLogUpdateCommand:', { id, input, manualTimeSlot, forceDelete }); - const tenantId = RequestContext.currentTenantId(); - const { employeeId, organizationId } = timeLog; + // Retrieve the tenant ID from the request context or the provided input + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; + console.log('Tenant ID:', tenantId); - let needToUpdateTimeSlots = false; - if (input.startedAt || input.stoppedAt) { - needToUpdateTimeSlots = true; - } + let timeLog: ITimeLog = await this.getTimeLogByIdOrInstance(id); + console.log('Retrieved TimeLog:', timeLog); + + const { employeeId, organizationId } = timeLog; let timesheet: ITimesheet; - let updateTimeSlots = []; + let updateTimeSlots: ITimeSlot[] = []; + + // Check if time slots need to be updated + let needToUpdateTimeSlots = Boolean(input.startedAt || input.stoppedAt); + console.log('Need to update time slots:', needToUpdateTimeSlots); if (needToUpdateTimeSlots) { timesheet = await this.commandBus.execute( - new TimesheetFirstOrCreateCommand( - input.startedAt, - employeeId, - organizationId - ) - ); - const { startedAt, stoppedAt } = Object.assign({}, timeLog, input); - updateTimeSlots = this.timeSlotService.generateTimeSlots( - startedAt, - stoppedAt + new TimesheetFirstOrCreateCommand(input.startedAt, employeeId, organizationId) ); - } + console.log('Generated or retrieved Timesheet:', timesheet); - console.log('Stopped Timer Request Updated TimeLog Request', { - input - }); + // Generate time slots based on the updated time log details + const { startedAt, stoppedAt } = { ...timeLog, ...input }; + updateTimeSlots = this.timeSlotService.generateTimeSlots(startedAt, stoppedAt); + console.log('Generated updated TimeSlots:', updateTimeSlots); + } + // Update the time log in the repository await this.typeOrmTimeLogRepository.update(timeLog.id, { ...input, ...(timesheet ? { timesheetId: timesheet.id } : {}) }); + console.log('Updated TimeLog in the repository:', { id: timeLog.id, input }); - const timeSlots = this.timeSlotService.generateTimeSlots( - timeLog.startedAt, - timeLog.stoppedAt - ); + // Regenerate the existing time slots for the time log + const timeSlots = this.timeSlotService.generateTimeSlots(timeLog.startedAt, timeLog.stoppedAt); + console.log('Generated existing TimeSlots for TimeLog:', timeSlots); - timeLog = await this.typeOrmTimeLogRepository.findOneBy({ - id: timeLog.id - }); - const { timesheetId } = timeLog; + // Retrieve the updated time log + timeLog = await this.typeOrmTimeLogRepository.findOneBy({ id: timeLog.id }); + console.log('Retrieved updated TimeLog from repository:', timeLog); + // Check if time slots need to be updated if (needToUpdateTimeSlots) { - const startTimes = timeSlots - .filter((timeSlot) => { - return ( - updateTimeSlots.filter( - (newSlot) => moment(newSlot.startedAt).isSame( - timeSlot.startedAt - ) - ).length === 0 - ); - }) - .map((timeSlot) => new Date(timeSlot.startedAt)); + // Identify conflicting start times + const startTimes = this.getConflictingStartTimes(timeSlots, updateTimeSlots); + console.log('Identified conflicting start times:', startTimes); + // Remove conflicting time slots if (startTimes.length > 0) { - /** - * Removed Deleted TimeSlots - */ - const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); - query.setFindOptions({ - relations: { - screenshots: true - } - }); - query.where((qb: SelectQueryBuilder) => { - qb.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { - organizationId - }); - qb.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { - tenantId - }); - qb.andWhere(p(`"${qb.alias}"."employeeId" = :employeeId`), { - employeeId - }); - qb.andWhere(p(`"${qb.alias}"."startedAt" IN (:...startTimes)`), { - startTimes - }); - }); - const timeSlots = await query.getMany(); - await this.typeOrmTimeSlotRepository.remove(timeSlots); + await this.removeConflictingTimeSlots(tenantId, organizationId, employeeId, startTimes, forceDelete); + console.log('Removed conflicting TimeSlots:', startTimes); } - + // Create new time slots if needed for Web Timer if (!manualTimeSlot && timeLog.source === TimeLogSourceEnum.WEB_TIMER) { - updateTimeSlots = updateTimeSlots - .map((slot) => ({ - ...slot, - employeeId, - organizationId, - tenantId, - keyboard: 0, - mouse: 0, - overall: 0, - timeLogId: timeLog.id - })) - .filter((slot) => slot.tenantId && slot.organizationId); - /** - * Assign regenerated TimeSlot entries for existed TimeLog - */ - await this.timeSlotService.bulkCreate( - updateTimeSlots, - employeeId, - organizationId - ); + await this.bulkCreateTimeSlots(updateTimeSlots, timeLog, employeeId, organizationId, tenantId); + console.log('Created new TimeSlots for Web Timer:', updateTimeSlots); } - console.log('Last Updated Timer Time Log', { timeLog }); + // Update the time log in the repository + await this.saveUpdatedTimeLog(timeLog); + console.log('Saved updated TimeLog in the repository:', timeLog); - /** - * Update TimeLog Entry - */ - try { - await this.typeOrmTimeLogRepository.save(timeLog); - } catch (error) { - console.error('Error while updating TimeLog', error); - } + // Recalculate timesheets and employee hours + await this.recalculateTimesheetAndEmployeeHours(timeLog.timesheetId, employeeId); + console.log('Recalculated timesheets and employee hours:', timeLog.timesheetId, employeeId); + } - /** - * RECALCULATE timesheet activity - */ - await this.commandBus.execute( - new TimesheetRecalculateCommand(timesheetId) - ); + // Return the updated time log + const updatedTimeLog = await this.typeOrmTimeLogRepository.findOneBy({ id: timeLog.id }); + console.log('Final updated TimeLog:', updatedTimeLog); - /** - * UPDATE employee total worked hours - */ - if (employeeId) { - await this.commandBus.execute( - new UpdateEmployeeTotalWorkedHoursCommand(employeeId) - ); - } + return updatedTimeLog; + } + + /** + * Retrieves a time log by its ID or directly returns the instance if provided. + * + * If the `id` parameter is already a `TimeLog` instance, it is returned as is. Otherwise, it fetches + * the time log from the repository using the provided `id`. + * + * @param id - The time log ID or an instance of `TimeLog`. + * @returns A promise that resolves to the `ITimeLog` instance. + */ + private async getTimeLogByIdOrInstance(id: ID | TimeLog): Promise { + return id instanceof TimeLog ? id : this.typeOrmTimeLogRepository.findOneBy({ id }); + } + + /** + * Identifies the conflicting start times that need to be removed from time slots. + * + * This method filters out time slots that have matching `startedAt` times in the new slots and returns + * the start times of the slots that need to be removed. + * + * @param slots - The existing time slots. + * @param newSlots - The newly generated time slots. + * @returns An array of conflicting start times that need to be removed. + */ + private getConflictingStartTimes(slots: ITimeSlot[], newSlots: ITimeSlot[]): Date[] { + return slots + .filter( + (existingSlot) => !newSlots.some((newSlot) => moment(newSlot.startedAt).isSame(existingSlot.startedAt)) + ) + .map((slot) => new Date(slot.startedAt)); + } + + /** + * Removes or soft deletes conflicting time slots for a given employee within the specified time range. + * + * If `forceDelete` is true, the conflicting time slots will be hard deleted. Otherwise, they will be soft deleted. + * + * @param params - An object containing `tenantId`, `organizationId`, `employeeId`, and `startTimes`. + * @param forceDelete - A boolean flag indicating whether to perform a hard delete (`true`) or a soft delete (`false`). + * @returns A promise that resolves after the time slots have been deleted or soft deleted. + */ + private async removeConflictingTimeSlots( + tenantId: ID, + organizationId: ID, + employeeId: ID, + startTimes: Date[], + forceDelete: boolean + ): Promise { + // Query to fetch conflicting time slots + const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); + + // Add joins to the query + query + .leftJoinAndSelect(`${query.alias}.timeLogs`, 'timeLogs') + .leftJoinAndSelect(`${query.alias}.screenshots`, 'screenshots') + .leftJoinAndSelect(`${query.alias}.activities`, 'activities') + .leftJoinAndSelect(`${query.alias}.timeSlotMinutes`, 'timeSlotMinutes'); + + // Add where clauses to the query + query + .where(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }) + .andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }) + .andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }) + .andWhere(p(`"${query.alias}"."startedAt" IN (:...startTimes)`), { startTimes }); + + // Get the conflicting time slots + const slots = await query.getMany(); + console.log(`conflicting time slots for ${forceDelete ? 'hard' : 'soft'} deleting: %s`, slots.length); + + if (isEmpty(slots)) { + return []; } - return await this.typeOrmTimeLogRepository.findOneBy({ - id: timeLog.id - }); + // Delete or soft delete the conflicting time slots + return forceDelete + ? this.typeOrmTimeSlotRepository.remove(slots) + : this.typeOrmTimeSlotRepository.softRemove(slots); + } + + /** + * Bulk creates time slots for a given time log. + * + * This method enriches the provided time slots by adding additional fields like `employeeId`, `organizationId`, + * `tenantId`, and `timeLogId`, along with initializing `keyboard`, `mouse`, and `overall` activity metrics to zero. + * It filters out any slots that do not have valid `tenantId` or `organizationId` values and then performs a bulk creation of time slots. + * + * @param updateTimeSlots - The array of time slots that need to be enriched and created. + * @param timeLog - The time log associated with the time slots. + * @param employeeId - The ID of the employee associated with the time slots. + * @param organizationId - The ID of the organization associated with the time slots. + * @param tenantId - The tenant ID associated with the time slots. + * @returns A promise that resolves to an array of created time slots. + */ + private async bulkCreateTimeSlots( + updateTimeSlots: ITimeSlot[], + timeLog: ITimeLog, + employeeId: ID, + organizationId: ID, + tenantId: ID + ): Promise { + const slots = updateTimeSlots + .map((slot) => ({ + ...slot, + employeeId, + organizationId, + tenantId, + keyboard: 0, + mouse: 0, + overall: 0, + timeLogId: timeLog.id + })) + .filter((slot) => slot.tenantId && slot.organizationId); // Filter slots with valid tenant and organization IDs + + // Assign regenerated TimeSlot entries for existed TimeLog + return await this.timeSlotService.bulkCreate(slots, employeeId, organizationId); + } + + /** + * Saves the updated time log to the repository. + * + * @param timeLog - The time log to be saved. + * @returns A promise that resolves to the saved `ITimeLog` or throws an error if saving fails. + */ + private async saveUpdatedTimeLog(timeLog: ITimeLog): Promise { + try { + return await this.typeOrmTimeLogRepository.save(timeLog); + } catch (error) { + console.log('Failed to update the time log at line: 217', error); + } + } + + /** + * Recalculates the timesheet activities and updates the employee's total worked hours. + * + * This method first recalculates the total activity for the given timesheet by executing the + * `TimesheetRecalculateCommand`. Then, if an `employeeId` is provided, it updates the total + * worked hours for that employee by executing the `UpdateEmployeeTotalWorkedHoursCommand`. + * + * @param timesheetId - The ID of the timesheet for which the activity needs to be recalculated. + * @param employeeId - The ID of the employee whose total worked hours should be updated. If `null` or `undefined`, no update will be performed for the employee. + * @returns A promise that resolves when both recalculation operations are complete. + */ + + private async recalculateTimesheetAndEmployeeHours(timesheetId: ID, employeeId: ID): Promise { + // Recalculate timesheets + await this.commandBus.execute(new TimesheetRecalculateCommand(timesheetId)); + + // Update employee total worked hours + if (employeeId) { + await this.commandBus.execute(new UpdateEmployeeTotalWorkedHoursCommand(employeeId)); + } } } diff --git a/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts b/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts index b602226f313..3c0d125bb72 100644 --- a/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts +++ b/packages/core/src/time-tracking/time-log/commands/time-log-delete.command.ts @@ -1,11 +1,9 @@ import { ICommand } from '@nestjs/cqrs'; +import { ID } from '@gauzy/contracts'; import { TimeLog } from './../time-log.entity'; export class TimeLogDeleteCommand implements ICommand { static readonly type = '[TimeLog] delete'; - constructor( - public readonly ids: string | string[] | TimeLog | TimeLog[], - public readonly forceDelete = false - ) {} + constructor(public readonly ids: ID | ID[] | TimeLog | TimeLog[], public readonly forceDelete = false) {} } diff --git a/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts b/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts index 5e2cbf3a86f..15cd88864f6 100644 --- a/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts +++ b/packages/core/src/time-tracking/time-log/commands/time-log-update.command.ts @@ -8,6 +8,7 @@ export class TimeLogUpdateCommand implements ICommand { constructor( public readonly input: Partial, public readonly id: ID | TimeLog, - public readonly manualTimeSlot?: boolean | null + public readonly manualTimeSlot?: boolean | null, + public readonly forceDelete: boolean = false ) {} } diff --git a/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts b/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts index 5a6506c47bc..0b8bc19e2b0 100644 --- a/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts +++ b/packages/core/src/time-tracking/time-log/dto/delete-time-log.dto.ts @@ -1,16 +1,19 @@ -import { IDeleteTimeLog } from "@gauzy/contracts"; -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { ArrayNotEmpty, IsBoolean, IsOptional } from "class-validator"; -import { TenantOrganizationBaseDTO } from "./../../../core/dto"; - -export class DeleteTimeLogDTO extends TenantOrganizationBaseDTO implements IDeleteTimeLog { +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty } from 'class-validator'; +import { ID, IDeleteTimeLog } from '@gauzy/contracts'; +import { ForceDeleteBaseDTO } from '../../../core/dto'; +import { TimeLog } from '../time-log.entity'; +/** + * Data Transfer Object (DTO) for deleting time logs with the `forceDelete` flag. + * This DTO extends the `ForceDeleteBaseDTO` to include the `forceDelete` flag. + */ +export class DeleteTimeLogDTO extends ForceDeleteBaseDTO implements IDeleteTimeLog { + /** + * An array of time log IDs that need to be deleted. + * This field is required and must contain at least one ID. + */ @ApiProperty({ type: () => Array }) @ArrayNotEmpty() - logIds: string[] = []; - - @ApiPropertyOptional({ type: () => Boolean }) - @IsOptional() - @IsBoolean() - forceDelete: boolean = true; + readonly logIds: ID[] = []; } diff --git a/packages/core/src/time-tracking/time-log/dto/index.ts b/packages/core/src/time-tracking/time-log/dto/index.ts index de80001f618..b6bf47183be 100644 --- a/packages/core/src/time-tracking/time-log/dto/index.ts +++ b/packages/core/src/time-tracking/time-log/dto/index.ts @@ -1,3 +1,3 @@ +export * from './create-time-log.dto'; export * from './delete-time-log.dto'; export * from './update-time-log.dto'; -export * from './create-time-log.dto'; diff --git a/packages/core/src/time-tracking/time-log/time-log.controller.ts b/packages/core/src/time-tracking/time-log/time-log.controller.ts index a7b2111d9cb..983d383e283 100644 --- a/packages/core/src/time-tracking/time-log/time-log.controller.ts +++ b/packages/core/src/time-tracking/time-log/time-log.controller.ts @@ -14,7 +14,7 @@ import { import { CommandBus } from '@nestjs/cqrs'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { DeleteResult, FindOneOptions, UpdateResult } from 'typeorm'; -import { ITimeLog, PermissionsEnum, IGetTimeLogConflictInput } from '@gauzy/contracts'; +import { ITimeLog, PermissionsEnum, IGetTimeLogConflictInput, ID } from '@gauzy/contracts'; import { TimeLogService } from './time-log.service'; import { Permissions } from './../../shared/decorators'; import { OrganizationPermissionGuard, PermissionGuard, TenantBaseGuard } from './../../shared/guards'; @@ -29,11 +29,7 @@ import { IGetConflictTimeLogCommand } from './commands'; @Permissions(PermissionsEnum.TIME_TRACKER, PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ALL_ORG_VIEW) @Controller() export class TimeLogController { - - constructor( - private readonly timeLogService: TimeLogService, - private readonly commandBus: CommandBus - ) { } + constructor(private readonly _timeLogService: TimeLogService, private readonly _commandBus: CommandBus) {} /** * Get conflicting timer logs based on the provided entity. @@ -52,7 +48,7 @@ export class TimeLogController { }) @Get('conflict') async getConflict(@Query() request: IGetTimeLogConflictInput): Promise { - return await this.commandBus.execute(new IGetConflictTimeLogCommand(request)); + return await this._commandBus.execute(new IGetConflictTimeLogCommand(request)); } /** @@ -72,7 +68,7 @@ export class TimeLogController { @Get('report/daily') @UseValidationPipe({ whitelist: true, transform: true }) async getDailyReport(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getDailyReport(options); + return await this._timeLogService.getDailyReport(options); } /** @@ -92,7 +88,7 @@ export class TimeLogController { @Get('report/daily-chart') @UseValidationPipe({ whitelist: true }) async getDailyReportChartData(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getDailyReportCharts(options); + return await this._timeLogService.getDailyReportCharts(options); } /** @@ -112,7 +108,7 @@ export class TimeLogController { @Get('report/owed-report') @UseValidationPipe({ whitelist: true, transform: true }) async getOwedAmountReport(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getOwedAmountReport(options); + return await this._timeLogService.getOwedAmountReport(options); } /** @@ -132,7 +128,7 @@ export class TimeLogController { @Get('report/owed-charts') @UseValidationPipe({ whitelist: true, transform: true }) async getOwedAmountReportChartData(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getOwedAmountReportCharts(options); + return await this._timeLogService.getOwedAmountReportCharts(options); } /** @@ -152,7 +148,7 @@ export class TimeLogController { @Get('report/weekly') @UseValidationPipe({ whitelist: true, transform: true }) async getWeeklyReport(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getWeeklyReport(options); + return await this._timeLogService.getWeeklyReport(options); } /** @@ -172,7 +168,7 @@ export class TimeLogController { @Get('time-limit') @UseValidationPipe({ whitelist: true, transform: true }) async getTimeLimitReport(@Query() options: TimeLogLimitQueryDTO): Promise { - return await this.timeLogService.getTimeLimit(options); + return await this._timeLogService.getTimeLimit(options); } /** @@ -192,7 +188,7 @@ export class TimeLogController { @Get('project-budget-limit') @UseValidationPipe({ whitelist: true, transform: true }) async getProjectBudgetLimit(@Query() options: TimeLogQueryDTO) { - return await this.timeLogService.getProjectBudgetLimit(options); + return await this._timeLogService.getProjectBudgetLimit(options); } /** @@ -212,7 +208,7 @@ export class TimeLogController { @Get('client-budget-limit') @UseValidationPipe({ whitelist: true, transform: true }) async clientBudgetLimit(@Query() options: TimeLogQueryDTO) { - return await this.timeLogService.getClientBudgetLimit(options); + return await this._timeLogService.getClientBudgetLimit(options); } /** @@ -233,7 +229,7 @@ export class TimeLogController { @Get() @UseValidationPipe({ whitelist: true, transform: true }) async getLogs(@Query() options: TimeLogQueryDTO): Promise { - return await this.timeLogService.getTimeLogs(options); + return await this._timeLogService.getTimeLogs(options); } /** @@ -243,11 +239,8 @@ export class TimeLogController { * @returns The found time log. */ @Get(':id') - async findById( - @Param('id', UUIDValidationPipe) id: ITimeLog['id'], - @Query() options: FindOneOptions - ): Promise { - return await this.timeLogService.findOneByIdString(id, options); + async findById(@Param('id', UUIDValidationPipe) id: ID, @Query() options: FindOneOptions): Promise { + return await this._timeLogService.findOneByIdString(id, options); } /** @@ -270,7 +263,7 @@ export class TimeLogController { async addManualTime( @Body(TimeLogBodyTransformPipe, new ValidationPipe({ transform: true })) entity: CreateManualTimeLogDTO ): Promise { - return await this.timeLogService.addManualTime(entity); + return await this._timeLogService.addManualTime(entity); } /** @@ -292,15 +285,18 @@ export class TimeLogController { @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_MODIFY_TIME) async updateManualTime( - @Param('id', UUIDValidationPipe) id: ITimeLog['id'], + @Param('id', UUIDValidationPipe) id: ID, @Body(TimeLogBodyTransformPipe, new ValidationPipe({ transform: true })) entity: UpdateManualTimeLogDTO ): Promise { - return await this.timeLogService.updateManualTime(id, entity); + return await this._timeLogService.updateManualTime(id, entity); } /** - * Delete time log - * @param deleteQuery The query parameters for deleting time logs. + * Deletes a time log based on the provided query parameters. + * + * @param options - The query parameters for deleting time logs, including conditions like log IDs and force delete flag. + * @returns A Promise that resolves to either a DeleteResult or UpdateResult, depending on whether it's a soft or hard delete. + * @throws BadRequestException if the input is invalid or deletion fails. */ @ApiOperation({ summary: 'Delete time log' }) @ApiResponse({ @@ -309,13 +305,13 @@ export class TimeLogController { }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, The response body may contain clues as to what went wrong.' }) @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_DELETE_TIME) @Delete() @UseValidationPipe({ transform: true }) - async deleteTimeLog(@Query() deleteQuery: DeleteTimeLogDTO): Promise { - return await this.timeLogService.deleteTimeLogs(deleteQuery); + async deleteTimeLog(@Query() options: DeleteTimeLogDTO): Promise { + return await this._timeLogService.deleteTimeLogs(options); } } diff --git a/packages/core/src/time-tracking/time-log/time-log.service.ts b/packages/core/src/time-tracking/time-log/time-log.service.ts index 6b2b483a0fd..3bb908331cf 100644 --- a/packages/core/src/time-tracking/time-log/time-log.service.ts +++ b/packages/core/src/time-tracking/time-log/time-log.service.ts @@ -1,7 +1,7 @@ import { Injectable, BadRequestException, NotAcceptableException } from '@nestjs/common'; -import { TimeLog } from './time-log.entity'; +import { CommandBus } from '@nestjs/cqrs'; import { SelectQueryBuilder, Brackets, WhereExpressionBuilder, DeleteResult, UpdateResult } from 'typeorm'; -import { RequestContext } from '../../core/context'; +import { chain, pluck } from 'underscore'; import { IManualTimeInput, PermissionsEnum, @@ -21,10 +21,9 @@ import { IDeleteTimeLog, IOrganizationContact, IEmployee, - IOrganization + IOrganization, + ID } from '@gauzy/contracts'; -import { CommandBus } from '@nestjs/cqrs'; -import { chain, pluck } from 'underscore'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { TenantAwareCrudService } from './../../core/crud'; import { @@ -39,6 +38,7 @@ import { TimeLogUpdateCommand } from './commands'; import { getDateRangeFormat, getDaysBetweenDates } from './../../core/utils'; +import { RequestContext } from '../../core/context'; import { moment } from './../../core/moment-extend'; import { calculateAverage, calculateAverageActivity, calculateDuration } from './time-log.utils'; import { prepareSQLQuery as p } from './../../database/database.helper'; @@ -50,6 +50,7 @@ import { TypeOrmOrganizationProjectRepository } from '../../organization-project import { MikroOrmOrganizationProjectRepository } from '../../organization-project/repository/mikro-orm-organization-project.repository'; import { TypeOrmOrganizationContactRepository } from '../../organization-contact/repository/type-orm-organization-contact.repository'; import { MikroOrmOrganizationContactRepository } from '../../organization-contact/repository/mikro-orm-organization-contact.repository'; +import { TimeLog } from './time-log.entity'; @Injectable() export class TimeLogService extends TenantAwareCrudService { @@ -1063,7 +1064,7 @@ export class TimeLogService extends TenantAwareCrudService { */ async addManualTime(request: IManualTimeInput): Promise { try { - const tenantId = RequestContext.currentTenantId(); + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; const { employeeId, startedAt, stoppedAt, organizationId } = request; // Validate input @@ -1077,7 +1078,7 @@ export class TimeLogService extends TenantAwareCrudService { relations: { organization: true } }); - // + // Check if future dates are allowed for the organization const futureDateAllowed: IOrganization['futureDateAllowed'] = employee.organization.futureDateAllowed; // Check if the selected date and time range is allowed for the organization @@ -1094,18 +1095,20 @@ export class TimeLogService extends TenantAwareCrudService { employeeId, organizationId, tenantId, - ...(request.id ? { ignoreId: request.id } : {}) + ...(request.id && { ignoreId: request.id }) // Simplified ternary check }) ); // Resolve conflicts by deleting conflicting time slots - if (conflicts && conflicts.length > 0) { + if (conflicts?.length) { const times: IDateRange = { start: new Date(startedAt), end: new Date(stoppedAt) }; + // Loop through each conflicting time log for await (const timeLog of conflicts) { const { timeSlots = [] } = timeLog; + // Delete conflicting time slots for await (const timeSlot of timeSlots) { await this.commandBus.execute(new DeleteTimeSpanCommand(times, timeLog, timeSlot)); } @@ -1127,9 +1130,9 @@ export class TimeLogService extends TenantAwareCrudService { * @param request The updated data for the manual time log. * @returns The updated time log entry. */ - async updateManualTime(id: ITimeLog['id'], request: IManualTimeInput): Promise { + async updateManualTime(id: ID, request: IManualTimeInput): Promise { try { - const tenantId = RequestContext.currentTenantId(); + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; const { startedAt, stoppedAt, employeeId, organizationId } = request; // Validate input @@ -1143,7 +1146,7 @@ export class TimeLogService extends TenantAwareCrudService { relations: { organization: true } }); - // + // Check if future dates are allowed for the organization const futureDateAllowed: IOrganization['futureDateAllowed'] = employee.organization.futureDateAllowed; // Check if the selected date and time range is allowed for the organization @@ -1153,9 +1156,7 @@ export class TimeLogService extends TenantAwareCrudService { } // Check for conflicts with existing time logs - const timeLog = await this.typeOrmRepository.findOneBy({ - id: id - }); + const timeLog = await this.typeOrmRepository.findOneBy({ id }); // Check for conflicts with existing time logs const conflicts = await this.commandBus.execute( @@ -1165,18 +1166,18 @@ export class TimeLogService extends TenantAwareCrudService { employeeId, organizationId, tenantId, - ...(id ? { ignoreId: id } : {}) + ...(id && { ignoreId: id }) // Simplified check for id }) ); // Resolve conflicts by deleting conflicting time slots - if (isNotEmpty(conflicts)) { - const times: IDateRange = { - start: new Date(startedAt), - end: new Date(stoppedAt) - }; + if (conflicts?.length) { + const times: IDateRange = { start: new Date(startedAt), end: new Date(stoppedAt) }; + + // Loop through each conflicting time log for await (const timeLog of conflicts) { const { timeSlots = [] } = timeLog; + // Delete conflicting time slots for await (const timeSlot of timeSlots) { await this.commandBus.execute(new DeleteTimeSpanCommand(times, timeLog, timeSlot)); } @@ -1198,51 +1199,48 @@ export class TimeLogService extends TenantAwareCrudService { } /** + * Deletes time logs based on the provided parameters. * - * @param params - * @returns + * @param params - The parameters for deleting the time logs, including `logIds`, `organizationId`, and `forceDelete`. + * @returns A promise that resolves to the result of the delete or soft delete operation. + * @throws NotAcceptableException if no log IDs are provided. */ async deleteTimeLogs(params: IDeleteTimeLog): Promise { - let logIds: string | string[] = params.logIds; - if (isEmpty(logIds)) { - throw new NotAcceptableException('You can not delete time logs'); - } - if (typeof logIds === 'string') { - logIds = [logIds]; + // Early return if no logIds are provided + if (isEmpty(params.logIds)) { + throw new NotAcceptableException('You cannot delete time logs without IDs'); } - const tenantId = RequestContext.currentTenantId(); - const user = RequestContext.currentUser(); + // Ensure logIds is an array + const logIds: ID[] = Array.isArray(params.logIds) ? params.logIds : [params.logIds]; + + // Get the tenant ID from the request context or the provided tenant ID + const tenantId = RequestContext.currentTenantId() ?? params.tenantId; const { organizationId, forceDelete } = params; - const query = this.typeOrmRepository.createQueryBuilder('time_log'); + // Create a query builder for the TimeLog entity + const query = this.typeOrmRepository.createQueryBuilder(); + + // Set find options for the query query.setFindOptions({ - relations: { - timeSlots: true - } - }); - query.where((db: SelectQueryBuilder) => { - db.andWhere({ - ...(RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE) - ? {} - : { - employeeId: user.employeeId - }) - }); - db.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${db.alias}"."tenantId" = :tenantId`), { - tenantId - }); - web.andWhere(p(`"${db.alias}"."organizationId" = :organizationId`), { organizationId }); - web.andWhere(p(`"${db.alias}"."id" IN (:...logIds)`), { - logIds - }); - }) - ); + relations: { timeSlots: true } }); + // Add where clauses to the query + query.where(p(`"${query.alias}"."id" IN (:...logIds)`), { logIds }); + query.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + + // If user don't have permission to change selected employee, filter by current employee ID + if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { + const employeeId = RequestContext.currentEmployeeId(); + query.andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }); + } + + // Get the time logs from the database const timeLogs = await query.getMany(); + + // Invoke the command bus to delete the time logs return await this.commandBus.execute(new TimeLogDeleteCommand(timeLogs, forceDelete)); } diff --git a/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts b/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts index b48f6adf5e8..966da56af89 100644 --- a/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts +++ b/packages/core/src/time-tracking/time-slot/commands/delete-time-slot.command.ts @@ -4,7 +4,5 @@ import { IDeleteTimeSlot } from '@gauzy/contracts'; export class DeleteTimeSlotCommand implements ICommand { static readonly type = '[TimeSlot] delete'; - constructor( - public readonly query: IDeleteTimeSlot - ) {} + constructor(public readonly options: IDeleteTimeSlot) {} } diff --git a/packages/core/src/time-tracking/time-slot/commands/handlers/create-time-slot.handler.ts b/packages/core/src/time-tracking/time-slot/commands/handlers/create-time-slot.handler.ts index d3ede6bd31e..68d33daae93 100644 --- a/packages/core/src/time-tracking/time-slot/commands/handlers/create-time-slot.handler.ts +++ b/packages/core/src/time-tracking/time-slot/commands/handlers/create-time-slot.handler.ts @@ -1,75 +1,60 @@ - import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; +import { In } from 'typeorm'; import * as moment from 'moment'; import { omit } from 'underscore'; import * as chalk from 'chalk'; -import { ITimeSlot, PermissionsEnum, TimeLogSourceEnum, TimeLogType } from '@gauzy/contracts'; -import { isEmpty } from '@gauzy/common'; +import { ID, ITimeLog, ITimeSlot, PermissionsEnum, TimeLogSourceEnum, TimeLogType } from '@gauzy/contracts'; +import { isEmpty, isNotEmpty } from '@gauzy/common'; +import { prepareSQLQuery as p } from './../../../../database/database.helper'; import { RequestContext } from '../../../../core/context'; -import { - Employee, - TimeLog -} from './../../../../core/entities/internal'; -import { TimeSlot } from './../../time-slot.entity'; import { CreateTimeSlotCommand } from '../create-time-slot.command'; import { BulkActivitiesSaveCommand } from '../../../activity/commands'; import { TimeSlotMergeCommand } from './../time-slot-merge.command'; -import { prepareSQLQuery as p } from './../../../../database/database.helper'; -import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.repository'; +import { TypeOrmEmployeeRepository } from '../../../../employee/repository'; import { TypeOrmTimeLogRepository } from '../../../time-log/repository/type-orm-time-log.repository'; -import { TypeOrmEmployeeRepository } from '../../../../employee/repository/type-orm-employee.repository'; +import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.repository'; +import { TimeSlot } from './../../time-slot.entity'; @CommandHandler(CreateTimeSlotCommand) export class CreateTimeSlotHandler implements ICommandHandler { - private logging: boolean = true; constructor( - @InjectRepository(TimeSlot) - private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - - @InjectRepository(TimeLog) - private readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - - @InjectRepository(Employee) - private readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository, - - private readonly commandBus: CommandBus - ) { } + readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, + readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, + readonly typeOrmEmployeeRepository: TypeOrmEmployeeRepository, + private readonly _commandBus: CommandBus + ) {} + /** + * Executes the creation or retrieval of a time slot for the given command. + * It manages the retrieval of existing time slots, time logs, and activities, + * handles permissions, and ensures the time slot is created or updated appropriately. + * Also, it merges the time slot into a 10-minute interval if applicable. + * + * @param {CreateTimeSlotCommand} command - The command containing the input parameters for the time slot creation. + * @returns {Promise} - A promise that resolves to the created or updated TimeSlot instance. + */ public async execute(command: CreateTimeSlotCommand): Promise { const { input } = command; - let { organizationId, employeeId, activities = [] } = input; - - /** Get already running TimeLog based on source and logType */ - const source = input.source || TimeLogSourceEnum.DESKTOP; - const logType = input.logType || TimeLogType.TRACKED; + let { + organizationId, + employeeId, + projectId, + activities = [], + source = TimeLogSourceEnum.DESKTOP, + logType = TimeLogType.TRACKED + } = input; - this.log(`Time Slot Interval Request: ${JSON.stringify(input)}`); + this.log(`Time Slot Request - Input: ${JSON.stringify(input)}`); - const user = RequestContext.currentUser(); - const tenantId = RequestContext.currentTenantId(); + const tenantId = RequestContext.currentTenantId() || input.tenantId; // Retrieve the current tenant ID + const user = RequestContext.currentUser(); // Retrieve the current user + const hasChangeEmployeePermission = RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE); - /** - * Check logged user does not have employee selection permission - */ - if (!RequestContext.hasPermission(PermissionsEnum.CHANGE_SELECTED_EMPLOYEE)) { - try { - const employee = await this.typeOrmEmployeeRepository.findOneByOrFail({ - userId: user.id, - tenantId - }); - employeeId = employee.id; - organizationId = employee.organizationId; - } catch (error) { - console.error(`Error finding logged in employee for (${user.name}) create bulk activities`, error); - } - } else if (isEmpty(employeeId) && RequestContext.currentEmployeeId()) { - /* - * If employeeId not send from desktop timer request payload - */ + // Check if the logged user does not have employee selection permission + if (!hasChangeEmployeePermission || (isEmpty(employeeId) && RequestContext.currentEmployeeId())) { + // Assign current employeeId if not provided in the request payload employeeId = RequestContext.currentEmployeeId(); } @@ -77,142 +62,130 @@ export class CreateTimeSlotHandler implements ICommandHandler) => { - qb.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { tenantId }); - qb.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { organizationId }); - qb.andWhere(p(`"${qb.alias}"."employeeId" = :employeeId`), { employeeId }); - qb.andWhere(p(`"${qb.alias}"."startedAt" = :startedAt`), { startedAt: input.startedAt }); - }); + + // Add where clauses + query.where(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + query.andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }); + query.andWhere(p(`"${query.alias}"."startedAt" = :startedAt`), { startedAt: input.startedAt }); + this.log(`Get Time Slot Query & Parameters For employee (${user.name}): ${query.getQueryAndParameters()}`); + + // Get the last time slot timeSlot = await query.getOneOrFail(); } catch (error) { - if (!timeSlot) { - timeSlot = new TimeSlot(omit(input, ['timeLogId'])); - timeSlot.tenantId = tenantId; - timeSlot.organizationId = organizationId; - timeSlot.employeeId = employeeId; - timeSlot.timeLogs = []; - } + // Create a new TimeSlot instance if not found + timeSlot = new TimeSlot({ + ...omit(input, ['timeLogId']), + tenantId, + organizationId, + employeeId, + timeLogs: [] + }); } - this.log(`Find Time Slot For Time: ${input.startedAt} for employee (${user.name}): ${JSON.stringify(timeSlot)}`); - try { - /** - * Find TimeLog for TimeSlot Range - */ + // Find TimeLog for TimeSlot Range const query = this.typeOrmTimeLogRepository.createQueryBuilder(); - query.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); - web.andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }); - web.andWhere(p(`"${query.alias}"."source" = :source`), { source }); - web.andWhere(p(`"${query.alias}"."logType" = :logType`), { logType }); - web.andWhere(p(`"${query.alias}"."stoppedAt" IS NOT NULL`)); - }) - ); + // Add where clauses + query.where(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + query.andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }); + query.andWhere(p(`"${query.alias}"."source" = :source`), { source }); + query.andWhere(p(`"${query.alias}"."logType" = :logType`), { logType }); + query.andWhere(p(`"${query.alias}"."stoppedAt" IS NOT NULL`)); + // Add order by clause query.addOrderBy(p(`"${query.alias}"."createdAt"`), 'DESC'); this.log(`Find timelog for specific query: ${query.getQueryAndParameters()}`); + // Get the last time log const timeLog = await query.getOneOrFail(); this.log(`Found timelog for specific timeLog: ${JSON.stringify(timeLog)}`); timeSlot.timeLogs.push(timeLog); } catch (error) { if (input.timeLogId) { - let timeLogIds: string[] = Array.isArray(input.timeLogId) ? input.timeLogId : [input.timeLogId]; - /** - * Find TimeLog for TimeSlot Range - */ + // Convert input.timeLogId to an array if it's not already + const timeLogIds: ID[] = [].concat(input.timeLogId); + + // Reuse the base query and add the condition for timeLogIds const query = this.typeOrmTimeLogRepository.createQueryBuilder(); - query.where((qb: SelectQueryBuilder) => { - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { organizationId }); - web.andWhere(p(`"${qb.alias}"."source" = :source`), { source }); - web.andWhere(p(`"${qb.alias}"."logType" = :logType`), { logType }); - web.andWhere(p(`"${qb.alias}"."employeeId" = :employeeId`), { employeeId }); - web.andWhere(p(`"${qb.alias}"."stoppedAt" IS NOT NULL`)); - }) - ); - qb.andWhere(p(`"${qb.alias}"."id" IN (:...timeLogIds)`), { timeLogIds }); - }); + + // Add where clauses + query.where(p(`"${query.alias}"."id" IN (:...timeLogIds)`), { timeLogIds }); + query.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + query.andWhere(p(`"${query.alias}"."source" = :source`), { source }); + query.andWhere(p(`"${query.alias}"."logType" = :logType`), { logType }); + query.andWhere(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }); + query.andWhere(p(`"${query.alias}"."stoppedAt" IS NOT NULL`)); this.log(`Timelog query for timeLog IDs for employee (${user.name}): ${query.getQueryAndParameters()}`); + // Retrieve time logs const timeLogs = await query.getMany(); - this.log(`Found recent time logs using timelog ids for employee (${user.name}): ${JSON.stringify(timeLogs)}`); + this.log(`Recent time logs using timelog ids for employee (${user.name}): ${JSON.stringify(timeLogs)}`); timeSlot.timeLogs.push(...timeLogs); } } - /** - * Update TimeLog Entry Every TimeSlot Request From Desktop Timer - */ - for await (const timeLog of timeSlot.timeLogs) { - if (timeLog.isRunning) { - await this.typeOrmTimeLogRepository.update(timeLog.id, { - stoppedAt: moment.utc().toDate() - }); - } + // Map only running time logs to an array of IDs + const ids: ID[] = timeSlot.timeLogs.filter((log: ITimeLog) => log.isRunning).map((log) => log.id); + // Set stoppedAt to current time + const stoppedAt = moment.utc().toDate(); + + // Only update running timer + if (isNotEmpty(ids)) { + await this.typeOrmTimeLogRepository.update( + { + id: In(ids), + isRunning: true + }, + { stoppedAt } + ); } - this.log(`Bulk activities save parameters employee (${user.name}): ${JSON.stringify({ - organizationId, - employeeId, - activities, - projectId: input.projectId, - })}`); + this.log(`Bulk activities save parameters employee (${user.name}): ${JSON.stringify({ activities })}`); - timeSlot.activities = await this.commandBus.execute( + // Save bulk activities + const bulkActivities = await this._commandBus.execute( new BulkActivitiesSaveCommand({ organizationId, employeeId, activities, - projectId: input.projectId, + projectId }) ); - this.log(`Timeslot save first time before bulk activities save for employee (${user.name}): ${JSON.stringify(timeSlot)}`); + // Update the time slot's activities + timeSlot.activities = bulkActivities || []; + + // Save the time slot await this.typeOrmTimeSlotRepository.save(timeSlot); - /* - * Merge timeSlots into 10 minutes slots - */ - let [mergedTimeSlot] = await this.commandBus.execute( - new TimeSlotMergeCommand( - organizationId, - employeeId, - minDate, - maxDate - ) + + // Merge timeSlots into 10 minutes slots + let [mergedTimeSlot] = await this._commandBus.execute( + new TimeSlotMergeCommand(organizationId, employeeId, minDate, maxDate) ); if (mergedTimeSlot) { timeSlot = mergedTimeSlot; } - this.log(`Final merged timeSlot for employee (${user.name}): ${JSON.stringify(timeSlot)}`); - return await this.typeOrmTimeSlotRepository.findOne({ - where: { - id: timeSlot.id - }, - relations: { - timeLogs: true, - screenshots: true - } + where: { id: timeSlot.id }, + relations: { timeLogs: true, screenshots: true } }); } diff --git a/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts b/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts index e2c336c405c..c78d3099a99 100644 --- a/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts +++ b/packages/core/src/time-tracking/time-slot/commands/handlers/delete-time-slot.handler.ts @@ -1,10 +1,8 @@ import { CommandHandler, ICommandHandler, CommandBus } from '@nestjs/cqrs'; import { NotAcceptableException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; -import { ITimeSlot, PermissionsEnum } from '@gauzy/contracts'; +import * as chalk from 'chalk'; +import { ID, ITimeSlot, PermissionsEnum } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; -import { TimeSlot } from './../../time-slot.entity'; import { DeleteTimeSpanCommand } from '../../../time-log/commands/delete-time-span.command'; import { DeleteTimeSlotCommand } from '../delete-time-slot.command'; import { RequestContext } from './../../../../core/context'; @@ -13,69 +11,81 @@ import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.r @CommandHandler(DeleteTimeSlotCommand) export class DeleteTimeSlotHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus - ) { } + ) {} + /** + * Executes the command to delete time slots based on the provided query. + * + * This method processes the deletion of time slots based on the provided IDs in the query. + * It checks for the current user's permission to change selected employees, and if not permitted, + * restricts the deletion to the current user's time slots. The method handles deleting time spans + * for each time slot, ensuring that only non-running time logs are deleted. + * + * @param command - The `DeleteTimeSlotCommand` containing the query with time slot IDs and organization data. + * @returns A promise that resolves to `true` if the deletion process is successful, or throws an exception if no IDs are provided. + * @throws NotAcceptableException if no time slot IDs are provided in the query. + */ public async execute(command: DeleteTimeSlotCommand): Promise { - const { query } = command; - const ids: string | string[] = query.ids; + const { ids, organizationId, forceDelete } = command.options; + + // Throw an error if no IDs are provided if (isEmpty(ids)) { throw new NotAcceptableException('You can not delete time slots'); } - let employeeIds: string[] = []; - if ( - !RequestContext.hasPermission( - PermissionsEnum.CHANGE_SELECTED_EMPLOYEE - ) - ) { - const user = RequestContext.currentUser(); - employeeIds = [user.employeeId]; - } + // Retrieve the tenant ID from the current request context + const tenantId = RequestContext.currentTenantId() || command.options.tenantId; - const tenantId = RequestContext.currentTenantId(); - const { organizationId } = query; + // Check if the current user has the permission to change the selected employee + const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( + PermissionsEnum.CHANGE_SELECTED_EMPLOYEE + ); + const employeeIds: ID[] = !hasChangeSelectedEmployeePermission ? [RequestContext.currentEmployeeId()] : []; for await (const id of Object.values(ids)) { - const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); - query.setFindOptions({ - relations: { - timeLogs: true, - screenshots: true - } - }); - query.where((qb: SelectQueryBuilder) => { - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { organizationId }); - web.andWhere(p(`"${qb.alias}"."id" = :id`), { id }); - }) - ); - if (isNotEmpty(employeeIds)) { - qb.andWhere(p(`"${qb.alias}"."employeeId" IN (:...employeeIds)`), { - employeeIds - }); - } - qb.addOrderBy(p(`"${qb.alias}"."createdAt"`), 'ASC'); - }); + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + query + .leftJoinAndSelect(`${query.alias}.timeLogs`, 'timeLogs') + .leftJoinAndSelect(`${query.alias}.screenshots`, 'screenshots') + .leftJoinAndSelect(`${query.alias}.activities`, 'activities') + .leftJoinAndSelect(`${query.alias}.timeSlotMinutes`, 'timeSlotMinutes'); + + // Add where clauses to the query + query.where(p(`"${query.alias}"."id" = :id`), { id }); + query.andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + query.andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }); + + // Restrict deletion based on employeeId if permission is not granted + if (isNotEmpty(employeeIds)) { + query.andWhere(p(`"${query.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); + } + + // Order by creation date + query.orderBy(p(`"${query.alias}"."createdAt"`), 'ASC'); const timeSlots: ITimeSlot[] = await query.getMany(); + + // If no time slots are found, stop processing if (isEmpty(timeSlots)) { continue; } + console.log(chalk.blue(`time slots for soft delete or hard delete:`), JSON.stringify(timeSlots)); + + // Loop through each time slot for await (const timeSlot of timeSlots) { - if (timeSlot && isNotEmpty(timeSlot.timeLogs)) { - const timeLogs = timeSlot.timeLogs.filter( - (timeLog) => timeLog.isRunning === false - ); - if (isNotEmpty(timeLogs)) { - for await (const timeLog of timeLogs) { + if (isNotEmpty(timeSlot.timeLogs)) { + // Filter non-running time logs + const nonRunningTimeLogs = timeSlot.timeLogs.filter((timeLog) => !timeLog.isRunning); + + // Delete non-running time logs + if (isNotEmpty(nonRunningTimeLogs)) { + // Sequentially execute delete commands for non-running time logs + for await (const timeLog of nonRunningTimeLogs) { + // Delete time span for non-running time log await this.commandBus.execute( new DeleteTimeSpanCommand( { @@ -83,7 +93,8 @@ export class DeleteTimeSlotHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeLog) private readonly typeOrmTimeLogRepository: TypeOrmTimeLogRepository, - - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus - ) { } + ) {} - public async execute( - command: TimeSlotBulkCreateCommand - ): Promise { + public async execute(command: TimeSlotBulkCreateCommand): Promise { let { slots, employeeId, organizationId } = command; if (slots.length === 0) { return []; } slots = slots.map((slot) => { - const { start } = getDateRangeFormat( - moment.utc(slot.startedAt), - moment.utc(slot.startedAt) - ); + const { start } = getDateRangeFormat(moment.utc(slot.startedAt), moment.utc(slot.startedAt)); slot.startedAt = start as Date; return slot; }); @@ -53,11 +41,10 @@ export class TimeSlotBulkCreateHandler implements ICommandHandler 0) { - slots = slots.filter((slot) => !insertedSlots.find( - (insertedSlot) => moment(insertedSlot.startedAt).isSame( - moment(slot.startedAt) - ) - )); + slots = slots.filter( + (slot) => + !insertedSlots.find((insertedSlot) => moment(insertedSlot.startedAt).isSame(moment(slot.startedAt))) + ); } if (slots.length === 0) { return []; @@ -102,13 +89,6 @@ export class TimeSlotBulkCreateHandler implements ICommandHandler b ? a : b; }); - return await this.commandBus.execute( - new TimeSlotMergeCommand( - organizationId, - employeeId, - minDate, - maxDate - ) - ); + return await this.commandBus.execute(new TimeSlotMergeCommand(organizationId, employeeId, minDate, maxDate)); } } diff --git a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts index 7467d81aa15..524d21efe8c 100644 --- a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts +++ b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-bulk-delete.handler.ts @@ -1,8 +1,7 @@ import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; -import { SelectQueryBuilder } from 'typeorm'; -import { isNotEmpty } from '@gauzy/common'; -import { TimeSlot } from '../../time-slot.entity'; +import * as chalk from 'chalk'; +import { ID, ITimeLog, ITimeSlot } from '@gauzy/contracts'; +import { isEmpty, isNotEmpty } from '@gauzy/common'; import { TimeSlotBulkDeleteCommand } from '../time-slot-bulk-delete.command'; import { RequestContext } from '../../../../core/context'; import { prepareSQLQuery as p } from './../../../../database/database.helper'; @@ -10,64 +9,136 @@ import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.r @CommandHandler(TimeSlotBulkDeleteCommand) export class TimeSlotBulkDeleteHandler implements ICommandHandler { + constructor(private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository) {} - constructor( - @InjectRepository(TimeSlot) - private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - ) { } + /** + * Execute bulk deletion of time slots + * + * @param command - The command containing input and deletion options + * @returns Promise - Returns true if deletion was successful, otherwise false + */ + public async execute(command: TimeSlotBulkDeleteCommand): Promise { + const { input, forceDelete, entireSlots } = command; + // Extract organizationId, employeeId, timeLog, and timeSlotsIds from the input + const { organizationId, employeeId, timeLog, timeSlotsIds = [] } = input; + // Retrieve the tenant ID from the current request context or the provided input + const tenantId = RequestContext.currentTenantId() ?? input.tenantId; - public async execute( - command: TimeSlotBulkDeleteCommand - ): Promise { - const tenantId = RequestContext.currentTenantId(); + // Step 1: Fetch time slots based on input parameters + const timeSlots = await this.fetchTimeSlots({ organizationId, employeeId, tenantId, timeSlotsIds }); + console.log(`fetched time slots for soft delete or hard delete:`, timeSlots); - const { input, forceDirectDelete } = command; - const { organizationId, employeeId, timeLog, timeSlotsIds = [] } = input; + // If timeSlots is empty, return an empty array + if (isEmpty(timeSlots)) { + return []; + } - const query = this.typeOrmTimeSlotRepository.createQueryBuilder('time_slot'); - query.setFindOptions({ - relations: { - timeLogs: true, - screenshots: true - } - }); - query.where((qb: SelectQueryBuilder) => { - if (isNotEmpty(timeSlotsIds)) { - qb.andWhere(p(`"${qb.alias}"."id" IN (:...timeSlotsIds)`), { - timeSlotsIds - }); - } - qb.andWhere(p(`"${qb.alias}"."employeeId" = :employeeId`), { - employeeId - }); - qb.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { - organizationId - }); - qb.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { - tenantId - }); - console.log('Time Slots Delete Range Query', qb.getQueryAndParameters()); - }); - const timeSlots = await query.getMany(); - console.log({ timeSlots, forceDirectDelete }, 'Time Slots Delete Range'); + // Step 2: Handle deletion based on the entireSlots flag + if (entireSlots) { + return await this.bulkDeleteTimeSlots(timeSlots, forceDelete); + } else { + return await this.conditionalDeleteTimeSlots(timeSlots, timeLog, forceDelete); + } + } + + /** + * Fetches time slots based on the provided parameters. + * + * @param params - The parameters for querying time slots. + * @returns A promise that resolves to an array of time slots. + */ + private async fetchTimeSlots({ + organizationId, + employeeId, + tenantId, + timeSlotsIds = [] + }: { + organizationId: ID; + employeeId: ID; + tenantId: ID; + timeSlotsIds: ID[]; + }): Promise { + // Create a query builder for the TimeSlot entity + const query = this.typeOrmTimeSlotRepository.createQueryBuilder(); + query + .leftJoinAndSelect(`${query.alias}.timeLogs`, 'timeLogs') + .leftJoinAndSelect(`${query.alias}.screenshots`, 'screenshots') + .leftJoinAndSelect(`${query.alias}.activities`, 'activities') + .leftJoinAndSelect(`${query.alias}.timeSlotMinutes`, 'timeSlotMinutes'); + + query + .where(p(`"${query.alias}"."employeeId" = :employeeId`), { employeeId }) + .andWhere(p(`"${query.alias}"."organizationId" = :organizationId`), { organizationId }) + .andWhere(p(`"${query.alias}"."tenantId" = :tenantId`), { tenantId }); + + // If timeSlotsIds is not empty, add a WHERE clause to the query + if (isNotEmpty(timeSlotsIds)) { + query.andWhere(p(`"${query.alias}"."id" IN (:...timeSlotsIds)`), { timeSlotsIds }); + } + + console.log('fetched time slots by parameters:', query.getParameters()); + return await query.getMany(); + } + + /** + * Handles bulk deletion of time slots, either soft or hard delete based on the `forceDelete` flag. + * + * @param timeSlots - The time slots to delete. + * @param forceDelete - A boolean flag to indicate whether to hard delete or soft delete. + * @returns A promise that resolves to the deleted time slots. + */ + private bulkDeleteTimeSlots(timeSlots: ITimeSlot[], forceDelete: boolean): Promise { + console.log(`bulk ${forceDelete ? 'hard' : 'soft'} deleting time slots:`, timeSlots); + + return forceDelete + ? this.typeOrmTimeSlotRepository.remove(timeSlots) + : this.typeOrmTimeSlotRepository.softRemove(timeSlots); + } + + /** + * Conditionally deletes time slots based on associated time logs. + * + * If a time slot only has one time log and that time log matches the provided one, the time slot is deleted. + * + * @param timeSlots - The time slots to conditionally delete. + * @param timeLog - The specific time log to check for deletion. + * @param forceDelete - A boolean flag to indicate whether to hard delete or soft delete. + * @returns A promise that resolves to true after deletion. + */ + private async conditionalDeleteTimeSlots( + timeSlots: ITimeSlot[], + timeLog: ITimeLog, + forceDelete: boolean + ): Promise { + console.log(`conditional ${forceDelete ? 'hard' : 'soft'} deleting time slots:`, timeSlots); + + // Loop through each time slot + for await (const timeSlot of timeSlots) { + const { timeLogs = [] } = timeSlot; + const [firstTimeLog] = timeLogs; + + console.log('Matching TimeLog ID:', firstTimeLog.id === timeLog.id); + console.log('TimeSlots Ids Will Be Deleted:', timeSlot.id); - if (isNotEmpty(timeSlots)) { - if (forceDirectDelete) { - await this.typeOrmTimeSlotRepository.remove(timeSlots); - return true; - } else { - for await (const timeSlot of timeSlots) { - const { timeLogs } = timeSlot; - if (timeLogs.length === 1) { - const [firstTimeLog] = timeLogs; - if (firstTimeLog.id === timeLog.id) { - await this.typeOrmTimeSlotRepository.remove(timeSlot); - } + if (timeLogs.length === 1) { + const [firstTimeLog] = timeLogs; + if (firstTimeLog.id === timeLog.id) { + // If the time slot has only one time log and it matches the provided time log, delete the time slot + if (forceDelete) { + console.log( + chalk.red('--------------------hard removing time slot--------------------'), + timeSlot.id + ); + return await this.typeOrmTimeSlotRepository.remove(timeSlot); + } else { + console.log( + chalk.yellow('--------------------soft removing time slot--------------------'), + timeSlot.id + ); + return await this.typeOrmTimeSlotRepository.softRemove(timeSlot); } } - return true; } } - return false; } } diff --git a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts index 6ae1d5e23e7..f4d237735d9 100644 --- a/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts +++ b/packages/core/src/time-tracking/time-slot/commands/handlers/time-slot-merge.handler.ts @@ -1,5 +1,4 @@ import { CommandBus, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { InjectRepository } from '@nestjs/typeorm'; import { In, SelectQueryBuilder } from 'typeorm'; import * as moment from 'moment'; import { chain, omit, pluck, uniq } from 'underscore'; @@ -16,13 +15,10 @@ import { TypeOrmTimeSlotRepository } from '../../repository/type-orm-time-slot.r @CommandHandler(TimeSlotMergeCommand) export class TimeSlotMergeHandler implements ICommandHandler { - constructor( - @InjectRepository(TimeSlot) private readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, - private readonly commandBus: CommandBus - ) { } + ) {} /** * @@ -36,11 +32,7 @@ export class TimeSlotMergeHandler implements ICommandHandler { - const [timeSlot] = timeSlots; - - let timeLogs: ITimeLog[] = []; - let screenshots: IScreenshot[] = []; - let activities: IActivity[] = []; - - let duration = 0; - let keyboard = 0; - let mouse = 0; - let overall = 0; - - const calculateValue = (value: number | undefined): number => parseInt(value as any, 10) || 0; - - duration += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.duration), 0); - keyboard += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.keyboard), 0); - mouse += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.mouse), 0); - overall += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.overall), 0); - - screenshots = screenshots.concat(...timeSlots.map(slot => slot.screenshots || [])); - timeLogs = timeLogs.concat(...timeSlots.map(slot => slot.timeLogs || [])); - activities = activities.concat(...timeSlots.map(slot => slot.activities || [])); - - const nonZeroKeyboardSlots = timeSlots.filter((item: ITimeSlot) => item.keyboard !== 0); - const timeSlotsLength = nonZeroKeyboardSlots.length; - - keyboard = Math.round(keyboard / timeSlotsLength || 0); - mouse = Math.round(mouse / timeSlotsLength || 0); - - const activity = { - duration: Math.max(0, Math.min(600, duration)), - overall: Math.max(0, Math.min(600, overall)), - keyboard: Math.max(0, Math.min(600, keyboard)), - mouse: Math.max(0, Math.min(600, mouse)), - }; - /* - * Map old screenshots newly created TimeSlot - */ - screenshots = screenshots.map((item) => new Screenshot(omit(item, ['timeSlotId']))); - /* - * Map old activities newly created TimeSlot - */ - activities = activities.map((item) => new Activity(omit(item, ['timeSlotId']))); - - timeLogs = uniq(timeLogs, (log: ITimeLog) => log.id); - - const newTimeSlot = new TimeSlot({ - ...omit(timeSlot), - ...activity, - screenshots, - activities, - timeLogs, - startedAt: moment(slotStart).toDate(), - tenantId, - organizationId, - employeeId - }); - console.log('Newly Created Time Slot', newTimeSlot); - - await this.updateTimeLogAndEmployeeTotalWorkedHours(newTimeSlot); - - await this.typeOrmTimeSlotRepository.save(newTimeSlot); - createdTimeSlots.push(newTimeSlot); - - const ids = pluck(timeSlots, 'id'); - ids.splice(0, 1); - console.log('TimeSlots Ids Will Be Deleted:', ids); - - if (ids.length > 0) { - await this.typeOrmTimeSlotRepository.delete({ - id: In(ids) + const savePromises = groupByTimeSlots + .mapObject(async (timeSlots, slotStart) => { + const [timeSlot] = timeSlots; + + let timeLogs: ITimeLog[] = []; + let screenshots: IScreenshot[] = []; + let activities: IActivity[] = []; + + let duration = 0; + let keyboard = 0; + let mouse = 0; + let overall = 0; + + const calculateValue = (value: number | undefined): number => parseInt(value as any, 10) || 0; + + duration += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.duration), 0); + keyboard += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.keyboard), 0); + mouse += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.mouse), 0); + overall += timeSlots.reduce((acc, slot) => acc + calculateValue(slot.overall), 0); + + screenshots = screenshots.concat(...timeSlots.map((slot) => slot.screenshots || [])); + timeLogs = timeLogs.concat(...timeSlots.map((slot) => slot.timeLogs || [])); + activities = activities.concat(...timeSlots.map((slot) => slot.activities || [])); + + const nonZeroKeyboardSlots = timeSlots.filter((item: ITimeSlot) => item.keyboard !== 0); + const timeSlotsLength = nonZeroKeyboardSlots.length; + + keyboard = Math.round(keyboard / timeSlotsLength || 0); + mouse = Math.round(mouse / timeSlotsLength || 0); + + const activity = { + duration: Math.max(0, Math.min(600, duration)), + overall: Math.max(0, Math.min(600, overall)), + keyboard: Math.max(0, Math.min(600, keyboard)), + mouse: Math.max(0, Math.min(600, mouse)) + }; + /* + * Map old screenshots newly created TimeSlot + */ + screenshots = screenshots.map((item) => new Screenshot(omit(item, ['timeSlotId']))); + /* + * Map old activities newly created TimeSlot + */ + activities = activities.map((item) => new Activity(omit(item, ['timeSlotId']))); + + timeLogs = uniq(timeLogs, (log: ITimeLog) => log.id); + + const newTimeSlot = new TimeSlot({ + ...omit(timeSlot), + ...activity, + screenshots, + activities, + timeLogs, + startedAt: moment(slotStart).toDate(), + tenantId, + organizationId, + employeeId }); - } - }).values().value(); + console.log('Newly Created Time Slot', newTimeSlot); + + await this.updateTimeLogAndEmployeeTotalWorkedHours(newTimeSlot); + + await this.typeOrmTimeSlotRepository.save(newTimeSlot); + createdTimeSlots.push(newTimeSlot); + + const ids = pluck(timeSlots, 'id'); + ids.splice(0, 1); + console.log('TimeSlots Ids Will Be Deleted:', ids); + + if (ids.length > 0) { + await this.typeOrmTimeSlotRepository.delete({ + id: In(ids) + }); + } + }) + .values() + .value(); await Promise.all(savePromises); } return createdTimeSlots; @@ -163,13 +155,7 @@ export class TimeSlotMergeHandler implements ICommandHandler { + private async getTimeSlots({ organizationId, employeeId, tenantId, startedAt, stoppedAt }): Promise { /** * GET Time Slots for given date range slot */ @@ -194,7 +180,7 @@ export class TimeSlotMergeHandler implements ICommandHandler implements IDeleteTimeSlot { + /** + * An array of IDs representing the time slots to be deleted. + * This array must not be empty and ensures that at least one time slot is selected for deletion. + */ @ApiProperty({ type: () => Array }) @ArrayNotEmpty() - readonly ids: string[] = []; -} \ No newline at end of file + readonly ids: ID[] = []; +} diff --git a/packages/core/src/time-tracking/time-slot/dto/index.ts b/packages/core/src/time-tracking/time-slot/dto/index.ts index e0967f6e6eb..8930d1d997c 100644 --- a/packages/core/src/time-tracking/time-slot/dto/index.ts +++ b/packages/core/src/time-tracking/time-slot/dto/index.ts @@ -1 +1,2 @@ -export { DeleteTimeSlotDTO } from './delete-time-slot.dto'; \ No newline at end of file +export * from './time-slot-query.dto'; +export * from './delete-time-slot.dto'; diff --git a/packages/core/src/time-tracking/time-slot/dto/query/index.ts b/packages/core/src/time-tracking/time-slot/dto/query/index.ts deleted file mode 100644 index 1f0f0102528..00000000000 --- a/packages/core/src/time-tracking/time-slot/dto/query/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TimeSlotQueryDTO } from './time-slot-query.dto'; \ No newline at end of file diff --git a/packages/core/src/time-tracking/time-slot/dto/query/time-slot-query.dto.ts b/packages/core/src/time-tracking/time-slot/dto/query/time-slot-query.dto.ts deleted file mode 100644 index 78d81407c40..00000000000 --- a/packages/core/src/time-tracking/time-slot/dto/query/time-slot-query.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IntersectionType } from "@nestjs/swagger"; -import { IGetTimeSlotInput } from "@gauzy/contracts"; -import { FiltersQueryDTO, RelationsQueryDTO, SelectorsQueryDTO } from "./../../../../shared/dto"; - -/** - * Get time slot request DTO validation - */ -export class TimeSlotQueryDTO extends IntersectionType( - FiltersQueryDTO, - IntersectionType(RelationsQueryDTO, SelectorsQueryDTO) -) implements IGetTimeSlotInput {} \ No newline at end of file diff --git a/packages/core/src/time-tracking/time-slot/dto/time-slot-query.dto.ts b/packages/core/src/time-tracking/time-slot/dto/time-slot-query.dto.ts new file mode 100644 index 00000000000..2f5f31b122d --- /dev/null +++ b/packages/core/src/time-tracking/time-slot/dto/time-slot-query.dto.ts @@ -0,0 +1,10 @@ +import { IntersectionType } from '@nestjs/swagger'; +import { IGetTimeSlotInput } from '@gauzy/contracts'; +import { FiltersQueryDTO, RelationsQueryDTO, SelectorsQueryDTO } from '../../../shared/dto'; + +/** + * Get time slot request DTO validation + */ +export class TimeSlotQueryDTO + extends IntersectionType(FiltersQueryDTO, RelationsQueryDTO, SelectorsQueryDTO) + implements IGetTimeSlotInput {} diff --git a/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts b/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts index 628b79e49bc..e4be776daa4 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot-minute.entity.ts @@ -1,11 +1,7 @@ -import { - RelationId, - Unique, - JoinColumn -} from 'typeorm'; -import { ITimeSlot, ITimeSlotMinute } from '@gauzy/contracts'; +import { RelationId, Unique, JoinColumn } from 'typeorm'; +import { ID, ITimeSlot, ITimeSlotMinute } from '@gauzy/contracts'; import { ApiProperty } from '@nestjs/swagger'; -import { IsNumber, IsDateString, IsString } from 'class-validator'; +import { IsNumber, IsDateString, IsUUID } from 'class-validator'; import { TenantOrganizationBaseEntity } from './../../core/entities/internal'; import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from './../../core/decorators/entity'; import { TimeSlot } from './time-slot.entity'; @@ -14,17 +10,28 @@ import { MikroOrmTimeSlotMinuteRepository } from './repository/mikro-orm-time-sl @MultiORMEntity('time_slot_minute', { mikroOrmRepository: () => MikroOrmTimeSlotMinuteRepository }) @Unique(['timeSlotId', 'datetime']) export class TimeSlotMinute extends TenantOrganizationBaseEntity implements ITimeSlotMinute { - + /** + * The number of keyboard interactions in the given time slot minute. + * Defaults to 0 if not provided. + */ @ApiProperty({ type: () => Number }) @IsNumber() @MultiORMColumn({ default: 0 }) keyboard?: number; + /** + * The number of mouse interactions in the given time slot minute. + * Defaults to 0 if not provided. + */ @ApiProperty({ type: () => Number }) @IsNumber() @MultiORMColumn({ default: 0 }) mouse?: number; + /** + * The specific datetime for this time slot minute. + * It records the exact minute in which the activity was tracked. + */ @ApiProperty({ type: () => 'timestamptz' }) @IsDateString() @MultiORMColumn() @@ -32,21 +39,28 @@ export class TimeSlotMinute extends TenantOrganizationBaseEntity implements ITim /* |-------------------------------------------------------------------------- - | @ManyToOne + | @ManyToOne Relationship |-------------------------------------------------------------------------- */ - - @ApiProperty({ type: () => TimeSlot }) + /** + * The reference to the `TimeSlot` entity to which this minute belongs. + * This establishes a many-to-one relationship with the `TimeSlot` entity. + * The deletion of a `TimeSlot` cascades down to its `TimeSlotMinute` records. + */ @MultiORMManyToOne(() => TimeSlot, (it) => it.timeSlotMinutes, { onDelete: 'CASCADE' }) @JoinColumn() timeSlot?: ITimeSlot; - @ApiProperty({ type: () => String, readOnly: true }) + /** + * The ID of the related `TimeSlot` entity, stored as a UUID. + * This is a relation ID that helps link the minute to the corresponding `TimeSlot`. + */ + @ApiProperty({ type: () => String }) @RelationId((it: TimeSlotMinute) => it.timeSlot) - @IsString() + @IsUUID() @ColumnIndex() @MultiORMColumn({ relationId: true }) - readonly timeSlotId?: string; + timeSlotId?: ID; } diff --git a/packages/core/src/time-tracking/time-slot/time-slot.controller.ts b/packages/core/src/time-tracking/time-slot/time-slot.controller.ts index 83b9b7f0f9b..ca283238efc 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.controller.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.controller.ts @@ -1,92 +1,94 @@ -import { - Controller, - UseGuards, - Get, - Query, - HttpStatus, - Delete, - Param, - Post, - Body, - Put, - ValidationPipe, - UsePipes -} from '@nestjs/common'; +import { Controller, UseGuards, Get, Query, HttpStatus, Delete, Param, Post, Body, Put } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CommandBus } from '@nestjs/cqrs'; import { DeleteResult, FindOneOptions, UpdateResult } from 'typeorm'; -import { ITimeSlot, PermissionsEnum } from '@gauzy/contracts'; -import { CreateTimeSlotCommand, DeleteTimeSlotCommand, UpdateTimeSlotCommand } from './commands'; -import { TimeSlotService } from './time-slot.service'; -import { TimeSlot } from './time-slot.entity'; +import { ID, ITimeSlot, PermissionsEnum } from '@gauzy/contracts'; +import { Permissions } from './../../shared/decorators'; import { OrganizationPermissionGuard, PermissionGuard, TenantPermissionGuard } from '../../shared/guards'; import { UUIDValidationPipe, UseValidationPipe } from './../../shared/pipes'; -import { Permissions } from './../../shared/decorators'; -import { DeleteTimeSlotDTO } from './dto'; -import { TimeSlotQueryDTO } from './dto/query'; +import { CreateTimeSlotCommand, DeleteTimeSlotCommand, UpdateTimeSlotCommand } from './commands'; +import { TimeSlotService } from './time-slot.service'; +import { DeleteTimeSlotDTO, TimeSlotQueryDTO } from './dto'; @ApiTags('TimeSlot') @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.TIME_TRACKER, PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ALL_ORG_VIEW) @Controller() export class TimeSlotController { - constructor(private readonly timeSlotService: TimeSlotService, private readonly commandBus: CommandBus) {} + constructor(private readonly _timeSlotService: TimeSlotService, private readonly _commandBus: CommandBus) {} /** + * Retrieves all time slots based on the provided query options. + * + * This method accepts query parameters to filter the list of time slots + * and uses the `TimeSlotQueryDTO` for validation and transformation. + * The method calls the `timeSlotService` to fetch the matching time slots. * - * @param options - * @returns + * @param options - Query parameters for filtering the time slots. + * @returns A promise that resolves to an array of time slots matching the specified criteria. */ @ApiOperation({ summary: 'Get Time Slots' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input, The response body may contain clues as to what went wrong' }) - @Get() + @Get('/') @UseValidationPipe({ whitelist: true, transform: true }) async findAll(@Query() options: TimeSlotQueryDTO): Promise { - return await this.timeSlotService.getTimeSlots(options); + return await this._timeSlotService.getTimeSlots(options); } /** + * Retrieves a specific time slot by its ID. + * + * This method accepts a time slot ID as a parameter and query options for + * additional filtering or selecting specific fields. It uses `UUIDValidationPipe` + * to ensure that the provided ID is a valid UUID. The method calls the + * `timeSlotService` to find the time slot by its ID. * - * @param id - * @param options - * @returns + * @param id - The UUID of the time slot to retrieve. + * @param options - Additional query options to refine the search (e.g., relations). + * @returns A promise that resolves to the time slot object if found. */ @ApiOperation({ summary: 'Get Time Slot By Id' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input, The response body may contain clues as to what went wrong' }) - @Get(':id') - async findById( - @Param('id', UUIDValidationPipe) id: ITimeSlot['id'], - @Query() options: FindOneOptions - ): Promise { - return await this.timeSlotService.findOneByIdString(id, options); + @Get('/:id') + async findById(@Param('id', UUIDValidationPipe) id: ID, @Query() options: FindOneOptions): Promise { + return await this._timeSlotService.findOneByIdString(id, options); } /** + * Handles the creation of a new time slot based on the provided request data. + * This method is called via an HTTP POST request and invokes the `CreateTimeSlotCommand` + * to execute the time slot creation logic. * - * @param entity - * @returns + * @param {ITimeSlot} request - The time slot data provided in the request body. + * @returns {Promise} - A promise that resolves to the created TimeSlot instance. */ @ApiOperation({ summary: 'Create Time Slot' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Invalid input, The response body may contain clues as to what went wrong' }) - @Post() - async create(@Body() requst: ITimeSlot): Promise { - return await this.commandBus.execute(new CreateTimeSlotCommand(requst)); + @Post('/') + async create(@Body() request: ITimeSlot): Promise { + return await this._commandBus.execute(new CreateTimeSlotCommand(request)); } /** + * Updates a specific time slot by its ID. * - * @param id - * @param entity - * @returns + * This method allows modifying the details of a time slot using its unique ID. + * It accepts a time slot ID as a parameter and the updated time slot data as the + * request body. The method is guarded by `OrganizationPermissionGuard` to ensure + * only authorized users with the `ALLOW_MODIFY_TIME` permission can perform updates. + * + * @param id - The UUID of the time slot to update. + * @param request - The updated time slot data to apply. + * @returns A promise that resolves to the updated time slot. */ @ApiOperation({ summary: 'Update Time Slot' }) @ApiResponse({ @@ -95,15 +97,20 @@ export class TimeSlotController { }) @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_MODIFY_TIME) - @Put(':id') - async update(@Param('id', UUIDValidationPipe) id: ITimeSlot['id'], @Body() request: TimeSlot): Promise { - return await this.commandBus.execute(new UpdateTimeSlotCommand(id, request)); + @Put('/:id') + async update(@Param('id', UUIDValidationPipe) id: ID, @Body() request: ITimeSlot): Promise { + return await this._commandBus.execute(new UpdateTimeSlotCommand(id, request)); } /** + * Deletes time slots based on the provided query parameters. + * + * This method allows deleting multiple time slots by accepting a list of time slot IDs + * in the query parameters. The method is protected by `OrganizationPermissionGuard` + * to ensure that only authorized users with the `ALLOW_DELETE_TIME` permission can delete time slots. * - * @param query - * @returns + * @param query - The DTO containing the IDs of the time slots to delete. + * @returns A promise that resolves to either a `DeleteResult` or `UpdateResult` indicating the outcome of the deletion process. */ @ApiOperation({ summary: 'Delete TimeSlot' }) @ApiResponse({ @@ -116,9 +123,9 @@ export class TimeSlotController { }) @UseGuards(OrganizationPermissionGuard) @Permissions(PermissionsEnum.ALLOW_DELETE_TIME) - @Delete() + @Delete('/') @UseValidationPipe({ transform: true }) - async deleteTimeSlot(@Query() query: DeleteTimeSlotDTO): Promise { - return await this.commandBus.execute(new DeleteTimeSlotCommand(query)); + async deleteTimeSlot(@Query() options: DeleteTimeSlotDTO): Promise { + return await this._commandBus.execute(new DeleteTimeSlotCommand(options)); } } diff --git a/packages/core/src/time-tracking/time-slot/time-slot.entity.ts b/packages/core/src/time-tracking/time-slot/time-slot.entity.ts index a229b3e85dc..3c2ad2d8259 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.entity.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.entity.ts @@ -1,33 +1,22 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { - RelationId, - JoinTable -} from 'typeorm'; +import { RelationId, JoinTable } from 'typeorm'; import { IsNumber, IsDateString, IsUUID, IsNotEmpty, IsOptional } from 'class-validator'; -import { - ITimeSlot, - ITimeSlotMinute, - IActivity, - IScreenshot, - IEmployee, - ITimeLog, - ID -} from '@gauzy/contracts'; -import { - Activity, - Employee, - Screenshot, - TenantOrganizationBaseEntity, - TimeLog -} from './../../core/entities/internal'; +import { ITimeSlot, ITimeSlotMinute, IActivity, IScreenshot, IEmployee, ITimeLog, ID } from '@gauzy/contracts'; +import { Activity, Employee, Screenshot, TenantOrganizationBaseEntity, TimeLog } from './../../core/entities/internal'; import { TimeSlotMinute } from './time-slot-minute.entity'; -import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToMany, MultiORMManyToOne, MultiORMOneToMany, VirtualMultiOrmColumn } from './../../core/decorators/entity'; +import { + ColumnIndex, + MultiORMColumn, + MultiORMEntity, + MultiORMManyToMany, + MultiORMManyToOne, + MultiORMOneToMany, + VirtualMultiOrmColumn +} from './../../core/decorators/entity'; import { MikroOrmTimeSlotRepository } from './repository/mikro-orm-time-slot.repository'; @MultiORMEntity('time_slot', { mikroOrmRepository: () => MikroOrmTimeSlotRepository }) -export class TimeSlot extends TenantOrganizationBaseEntity - implements ITimeSlot { - +export class TimeSlot extends TenantOrganizationBaseEntity implements ITimeSlot { @ApiPropertyOptional({ type: () => Number, default: 0 }) @IsOptional() @IsNumber() @@ -107,7 +96,9 @@ export class TimeSlot extends TenantOrganizationBaseEntity /** * Screenshot */ - @MultiORMOneToMany(() => Screenshot, (it) => it.timeSlot) + @MultiORMOneToMany(() => Screenshot, (it) => it.timeSlot, { + cascade: true + }) screenshots?: IScreenshot[]; /** diff --git a/packages/core/src/time-tracking/time-slot/time-slot.module.ts b/packages/core/src/time-tracking/time-slot/time-slot.module.ts index 48738d925b4..2e848d2d0af 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.module.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.module.ts @@ -12,6 +12,7 @@ import { TimeLogModule } from './../time-log/time-log.module'; import { EmployeeModule } from './../../employee/employee.module'; import { ActivityModule } from './../activity/activity.module'; import { TypeOrmTimeSlotRepository } from './repository/type-orm-time-slot.repository'; +import { TypeOrmTimeSlotMinuteRepository } from './repository/type-orm-time-slot-minute.repository'; @Module({ controllers: [TimeSlotController], @@ -24,7 +25,13 @@ import { TypeOrmTimeSlotRepository } from './repository/type-orm-time-slot.repos forwardRef(() => ActivityModule), CqrsModule ], - providers: [TimeSlotService, TypeOrmTimeSlotRepository, ...CommandHandlers], - exports: [TypeOrmModule, MikroOrmModule, TimeSlotService, TypeOrmTimeSlotRepository] + providers: [TimeSlotService, TypeOrmTimeSlotRepository, TypeOrmTimeSlotMinuteRepository, ...CommandHandlers], + exports: [ + TypeOrmModule, + MikroOrmModule, + TimeSlotService, + TypeOrmTimeSlotRepository, + TypeOrmTimeSlotMinuteRepository + ] }) export class TimeSlotModule {} diff --git a/packages/core/src/time-tracking/time-slot/time-slot.seed.ts b/packages/core/src/time-tracking/time-slot/time-slot.seed.ts index 2d2c01faa21..430ba08ead2 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.seed.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.seed.ts @@ -1,26 +1,35 @@ import { faker } from '@faker-js/faker'; -import { TimeSlot } from './time-slot.entity'; import { generateTimeSlots } from './utils'; +import { TimeSlot } from './time-slot.entity'; -export function createTimeSlots(start, end) { - const timeSlots: TimeSlot[] = generateTimeSlots(start, end).map( - ({ duration, startedAt, stoppedAt }) => { - const keyboard = faker.number.int(duration); - const mouse = faker.number.int(duration); - const overall = (keyboard + mouse) / 2; +/** + * Generates an array of time slots between the provided start and end times. + * + * This function generates time slots using the `generateTimeSlots` function, + * which creates slot data with a duration, start time, and end time. For each + * time slot, the function randomly generates keyboard and mouse activity + * using Faker, calculates the overall activity, and constructs a `TimeSlot` object. + * + * @param start - The starting time of the time slots (as a Date object). + * @param end - The ending time of the time slots (as a Date object). + * @returns An array of `TimeSlot` objects containing the generated time slots. + */ +export function createTimeSlots(start: Date, end: Date): TimeSlot[] { + return generateTimeSlots(start, end).map(({ duration, startedAt, stoppedAt }) => { + const keyboard = faker.number.int(duration); // Randomly generate keyboard activity based on duration + const mouse = faker.number.int(duration); // Randomly generate mouse activity based on duration + const overall = Math.ceil((keyboard + mouse) / 2); // Calculate the average activity - const slot = new TimeSlot(); - slot.startedAt = startedAt; - slot.stoppedAt = stoppedAt; - slot.duration = duration; - slot.screenshots = []; - slot.timeSlotMinutes = []; - slot.keyboard = keyboard; - slot.mouse = mouse; - slot.overall = Math.ceil(overall); - return slot; - } - ); + const slot = new TimeSlot(); + slot.startedAt = startedAt; // Set the start time of the time slot + slot.stoppedAt = stoppedAt; // Set the end time of the time slot + slot.duration = duration; // Set the duration of the time slot + slot.screenshots = []; // Initialize an empty array for screenshots + slot.timeSlotMinutes = []; // Initialize an empty array for time slot minutes + slot.keyboard = keyboard; // Set the keyboard activity + slot.mouse = mouse; // Set the mouse activity + slot.overall = overall; // Set the overall activity (rounded) - return timeSlots; + return slot; // Return the constructed TimeSlot object + }); } diff --git a/packages/core/src/time-tracking/time-slot/time-slot.service.ts b/packages/core/src/time-tracking/time-slot/time-slot.service.ts index f045ce3edf7..b6c4a49db3f 100644 --- a/packages/core/src/time-tracking/time-slot/time-slot.service.ts +++ b/packages/core/src/time-tracking/time-slot/time-slot.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; -import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm'; -import { PermissionsEnum, IGetTimeSlotInput,ID, ITimeSlot } from '@gauzy/contracts'; +import { SelectQueryBuilder } from 'typeorm'; +import { PermissionsEnum, IGetTimeSlotInput, ID, ITimeSlot } from '@gauzy/contracts'; import { isEmpty, isNotEmpty } from '@gauzy/common'; import { TenantAwareCrudService } from './../../core/crud'; import { moment } from '../../core/moment-extend'; @@ -25,48 +25,62 @@ export class TimeSlotService extends TenantAwareCrudService { constructor( readonly typeOrmTimeSlotRepository: TypeOrmTimeSlotRepository, readonly mikroOrmTimeSlotRepository: MikroOrmTimeSlotRepository, - private readonly commandBus: CommandBus + private readonly _commandBus: CommandBus ) { super(typeOrmTimeSlotRepository, mikroOrmTimeSlotRepository); } /** * Retrieves time slots based on the provided input parameters. + * * @param request - Input parameters for querying time slots. * @returns A list of time slots matching the specified criteria. */ async getTimeSlots(request: IGetTimeSlotInput) { - // Extract parameters from the request object - const { organizationId, startDate, endDate, syncSlots = false } = request; - let { employeeIds = [] } = request; - - const tenantId = RequestContext.currentTenantId(); - const user = RequestContext.currentUser(); - - // Calculate start and end dates using a utility function - const { start, end } = getDateRangeFormat( - moment.utc(startDate || moment().startOf('day')), - moment.utc(endDate || moment().endOf('day')) - ); + // Extract parameters from the request object with default values + let { + organizationId, + startDate, + endDate, + syncSlots = false, + employeeIds = [], + projectIds = [], + activityLevel, + source, + logType, + onlyMe: isOnlyMeSelected // Indicates whether to retrieve data for the current user only + } = request; + + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the tenant ID from the request context or the provided input + const user = RequestContext.currentUser(); // Retrieve the current user from the request context // Check if the current user has the permission to change the selected employee const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( PermissionsEnum.CHANGE_SELECTED_EMPLOYEE ); - // Determine if the request specifies to retrieve data for the current user only - const isOnlyMeSelected: boolean = request.onlyMe; - // Set employeeIds based on permissions and request - if ((user.employeeId && isOnlyMeSelected) || (!hasChangeSelectedEmployeePermission && user.employeeId)) { + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { employeeIds = [user.employeeId]; } + // Calculate start and end dates using a utility function + const { start, end } = getDateRangeFormat( + moment.utc(startDate || moment().startOf('day')), + moment.utc(endDate || moment().endOf('day')) + ); + // Create a query builder for the TimeSlot entity const query = this.typeOrmRepository.createQueryBuilder('time_slot'); - query.leftJoin(`${query.alias}.employee`, 'employee'); + query.leftJoin( + `${query.alias}.employee`, + 'employee', + `"employee"."tenantId" = :tenantId AND "employee"."organizationId" = :organizationId`, + { tenantId, organizationId } + ); query.innerJoin(`${query.alias}.timeLogs`, 'time_log'); + // Set find options for the query query.setFindOptions({ // Define selected fields for the result select: { @@ -84,115 +98,97 @@ export class TimeSlotService extends TenantAwareCrudService { } } }, - relations: [...(request.relations ? request.relations : [])] + // Spread relations if provided, otherwise an empty array + relations: request.relations || [] }); + + // Add where conditions to the query query.where((qb: SelectQueryBuilder) => { + // Filter by time range for both time_slot and time_log + qb.andWhere(p(`"${qb.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { + startDate: start, + endDate: end + }); + + // If syncSlots is true, filter by time_log.startedAt + if (isEmpty(syncSlots)) { + qb.andWhere(p(`"time_log"."startedAt" BETWEEN :startDate AND :endDate`), { + startDate: start, + endDate: end + }); + } + + // Filter by employeeIds and projectIds if provided + if (isNotEmpty(employeeIds)) { + qb.andWhere(p(`"${qb.alias}"."employeeId" IN (:...employeeIds)`), { employeeIds }); + qb.andWhere(p(`"time_log"."employeeId" IN (:...employeeIds)`), { employeeIds }); + } + if (isNotEmpty(projectIds)) { + qb.andWhere(p(`"time_log"."projectId" IN (:...projectIds)`), { projectIds }); + } + + // Filter by activity level if provided + if (isNotEmpty(activityLevel)) { + /** + * Activity Level should be 0-100% + * Convert it into a 10-minute time slot by multiplying by 6 + */ + // Filters records based on the overall column, representing the activity level. + qb.andWhere(p(`"${qb.alias}"."overall" BETWEEN :start AND :end`), { + start: activityLevel.start * 6, + end: activityLevel.end * 6 + }); + } + + // Filters records based on the source column. + if (isNotEmpty(source)) { + const whereClause = + source instanceof Array + ? p(`"time_log"."source" IN (:...source)`) + : p(`"time_log"."source" = :source`); + + qb.andWhere(whereClause, { source }); + } + + // Filter by logType if provided + if (isNotEmpty(logType)) { + const whereClause = + logType instanceof Array + ? p(`"time_log"."logType" IN (:...logType)`) + : p(`"time_log"."logType" = :logType`); + + qb.andWhere(whereClause, { logType }); + } + + // Filter by tenantId and organizationId for both time_slot and time_log in a single AND condition qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."startedAt" BETWEEN :startDate AND :endDate`), { - startDate: start, - endDate: end - }); - if (isEmpty(syncSlots)) { - web.andWhere(p(`"time_log"."startedAt" BETWEEN :startDate AND :endDate`), { - startDate: start, - endDate: end - }); - } - }) - ); - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - if (isNotEmpty(employeeIds)) { - web.andWhere(p(`"${qb.alias}"."employeeId" IN (:...employeeIds)`), { - employeeIds - }); - web.andWhere(p(`"time_log"."employeeId" IN (:...employeeIds)`), { - employeeIds - }); - } - if (isNotEmpty(request.projectIds)) { - const { projectIds } = request; - web.andWhere(p('"time_log"."projectId" IN (:...projectIds)'), { - projectIds - }); - } - }) + `"${qb.alias}"."tenantId" = :tenantId AND "${qb.alias}"."organizationId" = :organizationId AND + "time_log"."tenantId" = :tenantId AND "time_log"."organizationId" = :organizationId`, + { tenantId, organizationId } ); - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - // Filters records based on the overall column, representing the activity level. - if (isNotEmpty(request.activityLevel)) { - /** - * Activity Level should be 0-100% - * Convert it into a 10-minute time slot by multiplying by 6 - */ - const { activityLevel } = request; - - web.andWhere(p(`"${qb.alias}"."overall" BETWEEN :start AND :end`), { - start: activityLevel.start * 6, - end: activityLevel.end * 6 - }); - } - - // Filters records based on the source column. - if (isNotEmpty(request.source)) { - const { source } = request; - - const condition = - source instanceof Array - ? p(`"time_log"."source" IN (:...source)`) - : p(`"time_log"."source" = :source`); - web.andWhere(condition, { source }); - } - // Filters records based on the logType column. - if (isNotEmpty(request.logType)) { - const { logType } = request; - const condition = - logType instanceof Array - ? p(`"time_log"."logType" IN (:...logType)`) - : p(`"time_log"."logType" = :logType`); - - web.andWhere(condition, { logType }); - } - }) - ); - // Additional conditions for filtering by tenantId and organizationId - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"time_log"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"time_log"."organizationId" = :organizationId`), { organizationId }); - }) - ); - // Additional conditions for filtering by tenantId and organizationId - qb.andWhere( - new Brackets((web: WhereExpressionBuilder) => { - web.andWhere(p(`"${qb.alias}"."tenantId" = :tenantId`), { tenantId }); - web.andWhere(p(`"${qb.alias}"."organizationId" = :organizationId`), { organizationId }); - }) - ); - qb.addOrderBy(p(`"${qb.alias}"."createdAt"`), 'ASC'); + // Sort by createdAt + qb.addOrderBy(`"${qb.alias}"."createdAt"`, 'ASC'); }); + const slots = await query.getMany(); return slots; } /** + * Bulk creates or updates time slots for a given employee within an organization. * - * @param slots - * @param employeeId - * @param organizationId - * @returns + * This method will either create new time slots or update existing ones based on + * the provided slots, employeeId, and organizationId. The actual logic for bulk + * creation or updating is delegated to a command handler (`TimeSlotBulkCreateOrUpdateCommand`). + * + * @param slots - An array of time slots to be created or updated. + * @param employeeId - The ID of the employee for whom the time slots belong. + * @param organizationId - The ID of the organization associated with the time slots. + * @returns A promise that resolves when the command is executed, performing bulk creation or update. */ - async bulkCreateOrUpdate( - slots: ITimeSlot[], - employeeId: ID, - organizationId: ID - ) { - return await this.commandBus.execute( - new TimeSlotBulkCreateOrUpdateCommand(slots, employeeId, organizationId) - ); + async bulkCreateOrUpdate(slots: ITimeSlot[], employeeId: ID, organizationId: ID) { + return await this._commandBus.execute(new TimeSlotBulkCreateOrUpdateCommand(slots, employeeId, organizationId)); } /** @@ -203,14 +199,8 @@ export class TimeSlotService extends TenantAwareCrudService { * @param organizationId The ID of the organization * @returns The result of the bulk creation command */ - async bulkCreate( - slots: ITimeSlot[], - employeeId: ID, - organizationId: ID - ) { - return await this.commandBus.execute( - new TimeSlotBulkCreateCommand(slots, employeeId, organizationId) - ); + async bulkCreate(slots: ITimeSlot[], employeeId: ID, organizationId: ID): Promise { + return await this._commandBus.execute(new TimeSlotBulkCreateCommand(slots, employeeId, organizationId)); } /** @@ -228,17 +218,13 @@ export class TimeSlotService extends TenantAwareCrudService { */ async createTimeSlotMinute(request: TimeSlotMinute) { // const { keyboard, mouse, datetime, timeSlot } = request; - return await this.commandBus.execute( - new CreateTimeSlotMinutesCommand(request) - ); + return await this._commandBus.execute(new CreateTimeSlotMinutesCommand(request)); } /* * Update TimeSlot minute activity for specific TimeSlot */ - async updateTimeSlotMinute(id: string, request: TimeSlotMinute) { - return await this.commandBus.execute( - new UpdateTimeSlotMinutesCommand(id, request) - ); + async updateTimeSlotMinute(id: ID, request: TimeSlotMinute) { + return await this._commandBus.execute(new UpdateTimeSlotMinutesCommand(id, request)); } } diff --git a/packages/core/src/time-tracking/timer/timer.service.ts b/packages/core/src/time-tracking/timer/timer.service.ts index 22cd9275d6d..dd0d0ec0a44 100644 --- a/packages/core/src/time-tracking/timer/timer.service.ts +++ b/packages/core/src/time-tracking/timer/timer.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ForbiddenException, NotAcceptableExcepti import { CommandBus } from '@nestjs/cqrs'; import { IsNull, Between, Not, In } from 'typeorm'; import * as moment from 'moment'; +import * as chalk from 'chalk'; import { TimeLogType, ITimerStatus, @@ -199,9 +200,6 @@ export class TimerService { JSON.stringify(request) ); - // Retrieve the tenant ID from the current context or the provided one in the request - const tenantId = RequestContext.currentTenantId() || request.tenantId; - // Destructure the necessary parameters from the request const { source, @@ -215,9 +213,12 @@ export class TimerService { version } = request; + // Retrieve the tenant ID from the current context or the provided one in the request + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; + // Determine the start date and time in UTC const startedAt = moment.utc(request.startedAt ?? moment.utc()).toDate(); - console.log('timer start date', startedAt); + console.log(chalk.green('new timer started at:'), startedAt); // Retrieve the employee information const employee = await this.findEmployee(); @@ -230,18 +231,8 @@ export class TimerService { // Get the employee ID const { id: employeeId, organizationId } = employee; - try { - // Retrieve any existing running logs for the employee - const logs = await this.getLastRunningLogs(); - console.log('Last Running Logs Count:', logs.length); - - // If there are existing running logs, stop them before starting a new one - if (logs.length > 0) { - await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); - } - } catch (error) { - console.error('Error while getting last running logs', error); - } + // Stop any previous running timers + await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); // Create a new time log entry using the command bus const timeLog = await this._commandBus.execute( @@ -271,6 +262,8 @@ export class TimerService { isTrackingTime: true }); + console.log(chalk.green(`last created time log: ${JSON.stringify(timeLog)}`)); + // Return the newly created time log entry return timeLog; } @@ -283,7 +276,7 @@ export class TimerService { */ async stopTimer(request: ITimerToggleInput): Promise { console.log( - `-------------Stop Timer Request (${moment.utc(request.startedAt).toDate()})-------------`, + `-------------Stop Timer Request (${moment.utc(request.stoppedAt).toDate()})-------------`, JSON.stringify(request) ); @@ -292,66 +285,65 @@ export class TimerService { // Fetch the employee details const employee = await this.findEmployee(); - - // Retrieve the employee ID and organization ID - const { id: employeeId, organizationId } = employee; - // Retrieve tenant ID - const tenantId = RequestContext.currentTenantId() || employee.tenantId || request.tenantId; - // Check if time tracking is enabled for the employee if (!employee.isTrackingEnabled) { throw new ForbiddenException('The time tracking functionality has been disabled for you.'); } - // Determine whether to include time slots in the result - const includeTimeSlots = true; + // Retrieve tenant ID + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; + // Retrieve the employee ID and organization ID + const { id: employeeId, organizationId } = employee; // Retrieve the last running log + const includeTimeSlots = true; let lastLog = await this.getLastRunningLog(includeTimeSlots); - // If no running log is found throw an NotAcceptableException with a message + // If no running log is found, throw a NotAcceptableException if (!lastLog) { - console.log(`No running log found. Can't stop timer because it was already stopped.`); + console.log(chalk.yellow(`No running log found. Can't stop timer because it was already stopped.`)); throw new NotAcceptableException(`No running log found. Can't stop timer because it was already stopped.`); } - // Retrieve stoppedAt date or use current date if not provided - let stoppedAt = await this.calculateStoppedAt(request, lastLog); + // Calculate stoppedAt date or use current date if not provided + const stoppedAt = await this.calculateStoppedAt(request, lastLog); + console.log(chalk.blue(`last stopped at: ${stoppedAt}`)); - // Update the time log entry to mark it as stopped - lastLog = await this._commandBus.execute( - new TimeLogUpdateCommand( - { - stoppedAt, - isRunning: false - }, - lastLog.id, - request.manualTimeSlot - ) - ); + // Log the case where stoppedAt is less than startedAt + if (stoppedAt < lastLog.startedAt) { + console.log( + chalk.yellow( + `stoppedAt (${stoppedAt}) is less than startedAt (${lastLog.startedAt}), skipping stoppedAt update.` + ) + ); + } - try { - // Retrieve any existing running logs for the employee - const logs = await this.getLastRunningLogs(); - console.log('Last Running Logs Count:', logs.length); + // Construct the update payload, conditionally excluding stoppedAt if it shouldn't be updated + const partialTimeLog: Partial = { + isRunning: false, + ...(stoppedAt >= lastLog.startedAt && { stoppedAt }) // Only include stoppedAt if it's valid + }; - // If there are existing running logs, stop them before starting a new one - if (logs.length > 0) { - await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); - } - } catch (error) { - console.error('Error while getting last running logs', error); - } + console.log(chalk.blue(`partial time log: ${JSON.stringify(partialTimeLog)}`)); - // Update the employee's tracking status + // Execute the command to update the time log entry + lastLog = await this._commandBus.execute( + new TimeLogUpdateCommand(partialTimeLog, lastLog.id, request.manualTimeSlot) + ); + + // Update the employee's tracking status to reflect they are now tracking time await this._employeeService.update(employeeId, { isOnline: false, // Employee status (Online/Offline) isTrackingTime: false // Employee time tracking status }); + // Stop previous running timers + await this.stopPreviousRunningTimers(employeeId, organizationId, tenantId); + // Handle conflicting time logs await this.handleConflictingTimeLogs(lastLog, tenantId, organizationId); + // Return the last log return lastLog; } @@ -361,9 +353,18 @@ export class TimerService { * @param lastLog The last running time log entry. * @param tenantId The tenant ID. * @param organizationId The organization ID. + * @param forceDelete Flag indicating whether to force delete the conflicts. */ - private async handleConflictingTimeLogs(lastLog: ITimeLog, tenantId: ID, organizationId: ID): Promise { + private async handleConflictingTimeLogs( + lastLog: ITimeLog, + tenantId: ID, + organizationId: ID, + forceDelete: boolean = false + ): Promise { try { + // Validate the date range and check if the timer is running + validateDateRange(lastLog.startedAt, lastLog.stoppedAt); + // Retrieve conflicting time logs const conflicts = await this._commandBus.execute( new IGetConflictTimeLogCommand({ @@ -385,27 +386,42 @@ export class TimerService { tenantId }); - if (isNotEmpty(conflicts)) { + // Resolve conflicts by deleting conflicting time slots + if (conflicts?.length) { const times: IDateRange = { start: new Date(lastLog.startedAt), end: new Date(lastLog.stoppedAt) }; - // Delete conflicting time slots - await Promise.all( - conflicts.flatMap((timeLog: ITimeLog) => { - const { timeSlots = [] } = timeLog; - return timeSlots.map((timeSlot: ITimeSlot) => - this._commandBus.execute(new DeleteTimeSpanCommand(times, timeLog, timeSlot)) + // Loop through each conflicting time log + for await (const timeLog of conflicts) { + const { timeSlots = [] } = timeLog; + // Delete conflicting time slots + for await (const timeSlot of timeSlots) { + await this._commandBus.execute( + new DeleteTimeSpanCommand(times, timeLog, timeSlot, forceDelete) ); - }) - ); + } + } } } catch (error) { - console.error('Error while handling conflicts in time logs:', error); + console.warn('Error while handling conflicts in time logs:', error?.message); } } + /** + * Calculates the stoppedAt time for the current time log based on the request and the last running time log. + * It adjusts the stoppedAt time based on various conditions, such as the time log source (e.g., DESKTOP) and time slots. + * + * - If the source is DESKTOP and the last time slot was created more than 10 minutes ago, + * the stoppedAt time is adjusted based on the last time slot's duration. + * - If no time slots exist and the last log's startedAt time exceeds 10 minutes from the current time, + * the stoppedAt time is adjusted by 10 seconds from the startedAt time. + * + * @param {ITimerToggleInput} request - The input data for stopping the timer, including stoppedAt and source. + * @param {ITimeLog} lastLog - The last running time log, which may include time slots for more detailed tracking. + * @returns {Promise} - A promise that resolves to the calculated stoppedAt date, adjusted as necessary. + */ async calculateStoppedAt(request: ITimerToggleInput, lastLog: ITimeLog): Promise { // Retrieve stoppedAt date or default to the current date if not provided let stoppedAt = moment.utc(request.stoppedAt ?? moment.utc()).toDate(); @@ -555,11 +571,20 @@ export class TimerService { */ async stopPreviousRunningTimers(employeeId: ID, organizationId: ID, tenantId: ID): Promise { try { - // Execute the ScheduleTimeLogEntriesCommand to stop all previous running timers - await this._commandBus.execute(new ScheduleTimeLogEntriesCommand(employeeId, organizationId, tenantId)); + console.log(chalk.green('Start previous running timers...')); + // Retrieve any existing running logs for the employee + const logs = await this.getLastRunningLogs(); + console.log(chalk.blue('Last Running Logs Count:'), logs.length); + + // If there are existing running logs, stop them before starting a new one + if (logs.length > 0) { + // Execute the ScheduleTimeLogEntriesCommand to stop all previous running timers + await this._commandBus.execute(new ScheduleTimeLogEntriesCommand(employeeId, organizationId, tenantId)); + } + console.log(chalk.green('Stop previous running timers...')); } catch (error) { // Log the error or handle it appropriately - console.log('Failed to stop previous running timers:', error); + console.log(chalk.red('Failed to stop previous running timers:'), error?.message); } } @@ -655,7 +680,7 @@ export class TimerService { * @returns The timer status for the employee. */ public async getTimerWorkedStatus(request: ITimerStatusInput): Promise { - const tenantId = RequestContext.currentTenantId() || request.tenantId; + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; const { organizationId, organizationTeamId, source } = request; // Define the array to store employeeIds @@ -665,14 +690,11 @@ export class TimerService { // Check if the current user has any of the specified permissions if (RequestContext.hasAnyPermission(permissions)) { - // If yes, set employeeIds based on request.employeeIds or request.employeeId - employeeIds = request.employeeIds - ? request.employeeIds.filter(Boolean) - : [request.employeeId].filter(Boolean); + // Set employeeIds based on request.employeeIds or request.employeeId + employeeIds = (request.employeeIds ?? [request.employeeId]).filter(Boolean); } else { // EMPLOYEE have the ability to see only their own timer status - const employeeId = RequestContext.currentEmployeeId(); - employeeIds = [employeeId]; + employeeIds = [RequestContext.currentEmployeeId()]; } let lastLogs: TimeLog[] = []; diff --git a/packages/core/src/time-tracking/timesheet/commands/timesheet-recalculate.command.ts b/packages/core/src/time-tracking/timesheet/commands/timesheet-recalculate.command.ts index e81d6d9e3d4..127b1e1ddfb 100644 --- a/packages/core/src/time-tracking/timesheet/commands/timesheet-recalculate.command.ts +++ b/packages/core/src/time-tracking/timesheet/commands/timesheet-recalculate.command.ts @@ -1,10 +1,8 @@ import { ICommand } from '@nestjs/cqrs'; -import { ITimesheet } from '@gauzy/contracts'; +import { ID } from '@gauzy/contracts'; export class TimesheetRecalculateCommand implements ICommand { static readonly type = '[Timesheet] Recalculate'; - constructor( - public readonly id: ITimesheet['id'] - ) { } + constructor(public readonly id: ID) {} } diff --git a/packages/core/src/time-tracking/timesheet/dto/query/submit-timesheet-status.dto.ts b/packages/core/src/time-tracking/timesheet/dto/query/submit-timesheet-status.dto.ts index 3031712d487..526389a5c3a 100644 --- a/packages/core/src/time-tracking/timesheet/dto/query/submit-timesheet-status.dto.ts +++ b/packages/core/src/time-tracking/timesheet/dto/query/submit-timesheet-status.dto.ts @@ -1,19 +1,21 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { ArrayNotEmpty, IsNotEmpty } from "class-validator"; -import { ISubmitTimesheetInput } from "@gauzy/contracts"; -import { TenantOrganizationBaseDTO } from "../../../../core/dto"; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsEnum, IsNotEmpty } from 'class-validator'; +import { ID, ISubmitTimesheetInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from '../../../../core/dto'; /** * Submit timesheets status request DTO validation */ -export class SubmitTimesheetStatusDTO extends TenantOrganizationBaseDTO - implements ISubmitTimesheetInput { +export class SubmitTimesheetStatusDTO extends TenantOrganizationBaseDTO implements ISubmitTimesheetInput { + @ApiProperty({ description: 'Array of timesheet IDs to submit/unsubmit' }) + @ArrayNotEmpty({ message: 'At least one timesheet ID must be provided' }) + readonly ids: ID[]; - @ApiProperty({ type: () => Array, readOnly: true }) - @ArrayNotEmpty() - readonly ids: string[] = []; - - @ApiProperty({ type: () => String, readOnly: true }) - @IsNotEmpty() + @ApiProperty({ + enum: ['submit', 'unsubmit'], + description: 'Status to set on the timesheet (either "submit" or "unsubmit")' + }) + @IsEnum(['submit', 'unsubmit'], { message: 'Status must be either "submit" or "unsubmit"' }) + @IsNotEmpty({ message: 'Status must not be empty' }) readonly status: 'submit' | 'unsubmit' = 'submit'; -} \ No newline at end of file +} diff --git a/packages/core/src/time-tracking/timesheet/dto/query/update-timesheet-status.dto.ts b/packages/core/src/time-tracking/timesheet/dto/query/update-timesheet-status.dto.ts index adfb7efb301..9d465c63970 100644 --- a/packages/core/src/time-tracking/timesheet/dto/query/update-timesheet-status.dto.ts +++ b/packages/core/src/time-tracking/timesheet/dto/query/update-timesheet-status.dto.ts @@ -1,19 +1,17 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { ArrayNotEmpty, IsEnum } from "class-validator"; -import { IUpdateTimesheetStatusInput, TimesheetStatus } from "@gauzy/contracts"; -import { TenantOrganizationBaseDTO } from "./../../../../core/dto"; +import { ApiProperty, IntersectionType, PickType } from '@nestjs/swagger'; +import { ArrayNotEmpty } from 'class-validator'; +import { ID, IUpdateTimesheetStatusInput } from '@gauzy/contracts'; +import { TenantOrganizationBaseDTO } from './../../../../core/dto'; +import { Timesheet } from '../../timesheet.entity'; /** * Update timesheets status request DTO validation */ -export class UpdateTimesheetStatusDTO extends TenantOrganizationBaseDTO - implements IUpdateTimesheetStatusInput { - - @ApiProperty({ type: () => Array }) +export class UpdateTimesheetStatusDTO + extends IntersectionType(TenantOrganizationBaseDTO, PickType(Timesheet, ['status'] as const)) + implements IUpdateTimesheetStatusInput +{ + @ApiProperty({ type: () => Array }) @ArrayNotEmpty() - readonly ids: string[] = []; - - @ApiProperty({ enum: TimesheetStatus }) - @IsEnum(TimesheetStatus) - readonly status: TimesheetStatus; -} \ No newline at end of file + ids: ID[] = []; +} diff --git a/packages/core/src/time-tracking/timesheet/timesheet.controller.ts b/packages/core/src/time-tracking/timesheet/timesheet.controller.ts index 39fd3501032..8ce2f746245 100644 --- a/packages/core/src/time-tracking/timesheet/timesheet.controller.ts +++ b/packages/core/src/time-tracking/timesheet/timesheet.controller.ts @@ -1,7 +1,7 @@ -import { Controller, UseGuards, Put, HttpStatus, Body, Get, Query, Param, BadRequestException } from '@nestjs/common'; +import { Controller, UseGuards, Put, HttpStatus, Body, Get, Query, Param, HttpException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CommandBus } from '@nestjs/cqrs'; -import { ITimesheet, PermissionsEnum } from '@gauzy/contracts'; +import { ID, ITimesheet, PermissionsEnum } from '@gauzy/contracts'; import { TimeSheetService } from './timesheet.service'; import { PermissionGuard, TenantPermissionGuard } from './../../shared/guards'; import { UUIDValidationPipe, UseValidationPipe } from './../../shared/pipes'; @@ -14,38 +14,44 @@ import { TimesheetSubmitCommand, TimesheetUpdateStatusCommand } from './commands @Permissions(PermissionsEnum.CAN_APPROVE_TIMESHEET) @Controller() export class TimeSheetController { - constructor(private readonly commandBus: CommandBus, private readonly timeSheetService: TimeSheetService) {} + constructor(private readonly _commandBus: CommandBus, private readonly _timeSheetService: TimeSheetService) {} /** - * GET timesheet counts in the same tenant + * GET timesheet counts for the same tenant + * This method retrieves the count of timesheets for a tenant, filtered by the provided query options. * - * @param options - * @returns + * @param options - The query parameters for filtering timesheets, such as tenant ID, date range, employee ID, etc. + * @returns Promise - The count of timesheets matching the provided filters. + * @throws HttpException - If an error occurs during query execution, it returns an HTTP 400 error with an error message. */ - @ApiOperation({ summary: 'Get timesheet Count' }) + @ApiOperation({ summary: 'Get timesheet count' }) @ApiResponse({ status: HttpStatus.OK, - description: 'Get timesheet Count' + description: 'Timesheet count successfully retrieved' }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, - description: 'Invalid input, The response body may contain clues as to what went wrong' + description: 'Invalid input, check the response body for more details' }) @Get('/count') @UseValidationPipe({ whitelist: true }) async getTimesheetCount(@Query() options: TimesheetQueryDTO): Promise { try { - return await this.timeSheetService.getTimeSheetCount(options); + // Return the timesheet count directly + return await this._timeSheetService.getTimeSheetCount(options); } catch (error) { - throw new BadRequestException(error); + // Handle errors and throw an appropriate error response + throw new HttpException(`Error retrieving timesheet count: ${error.message}`, HttpStatus.BAD_REQUEST); } } /** * UPDATE timesheet status + * This method updates the status of a timesheet based on the data provided in the DTO. * - * @param entity - * @returns + * @param entity - The DTO containing the updated status for the timesheet. + * @returns Promise - The updated list of timesheets after applying the status changes. + * @throws HttpException - If an error occurs during status update, it throws an HTTP 400 error. */ @ApiOperation({ summary: 'Update timesheet' }) @ApiResponse({ @@ -59,14 +65,16 @@ export class TimeSheetController { @Put('/status') @UseValidationPipe({ whitelist: true }) async updateTimesheetStatus(@Body() entity: UpdateTimesheetStatusDTO): Promise { - return await this.commandBus.execute(new TimesheetUpdateStatusCommand(entity)); + return await this._commandBus.execute(new TimesheetUpdateStatusCommand(entity)); } /** * UPDATE timesheet submit status + * This method submits a timesheet by updating its submission status. * - * @param entity - * @returns + * @param entity - The DTO containing the submission details for the timesheet. + * @returns Promise - The updated list of timesheets after the submission. + * @throws HttpException - If an error occurs during submission, it throws an HTTP 400 error. */ @ApiOperation({ summary: 'Submit timesheet' }) @ApiResponse({ @@ -80,14 +88,16 @@ export class TimeSheetController { @Put('/submit') @UseValidationPipe({ whitelist: true }) async submitTimeSheet(@Body() entity: SubmitTimesheetStatusDTO): Promise { - return await this.commandBus.execute(new TimesheetSubmitCommand(entity)); + return await this._commandBus.execute(new TimesheetSubmitCommand(entity)); } /** - * GET all timesheet in same tenant + * GET all timesheets in the same tenant + * This method retrieves all timesheets for the same tenant based on the provided query options. * - * @param options - * @returns + * @param options - The query parameters for filtering timesheets, such as tenant ID, date range, employee ID, etc. + * @returns Promise - A list of timesheets matching the provided filters. + * @throws HttpException - If an error occurs during query execution, it throws an HTTP 400 error with an error message. */ @ApiOperation({ summary: 'Get timesheet' }) @ApiResponse({ @@ -102,17 +112,20 @@ export class TimeSheetController { @UseValidationPipe({ whitelist: true }) async get(@Query() options: TimesheetQueryDTO): Promise { try { - return await this.timeSheetService.getTimeSheets(options); + return await this._timeSheetService.getTimeSheets(options); } catch (error) { - throw new BadRequestException(error); + // Handle errors and throw an appropriate error response + throw new HttpException(`Error retrieving timesheets: ${error.message}`, HttpStatus.BAD_REQUEST); } } /** - * Find timesheet by id + * Find timesheet by ID + * This method retrieves a specific timesheet by its unique identifier. * - * @param id - * @returns + * @param id - The UUID of the timesheet to retrieve. + * @returns Promise - The timesheet with the specified ID. + * @throws HttpException - If the timesheet with the specified ID is not found, it throws an HTTP 400 error. */ @ApiOperation({ summary: 'Find timesheet by id' }) @ApiResponse({ @@ -124,11 +137,7 @@ export class TimeSheetController { description: 'Invalid input, The response body may contain clues as to what went wrong' }) @Get('/:id') - async findById(@Param('id', UUIDValidationPipe) id: ITimesheet['id']): Promise { - try { - return await this.timeSheetService.findOneByIdString(id); - } catch (error) { - throw new BadRequestException(error); - } + async findById(@Param('id', UUIDValidationPipe) id: ID): Promise { + return await this._timeSheetService.findOneByIdString(id); } } diff --git a/packages/core/src/time-tracking/timesheet/timesheet.entity.ts b/packages/core/src/time-tracking/timesheet/timesheet.entity.ts index 0ce57c225a4..de81e389f85 100644 --- a/packages/core/src/time-tracking/timesheet/timesheet.entity.ts +++ b/packages/core/src/time-tracking/timesheet/timesheet.entity.ts @@ -1,22 +1,19 @@ -import { - RelationId, - JoinColumn, - AfterLoad -} from 'typeorm'; +import { RelationId, JoinColumn } from 'typeorm'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsDateString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator'; -import { IEmployee, ITimesheet, IUser, TimesheetStatus } from '@gauzy/contracts'; +import { ID, IEmployee, ITimesheet, IUser, TimesheetStatus } from '@gauzy/contracts'; +import { Employee, TenantOrganizationBaseEntity, User } from './../../core/entities/internal'; import { - Employee, - TenantOrganizationBaseEntity, - User -} from './../../core/entities/internal'; -import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne, VirtualMultiOrmColumn } from './../../core/decorators/entity'; + ColumnIndex, + MultiORMColumn, + MultiORMEntity, + MultiORMManyToOne, + VirtualMultiOrmColumn +} from './../../core/decorators/entity'; import { MikroOrmTimesheetRepository } from './repository/mikro-orm-timesheet.repository'; @MultiORMEntity('timesheet', { mikroOrmRepository: () => MikroOrmTimesheetRepository }) export class Timesheet extends TenantOrganizationBaseEntity implements ITimesheet { - @ApiPropertyOptional({ type: () => Number, default: 0 }) @IsOptional() @IsNumber() @@ -90,12 +87,11 @@ export class Timesheet extends TenantOrganizationBaseEntity implements ITimeshee @MultiORMColumn({ default: false }) isBilled?: boolean; - @ApiPropertyOptional({ type: () => String, enum: TimesheetStatus, default: TimesheetStatus.PENDING }) - @IsOptional() + @ApiProperty({ type: () => String, enum: TimesheetStatus, default: TimesheetStatus.PENDING }) @IsEnum(TimesheetStatus) @ColumnIndex() @MultiORMColumn({ default: TimesheetStatus.PENDING }) - status: string; + status: TimesheetStatus; /** Additional virtual columns */ @@ -128,14 +124,14 @@ export class Timesheet extends TenantOrganizationBaseEntity implements ITimeshee @RelationId((it: Timesheet) => it.employee) @ColumnIndex() @MultiORMColumn({ relationId: true }) - employeeId?: IEmployee['id']; + employeeId?: ID; /** * Approve By User */ @MultiORMManyToOne(() => User, { /** Indicates if the relation column value can be nullable or not. */ - nullable: true, + nullable: true }) @JoinColumn() approvedBy?: IUser; @@ -146,19 +142,5 @@ export class Timesheet extends TenantOrganizationBaseEntity implements ITimeshee @RelationId((it: Timesheet) => it.approvedBy) @ColumnIndex() @MultiORMColumn({ nullable: true, relationId: true }) - approvedById?: IUser['id']; - - /** - * Called after entity is loaded. - */ - @AfterLoad() - afterLoadEntity?() { - /** - * Sets the 'isEdited' property based on the presence of 'editedAt'. - * If 'editedAt' is defined, 'isEdited' is set to true; otherwise, it is set to false. - */ - if ('editedAt' in this) { - this.isEdited = !!this.editedAt; - } - } + approvedById?: ID; } diff --git a/packages/core/src/time-tracking/timesheet/timesheet.module.ts b/packages/core/src/time-tracking/timesheet/timesheet.module.ts index 44daa6722a9..8276485e12b 100644 --- a/packages/core/src/time-tracking/timesheet/timesheet.module.ts +++ b/packages/core/src/time-tracking/timesheet/timesheet.module.ts @@ -10,6 +10,7 @@ import { CommandHandlers } from './commands/handlers'; import { TimeSheetController } from './timesheet.controller'; import { TimeSheetService } from './timesheet.service'; import { Timesheet } from './timesheet.entity'; +import { TypeOrmTimesheetRepository } from './repository/type-orm-timesheet.repository'; @Module({ controllers: [TimeSheetController], @@ -22,7 +23,7 @@ import { Timesheet } from './timesheet.entity'; TimeSlotModule, EmployeeModule ], - providers: [TimeSheetService, ...CommandHandlers], - exports: [TimeSheetService, TypeOrmModule, MikroOrmModule] + providers: [TimeSheetService, TypeOrmTimesheetRepository, ...CommandHandlers], + exports: [TypeOrmModule, MikroOrmModule, TimeSheetService, TypeOrmTimesheetRepository] }) export class TimesheetModule {} diff --git a/packages/core/src/time-tracking/timesheet/timesheet.service.ts b/packages/core/src/time-tracking/timesheet/timesheet.service.ts index 9be7c03d07f..e81f7b07c6d 100644 --- a/packages/core/src/time-tracking/timesheet/timesheet.service.ts +++ b/packages/core/src/time-tracking/timesheet/timesheet.service.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { Between, In, SelectQueryBuilder, Brackets, WhereExpressionBuilder } from 'typeorm'; import * as moment from 'moment'; import { IGetTimesheetInput, PermissionsEnum, ITimesheet } from '@gauzy/contracts'; @@ -14,38 +13,42 @@ import { MikroOrmTimesheetRepository } from './repository/mikro-orm-timesheet.re @Injectable() export class TimeSheetService extends TenantAwareCrudService { constructor( - @InjectRepository(Timesheet) typeOrmTimesheetRepository: TypeOrmTimesheetRepository, - mikroOrmTimesheetRepository: MikroOrmTimesheetRepository ) { super(typeOrmTimesheetRepository, mikroOrmTimesheetRepository); } /** - * GET timesheets count in date range for same tenant + * GET timesheets count in date range for the same tenant * * @param request - * @returns + * @returns number - Count of timesheets */ async getTimeSheetCount(request: IGetTimesheetInput): Promise { const query = this.typeOrmRepository.createQueryBuilder('timesheet'); query.innerJoin(`${query.alias}.employee`, 'employee'); + + // Apply filters to the query query.where((query: SelectQueryBuilder) => { this.getFilterTimesheetQuery(query, request); }); - return await query.getCount(); + + // Return the total count of timesheets + return query.getCount(); } /** - * GET timesheets in date range for same tenant + * GET timesheets in date range for the same tenant * * @param request - * @returns + * @returns Promise - List of timesheets */ async getTimeSheets(request: IGetTimesheetInput): Promise { const query = this.typeOrmRepository.createQueryBuilder('timesheet'); query.innerJoin(`${query.alias}.employee`, 'employee'); + + // Set select options and optional relations query.setFindOptions({ select: { employee: { @@ -61,15 +64,15 @@ export class TimeSheetService extends TenantAwareCrudService { brandColor: true } }, - ...(request && request.relations - ? { - relations: request.relations - } - : {}) + ...(request?.relations ? { relations: request.relations } : {}) }); + + // Apply filters to the query query.where((query: SelectQueryBuilder) => { this.getFilterTimesheetQuery(query, request); }); + + // Return the list of timesheets return await query.getMany(); } @@ -81,30 +84,26 @@ export class TimeSheetService extends TenantAwareCrudService { * @returns */ async getFilterTimesheetQuery(qb: SelectQueryBuilder, request: IGetTimesheetInput) { - const { organizationId, startDate, endDate } = request; - let { employeeIds = [] } = request; + let { organizationId, startDate, endDate, onlyMe: isOnlyMeSelected, employeeIds = [] } = request; - const tenantId = RequestContext.currentTenantId() || request.tenantId; - const user = RequestContext.currentUser(); - - // Calculate start and end dates using a utility function - const { start, end } = getDateRangeFormat( - moment.utc(startDate || moment().startOf('month')), // use current start of the month if startDate not found - moment.utc(endDate || moment().endOf('month')) // use current end of the month if endDate not found - ); + const tenantId = RequestContext.currentTenantId() ?? request.tenantId; // Retrieve the tenant ID from the request + const user = RequestContext.currentUser(); // Retrieve the current user // Check if the current user has the permission to change the selected employee const hasChangeSelectedEmployeePermission: boolean = RequestContext.hasPermission( PermissionsEnum.CHANGE_SELECTED_EMPLOYEE ); - // Determine if the request specifies to retrieve data for the current user only - const isOnlyMeSelected: boolean = request.onlyMe; - - if ((user.employeeId && isOnlyMeSelected) || (!hasChangeSelectedEmployeePermission && user.employeeId)) { + if (user.employeeId && (isOnlyMeSelected || !hasChangeSelectedEmployeePermission)) { employeeIds = [user.employeeId]; } + // Calculate start and end dates using a utility function + const { start, end } = getDateRangeFormat( + moment.utc(startDate || moment().startOf('month')), // use current start of the month if startDate not found + moment.utc(endDate || moment().endOf('month')) // use current end of the month if endDate not found + ); + qb.andWhere( new Brackets((qb: WhereExpressionBuilder) => { qb.where({ diff --git a/packages/core/src/time-tracking/timesheet/timesheet.subscriber.ts b/packages/core/src/time-tracking/timesheet/timesheet.subscriber.ts new file mode 100644 index 00000000000..226751dda19 --- /dev/null +++ b/packages/core/src/time-tracking/timesheet/timesheet.subscriber.ts @@ -0,0 +1,29 @@ +import { EventSubscriber } from 'typeorm'; +import { BaseEntityEventSubscriber } from '../../core/entities/subscribers/base-entity-event.subscriber'; +import { Timesheet } from './timesheet.entity'; + +@EventSubscriber() +export class TimesheetSubscriber extends BaseEntityEventSubscriber { + /** + * Indicates that this subscriber only listen to Timesheet events. + */ + listenTo() { + return Timesheet; + } + + /** + * Called after an Timesheet entity is loaded from the database. + * + * @param entity - The loaded Timesheet entity. + * @param event - The LoadEvent associated with the entity loading. + */ + async afterEntityLoad(entity: Timesheet): Promise { + /** + * Sets the 'isEdited' property based on the presence of 'editedAt'. + * If 'editedAt' is defined, 'isEdited' is set to true; otherwise, it is set to false. + */ + if (Object.prototype.hasOwnProperty.call(entity, 'editedAt')) { + entity.isEdited = !!entity.editedAt; + } + } +} diff --git a/packages/core/src/user-organization/user-organization.controller.ts b/packages/core/src/user-organization/user-organization.controller.ts index 10fd012a1e0..99c69ab5a81 100644 --- a/packages/core/src/user-organization/user-organization.controller.ts +++ b/packages/core/src/user-organization/user-organization.controller.ts @@ -95,11 +95,7 @@ export class UserOrganizationController extends CrudController // Attempt to count the user organizations const total = await this.userOrganizationService.count({ - where: { - userId, - isActive: true, - isArchived: false - } + where: { userId, isActive: true, isArchived: false } }); // Return the total count of user organizations diff --git a/packages/desktop-api/package.json b/packages/desktop-api/package.json index ed84adb55c6..408aa80ee31 100644 --- a/packages/desktop-api/package.json +++ b/packages/desktop-api/package.json @@ -30,7 +30,6 @@ "@grpc/grpc-js": "^1.6.7", "@mikro-orm/core": "^6.2.3", "@mikro-orm/nestjs": "^5.2.3", - "@mikro-orm/sqlite": "^6.2.3", "@nestjs/common": "^10.3.7", "@nestjs/core": "^10.3.7", "@nestjs/typeorm": "^10.0.2", diff --git a/packages/desktop-api/src/app/database.module.ts b/packages/desktop-api/src/app/database.module.ts index d2c58eca5a8..aa5b6e76f11 100644 --- a/packages/desktop-api/src/app/database.module.ts +++ b/packages/desktop-api/src/app/database.module.ts @@ -2,36 +2,36 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { EntityCaseNamingStrategy } from '@mikro-orm/core'; -import { SqliteDriver, Options as MikroOrmSqliteOptions } from '@mikro-orm/sqlite'; +import { BetterSqliteDriver, Options as MikroOrmSqliteOptions } from '@mikro-orm/better-sqlite'; import { Wakatime } from '@gauzy/integration-wakatime'; const coreEntities = [Wakatime]; const dbPath = process.env.GAUZY_USER_PATH ? `${process.env.GAUZY_USER_PATH}/gauzy.sqlite3` : ''; @Module({ - imports: [ - // TypeORM DB Config (SQLite3) - TypeOrmModule.forRootAsync({ - useFactory: () => ({ - type: 'sqlite', - database: dbPath, - keepConnectionAlive: true, - logging: 'all', - logger: 'file', //Removes console logging, instead logs all queries in a file ormlogs.log - synchronize: true, - entities: coreEntities - }) - }), - // MikroORM DB Config (SQLite3) - MikroOrmModule.forRootAsync({ - useFactory: (): MikroOrmSqliteOptions => ({ - driver: SqliteDriver, - dbName: dbPath, - entities: coreEntities, - namingStrategy: EntityCaseNamingStrategy, - debug: ['query'] // by default set to false only - }) - }), - ] + imports: [ + // TypeORM DB Config (SQLite3) + TypeOrmModule.forRootAsync({ + useFactory: () => ({ + type: 'better-sqlite3', + database: dbPath, + keepConnectionAlive: true, + logging: 'all', + logger: 'file', //Removes console logging, instead logs all queries in a file ormlogs.log + synchronize: true, + entities: coreEntities + }) + }), + // MikroORM DB Config (SQLite3) + MikroOrmModule.forRootAsync({ + useFactory: (): MikroOrmSqliteOptions => ({ + driver: BetterSqliteDriver, + dbName: dbPath, + entities: coreEntities, + namingStrategy: EntityCaseNamingStrategy, + debug: ['query'] // by default set to false only + }) + }) + ] }) -export class DatabaseModule { } +export class DatabaseModule {} diff --git a/packages/desktop-libs/package.json b/packages/desktop-libs/package.json index b40a4b64495..61fd567c186 100644 --- a/packages/desktop-libs/package.json +++ b/packages/desktop-libs/package.json @@ -31,7 +31,7 @@ "@electron/remote": "^2.0.8", "@gauzy/desktop-window": "^0.1.0", "active-win": "^8.1.0", - "better-sqlite3": "^9.4.3", + "better-sqlite3": "^9.5.0", "electron-log": "^4.4.8", "electron-store": "^8.1.0", "electron-util": "^0.17.2", @@ -50,7 +50,6 @@ "node-notifier": "^8.0.0", "screenshot-desktop": "^1.15.0", "sound-play": "1.1.0", - "sqlite3": "^5.1.7", "tar": "^7.4.3", "underscore": "^1.13.3", "undici": "^6.10.2", diff --git a/packages/desktop-libs/src/lib/desktop-menu.ts b/packages/desktop-libs/src/lib/desktop-menu.ts index 68ccf3658b3..a439701eed0 100644 --- a/packages/desktop-libs/src/lib/desktop-menu.ts +++ b/packages/desktop-libs/src/lib/desktop-menu.ts @@ -23,7 +23,7 @@ export class AppMenu { label: TranslateService.instant('MENU.ABOUT'), enabled: true, async click() { - const window: BrowserWindow = await createAboutWindow(windowPath.timeTrackerUi); + const window: BrowserWindow = await createAboutWindow(windowPath.timeTrackerUi, windowPath.preloadPath); window.show(); } }, diff --git a/packages/desktop-libs/src/lib/desktop-timer.ts b/packages/desktop-libs/src/lib/desktop-timer.ts index 096402692ca..df334d9e7ac 100644 --- a/packages/desktop-libs/src/lib/desktop-timer.ts +++ b/packages/desktop-libs/src/lib/desktop-timer.ts @@ -387,7 +387,10 @@ export default class TimerHandler { organizationContactId: params.organizationContactId, employeeId: params.employeeId, metaData: - this.configs && this.configs.db === 'sqlite' + this.configs && + (this.configs.db === 'sqlite' || + this.configs.db === 'better-sqlite' || + this.configs.db === 'better-sqlite3') ? JSON.stringify(activityMetadata) : activityMetadata }; diff --git a/packages/desktop-libs/src/lib/desktop-tray.ts b/packages/desktop-libs/src/lib/desktop-tray.ts index 3114a1bea2c..b3607c7d696 100644 --- a/packages/desktop-libs/src/lib/desktop-tray.ts +++ b/packages/desktop-libs/src/lib/desktop-tray.ts @@ -1,8 +1,8 @@ import { - createSettingsWindow, getApiBaseUrl, loginPage, RegisteredWindow, + settingsPage, timeTrackerPage, WindowManager } from '@gauzy/desktop-window'; @@ -43,9 +43,6 @@ export class TrayIcon { label: TranslateService.instant('TIMER_TRACKER.SETUP.SETTING'), accelerator: 'CmdOrCtrl+,', async click() { - if (!settingsWindow) { - settingsWindow = await createSettingsWindow(settingsWindow, windowPath.timeTrackerUi); - } manager.show(RegisteredWindow.SETTINGS); manager.webContents(settingsWindow).send('app_setting', LocalStore.getApplicationConfig()); manager.webContents(settingsWindow).send('goto_top_menu'); @@ -57,9 +54,6 @@ export class TrayIcon { label: TranslateService.instant('BUTTONS.CHECK_UPDATE'), accelerator: 'CmdOrCtrl+U', async click() { - if (!settingsWindow) { - settingsWindow = await createSettingsWindow(settingsWindow, windowPath.timeTrackerUi); - } manager.show(RegisteredWindow.SETTINGS); manager.webContents(settingsWindow).send('goto_update'); manager.webContents(settingsWindow).send('app_setting', LocalStore.getApplicationConfig()); @@ -94,9 +88,6 @@ export class TrayIcon { label: TranslateService.instant('TIMER_TRACKER.SETUP.SETTING'), accelerator: 'CmdOrCtrl+,', async click() { - if (!settingsWindow) { - settingsWindow = await createSettingsWindow(settingsWindow, windowPath.timeTrackerUi); - } manager.show(RegisteredWindow.SETTINGS); manager.webContents(settingsWindow).send('app_setting', LocalStore.getApplicationConfig()); manager.webContents(settingsWindow).send('goto_top_menu'); @@ -108,9 +99,6 @@ export class TrayIcon { label: TranslateService.instant('BUTTONS.CHECK_UPDATE'), accelerator: 'CmdOrCtrl+U', async click() { - if (!settingsWindow) { - settingsWindow = await createSettingsWindow(settingsWindow, windowPath.timeTrackerUi); - } manager.show(RegisteredWindow.SETTINGS); manager.webContents(settingsWindow).send('goto_update'); manager.webContents(settingsWindow).send('app_setting', LocalStore.getApplicationConfig()); @@ -188,9 +176,6 @@ export class TrayIcon { label: TranslateService.instant('BUTTONS.CHECK_UPDATE'), accelerator: 'CmdOrCtrl+U', async click() { - if (!settingsWindow) { - settingsWindow = await createSettingsWindow(settingsWindow, windowPath.timeTrackerUi); - } manager.show(RegisteredWindow.SETTINGS); manager.webContents(settingsWindow).send('goto_update'); manager.webContents(settingsWindow).send('app_setting', LocalStore.getApplicationConfig()); @@ -202,9 +187,6 @@ export class TrayIcon { label: TranslateService.instant('TIMER_TRACKER.SETUP.SETTING'), accelerator: 'CmdOrCtrl+,', async click() { - if (!settingsWindow) { - settingsWindow = await createSettingsWindow(settingsWindow, windowPath.timeTrackerUi); - } manager.show(RegisteredWindow.SETTINGS); manager.webContents(settingsWindow).send('app_setting', LocalStore.getApplicationConfig()); manager.webContents(settingsWindow).send('goto_top_menu'); @@ -371,6 +353,12 @@ export class TrayIcon { console.error('An error occurred while loading Time Tracker Page', error); } + try { + await settingsWindow.loadURL(settingsPage(windowPath.timeTrackerUi)); + } catch (error) { + console.error('An error occurred while loading settings Page', error); + } + manager.webContents(timeTrackerWindow).send('auth_success_tray_init', arg); if (!isGauzyWindow) { diff --git a/packages/desktop-libs/src/lib/offline/databases/better-sqlite-provider.ts b/packages/desktop-libs/src/lib/offline/databases/better-sqlite-provider.ts index e1459dadcdd..4221cc85880 100644 --- a/packages/desktop-libs/src/lib/offline/databases/better-sqlite-provider.ts +++ b/packages/desktop-libs/src/lib/offline/databases/better-sqlite-provider.ts @@ -30,6 +30,15 @@ export class BetterSqliteProvider implements IServerLessProvider { filename: path.resolve(app?.getPath('userData') || __dirname, 'gauzy.sqlite3'), timezone: 'utc' }, + pool: { + min: 0, + max: 1, + createTimeoutMillis: 3000, + acquireTimeoutMillis: 60 * 1000 * 2, + idleTimeoutMillis: 30000, + reapIntervalMillis: 1000, + createRetryIntervalMillis: 100 + }, migrations: { directory: __dirname + '/migrations' }, diff --git a/packages/desktop-libs/src/lib/offline/databases/sqlite-provider.ts b/packages/desktop-libs/src/lib/offline/databases/sqlite-provider.ts index 55aeb2d8067..8be158861e2 100644 --- a/packages/desktop-libs/src/lib/offline/databases/sqlite-provider.ts +++ b/packages/desktop-libs/src/lib/offline/databases/sqlite-provider.ts @@ -9,11 +9,25 @@ export class SqliteProvider implements IServerLessProvider { private constructor() { this._connection = require('knex')(this.config); - console.log('[provider]: ', 'sqlite connected...'); + console.log('[provider]: ', 'SQlite connected...'); } - get config(): Knex.Config { + + public static get instance(): IServerLessProvider { + if (!this._instance) { + this._instance = new SqliteProvider(); + } + return this._instance; + } + + public get connection(): Knex { + return this._connection; + } + + public get config(): Knex.Config { return { - client: 'sqlite3', + // Here we can use 'sqlite' if we want native SQlite3 driver + // But we need to install it manually and instead we prefer to use 'better-sqlite3' + client: 'better-sqlite3', connection: { filename: path.resolve(app?.getPath('userData') || __dirname, 'gauzy.sqlite3'), timezone: 'utc' @@ -35,15 +49,4 @@ export class SqliteProvider implements IServerLessProvider { asyncStackTraces: true }; } - - public get connection(): Knex { - return this._connection; - } - - public static get instance(): IServerLessProvider { - if (!this._instance) { - this._instance = new SqliteProvider(); - } - return this._instance; - } } diff --git a/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts b/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts index 4a3d6f12f50..5462f8eb75d 100644 --- a/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts +++ b/packages/desktop-libs/src/lib/plugin-system/data-access/plugin-manager.ts @@ -36,7 +36,10 @@ export class PluginManager implements IPluginManager { if (plugin) { await this.updatePlugin(metadata); } else { + /* Install plugin */ await this.installPlugin(metadata, pathDirname); + /* Activate plugin */ + await this.activatePlugin(metadata.name); } process.noAsar = false; } diff --git a/packages/desktop-libs/src/lib/plugin-system/data-access/strategies/npm-download.strategy.ts b/packages/desktop-libs/src/lib/plugin-system/data-access/strategies/npm-download.strategy.ts index 576f6188e44..449b5ddf669 100644 --- a/packages/desktop-libs/src/lib/plugin-system/data-access/strategies/npm-download.strategy.ts +++ b/packages/desktop-libs/src/lib/plugin-system/data-access/strategies/npm-download.strategy.ts @@ -1,4 +1,5 @@ import * as logger from 'electron-log'; +import { existsSync } from 'fs'; import * as fs from 'fs/promises'; import * as https from 'https'; import * as path from 'path'; @@ -31,6 +32,8 @@ export class NpmDownloadStrategy implements IPluginDownloadStrategy { await fs.rename(pluginDir, pathDirname); logger.info(`✔ Plugin directory renamed to: ${pathDirname}`); + // Rename native modules to node_modules if available + await this.renameNativeModules(pathDirname); // Install dependencies await this.installDependencies(pathDirname, config as INpmDownloadConfig); @@ -172,16 +175,14 @@ export class NpmDownloadStrategy implements IPluginDownloadStrategy { // Create the node_modules directory if it doesn't exist await fs.mkdir(path.join(dependencyDir, 'node_modules'), { recursive: true }); - // Install dependencies in parallel - await Promise.all( - Object.entries(dependencies).map(([dependency, version]) => - this.installDependency({ - ...config, - pkg: { name: dependency, version: formatNpmVersion(version) }, - pluginPath: path.join(dependencyDir, 'node_modules') - }) - ) - ); + // Install dependencies in sequence + for (const [dependency, version] of Object.entries(dependencies)) { + await this.installDependency({ + ...config, + pkg: { name: dependency, version: formatNpmVersion(version) }, + pluginPath: path.join(dependencyDir, 'node_modules') + }); + } } /** * Installs a single dependency by downloading and extracting it. @@ -210,4 +211,24 @@ export class NpmDownloadStrategy implements IPluginDownloadStrategy { logger.error(`Failed to install dependency: ${config.pkg.name}`, error); } } + + private async renameNativeModules(pathDirname: string) { + // Define paths for native and node modules + const nativeModulesPath = path.join(pathDirname, 'native_modules'); + const nodeModulesPath = path.join(pathDirname, 'node_modules'); + + try { + // Check if native modules directory exists + if (existsSync(nativeModulesPath)) { + // Rename native_modules to node_modules + await fs.rename(nativeModulesPath, nodeModulesPath); + logger.info(`✔ Plugin native modules ${nativeModulesPath} renamed to: ${nodeModulesPath}`); + } else { + logger.info(`✔ No native modules found`); + } + } catch (error) { + // Handle errors during renaming + logger.error(`✖ Error renaming ${nativeModulesPath} to ${nodeModulesPath}: ${error.message}`); + } + } } diff --git a/packages/desktop-ui-lib/package.json b/packages/desktop-ui-lib/package.json index 783121698e7..958f25e547d 100644 --- a/packages/desktop-ui-lib/package.json +++ b/packages/desktop-ui-lib/package.json @@ -53,7 +53,7 @@ "@ngx-translate/core": "^15.0.0", "@ngx-translate/http-loader": "^8.0.0", "@swimlane/ngx-charts": "20.5.0", - "angular2-smart-table": "^3.2.0", + "angular2-smart-table": "^3.3.0", "ckeditor4-angular": "4.0.1", "electron-log": "^4.4.8", "electron-store": "^8.1.0", diff --git a/packages/desktop-ui-lib/src/lib/auth/auth.module.ts b/packages/desktop-ui-lib/src/lib/auth/auth.module.ts index c79b122aab6..f9d13022936 100644 --- a/packages/desktop-ui-lib/src/lib/auth/auth.module.ts +++ b/packages/desktop-ui-lib/src/lib/auth/auth.module.ts @@ -12,7 +12,7 @@ export * from './auth.guard'; export * from './no-auth.guard'; const environment = injector.get(GAUZY_ENV); -const socialLinks = [ +export const socialLinks = [ { url: environment?.GOOGLE_AUTH_LINK, icon: 'google-outline', diff --git a/packages/desktop-ui-lib/src/lib/auth/auth.routes.ts b/packages/desktop-ui-lib/src/lib/auth/auth.routes.ts new file mode 100644 index 00000000000..8c5f161ecfd --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/auth/auth.routes.ts @@ -0,0 +1,75 @@ +import { Route } from '@angular/router'; +import { + NbAuthComponent, + NbLogoutComponent, + NbRegisterComponent, + NbRequestPasswordComponent, + NbResetPasswordComponent +} from '@nebular/auth'; +import { NgxLoginComponent } from '../login'; +import { NgxLoginMagicComponent } from '../login/features/login-magic/login-magic.component'; +import { NgxLoginWorkspaceComponent } from '../login/features/login-workspace/login-workspace.component'; +import { NgxMagicSignInWorkspaceComponent } from '../login/features/magic-login-workspace/magic-login-workspace.component'; +import { NoAuthGuard } from './no-auth.guard'; + +export const authRoutes: Route[] = [ + { + path: '', + component: NbAuthComponent, + children: [ + { + path: '', + redirectTo: 'login', + pathMatch: 'full' + }, + { + path: 'login', + component: NgxLoginComponent, + canActivate: [NoAuthGuard] + }, + { + path: 'register', + component: NbRegisterComponent, + canActivate: [NoAuthGuard] + }, + { + path: 'logout', + component: NbLogoutComponent + }, + { + path: 'request-password', + component: NbRequestPasswordComponent, + canActivate: [NoAuthGuard] + }, + { + path: 'reset-password', + component: NbResetPasswordComponent, + canActivate: [NoAuthGuard] + }, + { + // Register the path 'login-workspace' + path: 'login-workspace', + // Register the component to load component: NgxLoginWorkspaceComponent, + component: NgxLoginWorkspaceComponent, + // Register the data object + canActivate: [NoAuthGuard] + }, + { + // Register the path 'login-magic' + path: 'login-magic', + // Register the component to load component: NgxLoginMagicComponent, + component: NgxLoginMagicComponent, + // Register the data object + canActivate: [NoAuthGuard] + }, + { + // Register the path 'magic-sign-in' + path: 'magic-sign-in', + // Register the component to load component: NgxMagicSignInWorkspaceComponent, + component: NgxMagicSignInWorkspaceComponent, + // Register the data object + canActivate: [NoAuthGuard] + } + ] + } +]; diff --git a/packages/desktop-ui-lib/src/lib/auth/index.ts b/packages/desktop-ui-lib/src/lib/auth/index.ts index 2f08ce7dce2..33bc135d412 100644 --- a/packages/desktop-ui-lib/src/lib/auth/index.ts +++ b/packages/desktop-ui-lib/src/lib/auth/index.ts @@ -1,5 +1,5 @@ -export * from './services'; export * from './auth.guard'; -export * from './no-auth.guard'; export * from './auth.module'; +export * from './auth.routes'; export * from './no-auth.guard'; +export * from './services'; diff --git a/packages/desktop-ui-lib/src/lib/auth/services/auth-strategy.service.ts b/packages/desktop-ui-lib/src/lib/auth/services/auth-strategy.service.ts index 7dd872afe90..800b3597476 100644 --- a/packages/desktop-ui-lib/src/lib/auth/services/auth-strategy.service.ts +++ b/packages/desktop-ui-lib/src/lib/auth/services/auth-strategy.service.ts @@ -214,7 +214,7 @@ export class AuthStrategy extends NbAuthStrategy { this.store.token = token; this.store.user = user; - this.electronAuthentication({ user, token }); + this.authService.electronAuthentication({ user, token }); return new NbAuthResult( true, @@ -232,7 +232,7 @@ export class AuthStrategy extends NbAuthStrategy { user: this.store.user, token: this.store.token, }; - this.electronAuthentication(res); + this.authService.electronAuthentication(res); return of( new NbAuthResult( true, @@ -255,23 +255,4 @@ export class AuthStrategy extends NbAuthStrategy { }) ); } - - public electronAuthentication({ user, token }: IAuthResponse) { - try { - if (this.electronService.isElectron) { - this.electronService.ipcRenderer.send('auth_success', { - user: user, - token: token, - userId: user.id, - employeeId: user.employee ? user.employee.id : null, - organizationId: user.employee - ? user.employee.organizationId - : null, - tenantId: user.tenantId ? user.tenantId : null, - }); - } - } catch (error) { - console.log(error); - } - } } diff --git a/packages/desktop-ui-lib/src/lib/auth/services/auth.service.ts b/packages/desktop-ui-lib/src/lib/auth/services/auth.service.ts index c734e5e9d1d..b2eb648fcae 100644 --- a/packages/desktop-ui-lib/src/lib/auth/services/auth.service.ts +++ b/packages/desktop-ui-lib/src/lib/auth/services/auth.service.ts @@ -1,52 +1,44 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; import { + IAuthResponse, IUser, - RolesEnum, + IUserCodeInput, + IUserEmailInput, + IUserLoginInput, IUserRegistrationInput, + IUserSigninWorkspaceResponse, + IUserTokenInput, PermissionsEnum, - IAuthResponse + RolesEnum } from '@gauzy/contracts'; +import { toParams } from '@gauzy/ui-core/common'; import { Observable, firstValueFrom } from 'rxjs'; import { API_PREFIX } from '../../constants/app.constants'; +import { ElectronService } from '../../electron/services'; @Injectable() export class AuthService { - constructor(private http: HttpClient) { } + constructor(private http: HttpClient, private readonly electronService: ElectronService) {} isAuthenticated(): Promise { - return firstValueFrom(this.http - .get(`${API_PREFIX}/auth/authenticated`)); + return firstValueFrom(this.http.get(`${API_PREFIX}/auth/authenticated`)); } login(loginInput): Observable { - return this.http.post( - `${API_PREFIX}/auth/login`, - loginInput - ); + return this.http.post(`${API_PREFIX}/auth/login`, loginInput); } register(registerInput: IUserRegistrationInput): Observable { - return this.http.post( - `${API_PREFIX}/auth/register`, - registerInput - ); + return this.http.post(`${API_PREFIX}/auth/register`, registerInput); } - requestPassword( - requestPasswordInput - ): Observable<{ token: string }> { - return this.http.post( - `${API_PREFIX}/auth/request-password`, - requestPasswordInput - ); + requestPassword(requestPasswordInput): Observable<{ token: string }> { + return this.http.post(`${API_PREFIX}/auth/request-password`, requestPasswordInput); } resetPassword(resetPasswordInput) { - return this.http.post( - `${API_PREFIX}/auth/reset-password`, - resetPasswordInput - ); + return this.http.post(`${API_PREFIX}/auth/reset-password`, resetPasswordInput); } hasRole(roles: RolesEnum[]): Observable { @@ -60,4 +52,94 @@ export class AuthService { params: { permission } }); } + + confirmEmail(body: IUserEmailInput & IUserTokenInput) { + return this.http.post(`${API_PREFIX}/auth/email/verify`, body); + } + + /** + * Sign in to workspaces with the provided input. + * + * @param input - The input containing user login information. + * @returns An observable of the response for signing in to workspaces. + */ + findWorkspaces(input: IUserLoginInput): Observable { + // Send a POST request to the server endpoint with the provided input + return this.http.post(`${API_PREFIX}/auth/signin.email.password`, input); + } + + /** + * + */ + sendSigninCode(input: IUserEmailInput) { + // Send a POST request to the server endpoint with the provided input + return this.http.post(`${API_PREFIX}/auth/signin.email`, input); + } + + /** + * + */ + confirmSignInByCode(input: IUserEmailInput & IUserCodeInput) { + // Send a POST request to the server endpoint with the provided input + return this.http.post(`${API_PREFIX}/auth/signin.email/confirm`, input); + } + + /** + * Sign in to a specific tenant workspace using the provided input. + * + * @param input - The input containing user email and token. + * @returns An observable of the response for signing in to the specific tenant workspace. + */ + signinWorkspaceByToken(input: IUserEmailInput & IUserTokenInput) { + // Send a POST request to the server endpoint with the provided input + return this.http.post(`${API_PREFIX}/auth/signin.workspace`, input); + } + + /** + * Logout API Route + * + * @returns + */ + doLogout(): Observable { + return this.http.get(`${API_PREFIX}/auth/logout`); + } + + /** + * Checks if the user has the specified permissions. + * + * @param {...PermissionsEnum[]} permissions - The permissions to check. + * @return {Observable} An observable that emits a boolean indicating whether the user has the specified permissions. + */ + hasPermissions(...permissions: PermissionsEnum[]): Observable { + return this.http.get(`${API_PREFIX}/auth/permissions`, { + params: toParams({ permissions }) + }); + } + + /** + * GET access token from refresh token + * + * @param refresh_token + * @returns + */ + refreshToken(refresh_token: string): Promise<{ token: string } | null> { + return firstValueFrom(this.http.post<{ token: string }>(`${API_PREFIX}/auth/refresh-token`, { refresh_token })); + } + + public electronAuthentication({ user, token }: IAuthResponse) { + try { + if (this.electronService.isElectron) { + this.electronService.ipcRenderer.send('auth_success', { + user: user, + token: token, + userId: user.id, + employeeId: user.employee ? user.employee.id : null, + organizationId: user.employee ? user.employee.organizationId : null, + tenantId: user.tenantId ? user.tenantId : null + }); + } + } catch (error) { + console.log(error); + } + } } diff --git a/packages/desktop-ui-lib/src/lib/constants/app.constants.ts b/packages/desktop-ui-lib/src/lib/constants/app.constants.ts index 91709cb34c3..de0c430dfef 100644 --- a/packages/desktop-ui-lib/src/lib/constants/app.constants.ts +++ b/packages/desktop-ui-lib/src/lib/constants/app.constants.ts @@ -17,3 +17,11 @@ export const injector = Injector.create({ export const API_ACTIVITY_WATCH_PREFIX = '/buckets'; export const AUTO_REFRESH_DELAY = 60 * 10 * 1000; // milliseconds + +export const patterns = { + websiteUrl: /^((?:https?:\/\/)[^./]+(?:\.[^./]+)+(?:\/.*)?)$/, + imageUrl: /^(http)?s?:?(\/\/[^"']*\.(?:png|jpg|jpeg|gif|png|svg))/, + email: /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@(([^<>()[\]\.,;:\s@"]+\.)+[^<>()[\]\.,;:\s@"]{2,})$/i, + host: /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/, + passwordNoSpaceEdges: /^(?!\s).*[^\s]$/ +}; diff --git a/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.html b/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.html index 6314cb813f9..9f932d38438 100644 --- a/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.html +++ b/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.html @@ -19,11 +19,13 @@ Copyright © 2020-{{'FOOTER.PRESENT' | translate}} {{ application?.companyName}} -
    +
    {{'FOOTER.RIGHTS_RESERVED' | translate}} -
    - {{'FOOTER.TERMS_OF_SERVICE' | translate}} | - {{'FOOTER.PRIVACY_POLICY' | translate}} +
    + {{'FOOTER.TERMS_OF_SERVICE' | + translate}} | + {{'FOOTER.PRIVACY_POLICY' | + translate}}

    diff --git a/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.scss b/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.scss index 020389953df..cc210301002 100644 --- a/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.scss +++ b/packages/desktop-ui-lib/src/lib/dialogs/about/about.component.scss @@ -46,4 +46,4 @@ div.logo { ::ng-deep nb-layout .layout .layout-container .content nb-layout-footer nav { padding: 0px; - } \ No newline at end of file +} diff --git a/packages/desktop-ui-lib/src/lib/directives/debounce-click.directive.ts b/packages/desktop-ui-lib/src/lib/directives/debounce-click.directive.ts new file mode 100644 index 00000000000..08b1c28a426 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/directives/debounce-click.directive.ts @@ -0,0 +1,38 @@ +import { Directive, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { Subject, Subscription, tap } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; + +@Directive({ + selector: '[debounceClick]' +}) +export class DebounceClickDirective implements OnInit, OnDestroy { + private clicks: Subject = new Subject(); + private subscription: Subscription; + + @Input() debounceTime = 300; + @Output() throttledClick: EventEmitter = new EventEmitter(); + + /** + * Handles the click event and emits it after a debounce time. + * + * @param {Event} event - The click event object. + * @return {void} This function does not return a value. + */ + @HostListener('click', ['$event']) + clickEvent(event: Event): void { + this.clicks.next(event); + } + + ngOnInit() { + this.subscription = this.clicks + .pipe( + debounceTime(this.debounceTime), + tap((e) => this.throttledClick.emit(e)) + ) + .subscribe(); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } +} diff --git a/packages/desktop-ui-lib/src/lib/directives/desktop-directive.module.ts b/packages/desktop-ui-lib/src/lib/directives/desktop-directive.module.ts index 5a4518eee84..57468a196f8 100644 --- a/packages/desktop-ui-lib/src/lib/directives/desktop-directive.module.ts +++ b/packages/desktop-ui-lib/src/lib/directives/desktop-directive.module.ts @@ -1,13 +1,14 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { NbSpinnerModule } from '@nebular/theme'; +import { DebounceClickDirective } from './debounce-click.directive'; import { DynamicDirective } from './dynamic.directive'; import { SpinnerButtonDirective } from './spinner-button.directive'; import { TextMaskDirective } from './text-mask.directive'; @NgModule({ - declarations: [SpinnerButtonDirective, DynamicDirective, TextMaskDirective], - exports: [SpinnerButtonDirective, DynamicDirective, TextMaskDirective], + declarations: [SpinnerButtonDirective, DynamicDirective, TextMaskDirective, DebounceClickDirective], + exports: [SpinnerButtonDirective, DynamicDirective, TextMaskDirective, DebounceClickDirective], imports: [CommonModule, NbSpinnerModule] }) export class DesktopDirectiveModule {} diff --git a/packages/desktop-ui-lib/src/lib/electron/services/electron/logger.service.ts b/packages/desktop-ui-lib/src/lib/electron/services/electron/logger.service.ts index 0e57e358808..eb2d540e446 100644 --- a/packages/desktop-ui-lib/src/lib/electron/services/electron/logger.service.ts +++ b/packages/desktop-ui-lib/src/lib/electron/services/electron/logger.service.ts @@ -34,4 +34,8 @@ export class LoggerService { public error(...message: T[]): void { if (this._log) this._log.error(...message); } + + public warn(...message: any[]): void { + if (this._log) this._log.warn(...message); + } } diff --git a/packages/desktop-ui-lib/src/lib/image-viewer/image-viewer.component.scss b/packages/desktop-ui-lib/src/lib/image-viewer/image-viewer.component.scss index 1c97899988b..d3c1bfaf5be 100644 --- a/packages/desktop-ui-lib/src/lib/image-viewer/image-viewer.component.scss +++ b/packages/desktop-ui-lib/src/lib/image-viewer/image-viewer.component.scss @@ -1,6 +1,7 @@ // @import 'themes'; .top-bottom-space { padding: 0px !important; + margin-top: 28px; } :host { nb-icon { @@ -10,7 +11,7 @@ .gallery-inner { display: flex; flex-direction: column; - height: 100vh; + height: calc(100vh - 28px); width: 100%; gap: 8px; padding: 8px; diff --git a/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.html b/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.html new file mode 100644 index 00000000000..807f74de724 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.html @@ -0,0 +1,183 @@ + diff --git a/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.scss b/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.scss new file mode 100644 index 00000000000..a7192283a66 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.scss @@ -0,0 +1,465 @@ +@import 'themes'; +@import '@shared/reusable'; + +$button-color: #fa754e; + +@mixin submit-btn($padding: 13px 39px) { + padding: $padding; + box-shadow: 0px 19px 15px -14px rgba(0, 0, 0, 0.22); + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 16px; + letter-spacing: -0.009em; + text-align: left; + margin-bottom: 25px; + margin-top: 15px; + + &:not([disabled]) { + background-color: $button-color; + border: 1px solid $button-color; + color: var(--text-alternate-color); + cursor: pointer; + } +} + +@mixin hr-div-soft($margin-bottom: 12px) { + margin-bottom: $margin-bottom; +} + +.login-container { + width: 765px; + position: relative; + display: flex; + justify-content: space-between; + height: 100%; + + & .login-wrapper { + background: nb-theme(gauzy-card-2); + border-radius: nb-theme(border-radius); + box-sizing: border-box; + padding: 30px; + width: 476px; + height: 100%; + + & .svg-wrapper { + display: flex; + justify-content: space-between; + width: 100%; + + & .ever-logo-svg { + margin-bottom: 57px; + } + } + + & .headings { + display: flex; + justify-content: space-between; + flex-direction: column; + position: relative; + + & .headings-inner { + & .title { + font-family: Inter; + font-size: 24px; + font-style: normal; + font-weight: 700; + line-height: 30px; + letter-spacing: 0em; + margin-bottom: 18px; + text-align: start; + } + + & .sub-title { + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 11px; + letter-spacing: 0em; + margin-bottom: 26px; + text-align: start; + } + } + + & .sent-code-container { + margin-bottom: 1rem; + margin-right: -20px; + + & .normal-text { + font-size: 0.8rem; + } + + & .minimum-text { + font-size: 0.75rem; + } + + & p { + margin-bottom: 0; + } + + & b { + font-size: 0.8rem; + } + + & span { + font-size: 0.7rem; + color: var(--text-hint-color); + } + } + } + + & .hr-div-strong { + @include hr-div-strong; + } + + & .hr-div-soft { + @include hr-div-soft($margin-bottom: 16px); + } + } + + & form { + margin-top: 29px; + margin-bottom: 42px; + + & .form-control-group { + .not-allowed { + cursor: not-allowed; + } + + .edit-email { + transition: color 0.3s ease; + + &:hover { + color: var(--text-basic-color); + } + } + } + + & .new-code-wrapper { + font-size: 0.75rem; + text-align: right; + @include nb-rtl(text-align, left); + margin-top: 0.4rem; + margin-right: 0.4rem; + @include nb-rtl(margin-right, 0); + @include nb-rtl(margin-left, 0.4rem); + + & .resend-code { + margin-bottom: 0.4rem; + + &:hover { + cursor: pointer; + } + } + + & .request-new-code { + color: var(--text-hint-color); + } + } + + & .submit-btn-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + + & .forgot-email { + text-decoration-line: underline; + margin-bottom: 0; + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 17px; + letter-spacing: -0.01em; + text-align: left; + + @include mobile-screen { + display: none; + } + } + + & .forgot-email:hover { + color: $button-color; + } + + & .forgot-email-big { + display: block; + + @include mobile-screen { + display: none; + } + } + + & .submit-inner-wrapper { + display: inline-flex; + flex-direction: column; + align-items: center; + } + + & .submit-btn { + @include submit-btn($padding: 13px 20px); + display: flex; + justify-content: center; + align-items: center; + + & nb-icon { + position: relative; + background: transparent; + } + + & .spinner { + animation: spin 1s linear infinite; + } + } + } + + & .accept-group { + margin-bottom: 20px; + + @include mobile-screen { + display: flex; + justify-content: center; + } + } + + & .magic-description { + & p { + text-align: left; + font-size: 0.85rem; + + & a { + color: var(--link-text-color); + } + } + } + } + + & .links { + margin-top: 21px; + @include social-links-style; + + & .socials { + margin-top: 15px; + margin-bottom: 25px; + } + } + + & .another-action { + @include another-action; + margin-top: 10px; + } +} + +.features-wrapper { + width: 260px; + + & .card-body { + padding: 38px 15px; + background: nb-theme(color-primary-transparent-default); + border-radius: nb-theme(border-radius); + + &.dark { + background: nb-theme(color-primary-700); + + & .custom-btn { + color: var(--text-alternate-color); + background: nb-theme(color-primary-600); + border: 1px solid nb-theme(color-primary-600); + } + } + } + + & .title { + font-family: Inter; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 19px; + letter-spacing: -0.009em; + text-align: left; + padding-left: 13px; + } + + & .sub-title { + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 17px; + letter-spacing: 0em; + text-align: left; + margin-bottom: 20px; + padding-left: 13px; + color: nb-theme(text-hint-color); + } + + & .custom-btn { + -webkit-box-shadow: 3px 11px 30px -17px nb-theme(color-primary-500); + box-shadow: 3px 11px 30px -17px nb-theme(color-primary-500); + width: auto; + padding: 13px 28px; + display: inline-flex; + justify-content: flex-start; + background-color: nb-theme(background-basic-color-1); + color: nb-theme(color-primary-500); + border: 1px solid nb-theme(background-basic-color-1); + //styleName: Button label; + font-family: Inter; + font-size: 15px; + font-style: normal; + font-weight: 700; + line-height: 16px; + letter-spacing: -0.009em; + text-align: left; + + &>nb-icon { + width: 16px; + height: 14px; + } + + @include small-laptop-screen { + padding: 10px 20px; + } + + @include tablet-screen { + padding: 15px 25px; + } + + &:hover { + background-color: nb-theme(background-basic-color-2); + border: 1px solid nb-theme(background-basic-color-2); + } + } +} + +@include tablet-screen { + .login-container { + flex-direction: column; + align-items: center; + + & .another-action { + margin-top: 0; + } + } + + .features-wrapper { + width: 476px; + margin-top: 30px; + + & .demo-credentials-buttons { + & * { + text-align: center; + } + + & .title, + .sub-title { + text-align: center; + } + } + } +} + +@include mobile-screen { + .login-container { + width: 100%; + + & .login-wrapper { + width: 100%; + padding: 1rem; + + & .headings { + & .headings-inner { + width: 100%; + + & .title, + .sub-title { + width: 100%; + text-align: center; + } + } + } + + & .headings.headings-demo { + height: 135px; + align-items: flex-start; + } + } + + & form { + margin-bottom: 35px; + + & .submit-btn-wrapper { + justify-content: center; + + & .submit-inner-wrapper { + justify-content: center; + } + + & .submit-btn { + margin-bottom: 0; + } + } + + & .form-control-group { + margin-bottom: 13px; + } + + & .links { + margin-bottom: 10px; + } + } + } + + .features-wrapper { + width: 100%; + } +} + +@include small-mobile-screen { + .features-wrapper { + & .demo-credentials-buttons { + & * { + text-align: left; + } + + & .title, + .sub-title { + text-align: left; + } + } + } +} + +.hr-div-soft { + @include hr-div-soft; + margin-bottom: 12px; +} + +.theme-switch { + @include not-mobile-screen { + display: none; + } +} + +// input fields color +@include input-fields-color; + +::ng-deep .remember-me .text { + font-family: Inter; + font-size: 13px; + font-style: normal; + font-weight: 600; + line-height: 13px; + letter-spacing: 0em; + text-align: left; + color: nb-theme(text-primary-color); +} + +// changing the demo select border radius when its expanded, because color change, new border radiuses had to be introduced. +::ng-deep .accordion-item-header-expanded { + border-bottom-left-radius: 0 !important; + border-bottom-right-radius: 0 !important; + border-bottom: 1px solid transparent; +} diff --git a/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.ts b/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.ts new file mode 100644 index 00000000000..52059ce5d9f --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/login-magic/login-magic.component.ts @@ -0,0 +1,240 @@ +import { ChangeDetectorRef, Component, Inject, OnInit } from '@angular/core'; +import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { ActivatedRoute, Params, Router } from '@angular/router'; +import { NB_AUTH_OPTIONS, NbAuthService, NbLoginComponent } from '@nebular/auth'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { EMPTY, Subscription, catchError, filter, finalize, firstValueFrom, interval, tap } from 'rxjs'; +import { AuthService } from '../../../auth'; +import { GAUZY_ENV, patterns } from '../../../constants'; +import { ErrorHandlerService } from '../../../services'; + +@UntilDestroy({ checkProperties: true }) +@Component({ + selector: 'ngx-login-magic', + templateUrl: './login-magic.component.html', + styleUrls: ['./login-magic.component.scss'] +}) +export class NgxLoginMagicComponent extends NbLoginComponent implements OnInit { + public countdown: number; + private timer: Subscription; + + public isLoading: boolean = false; + public isCodeSent: boolean = false; + public isCodeResent: boolean = false; + public isDemo: boolean = false; + + /** + * FormGroup instance representing the magic login form. + */ + public form: FormGroup = NgxLoginMagicComponent.buildForm(this._fb); + /** + * Static method to build the magic login form using Angular's FormBuilder. + * + * @param fb - Angular FormBuilder instance. + * @returns {FormGroup} - The built magic login form. + */ + static buildForm(fb: FormBuilder): FormGroup { + return fb.group({ + email: [null, Validators.compose([Validators.required, Validators.pattern(patterns.email)])], + code: [null, Validators.compose([Validators.required, Validators.minLength(6), Validators.maxLength(6)])] + }); + } + + /** + * Gets the 'email' AbstractControl from the form. + * + * @returns {AbstractControl} - The 'email' form control. + */ + get email(): AbstractControl { + return this.form.get('email'); + } + + /** + * Gets the 'code' AbstractControl from the form. + * + * @returns {AbstractControl} - The 'code' form control. + */ + get code(): AbstractControl { + return this.form.get('code'); + } + + constructor( + private readonly _fb: FormBuilder, + private readonly _activatedRoute: ActivatedRoute, + public readonly nbAuthService: NbAuthService, + public readonly cdr: ChangeDetectorRef, + public readonly router: Router, + private readonly _authService: AuthService, + private readonly _errorHandlingService: ErrorHandlerService, + @Inject(NB_AUTH_OPTIONS) options, + @Inject(GAUZY_ENV) + private readonly _environment: any + ) { + super(nbAuthService, options, cdr, router); + this.isDemo = this._environment.DEMO; + } + + /** + * + */ + ngOnInit(): void { + // Create an observable to listen to query parameter changes in the current route. + this._activatedRoute.queryParams + .pipe( + // Filter and ensure that query parameters are present. + filter((params: Params) => !!params), + + // Tap into the observable to update the 'form.email' property with the 'email' query parameter. + tap(({ email }: Params) => { + if (email) { + this.form.patchValue({ email }); + this.form.updateValueAndValidity(); + } + }), + // Use 'untilDestroyed' to handle component lifecycle and avoid memory leaks. + untilDestroyed(this) + ) + .subscribe(); + } + + /** + * + */ + ngOnDestroy(): void { + this.stopTimer(); + } + + /** + * Initiates the login process. + * + * @remarks + * This method retrieves the email from the form, validates it, and sends a request to sign in + * to workspaces using the authentication service. Error handling and loading indicator management + * are included to ensure a smooth user experience. + * + * @returns An observable representing the login request. + */ + async sendLoginCode(): Promise { + // Get the email value from the form + const email = this.form.get('email').value; + if (!email) { + return; + } + + this.isLoading = true; + this.isCodeSent = false; + + // Send a request to sign in to workspaces using the authentication service + await firstValueFrom( + this._authService.sendSigninCode({ email }).pipe( + catchError((error) => { + // Handle and log errors using the error handling service + this._errorHandlingService.handleError(error); + return EMPTY; + }), + // Turn off loading indicator + finalize(() => { + this.isLoading = false; + }), + tap(() => { + this.isCodeSent = true; + this.form.get('email').disable(); + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ) + ); // Wait for the login request to complete + } + + /** + * Resend the sign-in code. + */ + async onResendCode(): Promise { + // Start the timer + this.startTimer(); + + // Get the email value from the form + const email = this.form.get('email').value; + + // Check if email is present + if (!email) { + return; + } + + // Send a request to sign in to workspaces using the authentication service + await firstValueFrom( + this._authService.sendSigninCode({ email }).pipe( + catchError((error) => { + // Handle and log errors using the error handling service + this._errorHandlingService.handleError(error); + return EMPTY; + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ) + ); // Wait for the login request to complete + } + + /** + * Confirms the sign-in code. + */ + async confirmSignInCode(): Promise { + this.isLoading = true; + // Check if the form is invalid + if (this.form.invalid) { + this.isLoading = false; + return; + } + // Get the email and code values from the form + const { email, code } = this.form.getRawValue(); + + // Check if both email and code are present + if (!email || !code) { + this.isLoading = false; + return; + } + + // Navigate to the 'auth/magic-sign-in' route with email and code as query parameters + await this.router.navigate(['auth/magic-sign-in'], { + queryParams: { + email, + code + } + }); + + this.isLoading = false; + } + + /** + * Starts a timer for a countdown. + */ + startTimer() { + this.stopTimer(); + + this.isCodeResent = true; + this.countdown = 30; + + this.timer = interval(1000) + .pipe( + tap(() => { + if (this.countdown > 0) { + this.countdown--; + } else { + this.stopTimer(); + } + }), + untilDestroyed(this) + ) + .subscribe(); + } + + /** + * Stops the timer and resets the code resent flag. + */ + stopTimer() { + this.isCodeResent = false; + if (this.timer) { + this.timer.unsubscribe(); + } + } +} diff --git a/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.html b/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.html new file mode 100644 index 00000000000..2360ad8cb42 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.html @@ -0,0 +1,112 @@ + + + +
    +
    +
    + +
    + +

    {{ 'WORKSPACES.SIGN_IN_TITLE' | translate }}

    + +
    + + + +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + + + + + + + + + +

    + {{ 'LOGIN_PAGE.VALIDATION.PASSWORD_REQUIRED' | translate }} +

    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + diff --git a/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.scss b/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.scss new file mode 100644 index 00000000000..edc15e34f26 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.scss @@ -0,0 +1,113 @@ +@import 'themes'; +@import '@shared/reusable'; + +:host { + .section-wrapper { + display: flex; + justify-content: space-between; + height: 100%; + + .-wrapper { + background: nb-theme(gauzy-card-2); + border-radius: nb-theme(border-radius); + padding: 1rem 0; + width: 100%; + + &>* { + padding-left: 15px; + padding-right: 15px; + } + + & .svg-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 57px; + } + + & .title { + font-family: Inter; + font-size: 24px; + font-style: normal; + font-weight: 600; + line-height: 30px; + letter-spacing: -1px; + text-align: left; + color: var(--gauzy-text-color-1); + } + + & .sub-title { + width: 358px; + height: 34px; + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 17px; + display: flex; + align-items: center; + text-align: start; + margin-bottom: 15px; + } + + & .hr-div-strong { + @include hr-div-strong; + } + + .form { + & .form-control-group { + margin-bottom: 24px; + + & .label { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-size: 11px; + line-height: 13px; + display: flex; + align-items: center; + letter-spacing: -0.01em; + color: #7e7e8f; + } + } + + & .submit-btn-wrapper { + display: flex; + justify-content: flex-end; + + & .submit-btn { + @include submit-btn; + margin-bottom: 25px; + margin-top: 15px; + } + } + } + + .hr-div-soft { + @include hr-div-soft; + } + + .redirect-link-p { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 17px; + /* identical to box height */ + color: #7e7e8f; + margin-bottom: 0; + + & .text-link { + font-family: Inter; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 17px; + letter-spacing: 0em; + color: #6e49e8; + text-decoration: none; + } + } + } + } +} diff --git a/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.ts b/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.ts new file mode 100644 index 00000000000..935d5608803 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/login-workspace/login-workspace.component.ts @@ -0,0 +1,174 @@ +import { Component, OnInit } from '@angular/core'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { NavigationExtras, Router } from '@angular/router'; +import { HttpStatus, IAuthResponse, IUser, IUserSigninWorkspaceResponse, IWorkspaceResponse } from '@gauzy/contracts'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { asyncScheduler, catchError, EMPTY, filter, tap } from 'rxjs'; +import { AuthService } from '../../../auth'; +import { ErrorHandlerService, Store, TimeTrackerDateManager } from '../../../services'; + +@UntilDestroy({ checkProperties: true }) +@Component({ + selector: 'ngx-login-workspace', + templateUrl: './login-workspace.component.html', + styleUrls: ['./login-workspace.component.scss'] +}) +export class NgxLoginWorkspaceComponent implements OnInit { + public confirmedEmail: string; + public totalWorkspaces: number; + public showPopup: boolean = false; + public loading: boolean = false; // Flag to indicate if data loading is in progress + public workspaces: IWorkspaceResponse[] = []; // Array of workspace users + public showPassword = false; + private state: NavigationExtras['state']; + + /** The FormGroup for the sign-in form */ + public form: FormGroup = NgxLoginWorkspaceComponent.buildForm(this._fb); + + /** + * Static method to build a FormGroup for the sign-in form. + * + * @param fb - The FormBuilder service for creating form controls. + * @returns A FormGroup for the sign-in form. + */ + static buildForm(fb: FormBuilder): FormGroup { + return fb.group({ + email: new FormControl(null, [Validators.required, Validators.email]), // Email input with email validation + password: new FormControl(null, [Validators.required]) // Password input with required validation + }); + } + + constructor( + private readonly _store: Store, + private readonly _fb: FormBuilder, + private readonly _authService: AuthService, + private readonly _errorHandlingService: ErrorHandlerService, + private readonly _router: Router + ) { + const navigation = this._router.getCurrentNavigation(); + this.state = navigation?.extras?.state; + } + + ngOnInit(): void { + this.handleWorkspaceNavigation(); + } + + /** + * Handle the form submission. + */ + onSubmit() { + if (this.form.invalid) { + return; // Exit if the form is invalid + } + + // + this.loading = true; + + // Get the values of email and password from the form + const email = this.email.value; + const password = this.password.value; + + // Send a request to sign in to workspaces using the authentication service + this._authService + .findWorkspaces({ email, password }) + .pipe( + tap((response) => { + if (response['status'] === HttpStatus.UNAUTHORIZED) { + throw new Error(`${response['message']}`); + } + }), + // Update component state with the fetched workspaces + tap(({ workspaces, show_popup, total_workspaces, confirmed_email }: IUserSigninWorkspaceResponse) => { + this.workspaces = workspaces; + this.showPopup = show_popup; + this.confirmedEmail = confirmed_email; + this.totalWorkspaces = total_workspaces; + /** */ + if (total_workspaces == 1) { + const [workspace] = this.workspaces; + this.signInWorkspace(workspace); + } else { + this.loading = false; + } + }), + catchError((error) => { + // Handle and log errors using the error handling service + this.loading = false; + this._errorHandlingService.handleError(error); + return EMPTY; + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ) + .subscribe(); + } + + /** + * Continue the workspace sign-in process. + */ + signInWorkspace(workspace: IWorkspaceResponse) { + if (!workspace || !this.confirmedEmail) { + return; // Exit if the no workspace + } + + this.loading = true; + + // Extract workspace, email, and token from the parameter and component state + const email = this.confirmedEmail; + const token = workspace.token; + + // Send a request to sign in to the workspace using the authentication service + this._authService + .signinWorkspaceByToken({ email, token }) + .pipe( + tap((response) => { + if (response['status'] === HttpStatus.UNAUTHORIZED) { + throw new Error(`${response['message']}`); + } + }), + filter(({ user, token }: IAuthResponse) => !!user && !!token), + tap((response: IAuthResponse) => { + const user: IUser = response.user; + const token: string = response.token; + + const { id, employee, tenantId } = user; + TimeTrackerDateManager.organization = employee.organization; + this._store.organizationId = employee.organizationId; + this._store.tenantId = tenantId; + this._store.userId = id; + this._store.token = token; + this._store.user = user; + + asyncScheduler.schedule(() => { + this._authService.electronAuthentication({ token, user }); + this.loading = false; + }, 3000); + }), + catchError((error) => { + // Handle and log errors using the error handling service + this._errorHandlingService.handleError(error); + this.loading = false; + return EMPTY; + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ) + .subscribe(); + } + + public get password() { + return this.form.get('password'); + } + + public get email() { + return this.form.get('email'); + } + + private handleWorkspaceNavigation(): void { + if (this.state) { + this.workspaces = this.state.workspaces; + this.showPopup = this.state.show_popup; + this.confirmedEmail = this.state.confirmed_email; + } + } +} diff --git a/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.html b/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.html new file mode 100644 index 00000000000..124d926b6d4 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.html @@ -0,0 +1,33 @@ + + + + + + + +
    + + +
    +
    +

    {{ 'WORKSPACES.FAIL_SIGNIN_TITLE' | translate }}

    +

    {{ 'WORKSPACES.FAIL_SIGNIN_SUB_TITLE' | translate }}

    +
    +
    + +
    +
    +

    {{ 'WORKSPACES.SUCCESS_SIGNIN_TITLE' | translate }}

    +

    {{ 'WORKSPACES.SUCCESS_SIGNIN_SUB_TITLE' | translate }}

    +
    +
    + +
    +

    {{ 'WORKSPACES.THANKING_TEXT' | translate }}

    +
    +
    +
    diff --git a/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.scss b/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.scss new file mode 100644 index 00000000000..39c77fe88de --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.scss @@ -0,0 +1,41 @@ +@import "themes"; + +.ever-logo-svg { + margin-top: 2rem; + align-self: center; +} + +.message-container { + display: flex; + flex-direction: column; + justify-content: space-between; + background: nb-theme(gauzy-card-2); + border-radius: nb-theme(border-radius); + box-sizing: border-box; + padding: 30px; + width: 100%; + height: 100%; +} + +.error .title { + color: #FF4040; +} +.title { + font-weight: 600; + font-size: 1.1rem; +} +.sub-title { + font-size: .8rem; + color: var(--text-hint-color); +} +.thanking-text { + text-align: center; + font-size: .8rem; +} +.icon { + font-size: 24px; + margin-right: 15px; +} +h3 { + margin-bottom: 0.625rem; +} diff --git a/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.ts b/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.ts new file mode 100644 index 00000000000..5876d751722 --- /dev/null +++ b/packages/desktop-ui-lib/src/lib/login/features/magic-login-workspace/magic-login-workspace.component.ts @@ -0,0 +1,147 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute, Params, Router } from '@angular/router'; +import { IAuthResponse, IUser, IUserSigninWorkspaceResponse, IWorkspaceResponse } from '@gauzy/contracts'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { asyncScheduler, catchError, EMPTY, filter, firstValueFrom, tap } from 'rxjs'; +import { AuthService } from '../../../auth'; +import { ErrorHandlerService, Store, TimeTrackerDateManager } from '../../../services'; + +@UntilDestroy({ checkProperties: true }) +@Component({ + selector: 'ngx-magic-sign-in-workspace', + templateUrl: './magic-login-workspace.component.html', + styleUrls: ['./magic-login-workspace.component.scss'] +}) +export class NgxMagicSignInWorkspaceComponent implements OnInit { + public error: boolean = false; + public success: boolean = false; + public confirmedEmail: string; + public totalWorkspaces: number; + public showPopup: boolean = false; + public workspaces: IWorkspaceResponse[] = []; // Array of workspace users + + constructor( + private readonly _activatedRoute: ActivatedRoute, + private readonly _router: Router, + private readonly _store: Store, + private readonly _authService: AuthService, + private readonly _errorHandlingService: ErrorHandlerService + ) {} + + ngOnInit(): void { + // Create an observable to listen to query parameter changes in the current route. + this._activatedRoute.queryParams + .pipe( + // Filter and ensure that query parameters are present. + filter((params: Params) => !!params), + // Filter and ensure that query parameters are present. + filter(({ email, code }: Params) => !!email && !!code), + // Tap into the observable to update the 'form.email' property with the 'email' query parameter. + tap(({ email, code }: Params) => { + if (email && code) { + this.confirmSignInCode(); + } + }), + // Use 'untilDestroyed' to handle component lifecycle and avoid memory leaks. + untilDestroyed(this) + ) + .subscribe(); + } + + /** + * Confirm the sign in code + */ + async confirmSignInCode() { + // Get the email & code value from the query params + const { email, code } = this._activatedRoute.snapshot.queryParams; + if (!email || !code) { + return; + } + + try { + // Send a request to sign in to workspaces using the authentication service + await firstValueFrom( + this._authService.confirmSignInByCode({ email, code }).pipe( + // Update component state with the fetched workspaces + tap( + ({ + workspaces, + show_popup, + total_workspaces, + confirmed_email + }: IUserSigninWorkspaceResponse) => { + if (confirmed_email) { + this.workspaces = workspaces; + this.showPopup = show_popup; + this.confirmedEmail = confirmed_email; + this.totalWorkspaces = total_workspaces; + + /** */ + if (total_workspaces == 1) { + const [workspace] = workspaces; + this.signInWorkspace(workspace); + } + } + } + ), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ) + ); // Wait for the login request to complete + } catch (error) { + this.error = true; + + await this._router.navigate(['/auth/login-magic']); + } + } + + /** + * Continue the workspace sign-in process. + */ + async signInWorkspace(workspace: IWorkspaceResponse) { + if (!workspace || !this.confirmedEmail) { + return; // Exit if the no workspace + } + + this.showPopup = false; + this.success = true; + + // Extract workspace, email, and token from the parameter and component state + const email = this.confirmedEmail; + const token = workspace.token; + // Send a request to sign in to the workspace using the authentication service + this._authService + .signinWorkspaceByToken({ email, token }) + .pipe( + filter(({ user, token }: IAuthResponse) => !!user && !!token), + tap((response: IAuthResponse) => { + const user: IUser = response.user; + const token: string = response.token; + + const { id, employee, tenantId } = user; + + if (employee) { + TimeTrackerDateManager.organization = employee.organization; + this._store.organizationId = employee.organizationId; + } + + this._store.tenantId = tenantId; + this._store.userId = id; + this._store.token = token; + this._store.user = user; + + asyncScheduler.schedule(() => { + this._authService.electronAuthentication({ token, user }); + }, 3000); + }), + catchError((error) => { + // Handle and log errors using the error handling service + this._errorHandlingService.handleError(error); + return EMPTY; + }), + // Handle component lifecycle to avoid memory leaks + untilDestroyed(this) + ) + .subscribe(); + } +} diff --git a/packages/desktop-ui-lib/src/lib/login/login.component.html b/packages/desktop-ui-lib/src/lib/login/login.component.html index ace9c12701f..f6f3e084637 100644 --- a/packages/desktop-ui-lib/src/lib/login/login.component.html +++ b/packages/desktop-ui-lib/src/lib/login/login.component.html @@ -1,7 +1,8 @@