diff --git a/.vscode/launch.json b/.vscode/launch.json index 829d0722da..2e594596b0 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -60,6 +60,7 @@ "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "runtimeVersion": "20", "showAsyncStacks": true, + "outputCapture": "std", "sourceMaps": true, "cwd": "${workspaceRoot}/pods/server", "protocol": "inspector" @@ -75,6 +76,7 @@ "TRANSACTOR_URL": "ws://localhost:3333", "ACCOUNT_PORT": "3000", "FRONT_URL": "http://localhost:8080", + "outputCapture": "std", "SES_URL": "http://localhost:8091", "MINIO_ACCESS_KEY": "minioadmin", "MINIO_SECRET_KEY": "minioadmin", @@ -186,12 +188,13 @@ "name": "Debug tool upgrade", "type": "node", "request": "launch", - "args": ["src/__start.ts", "upgrade"], + "args": ["src/__start.ts", "upgrade-workspace", "platform"], "env": { "SERVER_SECRET": "secret", "MINIO_ACCESS_KEY": "minioadmin", "MINIO_SECRET_KEY": "minioadmin", "MINIO_ENDPOINT": "localhost", + "TRANSACTOR_URL": "ws://localhost:3333", "MONGO_URL": "mongodb://localhost:27017", "ACCOUNTS_URL": "http://localhost:3000", "TELEGRAM_DATABASE": "telegram-service", @@ -386,6 +389,29 @@ "showAsyncStacks": true, "outputCapture": "std", "cwd": "${workspaceRoot}/services/analytics-collector/pod-analytics-collector" + }, + { + "name": "Debug AI bot", + "type": "node", + "request": "launch", + "args": ["src/index.ts"], + "env": { + "ACCOUNTS_URL": "http://localhost:3000", + "MONGO_URL": "mongodb://localhost:27017", + "PORT": "4008", + "SERVER_SECRET": "secret", + "SUPPORT_WORKSPACE": "support", + "FIRST_NAME": "Jolie", + "LAST_NAME": "AI", + "PASSWORD": "password", + "AVATAR_PATH": "./assets/avatar.png", + "AVATAR_CONTENT_TYPE": ".png" + }, + "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], + "sourceMaps": true, + "cwd": "${workspaceRoot}/services/ai-bot/pod-ai-bot", + "protocol": "inspector", + "outputCapture": "std" } ] } diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index 11fc7155cc..ecd0a088ac 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -238,7 +238,7 @@ "summary": "Build docker with platform", "description": "use to build all docker containers required for platform", "safeForSimultaneousRushProcesses": true, - "shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service" + "shellCommand": "rush docker:build -p 20 --to @hcengineering/pod-server --to @hcengineering/pod-front --to @hcengineering/prod --to @hcengineering/pod-account --to @hcengineering/pod-collaborator --to @hcengineering/tool --to @hcengineering/pod-print --to @hcengineering/pod-sign --to @hcengineering/pod-analytics-collector --to @hcengineering/rekoni-service --to @hcengineering/pod-ai-bot" }, { "commandKind": "global", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index cdc24873f0..22f3cf22f5 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -71,6 +71,9 @@ dependencies: '@rush-temp/activity-resources': specifier: file:./projects/activity-resources.tgz version: file:projects/activity-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2) + '@rush-temp/ai-bot': + specifier: file:./projects/ai-bot.tgz + version: file:projects/ai-bot.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) '@rush-temp/analytics': specifier: file:./projects/analytics.tgz version: file:projects/analytics.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) @@ -407,6 +410,9 @@ dependencies: '@rush-temp/model-server-activity': specifier: file:./projects/model-server-activity.tgz version: file:projects/model-server-activity.tgz + '@rush-temp/model-server-ai-bot': + specifier: file:./projects/model-server-ai-bot.tgz + version: file:projects/model-server-ai-bot.tgz '@rush-temp/model-server-attachment': specifier: file:./projects/model-server-attachment.tgz version: file:projects/model-server-attachment.tgz @@ -572,6 +578,9 @@ dependencies: '@rush-temp/pod-account': specifier: file:./projects/pod-account.tgz version: file:projects/pod-account.tgz + '@rush-temp/pod-ai-bot': + specifier: file:./projects/pod-ai-bot.tgz + version: file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4) '@rush-temp/pod-analytics-collector': specifier: file:./projects/pod-analytics-collector.tgz version: file:projects/pod-analytics-collector.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4) @@ -701,6 +710,12 @@ dependencies: '@rush-temp/server-activity-resources': specifier: file:./projects/server-activity-resources.tgz version: file:projects/server-activity-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) + '@rush-temp/server-ai-bot': + specifier: file:./projects/server-ai-bot.tgz + version: file:projects/server-ai-bot.tgz(esbuild@0.20.1)(ts-node@10.9.2) + '@rush-temp/server-ai-bot-resources': + specifier: file:./projects/server-ai-bot-resources.tgz + version: file:projects/server-ai-bot-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) '@rush-temp/server-analytics-collector': specifier: file:./projects/server-analytics-collector.tgz version: file:projects/server-analytics-collector.tgz(esbuild@0.20.1)(ts-node@10.9.2) @@ -1271,6 +1286,9 @@ dependencies: '@types/qs': specifier: ~6.9.7 version: 6.9.11 + '@types/querystringify': + specifier: ^2.0.2 + version: 2.0.2 '@types/request': specifier: ~2.48.8 version: 2.48.12 @@ -1694,6 +1712,9 @@ dependencies: qs: specifier: ~6.11.0 version: 6.11.2 + querystringify: + specifier: ^2.2.0 + version: 2.2.0 react: specifier: ^18.2.0 version: 18.2.0 @@ -9307,6 +9328,10 @@ packages: resolution: {integrity: sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==} dev: false + /@types/querystringify@2.0.2: + resolution: {integrity: sha512-7d6OQK6pJ//zE32XLK3vI6GHYhBDcYooaRco9cKFGNu59GVatL5+u7rkiAViq44DxDTd/7QQNBWSDHfJGBz/Pw==} + dev: false + /@types/range-parser@1.2.7: resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} dev: false @@ -23758,7 +23783,7 @@ packages: dev: false file:projects/account-service.tgz: - resolution: {integrity: sha512-m2wnBUxpiATGM4uSuNprCebldYmLYweixWs/lkbmn2oxzV797WaTrigIQEEPowYOydBskozS7uc/PgBj9WhkCw==, tarball: file:projects/account-service.tgz} + resolution: {integrity: sha512-ezhFLrsQ8CjpPUIvobegFaStE0c6PJyHqd4V8731uyNwVe+Vhm+Wx5Qoa/VOVqdCWOixjZdP9sBWILsRU9nKkA==, tarball: file:projects/account-service.tgz} name: '@rush-temp/account-service' version: 0.0.0 dependencies: @@ -23961,6 +23986,37 @@ packages: - ts-node dev: false + file:projects/ai-bot.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): + resolution: {integrity: sha512-TH/kDh1LKw+pJRxLZZIjR7da6BQslR9S9Uy9HHXDtvtu5es55OmjRZEWPUnkBjKRmY6hTPBmaO4veq7RiaNz7g==, tarball: file:projects/ai-bot.tgz} + id: file:projects/ai-bot.tgz + name: '@rush-temp/ai-bot' + version: 0.0.0 + dependencies: + '@types/jest': 29.5.12 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-prettier: 8.10.0(eslint@8.56.0) + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - '@types/node' + - babel-jest + - babel-plugin-macros + - esbuild + - node-notifier + - supports-color + - ts-node + dev: false + file:projects/analytics-collector-assets.tgz(esbuild@0.20.1)(svelte@4.2.12)(ts-node@10.9.2): resolution: {integrity: sha512-ThEhCV2dJchrcM0mfUjNFdD0/qOzl3qC7glianWUl4Qsj03IrYyjbxvdsV7Fy/49UhQ+YUt46X39kgIWVBnWiw==, tarball: file:projects/analytics-collector-assets.tgz} id: file:projects/analytics-collector-assets.tgz @@ -24236,7 +24292,7 @@ packages: dev: false file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-ixfEzxmzLbrHghRymDcvdyz35q9DsDnA1qqheztJNa2zLH5102sfEMEooVLShU3cD+uf/KhNbcTyea1on8GlfA==, tarball: file:projects/auth-providers.tgz} + resolution: {integrity: sha512-5ii9QDKZ+v5awKhzsUOf7Y83PSCUzX6BKFZjY21w8oy+pJRZPTVbKYJhQC4i1AuvahXCObkMbCc3ljs0L+vgIw==, tarball: file:projects/auth-providers.tgz} id: file:projects/auth-providers.tgz name: '@rush-temp/auth-providers' version: 0.0.0 @@ -24249,6 +24305,7 @@ packages: '@types/node-fetch': 2.6.11 '@types/passport-github2': 1.2.9 '@types/passport-google-oauth20': 2.0.14 + '@types/querystringify': 2.0.2 '@types/ws': 8.5.11 '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) @@ -24268,6 +24325,7 @@ packages: passport-google-oauth20: 2.0.0 prettier: 3.2.5 prettier-plugin-svelte: 3.2.1(prettier@3.2.5)(svelte@4.2.11) + querystringify: 2.2.0 ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: @@ -25340,7 +25398,7 @@ packages: dev: false file:projects/desktop.tgz(bufferutil@4.0.8)(sass@1.71.1)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-Zqlhi9UprYvF9L0ANw8sSkxTuqMYWTDmIUV6wYDTHd+NMt8NKshXy9J39mll8dW0h93TeY538RePwutrdvM4kA==, tarball: file:projects/desktop.tgz} + resolution: {integrity: sha512-j8onQxy51K29b/9q8g+lOG8e8dOFO2WpQQTdtnhP2vy2EcvIuSgckenQpg++0r1AtJ1G7FC7hr5QYNsrTPWkBA==, tarball: file:projects/desktop.tgz} id: file:projects/desktop.tgz name: '@rush-temp/desktop' version: 0.0.0 @@ -26601,7 +26659,7 @@ packages: dev: false file:projects/lead-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2): - resolution: {integrity: sha512-YFe9zCBoR5pPRxsoG3Oc9qVVHEWGDlmQrw27HY+3P0SMBXz8xWVjgM6SrL2458y83t/Xsj30krK1zEZkWORfpQ==, tarball: file:projects/lead-resources.tgz} + resolution: {integrity: sha512-DIL0rCz2r3nKYgE2fOrEdVSwLGq1Zz2MjsU1HvDxnIs4IE1Q4s8oNMBtvtunERIQ2fNnJHcE0XXijawtYilrjQ==, tarball: file:projects/lead-resources.tgz} id: file:projects/lead-resources.tgz name: '@rush-temp/lead-resources' version: 0.0.0 @@ -26820,7 +26878,7 @@ packages: dev: false file:projects/love-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2): - resolution: {integrity: sha512-+jxDfVCF8l8WnKd4Xmaau521zbt9KBRubhiB6N8gXjePkAFSiFXdiQGPnB7VGyYLnImqDuCCnfQNJJpomb4TNQ==, tarball: file:projects/love-resources.tgz} + resolution: {integrity: sha512-e49VrhVkk6U8nj/ZyKfYVj/gK/3rTSrVCicDM5Luxw/f/8pPEVf9uEzmdAV11uiXuXxmj5t0BmHdG5eeVhiK6A==, tarball: file:projects/love-resources.tgz} id: file:projects/love-resources.tgz name: '@rush-temp/love-resources' version: 0.0.0 @@ -26868,7 +26926,7 @@ packages: dev: false file:projects/love.tgz(@types/node@20.11.19)(esbuild@0.20.1)(svelte@4.2.12)(ts-node@10.9.2): - resolution: {integrity: sha512-qWAfcld2pm2sf/2kZM1RLYGnhIJWRC42pBsrjQUmztuosCriG9qFa8fdymEsTp4TJaFG9zAqlci7/NKHN9ldvA==, tarball: file:projects/love.tgz} + resolution: {integrity: sha512-IA1DOwfYE00ACQ47zgXq3yOcNghk1oU1nPcq15bqdjOxJeKaE5sa4w/yJ7WoGn1Dm5F0TmxKc67GGDxcTT6kDQ==, tarball: file:projects/love.tgz} id: file:projects/love.tgz name: '@rush-temp/love' version: 0.0.0 @@ -27347,7 +27405,7 @@ packages: dev: false file:projects/model-love.tgz: - resolution: {integrity: sha512-iYxy2iQSrCHIlzZLKrDvP5yGrpJrVopD79gegg6iVsqnDbRqrTbsN2H69Xn1KeDbOJllKgVw5y8yVFO+AMq41Q==, tarball: file:projects/model-love.tgz} + resolution: {integrity: sha512-vHa71QNYOwrIifwIvVUmNWd+qlSgxZ0ErEgrx9uBSnJCxff9iqRhtmrFlfJ27bvi/jiBWOro9XYt5liL7gOGhg==, tarball: file:projects/model-love.tgz} name: '@rush-temp/model-love' version: 0.0.0 dependencies: @@ -27550,6 +27608,24 @@ packages: - supports-color dev: false + file:projects/model-server-ai-bot.tgz: + resolution: {integrity: sha512-eo56EIdT6JLS0upi95IDRPBEXyASWLEpUdH9cfK2lLtpslJYlJkRPSqImEA5xk1fUUwexRPqVPW+bYFoIMsUxQ==, tarball: file:projects/model-server-ai-bot.tgz} + name: '@rush-temp/model-server-ai-bot' + version: 0.0.0 + dependencies: + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + prettier: 3.2.5 + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + dev: false + file:projects/model-server-attachment.tgz: resolution: {integrity: sha512-F45oxBvR4Gh7Y70CqozTOyXQ/lWOkP/EDURRVxxETMWbtXej37/QCtWXlLJ2YNWmoG6ildVMOZlK/IaMSdU9mA==, tarball: file:projects/model-server-attachment.tgz} name: '@rush-temp/model-server-attachment' @@ -28588,7 +28664,7 @@ packages: dev: false file:projects/onboard-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(file-loader@6.2.0)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2)(webpack@5.90.3): - resolution: {integrity: sha512-3Gt9PEcA7T44mLBBC0KwwZQ6Qs7pXYbqQaagWFjfT+4MTvR9XVk4L+67U0yFYqpS/EeODSc+dJklk3QBY2O36g==, tarball: file:projects/onboard-resources.tgz} + resolution: {integrity: sha512-xbIj2phiY1rWzXxo5gqyucDzJS7IKMWhgTS4qZ5V9Rh02mCECL2Fs6N5OiiHFJH66jllxThPdX7SIDNBVPcfmA==, tarball: file:projects/onboard-resources.tgz} id: file:projects/onboard-resources.tgz name: '@rush-temp/onboard-resources' version: 0.0.0 @@ -28849,6 +28925,62 @@ packages: - supports-color dev: false + file:projects/pod-ai-bot.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): + resolution: {integrity: sha512-5Upjw52FvxxuZY9eSN8N5FUKoNMhqME7DwEAp4TTQEKR/Rs+Fnxi7S5ZQ6G2G40RmZ0F0mWfXfWTcyhFlyio5Q==, tarball: file:projects/pod-ai-bot.tgz} + id: file:projects/pod-ai-bot.tgz + name: '@rush-temp/pod-ai-bot' + version: 0.0.0 + dependencies: + '@tsconfig/node16': 1.0.4 + '@types/cors': 2.8.17 + '@types/express': 4.17.21 + '@types/jest': 29.5.12 + '@types/node': 20.11.19 + '@types/node-fetch': 2.6.11 + '@types/ws': 8.5.11 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + cors: 2.8.5 + dotenv: 16.0.3 + esbuild: 0.20.1 + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-node: 11.1.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + express: 4.19.2 + fast-equals: 5.0.1 + form-data: 4.0.0 + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + mongodb: 6.8.0 + node-fetch: 2.7.0 + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + ts-node: 10.9.2(@types/node@20.11.19)(typescript@5.3.3) + typescript: 5.3.3 + ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4) + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@babel/core' + - '@jest/types' + - '@mongodb-js/zstd' + - '@swc/core' + - '@swc/wasm' + - babel-jest + - babel-plugin-macros + - bufferutil + - encoding + - gcp-metadata + - kerberos + - mongodb-client-encryption + - node-notifier + - snappy + - socks + - supports-color + - utf-8-validate + dev: false + file:projects/pod-analytics-collector.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): resolution: {integrity: sha512-Sx9mTOCsgDTcoC8h9VXeemVHerKLMDzdW4aJ08fdsF9UA35tLWmbDXw+fn3gPUNj2l+QT5ExEF9rV1Iez1FIbw==, tarball: file:projects/pod-analytics-collector.tgz} id: file:projects/pod-analytics-collector.tgz @@ -28930,7 +29062,7 @@ packages: dev: false file:projects/pod-calendar.tgz(bufferutil@4.0.8)(ts-node@10.9.2)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-DOvMMTPpDOAuO8Vi8gEBTXBMQATYdZgtNWxvUyG0xbCArW9jEmKbrOVBV1ydZGOJqfqhennjv09+uWR/YUR+SQ==, tarball: file:projects/pod-calendar.tgz} + resolution: {integrity: sha512-rnCLCkBGWm2o9YTk6AnoYlNCACtncXiYw+CUkhVFJmBNTE1l90C74Uci4ldJBpxEOyCHVBzJIjsp6sMbQwafkw==, tarball: file:projects/pod-calendar.tgz} id: file:projects/pod-calendar.tgz name: '@rush-temp/pod-calendar' version: 0.0.0 @@ -29160,7 +29292,7 @@ packages: dev: false file:projects/pod-gmail.tgz(bufferutil@4.0.8)(ts-node@10.9.2)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-ybpB+uVlKzWvEVZnrp6iPuDYR7OwWBEsHl3ivSmte8BhfH9Q5QfzBm/FrEUsyEBIN4KS/cGDUoNGv5Us5OVWVw==, tarball: file:projects/pod-gmail.tgz} + resolution: {integrity: sha512-qeyt7Pl1TwEo/JojBg4R4LR3uWEdxOfqfz6tYB5kB27oEdE+jBJx0LGgsrDYoQSsv7A0LLnaFVfS+XT17EoKeQ==, tarball: file:projects/pod-gmail.tgz} id: file:projects/pod-gmail.tgz name: '@rush-temp/pod-gmail' version: 0.0.0 @@ -29448,7 +29580,7 @@ packages: dev: false file:projects/pod-telegram.tgz(bufferutil@4.0.8)(ts-node@10.9.2)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-mBbMXZhgRl0R9gvFN+uxfdFHgXdF9cFyC200rXnS4PgGkGVm9jRmE8lJQTxiOOJyX8rPDm8qlLhCrlKyof+pEQ==, tarball: file:projects/pod-telegram.tgz} + resolution: {integrity: sha512-MF+eEeVhFR4XQj2YaAP6gjvm1tijtnRUMDrQt48vZBD8mwQA8B0drOVHCkOQ+NJOYqi1c3pRG/+kPKWqswbplQ==, tarball: file:projects/pod-telegram.tgz} id: file:projects/pod-telegram.tgz name: '@rush-temp/pod-telegram' version: 0.0.0 @@ -30578,6 +30710,74 @@ packages: - ts-node dev: false + file:projects/server-ai-bot-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): + resolution: {integrity: sha512-YOa1XOLVz87AjouR9Qn4Wkiib/9nXkIiMIW7XVzCl+lVqfdjXDRn4kjxXj363Wh8bw5Ato7kpuj0M9C1Vsh+zw==, tarball: file:projects/server-ai-bot-resources.tgz} + id: file:projects/server-ai-bot-resources.tgz + name: '@rush-temp/server-ai-bot-resources' + version: 0.0.0 + dependencies: + '@types/jest': 29.5.12 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + mongodb: 6.8.0 + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@babel/core' + - '@jest/types' + - '@mongodb-js/zstd' + - '@types/node' + - babel-jest + - babel-plugin-macros + - esbuild + - gcp-metadata + - kerberos + - mongodb-client-encryption + - node-notifier + - snappy + - socks + - supports-color + - ts-node + dev: false + + file:projects/server-ai-bot.tgz(esbuild@0.20.1)(ts-node@10.9.2): + resolution: {integrity: sha512-9rB0TFsDFJ2KgmJwTCJRQG/rh+NORXysidGZq8v77VEN7Sh/xQlB33k4XgHutF3U1Bd0KUdRC/pg9grlgezIdA==, tarball: file:projects/server-ai-bot.tgz} + id: file:projects/server-ai-bot.tgz + name: '@rush-temp/server-ai-bot' + version: 0.0.0 + dependencies: + '@types/jest': 29.5.12 + '@types/node': 20.11.19 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0)(eslint-plugin-import@2.29.1)(eslint-plugin-n@15.7.0)(eslint-plugin-promise@6.1.1)(eslint@8.56.0)(typescript@5.3.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + prettier: 3.2.5 + ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - esbuild + - node-notifier + - supports-color + - ts-node + dev: false + file:projects/server-analytics-collector-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): resolution: {integrity: sha512-cLqoyrE7bZmjUeQSenTvg0S2XdEUUulUTMYvYephOUR5orzG71xy3Z+HgV92YapcZMzi9RVdaataHwYhxTRDPw==, tarball: file:projects/server-analytics-collector-resources.tgz} id: file:projects/server-analytics-collector-resources.tgz @@ -31756,7 +31956,7 @@ packages: dev: false file:projects/server-pipeline.tgz: - resolution: {integrity: sha512-DpU8h5YS6u+d1uVTSyALfcGof3p2hXuSd8f1RyFI/YvNHxLHLrTu0AzquL8uhwxliB1bpNJtCWh1RWphdVJD4Q==, tarball: file:projects/server-pipeline.tgz} + resolution: {integrity: sha512-VMd/X1M3HotOPN51cVDAHjKrHhNm8/5GMMiZ4WklnQVtDfmDHocXKsC+FgOLXpb9RHYhtsz0Arwi7pW7xIar5A==, tarball: file:projects/server-pipeline.tgz} name: '@rush-temp/server-pipeline' version: 0.0.0 dependencies: @@ -32167,7 +32367,7 @@ packages: dev: false file:projects/server-telegram-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): - resolution: {integrity: sha512-jlZjP4X1pBtvaMNdCcrjmyAOJeVk/AQbQUwa0f28Oc4VU6OaktCMmG+0+GvnvMVAcpjUmb2fL3rnLxI2ckB2yQ==, tarball: file:projects/server-telegram-resources.tgz} + resolution: {integrity: sha512-4Pob/P+5sdtU7LGgb1jJbqVms72MXOpAvOZ+GivgI3j9JvqvbOfHn87O1jVhrWa865xZrdGlV9+T9grap3W3xg==, tarball: file:projects/server-telegram-resources.tgz} id: file:projects/server-telegram-resources.tgz name: '@rush-temp/server-telegram-resources' version: 0.0.0 @@ -32181,18 +32381,26 @@ packages: eslint-plugin-n: 15.7.0(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0) jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + mongodb: 6.8.0 prettier: 3.2.5 prettier-plugin-svelte: 3.2.1(prettier@3.2.5)(svelte@4.2.11) ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) typescript: 5.3.3 transitivePeerDependencies: + - '@aws-sdk/credential-providers' - '@babel/core' - '@jest/types' + - '@mongodb-js/zstd' - '@types/node' - babel-jest - babel-plugin-macros - esbuild + - gcp-metadata + - kerberos + - mongodb-client-encryption - node-notifier + - snappy + - socks - supports-color - ts-node dev: false @@ -33244,7 +33452,7 @@ packages: dev: false file:projects/telegram-resources.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2): - resolution: {integrity: sha512-TakwKag4NWDevTKXic9xdAmzPjjw9ezjHVHZYyYcweVBNgloGjjVfGqfBzWyrzcuGCFJRMddfygAHqvSUsuRiQ==, tarball: file:projects/telegram-resources.tgz} + resolution: {integrity: sha512-a2P52BNrZcs4YavNcVmsBRpfa2o2rq787sDtT2xxNqMhYn74+D3G6ZGkJGbeGJSm3GD4ZS1ELKfdrf5MXvgSkg==, tarball: file:projects/telegram-resources.tgz} id: file:projects/telegram-resources.tgz name: '@rush-temp/telegram-resources' version: 0.0.0 diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index aaca275732..a9a8405a4b 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -92,6 +92,7 @@ import '@hcengineering/training-assets' import '@hcengineering/products-assets' import '@hcengineering/controlled-documents-assets' import '@hcengineering/analytics-collector-assets' +import '@hcengineering/text-editor-assets' import { coreId } from '@hcengineering/core' import presentation, { parsePreviewConfig, presentationId } from '@hcengineering/presentation' diff --git a/desktop/src/ui/preload.ts b/desktop/src/ui/preload.ts index 164457e296..4502e52ec1 100644 --- a/desktop/src/ui/preload.ts +++ b/desktop/src/ui/preload.ts @@ -1,7 +1,6 @@ // preload.js import { BrandingMap, Config, IPCMainExposed, NotificationParams } from './types' - import { contextBridge, ipcRenderer } from 'electron' /** @@ -18,6 +17,31 @@ export function concatLink (host: string, path: string): string { } } +async function loadServerConfig (url: string): Promise { + let retries = 5 + let res: Response | undefined + + do { + try { + res = await fetch(url) + break + } catch (e) { + retries-- + if (retries === 0) { + throw new Error(`Failed to load server config: ${e}`) + } + await new Promise((resolve) => setTimeout(resolve, 1000 * (5 - retries))) + } + } while (retries > 0) + + if (res === undefined) { + // In theory should never get here + throw new Error('Failed to load server config') + } + + return await res.json() +} + const openArg = (process.argv.find((it) => it.startsWith('--open=')) ?? '').split('--open=')[1] console.log('Open passed', openArg) let configPromise: Promise | undefined @@ -41,7 +65,7 @@ const expose: IPCMainExposed = { configPromise = new Promise((resolve, reject) => { ipcRenderer.invoke('get-main-config').then( async (mainConfig) => { - const serverConfig = await (await fetch(concatLink(mainConfig.FRONT_URL, mainConfig.CONFIG_URL))).json() + const serverConfig = await loadServerConfig(concatLink(mainConfig.FRONT_URL, mainConfig.CONFIG_URL)) const combinedConfig = { ...serverConfig, ...mainConfig, diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 23377590e8..a7c7dd8abc 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -213,7 +213,24 @@ services: deploy: resources: limits: - memory: 300M + memory: 300M + aiBot: + image: hardcoreeng/ai-bot + restart: unless-stopped + environment: + - SERVER_SECRET=secret + - MONGO_URL=mongodb://mongodb:27017 + - ACCOUNTS_URL=http://account:3000 + - SUPPORT_WORKSPACE=support + - FIRST_NAME=Jolie + - LAST_NAME=AI + - PASSWORD=password + - AVATAR_PATH=./avatar.png + - AVATAR_CONTENT_TYPE=.png + deploy: + resources: + limits: + memory: 300M volumes: db: files: diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 02732ab600..f864bf55f9 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -135,13 +135,14 @@ export interface Config { GITHUB_URL: string SENTRY_DSN?: string LOVE_ENDPOINT?: string - LIVEKIT_WS?: string, - SIGN_URL?: string, - PRINT_URL?: string, - POSTHOG_API_KEY?: string, + LIVEKIT_WS?: string + SIGN_URL?: string + PRINT_URL?: string + POSTHOG_API_KEY?: string POSTHOG_HOST?: string ANALYTICS_COLLECTOR_URL?:string BRANDING_URL?: string + TELEGRAM_BOT_URL?: string // Could be defined for dev environment FRONT_URL?: string @@ -301,6 +302,7 @@ export async function configurePlatform() { setMetadata(presentation.metadata.FrontVersion, config.VERSION) } setMetadata(telegram.metadata.TelegramURL, config.TELEGRAM_URL ?? 'http://localhost:8086') + setMetadata(telegram.metadata.BotUrl, config.TELEGRAM_BOT_URL) setMetadata(gmail.metadata.GmailURL, config.GMAIL_URL ?? 'http://localhost:8087') setMetadata(calendar.metadata.CalendarServiceURL, config.CALENDAR_URL ?? 'http://localhost:8095') setMetadata(notification.metadata.PushPublicKey, config.PUSH_PUBLIC_KEY) diff --git a/dev/tool/package.json b/dev/tool/package.json index e30aecb505..4b701d3138 100644 --- a/dev/tool/package.json +++ b/dev/tool/package.json @@ -133,6 +133,8 @@ "@hcengineering/server-guest-resources": "^0.6.0", "@hcengineering/server-time": "^0.6.0", "@hcengineering/server-time-resources": "^0.6.0", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot-resources": "^0.6.0", "@hcengineering/setting": "^0.6.17", "@hcengineering/tags": "^0.6.16", "@hcengineering/task": "^0.6.20", diff --git a/dev/tool/src/__start.ts b/dev/tool/src/__start.ts index c1c0d9ff7b..5ba341d8ed 100644 --- a/dev/tool/src/__start.ts +++ b/dev/tool/src/__start.ts @@ -44,6 +44,7 @@ import { serverTelegramId } from '@hcengineering/server-telegram' import { serverTimeId } from '@hcengineering/server-time' import { serverTrackerId } from '@hcengineering/server-tracker' import { serverViewId } from '@hcengineering/server-view' +import { serverAiBotId } from '@hcengineering/server-ai-bot' addLocation(serverActivityId, () => import('@hcengineering/server-activity-resources')) addLocation(serverAttachmentId, () => import('@hcengineering/server-attachment-resources')) @@ -68,6 +69,7 @@ addLocation(serverDocumentId, () => import('@hcengineering/server-document-resou addLocation(serverTimeId, () => import('@hcengineering/server-time-resources')) addLocation(serverGuestId, () => import('@hcengineering/server-guest-resources')) addLocation(serverDriveId, () => import('@hcengineering/server-drive-resources')) +addLocation(serverAiBotId, () => import('@hcengineering/server-ai-bot-resources')) function prepareTools (): { mongodbUri: string diff --git a/dev/tool/src/clean.ts b/dev/tool/src/clean.ts index a7d6c51c0d..20cca7c2b5 100644 --- a/dev/tool/src/clean.ts +++ b/dev/tool/src/clean.ts @@ -1242,7 +1242,7 @@ async function updateId ( stages: {}, removed: false }) - await txop.update(docIndexState, { removed: true }) + await txop.update(docIndexState, { removed: true, needIndex: true }) } if (domain !== DOMAIN_MODEL) { diff --git a/models/activity/src/migration.ts b/models/activity/src/migration.ts index 0e3ec28d23..7d4b040834 100644 --- a/models/activity/src/migration.ts +++ b/models/activity/src/migration.ts @@ -25,6 +25,7 @@ import { tryMigrate } from '@hcengineering/model' import { htmlToMarkup } from '@hcengineering/text' +import contact from '@hcengineering/contact' import activity from './plugin' import { activityId, DOMAIN_ACTIVITY } from './index' @@ -197,6 +198,16 @@ export const activityOperation: MigrateOperation = { ({ attachedToClass }) => attachedToClass ) } + }, + { + state: 'migrate-employee-space-v1', + func: async () => { + await client.update( + DOMAIN_ACTIVITY, + { space: 'contact:space:Employee' as Ref }, + { space: contact.space.Contacts } + ) + } } ]) }, diff --git a/models/all/package.json b/models/all/package.json index 43d48c6c54..42350fa276 100644 --- a/models/all/package.json +++ b/models/all/package.json @@ -105,6 +105,7 @@ "@hcengineering/model-server-products": "^0.1.0", "@hcengineering/model-server-training": "^0.1.0", "@hcengineering/model-server-controlled-documents": "^0.1.0", - "@hcengineering/model-analytics-collector": "^0.6.0" + "@hcengineering/model-analytics-collector": "^0.6.0", + "@hcengineering/model-server-ai-bot": "^0.6.0" } } diff --git a/models/all/src/index.ts b/models/all/src/index.ts index 13decd8afc..37322b9d51 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -60,6 +60,7 @@ import { serverTelegramId, createModel as serverTelegramModel } from '@hcenginee import { serverTemplatesId, createModel as serverTemplatesModel } from '@hcengineering/model-server-templates' import { serverTrackerId, createModel as serverTrackerModel } from '@hcengineering/model-server-tracker' import { serverViewId, createModel as serverViewModel } from '@hcengineering/model-server-view' +import { serverAiBotId, createModel as serverAiBotModel } from '@hcengineering/model-server-ai-bot' import setting, { settingId, createModel as settingModel } from '@hcengineering/model-setting' import { driveId, createModel as driveModel } from '@hcengineering/model-drive' import { supportId, createModel as supportModel } from '@hcengineering/model-support' @@ -424,7 +425,8 @@ export default function buildModel (enabled: string[] = ['*'], disabled: string[ [serverDriveModel, serverDriveId], [serverProductsModel, serverProductsId], [serverTrainingModel, serverTrainingId], - [serverDocumentsModel, serverDocumentsId] + [serverDocumentsModel, serverDocumentsId], + [serverAiBotModel, serverAiBotId] ] for (const [b, id, config] of builders) { diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index 4156981bf9..dfd25f4c10 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import activity, { type ActivityMessage } from '@hcengineering/activity' +import activity, { type ActivityMessage, type ActivityMessageControl } from '@hcengineering/activity' import { type Channel, chunterId, @@ -62,6 +62,7 @@ import { type DocNotifyContext } from '@hcengineering/notification' import chunter from './plugin' import { defineActions } from './actions' +import { defineNotifications } from './notifications' export { chunterId } from '@hcengineering/chunter' export { chunterOperation } from './migration' @@ -197,37 +198,14 @@ export function createModel (builder: Builder): void { titleProvider: chunter.function.ChannelTitleProvider }) - builder.mixin(chunter.class.DirectMessage, core.class.Class, notification.mixin.ClassCollaborators, { - fields: ['members'] - }) - - builder.mixin(chunter.class.Channel, core.class.Class, notification.mixin.ClassCollaborators, { - fields: ['members'] - }) - builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.ObjectPresenter, { presenter: chunter.component.DmPresenter }) - builder.mixin(chunter.class.DirectMessage, core.class.Class, notification.mixin.NotificationPreview, { - presenter: chunter.component.ChannelPreview - }) - builder.mixin(chunter.class.Channel, core.class.Class, view.mixin.ObjectPresenter, { presenter: chunter.component.ChannelPresenter }) - builder.mixin(chunter.class.ChatMessage, core.class.Class, notification.mixin.NotificationContextPresenter, { - labelPresenter: chunter.component.ChatMessageNotificationLabel - }) - - builder.createDoc(notification.class.ActivityNotificationViewlet, core.space.Model, { - messageMatch: { - _class: chunter.class.ThreadMessage - }, - presenter: chunter.component.ThreadNotificationPresenter - }) - builder.mixin(chunter.class.DirectMessage, core.class.Class, view.mixin.SpaceHeader, { header: chunter.component.DmHeader }) @@ -284,78 +262,6 @@ export function createModel (builder: Builder): void { filters: [] }) - builder.createDoc( - notification.class.NotificationGroup, - core.space.Model, - { - label: chunter.string.ApplicationLabelChunter, - icon: chunter.icon.Chunter - }, - chunter.ids.ChunterNotificationGroup - ) - - builder.createDoc( - notification.class.NotificationType, - core.space.Model, - { - label: chunter.string.DM, - generated: false, - hidden: false, - txClasses: [core.class.TxCreateDoc], - objectClass: chunter.class.ChatMessage, - attachedToClass: chunter.class.DirectMessage, - defaultEnabled: false, - group: chunter.ids.ChunterNotificationGroup, - templates: { - textTemplate: '{sender} has sent you a message: {doc} {message}', - htmlTemplate: '

{sender} has sent you a message {doc}

{message}', - subjectTemplate: 'You have new direct message in {doc}' - } - }, - chunter.ids.DMNotification - ) - - builder.createDoc( - notification.class.NotificationType, - core.space.Model, - { - label: chunter.string.Message, - generated: false, - hidden: false, - txClasses: [core.class.TxCreateDoc], - objectClass: chunter.class.ChatMessage, - attachedToClass: chunter.class.Channel, - defaultEnabled: false, - group: chunter.ids.ChunterNotificationGroup, - templates: { - textTemplate: '{sender} has sent a message in {doc}: {message}', - htmlTemplate: '

{sender} has sent a message in {doc}

{message}', - subjectTemplate: 'You have new message in {doc}' - } - }, - chunter.ids.ChannelNotification - ) - - builder.createDoc( - notification.class.NotificationType, - core.space.Model, - { - label: chunter.string.ThreadMessage, - generated: false, - hidden: false, - txClasses: [core.class.TxCreateDoc], - objectClass: chunter.class.ThreadMessage, - defaultEnabled: false, - group: chunter.ids.ChunterNotificationGroup, - templates: { - textTemplate: '{body}', - htmlTemplate: '

{body}

', - subjectTemplate: '{title}' - } - }, - chunter.ids.ThreadNotification - ) - builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, { label: chunter.string.Comments, position: 60, @@ -419,11 +325,11 @@ export function createModel (builder: Builder): void { }) builder.mixin(chunter.class.Channel, core.class.Class, chunter.mixin.ObjectChatPanel, { - ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description'] + ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description', 'members', 'owners'] }) builder.mixin(chunter.class.DirectMessage, core.class.Class, chunter.mixin.ObjectChatPanel, { - ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description'] + ignoreKeys: ['archived', 'collaborators', 'lastMessage', 'pinned', 'topic', 'description', 'members', 'owners'] }) builder.mixin(chunter.class.ChatMessage, core.class.Class, activity.mixin.ActivityMessagePreview, { @@ -448,19 +354,50 @@ export function createModel (builder: Builder): void { strict: true }) - builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { - provider: notification.providers.InboxNotificationProvider, - ignoredTypes: [], - enabledTypes: [chunter.ids.DMNotification, chunter.ids.ChannelNotification, chunter.ids.ThreadNotification] + builder.createDoc>(activity.class.ActivityMessageControl, core.space.Model, { + objectClass: chunter.class.Channel, + skip: [ + { _class: core.class.TxMixin }, + { _class: core.class.TxCreateDoc, objectClass: { $ne: chunter.class.Channel } }, + { _class: core.class.TxRemoveDoc } + ], + allowedFields: ['members'] }) - builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { - provider: notification.providers.PushNotificationProvider, - ignoredTypes: [], - enabledTypes: [chunter.ids.DMNotification, chunter.ids.ChannelNotification, chunter.ids.ThreadNotification] + builder.createDoc>(activity.class.ActivityMessageControl, core.space.Model, { + objectClass: chunter.class.DirectMessage, + skip: [{ _class: core.class.TxMixin }, { _class: core.class.TxCreateDoc }, { _class: core.class.TxRemoveDoc }], + allowedFields: ['members'] + }) + + builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, { + objectClass: chunter.class.Channel, + action: 'create', + component: chunter.activity.ChannelCreatedMessage + }) + + builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, { + objectClass: chunter.class.Channel, + action: 'update', + config: { + members: { + presenter: chunter.activity.MembersChangedMessage + } + } + }) + + builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, { + objectClass: chunter.class.DirectMessage, + action: 'update', + config: { + members: { + presenter: chunter.activity.MembersChangedMessage + } + } }) defineActions(builder) + defineNotifications(builder) } export default chunter diff --git a/models/chunter/src/migration.ts b/models/chunter/src/migration.ts index cc49f9c27b..7faa157a29 100644 --- a/models/chunter/src/migration.ts +++ b/models/chunter/src/migration.ts @@ -38,20 +38,21 @@ import { DOMAIN_NOTIFICATION } from '@hcengineering/model-notification' import chunter from './plugin' import { DOMAIN_CHUNTER } from './index' +import { type DocUpdateMessage } from '@hcengineering/activity' export const DOMAIN_COMMENT = 'comment' as Domain export async function createDocNotifyContexts ( client: MigrationUpgradeClient, tx: TxOperations, - attachedTo: Ref, - attachedToClass: Ref> + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref ): Promise { const users = await client.findAll(core.class.Account, {}) const docNotifyContexts = await client.findAll(notification.class.DocNotifyContext, { user: { $in: users.map((it) => it._id) }, - attachedTo, - attachedToClass + objectId }) for (const user of users) { if (user._id === core.account.System) { @@ -62,8 +63,10 @@ export async function createDocNotifyContexts ( if (docNotifyContext === undefined) { await tx.createDoc(notification.class.DocNotifyContext, core.space.Space, { user: user._id, - attachedTo, - attachedToClass + objectId, + objectClass, + objectSpace, + isPinned: false }) } } @@ -93,7 +96,7 @@ export async function createGeneral (client: MigrationUpgradeClient, tx: TxOpera topic: 'General Channel', private: false, archived: false, - members: await getAllEmployeeAccounts(tx), + members: await getAllPersonAccounts(tx), autoJoin: true }, chunter.space.General @@ -101,10 +104,10 @@ export async function createGeneral (client: MigrationUpgradeClient, tx: TxOpera } } - await createDocNotifyContexts(client, tx, chunter.space.General, chunter.class.Channel) + await createDocNotifyContexts(client, tx, chunter.space.General, chunter.class.Channel, core.space.Space) } -async function getAllEmployeeAccounts (tx: TxOperations): Promise[]> { +async function getAllPersonAccounts (tx: TxOperations): Promise[]> { const employees = await tx.findAll(contactPlugin.mixin.Employee, { active: true }) const accounts = await tx.findAll(contactPlugin.class.PersonAccount, { person: { $in: employees.map((it) => it._id) } @@ -113,7 +116,7 @@ async function getAllEmployeeAccounts (tx: TxOperations): Promise { - const accs = await getAllEmployeeAccounts(tx) + const accs = await getAllPersonAccounts(tx) const newMembers: Ref[] = [...current.members] for (const acc of accs) { if (!newMembers.includes(acc)) { @@ -149,7 +152,7 @@ export async function createRandom (client: MigrationUpgradeClient, tx: TxOperat topic: 'Random Talks', private: false, archived: false, - members: await getAllEmployeeAccounts(tx), + members: await getAllPersonAccounts(tx), autoJoin: true }, chunter.space.Random @@ -157,7 +160,7 @@ export async function createRandom (client: MigrationUpgradeClient, tx: TxOperat } } - await createDocNotifyContexts(client, tx, chunter.space.Random, chunter.class.Channel) + await createDocNotifyContexts(client, tx, chunter.space.Random, chunter.class.Channel, core.space.Space) } async function convertCommentsToChatMessages (client: MigrationClient): Promise { @@ -195,6 +198,47 @@ async function removeOldClasses (client: MigrationClient): Promise { } } +async function removeWrongActivity (client: MigrationClient): Promise { + await client.deleteMany(DOMAIN_ACTIVITY, { + _class: activity.class.DocUpdateMessage, + attachedToClass: chunter.class.Channel, + action: 'update', + 'attributeUpdates.attrKey': { $ne: 'members' } + }) + + await client.deleteMany(DOMAIN_ACTIVITY, { + _class: activity.class.DocUpdateMessage, + attachedToClass: chunter.class.Channel, + action: 'create', + objectClass: { $ne: chunter.class.Channel } + }) + + await client.deleteMany(DOMAIN_ACTIVITY, { + _class: activity.class.DocUpdateMessage, + attachedToClass: chunter.class.Channel, + action: 'remove' + }) + + await client.deleteMany(DOMAIN_ACTIVITY, { + _class: activity.class.DocUpdateMessage, + attachedToClass: chunter.class.DirectMessage, + action: 'update', + 'attributeUpdates.attrKey': { $ne: 'members' } + }) + + await client.deleteMany(DOMAIN_ACTIVITY, { + _class: activity.class.DocUpdateMessage, + attachedToClass: chunter.class.DirectMessage, + action: 'create' + }) + + await client.deleteMany(DOMAIN_ACTIVITY, { + _class: activity.class.DocUpdateMessage, + attachedToClass: chunter.class.DirectMessage, + action: 'remove' + }) +} + export const chunterOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise { await tryMigrate(client, chunterId, [ @@ -233,6 +277,12 @@ export const chunterOperation: MigrateOperation = { func: async (client) => { await removeOldClasses(client) } + }, + { + state: 'remove-wrong-activity-v1', + func: async (client) => { + await removeWrongActivity(client) + } } ]) }, diff --git a/models/chunter/src/notifications.ts b/models/chunter/src/notifications.ts new file mode 100644 index 0000000000..2c4cd4bd05 --- /dev/null +++ b/models/chunter/src/notifications.ts @@ -0,0 +1,171 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Builder } from '@hcengineering/model' +import notification from '@hcengineering/model-notification' +import core from '@hcengineering/model-core' +import activity from '@hcengineering/activity' + +import chunter from './plugin' + +export function defineNotifications (builder: Builder): void { + builder.mixin(chunter.class.DirectMessage, core.class.Class, notification.mixin.ClassCollaborators, { + fields: ['members'] + }) + + builder.mixin(chunter.class.Channel, core.class.Class, notification.mixin.ClassCollaborators, { + fields: ['members'] + }) + + builder.mixin(chunter.class.DirectMessage, core.class.Class, notification.mixin.NotificationPreview, { + presenter: chunter.component.ChannelPreview + }) + + builder.mixin(chunter.class.ChatMessage, core.class.Class, notification.mixin.NotificationContextPresenter, { + labelPresenter: chunter.component.ChatMessageNotificationLabel + }) + + builder.createDoc(notification.class.ActivityNotificationViewlet, core.space.Model, { + messageMatch: { + _class: chunter.class.ThreadMessage + }, + presenter: chunter.component.ThreadNotificationPresenter + }) + + builder.createDoc( + notification.class.NotificationGroup, + core.space.Model, + { + label: chunter.string.ApplicationLabelChunter, + icon: chunter.icon.Chunter + }, + chunter.ids.ChunterNotificationGroup + ) + + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + label: chunter.string.DM, + generated: false, + hidden: false, + txClasses: [core.class.TxCreateDoc], + objectClass: chunter.class.ChatMessage, + attachedToClass: chunter.class.DirectMessage, + defaultEnabled: false, + group: chunter.ids.ChunterNotificationGroup, + templates: { + textTemplate: '{sender} has sent you a message: {doc} {message}', + htmlTemplate: '

{sender} has sent you a message {doc}

{message}', + subjectTemplate: 'You have new direct message in {doc}' + } + }, + chunter.ids.DMNotification + ) + + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + label: chunter.string.ChannelMessages, + generated: false, + hidden: false, + txClasses: [core.class.TxCreateDoc], + objectClass: chunter.class.ChatMessage, + attachedToClass: chunter.class.Channel, + defaultEnabled: false, + group: chunter.ids.ChunterNotificationGroup, + templates: { + textTemplate: '{sender} has sent a message in {doc}: {message}', + htmlTemplate: '

{sender} has sent a message in {doc}

{message}', + subjectTemplate: 'You have new message in {doc}' + } + }, + chunter.ids.ChannelNotification + ) + + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + label: chunter.string.JoinChannel, + generated: false, + hidden: false, + txClasses: [core.class.TxUpdateDoc], + objectClass: chunter.class.Channel, + defaultEnabled: false, + field: 'members', + group: chunter.ids.ChunterNotificationGroup, + templates: { + textTemplate: 'You have been added to #{doc}', + htmlTemplate: '

You have been added to #{doc}

', + subjectTemplate: 'You have been added to #{doc}' + } + }, + chunter.ids.JoinChannelNotification + ) + + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + label: chunter.string.ThreadMessage, + generated: false, + hidden: false, + txClasses: [core.class.TxCreateDoc], + objectClass: chunter.class.ThreadMessage, + defaultEnabled: false, + group: chunter.ids.ChunterNotificationGroup, + templates: { + textTemplate: '{body}', + htmlTemplate: '

{body}

', + subjectTemplate: '{title}' + } + }, + chunter.ids.ThreadNotification + ) + + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.InboxNotificationProvider, + ignoredTypes: [], + enabledTypes: [ + chunter.ids.DMNotification, + chunter.ids.ChannelNotification, + chunter.ids.ThreadNotification, + chunter.ids.JoinChannelNotification + ] + }) + + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.PushNotificationProvider, + ignoredTypes: [], + enabledTypes: [ + chunter.ids.DMNotification, + chunter.ids.ChannelNotification, + chunter.ids.ThreadNotification, + chunter.ids.JoinChannelNotification + ] + }) + + builder.createDoc(notification.class.ActivityNotificationViewlet, core.space.Model, { + messageMatch: { + _class: activity.class.DocUpdateMessage, + objectClass: chunter.class.Channel, + action: 'update', + 'attributeUpdates.attrKey': 'members' + }, + presenter: chunter.component.JoinChannelNotificationPresenter + }) +} diff --git a/models/chunter/src/plugin.ts b/models/chunter/src/plugin.ts index 6c5f131bda..0e039bbd7c 100644 --- a/models/chunter/src/plugin.ts +++ b/models/chunter/src/plugin.ts @@ -30,7 +30,8 @@ export default mergeIds(chunterId, chunter, { ChannelsPanel: '' as AnyComponent, Chat: '' as AnyComponent, ChatMessageNotificationLabel: '' as AnyComponent, - ThreadNotificationPresenter: '' as AnyComponent + ThreadNotificationPresenter: '' as AnyComponent, + JoinChannelNotificationPresenter: '' as AnyComponent }, action: { MarkCommentUnread: '' as Ref, @@ -51,6 +52,10 @@ export default mergeIds(chunterId, chunter, { category: { Chunter: '' as Ref }, + activity: { + ChannelCreatedMessage: '' as AnyComponent, + MembersChangedMessage: '' as AnyComponent + }, string: { ApplicationLabelChunter: '' as IntlString, MentionedIn: '' as IntlString, @@ -71,7 +76,9 @@ export default mergeIds(chunterId, chunter, { ConfigLabel: '' as IntlString, ConfigDescription: '' as IntlString, Reacted: '' as IntlString, - RepliedToThread: '' as IntlString + RepliedToThread: '' as IntlString, + ChannelMessages: '' as IntlString, + JoinChannel: '' as IntlString }, viewlet: { Chat: '' as Ref, diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index 9bc7c59686..d92da09886 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -29,7 +29,8 @@ import { type Organization, type Person, type PersonAccount, - type Status + type Status, + type PersonSpace } from '@hcengineering/contact' import { AccountRole, @@ -64,7 +65,7 @@ import { } from '@hcengineering/model' import attachment from '@hcengineering/model-attachment' import chunter from '@hcengineering/model-chunter' -import core, { TAccount, TAttachedDoc, TDoc } from '@hcengineering/model-core' +import core, { TAccount, TAttachedDoc, TDoc, TSpace } from '@hcengineering/model-core' import { createPublicLinkAction } from '@hcengineering/model-guest' import { generateClassNotificationTypes } from '@hcengineering/model-notification' import presentation from '@hcengineering/model-presentation' @@ -219,6 +220,12 @@ export class TContactsTab extends TDoc implements ContactsTab { index!: number } +@Model(contact.class.PersonSpace, core.class.Space) +export class TPersonSpace extends TSpace implements PersonSpace { + @Prop(TypeRef(contact.class.Person), contact.string.Person) + person!: Ref +} + export function createModel (builder: Builder): void { builder.createModel( TAvatarProvider, @@ -231,7 +238,8 @@ export function createModel (builder: Builder): void { TChannel, TStatus, TMember, - TContactsTab + TContactsTab, + TPersonSpace ) builder.mixin(contact.class.Contact, core.class.Class, activity.mixin.ActivityDoc, {}) @@ -246,16 +254,6 @@ export function createModel (builder: Builder): void { builder.mixin(contact.class.Organization, core.class.Class, activity.mixin.ActivityDoc, {}) - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: contact.class.Contact, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - builder.mixin(contact.class.Channel, core.class.Class, activity.mixin.ActivityDoc, {}) builder.mixin(contact.class.Person, core.class.Class, view.mixin.ObjectIcon, { diff --git a/models/contact/src/migration.ts b/models/contact/src/migration.ts index 506cc09d57..dc7989ea55 100644 --- a/models/contact/src/migration.ts +++ b/models/contact/src/migration.ts @@ -1,23 +1,32 @@ // -import { DOMAIN_TX, TxOperations, type Class, type Doc, type Domain, type Ref, type Space } from '@hcengineering/core' +import { + type Class, + type Doc, + type Domain, + DOMAIN_TX, + generateId, + type Ref, + type Space, + TxOperations +} from '@hcengineering/core' import { createDefaultSpace, - tryMigrate, - tryUpgrade, type MigrateOperation, type MigrateUpdate, type MigrationClient, type MigrationDocumentQuery, type MigrationUpgradeClient, - type ModelLogger + type ModelLogger, + tryMigrate, + tryUpgrade } from '@hcengineering/model' import activity, { DOMAIN_ACTIVITY } from '@hcengineering/model-activity' -import core from '@hcengineering/model-core' +import core, { DOMAIN_SPACE } from '@hcengineering/model-core' import { DOMAIN_VIEW } from '@hcengineering/model-view' +import { AvatarType, type Contact, type Person, type PersonSpace } from '@hcengineering/contact' -import { AvatarType, type Contact } from '@hcengineering/contact' -import contact, { DOMAIN_CONTACT, contactId } from './index' +import contact, { contactId, DOMAIN_CONTACT } from './index' async function createEmployeeEmail (client: TxOperations): Promise { const employees = await client.findAll(contact.mixin.Employee, {}) @@ -100,23 +109,55 @@ async function migrateAvatars (client: MigrationClient): Promise { ) } +async function createPersonSpaces (client: MigrationClient): Promise { + const spaces = await client.find(DOMAIN_SPACE, { _class: contact.class.PersonSpace }) + + if (spaces.length > 0) { + return + } + + const accounts = await client.model.findAll(contact.class.PersonAccount, {}) + const employees = await client.find(DOMAIN_CONTACT, { [contact.mixin.Employee]: { $exists: true } }) + + const newSpaces = new Map, PersonSpace>() + const now = Date.now() + + for (const account of accounts) { + const employee = employees.find(({ _id }) => _id === account.person) + if (employee === undefined) continue + + const space = newSpaces.get(account.person) + + if (space !== undefined) { + space.members.push(account._id) + } else { + newSpaces.set(account.person, { + _id: generateId(), + _class: contact.class.PersonSpace, + space: core.space.Space, + name: 'Personal space', + description: '', + private: true, + archived: false, + members: [account._id], + person: account.person, + modifiedBy: core.account.System, + createdBy: core.account.System, + modifiedOn: now, + createdOn: now + }) + } + } + + await client.create(DOMAIN_SPACE, Array.from(newSpaces.values())) +} + export const contactOperation: MigrateOperation = { async migrate (client: MigrationClient, logger: ModelLogger): Promise { await tryMigrate(client, contactId, [ { state: 'employees', func: async (client) => { - await client.update( - DOMAIN_TX, - { - objectClass: 'contact:class:EmployeeAccount' - }, - { - $rename: { 'attributes.employee': 'attributes.person' }, - $set: { objectClass: contact.class.PersonAccount } - } - ) - await client.update( DOMAIN_TX, { @@ -251,6 +292,10 @@ export const contactOperation: MigrateOperation = { { $rename: { avatarKind: 'avatarType' } } ) } + }, + { + state: 'create-person-spaces-v1', + func: createPersonSpaces } ]) }, diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 2f3bc0a777..5ddbb9cd77 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -38,7 +38,6 @@ import { type EnumOf, type FieldIndexConfig, type FullTextSearchContext, - type IndexStageState, type IndexingConfiguration, type Interface, type MigrationState, @@ -329,27 +328,24 @@ export class TDocIndexState extends TDoc implements DocIndexState { attributes!: Record @Prop(TypeBoolean(), getEmbeddedLabel('Removed')) - // @Index(IndexKind.Indexed) @Hidden() removed!: boolean + @Prop(TypeBoolean(), getEmbeddedLabel('NeedIndexing')) + @Hidden() + needIndex!: boolean + // States for different stages @Prop(TypeRecord(), getEmbeddedLabel('Stages')) // @Index(IndexKind.Indexed) @Hidden() - stages!: Record + stages!: Record @Prop(TypeString(), getEmbeddedLabel('Generation')) @Hidden() generationId?: string } -@Model(core.class.IndexStageState, core.class.Doc, DOMAIN_DOC_INDEX_STATE) -export class TIndexStageState extends TDoc implements IndexStageState { - stageId!: string - attributes!: Record -} - @MMixin(core.mixin.FullTextSearchContext, core.class.Class) export class TFullTextSearchContext extends TClass implements FullTextSearchContext {} diff --git a/models/core/src/index.ts b/models/core/src/index.ts index e8b21d7c19..b37e77d1ac 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -49,7 +49,6 @@ import { TEnumOf, TFullTextSearchContext, TIndexConfiguration, - TIndexStageState, TInterface, TMigrationState, TMixin, @@ -94,9 +93,9 @@ import { TUserStatus } from './transient' import { TTx, TTxApplyIf, - TTxCUD, TTxCollectionCUD, TTxCreateDoc, + TTxCUD, TTxMixin, TTxRemoveDoc, TTxUpdateDoc, @@ -164,7 +163,6 @@ export function createModel (builder: Builder): void { TTypeAny, TTypeRelatedDocument, TDocIndexState, - TIndexStageState, TFullTextSearchContext, TConfiguration, TConfigurationElement, @@ -284,8 +282,9 @@ export function createModel (builder: Builder): void { builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, { domain: DOMAIN_DOC_INDEX_STATE, indexes: [ - { keys: { removed: 1 }, filter: { removed: true } }, - { keys: { _class: 1 }, filter: { _class: core.class.DocIndexState } } + { + keys: { needIndex: 1 } + } ], disabled: [ { attachedToClass: 1 }, @@ -297,8 +296,7 @@ export function createModel (builder: Builder): void { { createdBy: 1 }, { createdBy: -1 }, { createdOn: -1 } - ], - skip: ['stages.'] + ] }) builder.mixin(core.class.Space, core.class.Class, core.mixin.FullTextSearchContext, { diff --git a/models/core/src/migration.ts b/models/core/src/migration.ts index 28fe968978..0844c55c33 100644 --- a/models/core/src/migration.ts +++ b/models/core/src/migration.ts @@ -155,7 +155,8 @@ export const coreOperation: MigrateOperation = { { objectClass: { $nin: allIndexed } }, { $set: { - removed: true + removed: true, + needIndex: true } } ) @@ -182,6 +183,12 @@ export const coreOperation: MigrateOperation = { { state: 'old-statuses-transactions', func: migrateStatusTransactions + }, + { + state: 'add-need-index', + func: async (client: MigrationClient) => { + await client.update(DOMAIN_DOC_INDEX_STATE, {}, { $set: { needIndex: true } }) + } } ]) }, diff --git a/models/gmail/src/index.ts b/models/gmail/src/index.ts index d110623772..b325245753 100644 --- a/models/gmail/src/index.ts +++ b/models/gmail/src/index.ts @@ -268,7 +268,7 @@ export function createModel (builder: Builder): void { defaultEnabled: true, canDisable: true, depends: notification.providers.InboxNotificationProvider, - order: 30 + order: 300 }, gmail.providers.EmailNotificationProvider ) diff --git a/models/lead/src/index.ts b/models/lead/src/index.ts index 7e9e6ed71d..63015a8001 100644 --- a/models/lead/src/index.ts +++ b/models/lead/src/index.ts @@ -47,16 +47,6 @@ export function createModel (builder: Builder): void { builder.mixin(lead.mixin.Customer, core.class.Class, activity.mixin.ActivityDoc, {}) - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: lead.class.Lead, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - builder.mixin(lead.class.Funnel, core.class.Class, activity.mixin.ActivityDoc, {}) builder.createDoc(activity.class.ActivityExtension, core.space.Model, { diff --git a/models/love/src/index.ts b/models/love/src/index.ts index b71ee1ef3e..e92ef5a5a9 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -87,6 +87,8 @@ export class TParticipantInfo extends TDoc implements ParticipantInfo { x!: number y!: number + + sessionId!: string | null } @Model(love.class.JoinRequest, core.class.Doc, DOMAIN_TRANSIENT) diff --git a/models/notification/package.json b/models/notification/package.json index 11ecb5b4d5..12dbe6f086 100644 --- a/models/notification/package.json +++ b/models/notification/package.json @@ -30,6 +30,7 @@ "dependencies": { "@hcengineering/activity": "^0.6.0", "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", "@hcengineering/core": "^0.6.32", "@hcengineering/model": "^0.6.11", "@hcengineering/model-attachment": "^0.6.0", @@ -39,10 +40,10 @@ "@hcengineering/model-workbench": "^0.6.1", "@hcengineering/notification": "^0.6.23", "@hcengineering/platform": "^0.6.11", + "@hcengineering/preference": "^0.6.13", "@hcengineering/setting": "^0.6.17", "@hcengineering/ui": "^0.6.15", "@hcengineering/view": "^0.6.13", - "@hcengineering/workbench": "^0.6.16", - "@hcengineering/preference": "^0.6.13" + "@hcengineering/workbench": "^0.6.16" } } diff --git a/models/notification/src/index.ts b/models/notification/src/index.ts index 2f7431ac37..04a2b8233c 100644 --- a/models/notification/src/index.ts +++ b/models/notification/src/index.ts @@ -49,6 +49,7 @@ import { UX, type Builder } from '@hcengineering/model' +import { type PersonSpace } from '@hcengineering/contact' import core, { TClass, TDoc } from '@hcengineering/model-core' import preference, { TPreference } from '@hcengineering/model-preference' import view, { createAction, template } from '@hcengineering/model-view' @@ -79,7 +80,7 @@ import { type PushSubscription, type PushSubscriptionKeys } from '@hcengineering/notification' -import { type Asset, type IntlString } from '@hcengineering/platform' +import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' import setting from '@hcengineering/setting' import { type AnyComponent, type Location } from '@hcengineering/ui/src/types' @@ -195,13 +196,18 @@ export class TDocNotifyContext extends TDoc implements DocNotifyContext { @Index(IndexKind.Indexed) user!: Ref - @Prop(TypeRef(core.class.Doc), core.string.AttachedTo) + @Prop(TypeRef(core.class.Doc), core.string.Object) @Index(IndexKind.Indexed) - attachedTo!: Ref + objectId!: Ref - @Prop(TypeRef(core.class.Class), core.string.AttachedToClass) + @Prop(TypeRef(core.class.Class), core.string.Class) @Index(IndexKind.Indexed) - attachedToClass!: Ref> + objectClass!: Ref> + + @Prop(TypeRef(core.class.Space), core.string.Space) + objectSpace!: Ref + + declare space: Ref @Prop(TypeDate(), core.string.Date) lastViewedTimestamp?: Timestamp @@ -210,7 +216,7 @@ export class TDocNotifyContext extends TDoc implements DocNotifyContext { lastUpdateTimestamp?: Timestamp @Prop(TypeBoolean(), notification.string.Pinned) - isPinned?: boolean + isPinned!: boolean } @Model(notification.class.InboxNotification, core.class.Doc, DOMAIN_NOTIFICATION) @@ -228,7 +234,9 @@ export class TInboxNotification extends TDoc implements InboxNotification { isViewed!: boolean @Prop(TypeBoolean(), core.string.Boolean) - archived?: boolean + archived!: boolean + + declare space: Ref title?: IntlString body?: IntlString @@ -295,6 +303,8 @@ export class TNotificationProvider extends TDoc implements NotificationProvider depends?: Ref ignoreAll?: boolean canDisable!: boolean + presenter?: AnyComponent + isAvailableFn?: Resource<() => boolean> } @Model(notification.class.NotificationProviderDefaults, core.class.Doc) @@ -723,7 +733,7 @@ export function createModel (builder: Builder): void { description: notification.string.InboxNotificationsDescription, defaultEnabled: true, canDisable: false, - order: 10 + order: 100 }, notification.providers.InboxNotificationProvider ) @@ -738,7 +748,7 @@ export function createModel (builder: Builder): void { depends: notification.providers.InboxNotificationProvider, defaultEnabled: true, canDisable: true, - order: 20 + order: 200 }, notification.providers.PushNotificationProvider ) @@ -754,7 +764,7 @@ export function createModel (builder: Builder): void { defaultEnabled: true, canDisable: true, ignoreAll: true, - order: 25 + order: 250 }, notification.providers.SoundNotificationProvider ) diff --git a/models/notification/src/migration.ts b/models/notification/src/migration.ts index cc49e302b3..5a0d2c70e5 100644 --- a/models/notification/src/migration.ts +++ b/models/notification/src/migration.ts @@ -25,11 +25,14 @@ import notification, { notificationId, NotificationStatus, type BrowserNotification, - type DocNotifyContext + type DocNotifyContext, + type InboxNotification } from '@hcengineering/notification' import { DOMAIN_PREFERENCE } from '@hcengineering/preference' +import contact, { type PersonSpace } from '@hcengineering/contact' import { DOMAIN_DOC_NOTIFY, DOMAIN_NOTIFICATION, DOMAIN_USER_NOTIFY } from './index' +import { DOMAIN_SPACE } from '@hcengineering/model-core' export async function removeNotifications ( client: MigrationClient, @@ -73,6 +76,115 @@ export async function removeNotifications ( } } +export async function migrateNotificationsSpace (client: MigrationClient): Promise { + const personSpaces = await client.find(DOMAIN_SPACE, { _class: contact.class.PersonSpace }, {}) + + await client.update( + DOMAIN_DOC_NOTIFY, + { + _class: notification.class.DocNotifyContext, + objectSpace: { $exists: false } + }, + { $rename: { space: 'objectSpace' } } + ) + + for (const space of personSpaces) { + await client.update( + DOMAIN_DOC_NOTIFY, + { + _class: notification.class.DocNotifyContext, + user: { $in: space.members } + }, + { space: space._id } + ) + await client.update( + DOMAIN_NOTIFICATION, + { + _class: notification.class.ActivityInboxNotification, + user: { $in: space.members } + }, + { space: space._id } + ) + await client.update( + DOMAIN_NOTIFICATION, + { + _class: notification.class.CommonInboxNotification, + user: { $in: space.members } + }, + { space: space._id } + ) + await client.update( + DOMAIN_NOTIFICATION, + { + _class: notification.class.MentionInboxNotification, + user: { $in: space.members } + }, + { space: space._id } + ) + } + + await client.deleteMany(DOMAIN_DOC_NOTIFY, { space: { $nin: personSpaces.map(({ _id }) => _id) } }) + await client.deleteMany(DOMAIN_NOTIFICATION, { + _class: notification.class.ActivityInboxNotification, + space: { $nin: personSpaces.map(({ _id }) => _id) } + }) + await client.deleteMany(DOMAIN_NOTIFICATION, { + _class: notification.class.CommonInboxNotification, + space: { $nin: personSpaces.map(({ _id }) => _id) } + }) + await client.deleteMany(DOMAIN_NOTIFICATION, { + _class: notification.class.MentionInboxNotification, + space: { $nin: personSpaces.map(({ _id }) => _id) } + }) + + while (true) { + const contexts = await client.find( + DOMAIN_DOC_NOTIFY, + { + _class: notification.class.DocNotifyContext, + attachedTo: { $exists: true } + }, + { limit: 500 } + ) + + if (contexts.length === 0) { + break + } + + const classesOfSpace = new Set>>() + + for (const context of contexts) { + const _class = (context as any).attachedToClass + if (client.hierarchy.isDerived(_class, core.class.Space)) { + classesOfSpace.add(_class) + } + } + if (classesOfSpace.size > 0) { + await client.update( + DOMAIN_DOC_NOTIFY, + { objectClass: { $in: Array.from(classesOfSpace) } }, + { objectSpace: core.space.Space } + ) + await client.update( + DOMAIN_DOC_NOTIFY, + { objectClass: { $in: Array.from(classesOfSpace) } }, + { $rename: { attachedTo: 'objectId', attachedToClass: 'objectClass' } } + ) + } + await client.update( + DOMAIN_DOC_NOTIFY, + { + _class: notification.class.DocNotifyContext, + _id: { $in: contexts.map(({ _id }) => _id) } + }, + { $rename: { attachedTo: 'objectId', attachedToClass: 'objectClass' } } + ) + } + + await client.deleteMany(DOMAIN_NOTIFICATION, { _class: notification.class.BrowserNotification }) + await client.deleteMany(DOMAIN_USER_NOTIFY, { _class: notification.class.BrowserNotification }) +} + export async function migrateSettings (client: MigrationClient): Promise { await client.update( DOMAIN_PREFERENCE, @@ -166,8 +278,53 @@ export const notificationOperation: MigrateOperation = { DOMAIN_USER_NOTIFY ) } + }, + { + state: 'fill-notification-archived-field-v1', + func: async (client) => { + await client.update( + DOMAIN_NOTIFICATION, + { _class: notification.class.ActivityInboxNotification, archived: { $exists: false } }, + { archived: false } + ) + await client.update( + DOMAIN_NOTIFICATION, + { _class: notification.class.CommonInboxNotification, archived: { $exists: false } }, + { archived: false } + ) + await client.update( + DOMAIN_NOTIFICATION, + { _class: notification.class.MentionInboxNotification, archived: { $exists: false } }, + { archived: false } + ) + } + }, + { + state: 'fill-contexts-pinned-field-v1', + func: async (client) => { + await client.update( + DOMAIN_DOC_NOTIFY, + { _class: notification.class.DocNotifyContext, isPinned: { $exists: false } }, + { isPinned: false } + ) + } + }, + { + state: 'migrate-notifications-space-v1', + func: migrateNotificationsSpace + }, + { + state: 'migrate-employee-space-v1', + func: async () => { + await client.update( + DOMAIN_DOC_NOTIFY, + { _class: notification.class.DocNotifyContext, objectSpace: 'contact:space:Employee' as Ref }, + { objectSpace: contact.space.Contacts } + ) + } } ]) + await client.deleteMany(DOMAIN_USER_NOTIFY, { _class: notification.class.BrowserNotification, status: NotificationStatus.Notified diff --git a/models/recruit/src/index.ts b/models/recruit/src/index.ts index bce4f9a7b7..f9604d7408 100644 --- a/models/recruit/src/index.ts +++ b/models/recruit/src/index.ts @@ -54,26 +54,6 @@ export function createModel (builder: Builder): void { builder.mixin(recruit.class.Review, core.class.Class, activity.mixin.ActivityDoc, {}) builder.mixin(recruit.mixin.Candidate, core.class.Class, activity.mixin.ActivityDoc, {}) - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: recruit.class.Vacancy, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: recruit.class.Applicant, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - builder.createDoc(activity.class.ActivityExtension, core.space.Model, { ofClass: recruit.class.Vacancy, components: { input: chunter.component.ChatMessageInput } diff --git a/models/server-activity/src/index.ts b/models/server-activity/src/index.ts index c2569236e1..9a88c922e1 100644 --- a/models/server-activity/src/index.ts +++ b/models/server-activity/src/index.ts @@ -28,6 +28,10 @@ export function createModel (builder: Builder): void { presenter: serverActivity.function.ReactionNotificationContentProvider }) + builder.mixin(activity.class.DocUpdateMessage, core.class.Class, serverNotification.mixin.TextPresenter, { + presenter: serverActivity.function.DocUpdateMessageTextPresenter + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverActivity.trigger.OnReactionChanged, txMatch: { @@ -38,7 +42,8 @@ export function createModel (builder: Builder): void { }) builder.createDoc(serverCore.class.Trigger, core.space.Model, { - trigger: serverActivity.trigger.ActivityMessagesHandler + trigger: serverActivity.trigger.ActivityMessagesHandler, + isAsync: true }) builder.createDoc(serverCore.class.Trigger, core.space.Model, { diff --git a/models/server-ai-bot/.eslintrc.js b/models/server-ai-bot/.eslintrc.js new file mode 100644 index 0000000000..c1cf82cba0 --- /dev/null +++ b/models/server-ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/model/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/models/server-ai-bot/.npmignore b/models/server-ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/models/server-ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/models/server-ai-bot/config/rig.json b/models/server-ai-bot/config/rig.json new file mode 100644 index 0000000000..2f6be36605 --- /dev/null +++ b/models/server-ai-bot/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "model" +} diff --git a/models/server-ai-bot/package.json b/models/server-ai-bot/package.json new file mode 100644 index 0000000000..3f83963a1e --- /dev/null +++ b/models/server-ai-bot/package.json @@ -0,0 +1,49 @@ +{ + "name": "@hcengineering/model-server-ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "author": "Hardcore Engineering Inc.", + "template": "@hcengineering/model-package", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "prettier": "^3.1.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/activity": "^0.6.0", + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/model": "^0.6.11", + "@hcengineering/model-activity": "^0.6.0", + "@hcengineering/model-chunter": "^0.6.0", + "@hcengineering/model-contact": "^0.6.1", + "@hcengineering/model-core": "^0.6.0", + "@hcengineering/model-notification": "^0.6.0", + "@hcengineering/model-presentation": "^0.6.0", + "@hcengineering/model-view": "^0.6.0", + "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/view": "^0.6.13" + } +} diff --git a/models/server-ai-bot/src/index.ts b/models/server-ai-bot/src/index.ts new file mode 100644 index 0000000000..25bf6d947f --- /dev/null +++ b/models/server-ai-bot/src/index.ts @@ -0,0 +1,111 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Builder, Mixin, Model, Prop, TypeRef, TypeString } from '@hcengineering/model' +import core, { type Account, type Class, type Doc, type Domain, type Ref, type Space } from '@hcengineering/core' +import serverCore from '@hcengineering/server-core' +import serverAiBot from '@hcengineering/server-ai-bot' +import { TDoc } from '@hcengineering/model-core' +import { getEmbeddedLabel } from '@hcengineering/platform' +import aiBot, { + type AIBotEvent, + type AIBotTransferEvent, + type AIBotResponseEvent, + type TransferredMessage +} from '@hcengineering/ai-bot' +import chunter, { type ChatMessage } from '@hcengineering/chunter' +import notification from '@hcengineering/notification' +import { TChatMessage } from '@hcengineering/model-chunter' + +export { serverAiBotId } from '@hcengineering/server-ai-bot' + +export const DOMAIN_AI_BOT = 'ai_bot' as Domain + +@Model(aiBot.class.AIBotEvent, core.class.Doc, DOMAIN_AI_BOT) +export class TAIBotEvent extends TDoc implements AIBotEvent { + @Prop(TypeRef(chunter.class.ChatMessage), core.string.Class) + messageClass!: Ref> + + @Prop(TypeString(), getEmbeddedLabel('Collection')) + collection!: string + + @Prop(TypeString(), getEmbeddedLabel('Message')) + message!: string +} + +@Model(aiBot.class.AIBotResponseEvent, core.class.Doc) +export class TAIBotResponseEvent extends TAIBotEvent implements AIBotResponseEvent { + @Prop(TypeRef(core.class.Doc), core.string.Object) + objectId!: Ref + + @Prop(TypeRef(core.class.Class), core.string.Class) + objectClass!: Ref> + + @Prop(TypeRef(core.class.Space), core.string.Space) + objectSpace!: Ref + + @Prop(TypeRef(core.class.Account), core.string.Account) + user!: Ref +} + +@Model(aiBot.class.AIBotTransferEvent, aiBot.class.AIBotEvent) +export class TAIBotTransferEvent extends TAIBotEvent implements AIBotTransferEvent { + toEmail!: string + toWorkspace!: string + fromWorkspace!: string + messageId!: Ref + parentMessageId?: Ref +} + +@Mixin(aiBot.mixin.TransferredMessage, chunter.class.ChatMessage) +export class TTransferredMessage extends TChatMessage implements TransferredMessage { + messageId!: Ref + parentMessageId?: Ref +} + +export function createModel (builder: Builder): void { + builder.createModel(TAIBotEvent, TAIBotTransferEvent, TAIBotResponseEvent, TTransferredMessage) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnMessageSend, + isAsync: true + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnMention, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: notification.class.MentionInboxNotification + }, + isAsync: true + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnMessageNotified, + txMatch: { + _class: core.class.TxCreateDoc, + objectClass: notification.class.ActivityInboxNotification + }, + isAsync: true + }) + + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverAiBot.trigger.OnUserStatus, + txMatch: { + objectClass: core.class.UserStatus + }, + isAsync: true + }) +} diff --git a/models/server-ai-bot/tsconfig.json b/models/server-ai-bot/tsconfig.json new file mode 100644 index 0000000000..9765b6086d --- /dev/null +++ b/models/server-ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/model/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/models/server-chunter/src/index.ts b/models/server-chunter/src/index.ts index cc93beffad..9302dfbf15 100644 --- a/models/server-chunter/src/index.ts +++ b/models/server-chunter/src/index.ts @@ -54,14 +54,6 @@ export function createModel (builder: Builder): void { trigger: serverChunter.trigger.ChunterTrigger }) - builder.createDoc(serverCore.class.Trigger, core.space.Model, { - trigger: serverChunter.trigger.OnChannelMembersChanged, - txMatch: { - _class: core.class.TxUpdateDoc, - objectClass: chunter.class.Channel - } - }) - builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverChunter.trigger.OnUserStatus, txMatch: { @@ -70,6 +62,15 @@ export function createModel (builder: Builder): void { isAsync: true }) + builder.mixin( + chunter.ids.JoinChannelNotification, + notification.class.NotificationType, + serverNotification.mixin.TypeMatch, + { + func: serverChunter.function.JoinChannelTypeMatch + } + ) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverChunter.trigger.OnContextUpdate, txMatch: { diff --git a/models/server-telegram/src/index.ts b/models/server-telegram/src/index.ts index 5a38fc38ba..08f2899a90 100644 --- a/models/server-telegram/src/index.ts +++ b/models/server-telegram/src/index.ts @@ -37,6 +37,11 @@ export function createModel (builder: Builder): void { } ) + builder.createDoc(serverNotification.class.NotificationProviderResources, core.space.Model, { + provider: telegram.providers.TelegramNotificationProvider, + fn: serverTelegram.function.SendTelegramNotifications + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverTelegram.trigger.OnMessageCreate, txMatch: { diff --git a/models/telegram/src/index.ts b/models/telegram/src/index.ts index 2c3abd493a..c950f28c55 100644 --- a/models/telegram/src/index.ts +++ b/models/telegram/src/index.ts @@ -41,8 +41,9 @@ import type { } from '@hcengineering/telegram' import templates from '@hcengineering/templates' import view from '@hcengineering/view' + import telegram from './plugin' -import notification from '@hcengineering/model-notification' +import { defineNotifications } from './notification' export { telegramId } from '@hcengineering/telegram' export { telegramOperation } from './migration' @@ -178,38 +179,6 @@ export function createModel (builder: Builder): void { telegram.ids.TelegramMessageSharedActivityViewlet ) - builder.createDoc( - notification.class.NotificationGroup, - core.space.Model, - { - label: telegram.string.Telegram, - icon: contact.icon.Telegram - }, - telegram.ids.NotificationGroup - ) - - builder.createDoc( - notification.class.NotificationType, - core.space.Model, - { - label: telegram.string.NewMessage, - generated: false, - allowedForAuthor: true, - hidden: false, - txClasses: [core.class.TxCreateDoc], - objectClass: telegram.class.Message, - group: telegram.ids.NotificationGroup, - defaultEnabled: false - }, - telegram.ids.NewMessageNotification - ) - - builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { - provider: notification.providers.InboxNotificationProvider, - ignoredTypes: [], - enabledTypes: [telegram.ids.NewMessageNotification] - }) - builder.mixin(telegram.class.Message, core.class.Class, core.mixin.FullTextSearchContext, { parentPropagate: false, childProcessingAllowed: true @@ -227,4 +196,6 @@ export function createModel (builder: Builder): void { { createdOn: -1 } ] }) + + defineNotifications(builder) } diff --git a/models/telegram/src/notification.ts b/models/telegram/src/notification.ts new file mode 100644 index 0000000000..e8dcb03505 --- /dev/null +++ b/models/telegram/src/notification.ts @@ -0,0 +1,72 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Builder } from '@hcengineering/model' +import notification from '@hcengineering/model-notification' +import core from '@hcengineering/model-core' +import contact from '@hcengineering/model-contact' + +import telegram from './plugin' + +export function defineNotifications (builder: Builder): void { + builder.createDoc( + notification.class.NotificationGroup, + core.space.Model, + { + label: telegram.string.Telegram, + icon: contact.icon.Telegram + }, + telegram.ids.NotificationGroup + ) + + builder.createDoc( + notification.class.NotificationProvider, + core.space.Model, + { + label: telegram.string.Telegram, + icon: contact.icon.Telegram, + depends: notification.providers.InboxNotificationProvider, + defaultEnabled: false, + canDisable: true, + description: telegram.string.TelegramNotificationDescription, + presenter: telegram.component.NotificationProviderPresenter, + order: 400, + isAvailableFn: telegram.function.IsTelegramNotificationsAvailable + }, + telegram.providers.TelegramNotificationProvider + ) + + builder.createDoc( + notification.class.NotificationType, + core.space.Model, + { + label: telegram.string.NewMessage, + generated: false, + allowedForAuthor: true, + hidden: false, + txClasses: [core.class.TxCreateDoc], + objectClass: telegram.class.Message, + group: telegram.ids.NotificationGroup, + defaultEnabled: false + }, + telegram.ids.NewMessageNotification + ) + + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.InboxNotificationProvider, + ignoredTypes: [notification.ids.CollaboratoAddNotification], + enabledTypes: [telegram.ids.NewMessageNotification] + }) +} diff --git a/models/telegram/src/plugin.ts b/models/telegram/src/plugin.ts index aebb87d272..487a43ba1c 100644 --- a/models/telegram/src/plugin.ts +++ b/models/telegram/src/plugin.ts @@ -36,7 +36,8 @@ export default mergeIds(telegramId, telegram, { ConfigLabel: '' as IntlString, ConfigDescription: '' as IntlString, NewMessage: '' as IntlString, - NewIncomingMessage: '' as IntlString + NewIncomingMessage: '' as IntlString, + TelegramNotificationDescription: '' as IntlString }, ids: { NotificationGroup: '' as Ref, @@ -45,9 +46,13 @@ export default mergeIds(telegramId, telegram, { }, function: { GetCurrentEmployeeTG: '' as Resource, - GetIntegrationOwnerTG: '' as Resource + GetIntegrationOwnerTG: '' as Resource, + IsTelegramNotificationsAvailable: '' as Resource<() => boolean> }, activity: { TelegramMessageCreated: '' as AnyComponent + }, + component: { + NotificationProviderPresenter: '' as AnyComponent } }) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index b41eb03e95..cf5e11bc30 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -449,46 +449,6 @@ export function createModel (builder: Builder): void { decode: tracker.function.GetIssueIdByIdentifier }) - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: tracker.class.Issue, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: tracker.class.Milestone, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: tracker.class.Component, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - - builder.createDoc(activity.class.ActivityMessageControl, core.space.Model, { - objectClass: tracker.class.IssueTemplate, - skip: [ - { - _class: core.class.TxCollectionCUD, - collection: 'comments' - } - ] - }) - builder.createDoc(activity.class.ActivityExtension, core.space.Model, { ofClass: tracker.class.Issue, components: { input: chunter.component.ChatMessageInput } diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index df6802b66a..27e8c0c912 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -510,7 +510,9 @@ export interface DocIndexState extends Doc { generationId?: string // States for stages - stages: Record + stages: Record + + needIndex: boolean removed: boolean @@ -522,14 +524,6 @@ export interface DocIndexState extends Doc { shortSummary?: string | null } -/** - * @public - */ -export interface IndexStageState extends Doc { - stageId: string - attributes: Record -} - /** * @public * diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 2af9823260..436d77f8c6 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -32,7 +32,6 @@ import type { EnumOf, FullTextSearchContext, Hyperlink, - IndexStageState, IndexingConfiguration, Interface, Markup, @@ -135,7 +134,6 @@ export default plugin(coreId, { UserStatus: '' as Ref>, TypeRelatedDocument: '' as Ref>>, DocIndexState: '' as Ref>, - IndexStageState: '' as Ref>, DomainIndexConfiguration: '' as Ref>, Configuration: '' as Ref>, diff --git a/packages/core/src/measurements/context.ts b/packages/core/src/measurements/context.ts index d88054cf68..5bcb4af6ca 100644 --- a/packages/core/src/measurements/context.ts +++ b/packages/core/src/measurements/context.ts @@ -116,6 +116,20 @@ export class MeasureMetricsContext implements MeasureContext { } } + withSync( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T, + fullParams?: ParamsType | (() => FullParamsType) + ): T { + const c = this.newChild(name, params, fullParams, this.logger) + try { + return op(c) + } finally { + c.end() + } + } + async withLog( name: string, params: ParamsType, diff --git a/packages/core/src/measurements/types.ts b/packages/core/src/measurements/types.ts index fbe77be055..e056ba1009 100644 --- a/packages/core/src/measurements/types.ts +++ b/packages/core/src/measurements/types.ts @@ -63,6 +63,13 @@ export interface MeasureContext { fullParams?: FullParamsType | (() => FullParamsType) ) => Promise + withSync: ( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T, + fullParams?: FullParamsType | (() => FullParamsType) + ) => T + withLog: ( name: string, params: ParamsType, diff --git a/packages/core/src/memdb.ts b/packages/core/src/memdb.ts index 5fe5dc5200..42cbfb9c87 100644 --- a/packages/core/src/memdb.ts +++ b/packages/core/src/memdb.ts @@ -337,13 +337,18 @@ export class ModelDb extends MemDb { } protected async txUpdateDoc (tx: TxUpdateDoc): Promise { - const doc = this.getObject(tx.objectId) as any - TxProcessor.updateDoc2Doc(doc, tx) - return tx.retrieve === true ? { object: doc } : {} + try { + const doc = this.getObject(tx.objectId) as any + TxProcessor.updateDoc2Doc(doc, tx) + return tx.retrieve === true ? { object: doc } : {} + } catch (err: any) {} + return {} } protected async txRemoveDoc (tx: TxRemoveDoc): Promise { - this.delDoc(tx.objectId) + try { + this.delDoc(tx.objectId) + } catch (err: any) {} return {} } diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 05efb3e116..93adbf2540 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -136,7 +136,8 @@ export default plugin(presentationId, { Endpoint: '' as Metadata, FrontUrl: '' as Asset, PreviewConfig: '' as Metadata, - ClientHook: '' as Metadata + ClientHook: '' as Metadata, + SessionId: '' as Metadata }, status: { FileTooLarge: '' as StatusCode diff --git a/packages/theme/styles/_colors.scss b/packages/theme/styles/_colors.scss index 40d2dc54be..6d5c260975 100644 --- a/packages/theme/styles/_colors.scss +++ b/packages/theme/styles/_colors.scss @@ -25,6 +25,7 @@ --primary-button-hovered: #3575DE; --primary-button-pressed: #1C52AB; --primary-button-focused: #205DC2; + --secondary-button-color: rgba(0, 0, 0, .8); --secondary-button-default: #D3E1F8; --secondary-button-hovered: #BDD2F5; --secondary-button-pressed: #A7C3F1; diff --git a/packages/theme/styles/button.scss b/packages/theme/styles/button.scss index 4159e78912..0a00cb20fd 100644 --- a/packages/theme/styles/button.scss +++ b/packages/theme/styles/button.scss @@ -241,6 +241,7 @@ } } &.secondary { + color: var(--secondary-button-color); background-color: var(--secondary-button-default); &:hover { background-color: var(--secondary-button-hovered); } &:active, diff --git a/packages/ui/src/components/CircleButton.svelte b/packages/ui/src/components/CircleButton.svelte index a53e382259..767e310706 100644 --- a/packages/ui/src/components/CircleButton.svelte +++ b/packages/ui/src/components/CircleButton.svelte @@ -110,6 +110,7 @@ } } &.primary { + color: var(--secondary-button-color); background-color: var(--secondary-button-default); &:hover { background-color: var(--secondary-button-hovered); diff --git a/packages/ui/src/components/CodeForm.svelte b/packages/ui/src/components/CodeForm.svelte new file mode 100644 index 0000000000..3481922172 --- /dev/null +++ b/packages/ui/src/components/CodeForm.svelte @@ -0,0 +1,161 @@ + + + + +
+
+ {#each fields as field, index (field.name)} + {#if index === fields.length / 2} +
+ {/if} +
+ { + trim(field.name) + }} + /> +
+ {/each} +
+ + + diff --git a/packages/ui/src/components/CodeInput.svelte b/packages/ui/src/components/CodeInput.svelte index a62ec51296..fe0ea239c1 100644 --- a/packages/ui/src/components/CodeInput.svelte +++ b/packages/ui/src/components/CodeInput.svelte @@ -18,9 +18,10 @@ export let id: string | undefined = undefined export let name: string | undefined = undefined export let size: 'small' | 'medium' = 'small' + export let kind: 'primary' | 'secondary' = 'primary' -
+
string = (index) => index.toString() @@ -76,35 +77,37 @@ > {#each Array(count) as _, row (getKey(row))} {#if lazy} - - dispatch('click', row)} - on:mouseover={mouseAttractor(() => { - if (updateOnMouse) { - onRow(row) - } - })} - on:mouseenter={mouseAttractor(() => { - if (updateOnMouse) { - onRow(row) - } - })} - > - - - - - - - - +
+ + dispatch('click', row)} + on:mouseover={mouseAttractor(() => { + if (updateOnMouse) { + onRow(row) + } + })} + on:mouseenter={mouseAttractor(() => { + if (updateOnMouse) { + onRow(row) + } + })} + > + + + + + + + + +
{:else} | undefined = undefined + export let object: Doc | undefined + export let message: DocUpdateMessage const client = getClient() const hierarchy = client.getHierarchy() @@ -41,7 +43,7 @@ {#if presenter} - + {:else} {#if attributeUpdates.added.length} diff --git a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageContent.svelte b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageContent.svelte index 8ec5e747e0..222306696f 100644 --- a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageContent.svelte +++ b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageContent.svelte @@ -58,30 +58,40 @@ {@const removeMessages = valueMessages.filter(({ action }) => action === 'remove')} {@const createMessages = valueMessages.filter(({ action }) => action === 'create')} + {@const createMessagesLen = createMessages.length} + {@const removeMessagesLen = removeMessages.length} + {#each createMessages as valueMessage, index} 1 && index !== createMessages.length - 1} + hasSeparator={createMessagesLen > 1 && index !== createMessagesLen - 1} {preview} /> {/each} {#each removeMessages as valueMessage, index} 1 && index !== removeMessages.length - 1} + hasSeparator={removeMessagesLen > 1 && index !== removeMessagesLen - 1} {preview} /> {/each} {:else} + {@const len = valueMessages.length} {#each valueMessages as valueMessage, index} 1 && index !== valueMessages.length - 1} + hasSeparator={len > 1 && index !== len - 1} {preview} /> {/each} diff --git a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageObjectValue.svelte b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageObjectValue.svelte index 346c23a44f..d2ce1ee29a 100644 --- a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageObjectValue.svelte +++ b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessageObjectValue.svelte @@ -13,14 +13,16 @@ // limitations under the License. --> {#if object} - {#if withIcon && message.action === 'create'} + {#if withIcon && action === 'create'} {/if} - {#if withIcon && message.action === 'remove'} + {#if withIcon && action === 'remove'} {/if} @@ -80,7 +83,7 @@ diff --git a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePresenter.svelte b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePresenter.svelte index 948ffef6a9..4571dcaa6b 100644 --- a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePresenter.svelte +++ b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePresenter.svelte @@ -26,7 +26,7 @@ import { Account, AttachedDoc, Class, Collection, Doc, Ref, Space } from '@hcengineering/core' import { IntlString } from '@hcengineering/platform' import { createQuery, getClient } from '@hcengineering/presentation' - import { Component, ShowMore, Action } from '@hcengineering/ui' + import { Action, Component, ShowMore } from '@hcengineering/ui' import { AttributeModel } from '@hcengineering/view' import { buildRemovedDoc, checkIsObjectRemoved } from '@hcengineering/view-resources' @@ -144,17 +144,17 @@ } async function loadParentObject ( - message: DocUpdateMessage, - parentMessage?: ActivityMessage, + message: Pick, + parentMessage?: Pick, doc?: Doc ): Promise { - if (!parentMessage && message.objectId === message.attachedTo) { + if (parentMessage === undefined && message.objectId === message.attachedTo) { return } - const _id = parentMessage ? parentMessage.attachedTo : message.attachedTo - const _class = parentMessage ? parentMessage.attachedToClass : message.attachedToClass - const space = parentMessage ? parentMessage.space : message.space + const _id = parentMessage !== undefined ? parentMessage.attachedTo : message.attachedTo + const _class = parentMessage !== undefined ? parentMessage.attachedToClass : message.attachedToClass + const space = parentMessage !== undefined ? parentMessage.space : message.space if (doc !== undefined && doc._id === _id) { parentObject = doc @@ -229,7 +229,14 @@ /> {:else if value.attributeUpdates && attributeModel} - + {/if} diff --git a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePreview.svelte b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePreview.svelte index 140ba05c86..1e3764af17 100644 --- a/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePreview.svelte +++ b/plugins/activity-resources/src/components/doc-update-message/DocUpdateMessagePreview.svelte @@ -130,6 +130,8 @@ {attributeModel} {space} {viewlet} + {object} + message={value} preview /> {/if} diff --git a/plugins/activity/src/index.ts b/plugins/activity/src/index.ts index 2d0cfa0673..256356380f 100644 --- a/plugins/activity/src/index.ts +++ b/plugins/activity/src/index.ts @@ -67,6 +67,7 @@ export interface ActivityMessageControl extends Doc { // Skip field activity operations. skipFields?: (keyof T)[] + allowedFields?: (keyof T)[] } /** @@ -308,7 +309,12 @@ export default plugin(activityId, { Messages: '' as IntlString, Thread: '' as IntlString, ReactionNotificationTitle: '' as IntlString, - ReactionNotificationBody: '' as IntlString + ReactionNotificationBody: '' as IntlString, + NewObject: '' as IntlString, + RemovedObject: '' as IntlString, + ChangedObject: '' as IntlString, + UnsetObject: '' as IntlString, + UpdatedObject: '' as IntlString }, component: { Activity: '' as AnyComponent, diff --git a/plugins/ai-bot/.eslintrc.js b/plugins/ai-bot/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/plugins/ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/plugins/ai-bot/.npmignore b/plugins/ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/plugins/ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/plugins/ai-bot/config/rig.json b/plugins/ai-bot/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/plugins/ai-bot/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/plugins/ai-bot/jest.config.js b/plugins/ai-bot/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/plugins/ai-bot/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/plugins/ai-bot/package.json b/plugins/ai-bot/package.json new file mode 100644 index 0000000000..55b8b14e40 --- /dev/null +++ b/plugins/ai-bot/package.json @@ -0,0 +1,47 @@ +{ + "name": "@hcengineering/ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-prettier": "^8.8.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/view": "^0.6.13" + } +} diff --git a/plugins/ai-bot/src/index.ts b/plugins/ai-bot/src/index.ts new file mode 100644 index 0000000000..4a58934ee8 --- /dev/null +++ b/plugins/ai-bot/src/index.ts @@ -0,0 +1,65 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Account, Class, Doc, type Mixin, Ref, Space } from '@hcengineering/core' +import type { Plugin } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import { ChatMessage } from '@hcengineering/chunter' + +export const aiBotId = 'ai-bot' as Plugin + +export const aiBotAccountEmail = 'huly.ai.bot@hc.engineering' + +export interface AIBotEvent extends Doc { + collection: string + messageClass: Ref> + message: string +} + +export interface AIBotResponseEvent extends AIBotEvent { + objectId: Ref + objectClass: Ref> + objectSpace: Ref + user: Ref +} + +export interface AIBotTransferEvent extends AIBotEvent { + toEmail: string + toWorkspace: string + fromWorkspace: string + messageId: Ref + parentMessageId?: Ref +} + +export interface TransferredMessage extends ChatMessage { + messageId: Ref + parentMessageId?: Ref +} + +const aiBot = plugin(aiBotId, { + class: { + AIBotEvent: '' as Ref>, + AIBotTransferEvent: '' as Ref>, + AIBotResponseEvent: '' as Ref> + }, + mixin: { + TransferredMessage: '' as Ref> + }, + account: { + AIBot: '' as Ref + } +}) + +export default aiBot diff --git a/plugins/ai-bot/tsconfig.json b/plugins/ai-bot/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/plugins/ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/plugins/chunter-assets/lang/en.json b/plugins/chunter-assets/lang/en.json index 47da2905af..72121e5360 100644 --- a/plugins/chunter-assets/lang/en.json +++ b/plugins/chunter-assets/lang/en.json @@ -111,6 +111,14 @@ "JoinChannelText": "Once you've joined, you'll be able to read all messages and contribute to the discussion.", "NoMessagesInChannel": "Currently there are no messages", "SendMessagesInChannel": "Send the first message to start the conversation", - "LatestMessages": "↓ Latest messages" + "LatestMessages": "↓ Latest messages", + "Joined": "Joined", + "Left": "Left", + "Added": "Added", + "Removed": "Removed", + "CreatedChannelOn": "Created this channel on {date}", + "ChannelMessages": "Channel messages", + "JoinChannel": "Join channel", + "YouJoinedChannel": "You have been joined to channel" } } \ No newline at end of file diff --git a/plugins/chunter-assets/lang/es.json b/plugins/chunter-assets/lang/es.json index aeb970861d..9cbd0877e7 100644 --- a/plugins/chunter-assets/lang/es.json +++ b/plugins/chunter-assets/lang/es.json @@ -111,6 +111,14 @@ "JoinChannelText": "Una vez que se haya unido, podrá leer todos los mensajes y contribuir a la discusión.", "NoMessagesInChannel": "No hay mensajes en este canal todavía.", "SendMessagesInChannel": "Envíe mensajes en este canal para comenzar la conversación.", - "LatestMessages": "↓ Últimos mensajes" + "LatestMessages": "↓ Últimos mensajes", + "Joined": "Unido", + "Left": "Abandonado", + "Added": "Añadido", + "Removed": "Eliminado", + "CreatedChannelOn": "Creó este canal el {date}", + "ChannelMessages": "Mensajes del canal", + "JoinChannel": "Unirse", + "YouJoinedChannel": "Te has unido al canal" } } \ No newline at end of file diff --git a/plugins/chunter-assets/lang/fr.json b/plugins/chunter-assets/lang/fr.json index 9a784ce568..fb42dd0389 100644 --- a/plugins/chunter-assets/lang/fr.json +++ b/plugins/chunter-assets/lang/fr.json @@ -111,6 +111,14 @@ "JoinChannelText": "Une fois que vous avez rejoint, vous pourrez lire tous les messages et participer à la discussion.", "NoMessagesInChannel": "Il n'y a pas encore de messages dans ce canal.", "SendMessagesInChannel": "Envoyez des messages pour commencer la conversation.", - "LatestMessages": "↓ Derniers messages" + "LatestMessages": "↓ Derniers messages", + "Joined": "Rejoint", + "Left": "Quitté", + "Added": "Ajouté", + "Removed": "Supprimé", + "CreatedChannelOn": "A créé ce canal le {date}", + "ChannelMessages": "Messages du canal", + "JoinChannel": "Rejoindre", + "YouJoinedChannel": "Vous avez rejoint le canal" } } \ No newline at end of file diff --git a/plugins/chunter-assets/lang/pt.json b/plugins/chunter-assets/lang/pt.json index 74633c8111..3758587fdf 100644 --- a/plugins/chunter-assets/lang/pt.json +++ b/plugins/chunter-assets/lang/pt.json @@ -111,6 +111,14 @@ "JoinChannelText": "Depois de entrar, você poderá ler todas as mensagens e contribuir na discussão.", "NoMessagesInChannel": "Ainda não existem mensagens neste canal.", "SendMessagesInChannel": "Envie a sua primeira mensagem!", - "LatestMessages": "↓ Últimas mensagens" + "LatestMessages": "↓ Últimas mensagens", + "Joined": "Entrou", + "Left": "Saiu", + "Added": "Adicionado(a)", + "Removed": "Removido(a)", + "CreatedChannelOn": "Criou este canal em {date}", + "ChannelMessages": "Mensagens do canal", + "JoinChannel": "Participar no canal", + "YouJoinedChannel": "Entrou no canal" } } \ No newline at end of file diff --git a/plugins/chunter-assets/lang/ru.json b/plugins/chunter-assets/lang/ru.json index e545f5bca9..f18301ff30 100644 --- a/plugins/chunter-assets/lang/ru.json +++ b/plugins/chunter-assets/lang/ru.json @@ -111,6 +111,14 @@ "JoinChannelText": "Присоединившись, вы сможете читать все сообщения и участвовать в обсуждении.", "NoMessagesInChannel": "В этом канале пока нет сообщений", "SendMessagesInChannel": "Отправьте первое сообщение, чтобы начать общение", - "LatestMessages": "↓ Последние сообщения" + "LatestMessages": "↓ Последние сообщения", + "Joined": "Присоединился", + "Left": "Покинул", + "Added": "Добавил(а)", + "Removed": "Исключил(а)", + "CreatedChannelOn": "Создал этот канал {date}", + "ChannelMessages": "Сообщения каналов", + "JoinChannel": "Приссоединение к каналу", + "YouJoinedChannel": "Вы присоединились к каналу" } } \ No newline at end of file diff --git a/plugins/chunter-assets/lang/zh.json b/plugins/chunter-assets/lang/zh.json index e182b46825..0da4296619 100644 --- a/plugins/chunter-assets/lang/zh.json +++ b/plugins/chunter-assets/lang/zh.json @@ -111,6 +111,14 @@ "JoinChannelText": "加入后,你将能够阅读所有消息并参与讨论。", "NoMessagesInChannel": "此频道中没有消息。", "SendMessagesInChannel": "在此频道中发送消息。", - "LatestMessages": "↓ 最新消息" + "LatestMessages": "↓ 最新消息", + "Joined": "已加入", + "Left": "已离开", + "Added": "已添加", + "Removed": "已移除", + "CreatedChannelOn": "于 {date} 创建此频道", + "ChannelMessages": "频道消息", + "JoinChannel": "加入频道", + "YouJoinedChannel": "你已加入频道" } } diff --git a/plugins/chunter-resources/src/channelDataProvider.ts b/plugins/chunter-resources/src/channelDataProvider.ts index 7334a5ad11..db3126ba62 100644 --- a/plugins/chunter-resources/src/channelDataProvider.ts +++ b/plugins/chunter-resources/src/channelDataProvider.ts @@ -29,11 +29,8 @@ import { derived, get, type Readable, writable } from 'svelte/store' import activity, { type ActivityMessage, type ActivityReference } from '@hcengineering/activity' import attachment from '@hcengineering/attachment' import { combineActivityMessages, sortActivityMessages } from '@hcengineering/activity-resources' -import { type ChatMessage } from '@hcengineering/chunter' import notification, { type DocNotifyContext } from '@hcengineering/notification' -import chunter from './plugin' - export type LoadMode = 'forward' | 'backward' export interface MessageMetadata { @@ -241,7 +238,7 @@ export class ChannelDataProvider implements IChannelDataProvider { if (loadAll) { this.isTailLoading.set(true) - this.loadTail(undefined, combineActivityMessages) + this.loadTail() } else if (isLoadingLatest) { const startIndex = Math.max(0, count - this.limit) this.isTailLoading.set(true) @@ -260,11 +257,7 @@ export class ChannelDataProvider implements IChannelDataProvider { this.isInitialLoadedStore.set(true) } - private loadTail ( - start?: Timestamp, - afterLoad?: (msgs: ActivityMessage[]) => Promise, - query?: DocumentQuery - ): void { + private loadTail (start?: Timestamp, query?: DocumentQuery): void { if (this.chatId === undefined) { this.isTailLoading.set(false) return @@ -283,12 +276,8 @@ export class ChannelDataProvider implements IChannelDataProvider { ...(this.tailStart !== undefined ? { createdOn: { $gte: this.tailStart } } : {}) }, async (res) => { - if (afterLoad !== undefined) { - const result = await afterLoad(res.reverse()) - this.tailStore.set(result) - } else { - this.tailStore.set(res.reverse()) - } + const result = await combineActivityMessages(res.reverse()) + this.tailStore.set(result) this.isTailLoaded.set(true) this.isTailLoading.set(false) @@ -325,7 +314,7 @@ export class ChannelDataProvider implements IChannelDataProvider { const skipIds = this.getChunkSkipIds(loadAfter) const messages = await client.findAll( - chunter.class.ChatMessage, + this.msgClass, { attachedTo: this.chatId, space: this.space, @@ -351,11 +340,11 @@ export class ChannelDataProvider implements IChannelDataProvider { return { from: from.createdOn ?? from.modifiedOn, to: to.createdOn ?? to.modifiedOn, - data: isBackward ? messages.reverse() : messages + data: isBackward ? await combineActivityMessages(messages.reverse()) : await combineActivityMessages(messages) } } - getChunkSkipIds (after: Timestamp, loadTail = false): Array> { + getChunkSkipIds (after: Timestamp, loadTail = false): Array> { const chunks = get(this.chunksStore) const metadata = get(this.metadataStore) const tail = get(this.tailStore) @@ -367,7 +356,7 @@ export class ChannelDataProvider implements IChannelDataProvider { .flat() .concat(loadTail ? [] : tailData) .filter(({ createdOn }) => createdOn === after) - .map(({ _id }) => _id) as Array> + .map(({ _id }) => _id) } async loadNext (mode: LoadMode, loadAfter?: Timestamp, limit?: number): Promise { @@ -467,7 +456,7 @@ export class ChannelDataProvider implements IChannelDataProvider { if (tailAfter !== undefined) { const skipIds = chunks[chunks.length - 1]?.data.map(({ _id }) => _id) ?? [] - this.loadTail(tailAfter, undefined, { _id: { $nin: skipIds } }) + this.loadTail(tailAfter, { _id: { $nin: skipIds } }) this.isLoadingMoreStore.set(false) return } diff --git a/plugins/chunter-resources/src/components/Channel.svelte b/plugins/chunter-resources/src/components/Channel.svelte index 1cdb6a9013..966c148705 100644 --- a/plugins/chunter-resources/src/components/Channel.svelte +++ b/plugins/chunter-resources/src/components/Channel.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> + +{#if value} + + + +{/if} + + diff --git a/plugins/chunter-resources/src/components/activity/MembersChangedMessage.svelte b/plugins/chunter-resources/src/components/activity/MembersChangedMessage.svelte new file mode 100644 index 0000000000..650b271481 --- /dev/null +++ b/plugins/chunter-resources/src/components/activity/MembersChangedMessage.svelte @@ -0,0 +1,128 @@ + + + + + + {#if addedPersons.length > 0} + + {#if isJoined} + + + {object?.name} + {:else} + + {/if} + {#if differentActions} + + + diff --git a/plugins/chunter-resources/src/components/chat/ChatAside.svelte b/plugins/chunter-resources/src/components/chat/ChatAside.svelte index b27f58598f..9e3f94c7f5 100644 --- a/plugins/chunter-resources/src/components/chat/ChatAside.svelte +++ b/plugins/chunter-resources/src/components/chat/ChatAside.svelte @@ -36,7 +36,7 @@ $: threadId = context ? undefined : (_id as Ref) $: context && - objectQuery.query(context.attachedToClass, { _id: context.attachedTo }, (res) => { + objectQuery.query(context.objectClass, { _id: context.objectId }, (res) => { ;[object] = res }) diff --git a/plugins/chunter-resources/src/components/chat/create/CreateChannel.svelte b/plugins/chunter-resources/src/components/chat/create/CreateChannel.svelte index 67bea5688f..ca7cdddffe 100644 --- a/plugins/chunter-resources/src/components/chat/create/CreateChannel.svelte +++ b/plugins/chunter-resources/src/components/chat/create/CreateChannel.svelte @@ -18,6 +18,7 @@ import presentation, { getClient } from '@hcengineering/presentation' import core, { getCurrentAccount } from '@hcengineering/core' import notification from '@hcengineering/notification' + import contact, { PersonAccount } from '@hcengineering/contact' import Lock from '../../icons/Lock.svelte' import chunter from '../../../plugin' @@ -51,19 +52,28 @@ $: canSave = !!channelName async function save (): Promise { - const accountId = getCurrentAccount()._id + const account = getCurrentAccount() as PersonAccount + const space = await client.findOne( + contact.class.PersonSpace, + { person: account.person }, + { projection: { _id: 1 } } + ) + if (!space) return const channelId = await client.createDoc(chunter.class.Channel, core.space.Space, { name: channelName, description: '', private: selectedVisibilityId === 'private', archived: false, - members: [accountId], + members: [account._id], topic: description }) - await client.createDoc(notification.class.DocNotifyContext, channelId, { - user: accountId, - attachedTo: channelId, - attachedToClass: chunter.class.Channel + + await client.createDoc(notification.class.DocNotifyContext, space._id, { + user: account._id, + objectId: channelId, + objectClass: chunter.class.Channel, + objectSpace: core.space.Space, + isPinned: false }) openChannel(channelId, chunter.class.Channel) diff --git a/plugins/chunter-resources/src/components/chat/create/CreateDirectChat.svelte b/plugins/chunter-resources/src/components/chat/create/CreateDirectChat.svelte index 4b64e245b0..08bcb2400b 100644 --- a/plugins/chunter-resources/src/components/chat/create/CreateDirectChat.svelte +++ b/plugins/chunter-resources/src/components/chat/create/CreateDirectChat.svelte @@ -31,7 +31,7 @@ const dispatch = createEventDispatcher() const client = getClient() - const myAccId = getCurrentAccount()._id + const myAcc = getCurrentAccount() as PersonAccount const query = createQuery() let employeeIds: Ref[] = [] @@ -52,7 +52,7 @@ async function createDirectMessage (): Promise { const employeeAccounts = await client.findAll(contact.class.PersonAccount, { person: { $in: employeeIds } }) - const accIds = [myAccId, ...employeeAccounts.filter(({ _id }) => _id !== myAccId).map(({ _id }) => _id)].sort() + const accIds = [myAcc._id, ...employeeAccounts.filter(({ _id }) => _id !== myAcc._id).map(({ _id }) => _id)].sort() const existingDms = await client.findAll(chunter.class.DirectMessage, {}) @@ -75,9 +75,9 @@ })) const context = await client.findOne(notification.class.DocNotifyContext, { - user: myAccId, - attachedTo: dmId, - attachedToClass: chunter.class.DirectMessage + person: myAcc.person, + objectId: dmId, + objectClass: chunter.class.DirectMessage }) if (context !== undefined) { @@ -86,10 +86,14 @@ return } - await client.createDoc(notification.class.DocNotifyContext, dmId, { - user: myAccId, - attachedTo: dmId, - attachedToClass: chunter.class.DirectMessage + const space = await client.findOne(contact.class.PersonSpace, { person: myAcc.person }, { projection: { _id: 1 } }) + if (!space) return + await client.createDoc(notification.class.DocNotifyContext, space._id, { + user: myAcc._id, + objectId: dmId, + objectClass: chunter.class.DirectMessage, + objectSpace: core.space.Space, + isPinned: false }) openChannel(dmId, chunter.class.DirectMessage) diff --git a/plugins/chunter-resources/src/components/chat/navigator/ChatNavGroup.svelte b/plugins/chunter-resources/src/components/chat/navigator/ChatNavGroup.svelte index 51511eb35b..83a6fc5255 100644 --- a/plugins/chunter-resources/src/components/chat/navigator/ChatNavGroup.svelte +++ b/plugins/chunter-resources/src/components/chat/navigator/ChatNavGroup.svelte @@ -14,8 +14,8 @@ --> diff --git a/plugins/chunter-resources/src/components/chat/navigator/ChatNavSection.svelte b/plugins/chunter-resources/src/components/chat/navigator/ChatNavSection.svelte index 9dd3f67b76..5e35179cad 100644 --- a/plugins/chunter-resources/src/components/chat/navigator/ChatNavSection.svelte +++ b/plugins/chunter-resources/src/components/chat/navigator/ChatNavSection.svelte @@ -120,7 +120,7 @@ noDivider > {#each sortedItems as item (item.id)} - {@const context = contexts.find(({ attachedTo }) => attachedTo === item.id)} + {@const context = contexts.find(({ objectId }) => objectId === item.id)} {/each} {#if canShowMore} @@ -130,7 +130,7 @@ {/if} {#if visibleItem !== undefined && !isOpen} - {@const context = contexts.find(({ attachedTo }) => attachedTo === visibleItem?.id)} + {@const context = contexts.find(({ objectId }) => objectId === visibleItem?.id)} {/if} diff --git a/plugins/chunter-resources/src/components/chat/navigator/ChatNavigator.svelte b/plugins/chunter-resources/src/components/chat/navigator/ChatNavigator.svelte index f7d6ae1663..42049ec6ea 100644 --- a/plugins/chunter-resources/src/components/chat/navigator/ChatNavigator.svelte +++ b/plugins/chunter-resources/src/components/chat/navigator/ChatNavigator.svelte @@ -13,7 +13,7 @@ // limitations under the License. --> + + diff --git a/plugins/chunter-resources/src/index.ts b/plugins/chunter-resources/src/index.ts index 9e7bb6719e..c053acb199 100644 --- a/plugins/chunter-resources/src/index.ts +++ b/plugins/chunter-resources/src/index.ts @@ -48,6 +48,9 @@ import ChatMessageNotificationLabel from './components/notification/ChatMessageN import ChatAside from './components/chat/ChatAside.svelte' import ThreadMessagePreview from './components/threads/ThreadMessagePreview.svelte' import ChatMessagePreview from './components/chat-message/ChatMessagePreview.svelte' +import ChannelCreatedMessage from './components/activity/ChannelCreatedMessage.svelte' +import MembersChangedMessage from './components/activity/MembersChangedMessage.svelte' +import JoinChannelNotificationPresenter from './components/notification/JoinChannelNotificationPresenter.svelte' import { ChannelTitleProvider, @@ -177,7 +180,12 @@ export default async (): Promise => ({ ThreadNotificationPresenter, ChatAside, ThreadMessagePreview, - ChatMessagePreview + ChatMessagePreview, + JoinChannelNotificationPresenter + }, + activity: { + ChannelCreatedMessage, + MembersChangedMessage }, function: { GetDmName: getDmName, diff --git a/plugins/chunter-resources/src/navigation.ts b/plugins/chunter-resources/src/navigation.ts index 06c02167aa..5dd14280c7 100644 --- a/plugins/chunter-resources/src/navigation.ts +++ b/plugins/chunter-resources/src/navigation.ts @@ -1,4 +1,11 @@ -import { getCurrentLocation, getCurrentResolvedLocation, getLocation, type Location, navigate } from '@hcengineering/ui' +import { + closePanel, + getCurrentLocation, + getCurrentResolvedLocation, + getLocation, + type Location, + navigate +} from '@hcengineering/ui' import { type Ref, type Doc, type Class } from '@hcengineering/core' import type { ActivityMessage } from '@hcengineering/activity' import { chunterId, type ChunterSpace, type ThreadMessage } from '@hcengineering/chunter' @@ -184,5 +191,6 @@ export async function resetChunterLocIfEqual (_id: Ref, _class: Ref })) + props?.object ?? (await client.findOne(chunter.class.Channel, { _id: context.objectId as Ref })) if (channel === undefined) { return } await leaveChannel(channel, getCurrentAccount()._id) + await client.remove(context) await resetChunterLocIfEqual(channel._id, channel._class, channel) } -export async function removeChannelAction ( - context?: DocNotifyContext, - _?: Event, - props?: { object?: Doc } -): Promise { +export async function removeChannelAction (context?: DocNotifyContext, _?: Event): Promise { if (context === undefined) { return } const client = getClient() const hierarchy = client.getHierarchy() - const inboxClient = InboxNotificationsClientImpl.getClient() + const { objectId, objectClass, objectSpace } = context - if (hierarchy.isDerived(context.attachedToClass, chunter.class.Channel)) { - const channel = await client.findOne(chunter.class.Channel, { _id: context.attachedTo as Ref }) + if (hierarchy.isDerived(objectClass, chunter.class.Channel)) { + const channel = await client.findOne(chunter.class.Channel, { _id: objectId as Ref, space: objectSpace }) await leaveChannel(channel, getCurrentAccount()._id) } else { - const object = await client.findOne(context.attachedToClass, { _id: context.attachedTo }) - const account = getCurrentAccount() as PersonAccount + const object = await client.findOne(objectClass, { _id: objectId, space: objectSpace }) + // const account = getCurrentAccount() as PersonAccount - await client.createMixin(context._id, context._class, context.space, chunter.mixin.ChannelInfo, { hidden: true }) - - const chatInfo = await client.findOne(chunter.class.ChatInfo, { user: account.person }) - - if (chatInfo !== undefined) { - await client.update(chatInfo, { hidden: chatInfo.hidden.concat([context._id]) }) - } - await resetChunterLocIfEqual(context.attachedTo, context.attachedToClass, object) + // await client.createMixin(context._id, context._class, context.space, chunter.mixin.ChannelInfo, { hidden: true }) + // + // const chatInfo = await client.findOne(chunter.class.ChatInfo, { user: account.person }) + // + // if (chatInfo !== undefined) { + // await client.update(chatInfo, { hidden: chatInfo.hidden.concat([context._id]) }) + // } + await resetChunterLocIfEqual(objectId, objectClass, object) } - void inboxClient.readDoc(client, context.attachedTo) + await client.remove(context) } export function isThreadMessage (message: ActivityMessage): message is ThreadMessage { diff --git a/plugins/chunter/src/index.ts b/plugins/chunter/src/index.ts index f33f08c6cf..894efcc516 100644 --- a/plugins/chunter/src/index.ts +++ b/plugins/chunter/src/index.ts @@ -171,12 +171,19 @@ export default plugin(chunterId, { UnstarChannel: '' as IntlString, UnstarConversation: '' as IntlString, NoMessagesInChannel: '' as IntlString, - SendMessagesInChannel: '' as IntlString + SendMessagesInChannel: '' as IntlString, + Joined: '' as IntlString, + Left: '' as IntlString, + Added: '' as IntlString, + Removed: '' as IntlString, + CreatedChannelOn: '' as IntlString, + YouJoinedChannel: '' as IntlString }, ids: { DMNotification: '' as Ref, ThreadNotification: '' as Ref, ChannelNotification: '' as Ref, + JoinChannelNotification: '' as Ref, ThreadMessageViewlet: '' as Ref }, app: { diff --git a/plugins/client-resources/src/connection.ts b/plugins/client-resources/src/connection.ts index 249496e044..138ad75cc3 100644 --- a/plugins/client-resources/src/connection.ts +++ b/plugins/client-resources/src/connection.ts @@ -261,7 +261,7 @@ class Connection implements ClientConnection { void this.opt?.onConnect?.( (resp as HelloResponse).reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected, - null + this.sessionId ) this.schedulePing(socketId) return diff --git a/plugins/contact-resources/src/components/PersonContent.svelte b/plugins/contact-resources/src/components/PersonContent.svelte index 55e4fed58a..2964d891b1 100644 --- a/plugins/contact-resources/src/components/PersonContent.svelte +++ b/plugins/contact-resources/src/components/PersonContent.svelte @@ -50,6 +50,7 @@ export let compact: boolean = false export let showStatus: boolean = false export let type: ObjectPresenterType = 'link' + export let overflowLabel = true const client = getClient() @@ -93,6 +94,7 @@ {type} {maxWidth} {showStatus} + {overflowLabel} /> {/if} {#if shouldShowName} - + {name} {/if} {:else if type === 'text'} - + {name} {/if} diff --git a/plugins/contact-resources/src/components/PersonPresenter.svelte b/plugins/contact-resources/src/components/PersonPresenter.svelte index 8e8b438150..3674461c1d 100644 --- a/plugins/contact-resources/src/components/PersonPresenter.svelte +++ b/plugins/contact-resources/src/components/PersonPresenter.svelte @@ -43,6 +43,7 @@ export let compact = false export let type: ObjectPresenterType = 'link' export let showStatus: boolean = false + export let overflowLabel = true const client = getClient() $: personValue = typeof value === 'string' ? $personByIdStore.get(value) : value @@ -99,6 +100,7 @@ {compact} {type} {showStatus} + {overflowLabel} on:accent-color /> {/if} diff --git a/plugins/contact/src/index.ts b/plugins/contact/src/index.ts index 6d489e8dd3..fe2df9abb9 100644 --- a/plugins/contact/src/index.ts +++ b/plugins/contact/src/index.ts @@ -179,6 +179,10 @@ export interface ContactsTab extends Doc { */ export const contactId = 'contact' as Plugin +export interface PersonSpace extends Space { + person: Ref +} + /** * @public */ @@ -193,7 +197,8 @@ export const contactPlugin = plugin(contactId, { Organization: '' as Ref>, PersonAccount: '' as Ref>, Status: '' as Ref>, - ContactsTab: '' as Ref> + ContactsTab: '' as Ref>, + PersonSpace: '' as Ref> }, mixin: { Employee: '' as Ref> diff --git a/plugins/document-resources/src/components/navigator/DocTreeElement.svelte b/plugins/document-resources/src/components/navigator/DocTreeElement.svelte index db0794da4c..43a6a0fba4 100644 --- a/plugins/document-resources/src/components/navigator/DocTreeElement.svelte +++ b/plugins/document-resources/src/components/navigator/DocTreeElement.svelte @@ -75,12 +75,20 @@ icon={action.icon} kind={'tertiary'} size={'extra-small'} + dataId={action.label} tooltip={{ label: action.label, direction: 'top' }} on:click={(evt) => action.action({}, evt)} /> {/if} {/each} - + diff --git a/plugins/guest-resources/src/connect.ts b/plugins/guest-resources/src/connect.ts index c9ef545952..69901c048e 100644 --- a/plugins/guest-resources/src/connect.ts +++ b/plugins/guest-resources/src/connect.ts @@ -72,9 +72,12 @@ export async function connect (title: string): Promise { }) }, // We need to refresh all active live queries and clear old queries. - onConnect: (event: ClientConnectEvent) => { + onConnect: (event: ClientConnectEvent, data: any) => { console.log('WorkbenchClient: onConnect', event) try { + if (event === ClientConnectEvent.Connected) { + setMetadata(presentation.metadata.SessionId, data) + } if ((_clientSet && event === ClientConnectEvent.Connected) || event === ClientConnectEvent.Refresh) { void refreshClient(true) } diff --git a/plugins/login-resources/src/components/Auth.svelte b/plugins/login-resources/src/components/Auth.svelte index 44c64d32a0..52ba47bdce 100644 --- a/plugins/login-resources/src/components/Auth.svelte +++ b/plugins/login-resources/src/components/Auth.svelte @@ -5,22 +5,23 @@ import { Loading, setMetadataLocalStorage } from '@hcengineering/ui' import { onMount } from 'svelte' import login from '../plugin' - import { afterConfirm, getSessionLoginInfo, goTo, navigateToWorkspace } from '../utils' + import { afterConfirm, getLoginInfoFromQuery, goTo, navigateToWorkspace } from '../utils' onMount(async () => { - const result = await getSessionLoginInfo() + const result = await getLoginInfoFromQuery() + if (result !== undefined) { if (isWorkspaceLoginInfo(result)) { - navigateToWorkspace(result.workspace, result) + navigateToWorkspace(result.workspace, result, undefined, true) return } setMetadata(presentation.metadata.Token, result.token) setMetadataLocalStorage(login.metadata.LastToken, result.token) setMetadataLocalStorage(login.metadata.LoginEndpoint, result.endpoint) setMetadataLocalStorage(login.metadata.LoginEmail, result.email) - await afterConfirm() + await afterConfirm(true) } else { - goTo('login') + goTo('login', true) } }) diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index dff5453977..558f8a9770 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -421,7 +421,12 @@ export function setLoginInfo (loginInfo: WorkspaceLoginInfo): void { setMetadataLocalStorage(login.metadata.LoginEmail, loginInfo.email) } -export function navigateToWorkspace (workspace: string, loginInfo?: WorkspaceLoginInfo, navigateUrl?: string): void { +export function navigateToWorkspace ( + workspace: string, + loginInfo?: WorkspaceLoginInfo, + navigateUrl?: string, + replace = false +): void { if (loginInfo == null) { return } @@ -432,7 +437,7 @@ export function navigateToWorkspace (workspace: string, loginInfo?: WorkspaceLog try { const loc = JSON.parse(decodeURIComponent(navigateUrl)) as Location if (loc.path[1] === workspace) { - navigate(loc) + navigate(loc, replace) return } } catch (err: any) { @@ -441,9 +446,9 @@ export function navigateToWorkspace (workspace: string, loginInfo?: WorkspaceLog } const last = localStorage.getItem(`${locationStorageKeyId}_${workspace}`) if (last !== null) { - navigate(JSON.parse(last)) + navigate(JSON.parse(last), replace) } else { - navigate({ path: [workbenchId, workspace] }) + navigate({ path: [workbenchId, workspace] }, replace) } } @@ -862,7 +867,7 @@ export function goTo (path: Pages, clearQuery: boolean = false): void { if (clearQuery) { loc.query = undefined } - navigate(loc) + navigate(loc, clearQuery) } export function getHref (path: Pages): string { @@ -872,10 +877,10 @@ export function getHref (path: Pages): string { return host + url } -export async function afterConfirm (): Promise { +export async function afterConfirm (clearQuery = false): Promise { const joinedWS = await getWorkspaces() if (joinedWS.length === 0) { - goTo('createWorkspace') + goTo('createWorkspace', clearQuery) } else if (joinedWS.length === 1) { const result = (await selectWorkspace(joinedWS[0].workspace, null))[1] if (result !== undefined) { @@ -883,29 +888,46 @@ export async function afterConfirm (): Promise { setMetadataLocalStorage(login.metadata.LastToken, result.token) setLoginInfo(result) - navigateToWorkspace(joinedWS[0].workspace, result) + navigateToWorkspace(joinedWS[0].workspace, result, undefined, clearQuery) } } else { - goTo('selectWorkspace') + goTo('selectWorkspace', clearQuery) } } -export async function getSessionLoginInfo (): Promise { +export async function getLoginInfoFromQuery (): Promise { + const token = getCurrentLocation().query?.token + + if (token === undefined) { + return undefined + } + const accountsUrl = getMetadata(login.metadata.AccountsUrl) if (accountsUrl === undefined) { throw new Error('accounts url not specified') } + const request = { + method: 'getAccountInfoByToken', + params: [] as any[] + } + try { - const response = await fetch(concatLink(accountsUrl, '/auth'), { - method: 'GET', - credentials: 'include' + const response = await fetch(accountsUrl, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(request) }) const result = await response.json() - return result + if (result.error != null) { + throw new PlatformError(result.error) + } + return result.result } catch (err: any) { - console.error('login error', err) Analytics.handleError(err) } } diff --git a/plugins/love-resources/src/components/ControlExt.svelte b/plugins/love-resources/src/components/ControlExt.svelte index a08fce5cfe..5cf23602a9 100644 --- a/plugins/love-resources/src/components/ControlExt.svelte +++ b/plugins/love-resources/src/components/ControlExt.svelte @@ -57,7 +57,6 @@ rooms } from '../stores' import { - connectRoom, disconnect, getRoomName, isCameraEnabled, diff --git a/plugins/love-resources/src/components/Room.svelte b/plugins/love-resources/src/components/Room.svelte index e06d5d4725..3f0bb1e38a 100644 --- a/plugins/love-resources/src/components/Room.svelte +++ b/plugins/love-resources/src/components/Room.svelte @@ -43,6 +43,7 @@ } from '../utils' import ControlBar from './ControlBar.svelte' import ParticipantView from './ParticipantView.svelte' + import presentation from '@hcengineering/presentation' export let withVideo: boolean export let room: TypeRoom @@ -223,7 +224,11 @@ await $storePromise - if (!$isConnected && !$isCurrentInstanceConnected) { + if ( + !$isConnected && + !$isCurrentInstanceConnected && + $myInfo?.sessionId === getMetadata(presentation.metadata.SessionId) + ) { const info = $infos.filter((p) => p.room === room._id) await tryConnect($personByIdStore, $myInfo, room, info, $myRequests, $invites) } diff --git a/plugins/love-resources/src/utils.ts b/plugins/love-resources/src/utils.ts index 2c3d7ff42f..0fa2f60d4b 100644 --- a/plugins/love-resources/src/utils.ts +++ b/plugins/love-resources/src/utils.ts @@ -485,14 +485,16 @@ async function moveToRoom ( y: number, currentInfo: ParticipantInfo | undefined, currentPerson: Person, - room: Room + room: Room, + sessionId: string | null ): Promise { const client = getClient() if (currentInfo !== undefined) { await client.diffUpdate(currentInfo, { x, y, - room: room._id + room: room._id, + sessionId }) } else { await client.createDoc(love.class.ParticipantInfo, core.space.Workspace, { @@ -500,7 +502,8 @@ async function moveToRoom ( y, room: room._id, person: currentPerson._id, - name: currentPerson.name + name: currentPerson.name, + sessionId }) } const loc = getCurrentLocation() @@ -529,7 +532,7 @@ export async function connectRoom ( room: Room ): Promise { await disconnect() - await moveToRoom(x, y, currentInfo, currentPerson, room) + await moveToRoom(x, y, currentInfo, currentPerson, room, getMetadata(presentation.metadata.SessionId) ?? null) await connectLK(currentPerson, room) } diff --git a/plugins/love/src/index.ts b/plugins/love/src/index.ts index 95816ec231..62eada1e03 100644 --- a/plugins/love/src/index.ts +++ b/plugins/love/src/index.ts @@ -51,6 +51,7 @@ export interface ParticipantInfo extends Doc { room: Ref x: number y: number + sessionId: string | null } export interface RoomInfo extends Doc { diff --git a/plugins/notification-assets/lang/en.json b/plugins/notification-assets/lang/en.json index 44baf53777..8f906a9060 100644 --- a/plugins/notification-assets/lang/en.json +++ b/plugins/notification-assets/lang/en.json @@ -55,6 +55,7 @@ "CommonNotificationCollectionAdded": "{senderName} added {collection}", "CommonNotificationCollectionRemoved": "{senderName} removed {collection}", "Sound": "Sound", - "SoundNotificationsDescription": "Receive sound notifications for events." + "SoundNotificationsDescription": "Receive sound notifications for events.", + "NoAccessToObject": "You no longer have access to this object" } } diff --git a/plugins/notification-assets/lang/es.json b/plugins/notification-assets/lang/es.json index 1aa45f9977..eda8de8712 100644 --- a/plugins/notification-assets/lang/es.json +++ b/plugins/notification-assets/lang/es.json @@ -54,6 +54,7 @@ "Sound": "Sonido", "SoundNotificationsDescription": "Reciba notificaciones de sonido para eventos.", "CommonNotificationCollectionAdded": "{senderName} añadió {collection}", - "CommonNotificationCollectionRemoved": "{senderName} eliminó {collection}" + "CommonNotificationCollectionRemoved": "{senderName} eliminó {collection}", + "NoAccessToObject": "Ya no tienes acceso a este objeto" } } \ No newline at end of file diff --git a/plugins/notification-assets/lang/fr.json b/plugins/notification-assets/lang/fr.json index edd97a0431..97cc8e7587 100644 --- a/plugins/notification-assets/lang/fr.json +++ b/plugins/notification-assets/lang/fr.json @@ -55,6 +55,7 @@ "Sound": "Son", "SoundNotificationsDescription": "Recevez des notifications sonores pour les événements.", "CommonNotificationCollectionAdded": "{senderName} a ajouté {collection}", - "CommonNotificationCollectionRemoved": "{senderName} a supprimé {collection}" + "CommonNotificationCollectionRemoved": "{senderName} a supprimé {collection}", + "NoAccessToObject": "Vous n'avez plus accès à cet objet" } } \ No newline at end of file diff --git a/plugins/notification-assets/lang/pt.json b/plugins/notification-assets/lang/pt.json index 148544ed2c..6eda5c6b4b 100644 --- a/plugins/notification-assets/lang/pt.json +++ b/plugins/notification-assets/lang/pt.json @@ -54,6 +54,7 @@ "Sound": "Som", "SoundNotificationsDescription": "Receba notificações sonoras para eventos.", "CommonNotificationCollectionAdded": "{senderName} adicionou {collection}", - "CommonNotificationCollectionRemoved": "{senderName} removeu {collection}" + "CommonNotificationCollectionRemoved": "{senderName} removeu {collection}", + "NoAccessToObject": "Você não tem mais acesso a este objeto" } } \ No newline at end of file diff --git a/plugins/notification-assets/lang/ru.json b/plugins/notification-assets/lang/ru.json index e30774435e..ddcd2db164 100644 --- a/plugins/notification-assets/lang/ru.json +++ b/plugins/notification-assets/lang/ru.json @@ -55,6 +55,7 @@ "Sound": "Звук", "SoundNotificationsDescription": "Получайте звуковые уведомления о событиях.", "CommonNotificationCollectionAdded": "{senderName} добавил {collection}", - "CommonNotificationCollectionRemoved": "{senderName} удалил {collection}" + "CommonNotificationCollectionRemoved": "{senderName} удалил {collection}", + "NoAccessToObject": "У вас больше нет доступа к этому объекту" } } diff --git a/plugins/notification-assets/lang/zh.json b/plugins/notification-assets/lang/zh.json index fb2824f982..43f9d51d1f 100644 --- a/plugins/notification-assets/lang/zh.json +++ b/plugins/notification-assets/lang/zh.json @@ -55,6 +55,7 @@ "Sound": "声音", "SoundNotificationsDescription": "接收事件的声音通知。", "CommonNotificationCollectionAdded": "{senderName} 添加了 {collection}", - "CommonNotificationCollectionRemoved": "{senderName} 移除了 {collection}" + "CommonNotificationCollectionRemoved": "{senderName} 移除了 {collection}", + "NoAccessToObject": "您不再可以访问此对象" } } diff --git a/plugins/notification-resources/src/components/DocNotifyContextCard.svelte b/plugins/notification-resources/src/components/DocNotifyContextCard.svelte index de71c3532f..9c5b3fd89a 100644 --- a/plugins/notification-resources/src/components/DocNotifyContextCard.svelte +++ b/plugins/notification-resources/src/components/DocNotifyContextCard.svelte @@ -13,17 +13,17 @@ // limitations under the License. -->
{#if iconMixin && object} - {:else if !iconMixin} - + {:else} + {/if}
diff --git a/plugins/notification-resources/src/components/inbox/Inbox.svelte b/plugins/notification-resources/src/components/inbox/Inbox.svelte index 495bbbdab8..ca8e3624d4 100644 --- a/plugins/notification-resources/src/components/inbox/Inbox.svelte +++ b/plugins/notification-resources/src/components/inbox/Inbox.svelte @@ -110,8 +110,8 @@ ) archivedOtherNotificationsQuery.query( - notification.class.InboxNotification, - { _class: { $ne: notification.class.ActivityInboxNotification }, archived: true, user: me._id }, + notification.class.CommonInboxNotification, + { archived: true, user: me._id }, (res) => { archivedOtherNotifications = res }, @@ -188,7 +188,7 @@ async function updateTabItems (inboxData: InboxData, notifyContexts: DocNotifyContext[]): Promise { const displayClasses = new Set( - notifyContexts.filter(({ _id }) => inboxData.has(_id)).map(({ attachedToClass }) => attachedToClass) + notifyContexts.filter(({ _id }) => inboxData.has(_id)).map(({ objectClass }) => objectClass) ) const classes = Array.from(displayClasses) @@ -249,8 +249,8 @@ return } - const isChunterChannel = hierarchy.isDerived(selectedContext.attachedToClass, chunter.class.ChunterSpace) - const panelComponent = hierarchy.classHierarchyMixin(selectedContext.attachedToClass, view.mixin.ObjectPanel) + const isChunterChannel = hierarchy.isDerived(selectedContext.objectClass, chunter.class.ChunterSpace) + const panelComponent = hierarchy.classHierarchyMixin(selectedContext.objectClass, view.mixin.ObjectPanel) selectedComponent = panelComponent?.component ?? view.component.EditDoc @@ -315,10 +315,10 @@ if ( selectedTabId === activity.class.ActivityMessage && - hierarchy.isDerived(context.attachedToClass, activity.class.ActivityMessage) + hierarchy.isDerived(context.objectClass, activity.class.ActivityMessage) ) { result.set(key, resNotifications) - } else if (context.attachedToClass === selectedTabId) { + } else if (context.objectClass === selectedTabId) { result.set(key, resNotifications) } } @@ -411,8 +411,8 @@ context === selectedContext)} noScroll + minHeight="5.625rem" kind="full-size" colorsSchema="lumia" lazy={true} diff --git a/plugins/notification-resources/src/components/settings/GeneralPreferencesGroup.svelte b/plugins/notification-resources/src/components/settings/GeneralPreferencesGroup.svelte index 484585ae91..14e1c6537e 100644 --- a/plugins/notification-resources/src/components/settings/GeneralPreferencesGroup.svelte +++ b/plugins/notification-resources/src/components/settings/GeneralPreferencesGroup.svelte @@ -16,10 +16,11 @@ -{#each providers as provider} - {@const setting = $providersSettings.find(({ attachedTo }) => attachedTo === provider._id)} - -
-
-
- - - -
- - -
- {#if provider.canDisable} - onToggle(provider)} - /> +
+ {#each providers as provider (provider._id)} + {#if provider.isAvailableFn} + {#await getResource(provider.isAvailableFn) then isAvailableFn} + {#if isAvailableFn()} + + {/if} + {/await} + {:else} + {/if} -
-{/each} - - + {/each} +
diff --git a/plugins/notification-resources/src/components/settings/NotificationGroupSetting.svelte b/plugins/notification-resources/src/components/settings/NotificationGroupSetting.svelte index 45e48249a9..a18df25410 100644 --- a/plugins/notification-resources/src/components/settings/NotificationGroupSetting.svelte +++ b/plugins/notification-resources/src/components/settings/NotificationGroupSetting.svelte @@ -22,7 +22,7 @@ NotificationTypeSetting, NotificationProviderDefaults } from '@hcengineering/notification' - import { IntlString } from '@hcengineering/platform' + import { getResource, IntlString } from '@hcengineering/platform' import { getClient } from '@hcengineering/presentation' import { Grid, Label, ModernToggle } from '@hcengineering/ui' @@ -130,26 +130,42 @@ return false } - $: filteredProviders = providers.filter((provider) => { - const providerSetting = $providersSettings.find((p) => p.attachedTo === provider._id) + async function getFilteredProviders ( + providers: NotificationProvider[], + types: BaseNotificationType[] + ): Promise { + const result: NotificationProvider[] = [] - if (providerSetting !== undefined && !providerSetting.enabled) { - return false + for (const provider of providers) { + const providerSetting = $providersSettings.find((p) => p.attachedTo === provider._id) + + if (providerSetting !== undefined && !providerSetting.enabled) continue + if (providerSetting === undefined && !provider.defaultEnabled) continue + + if (provider.isAvailableFn !== undefined) { + const isAvailableFn = await getResource(provider.isAvailableFn) + if (!isAvailableFn()) continue + } + + if (provider.ignoreAll === true) { + const ignoreExcluded = providerDefaults + .map((it) => (provider._id === it.provider && it.excludeIgnore !== undefined ? it.excludeIgnore : [])) + .flat() + + const included = types.some((type) => ignoreExcluded.includes(type._id)) + if (!included) continue + } + + result.push(provider) } - if (providerSetting === undefined && !provider.defaultEnabled) { - return false - } + return result + } - if (provider.ignoreAll === true) { - const ignoreExcluded = providerDefaults - .map((it) => (provider._id === it.provider && it.excludeIgnore !== undefined ? it.excludeIgnore : [])) - .flat() + let filteredProviders: NotificationProvider[] = [] - return types.some((type) => ignoreExcluded.includes(type._id)) - } - - return true + $: void getFilteredProviders(providers, types).then((result) => { + filteredProviders = result }) $: column = filteredProviders.length + 1 diff --git a/plugins/notification-resources/src/components/settings/ProviderPreferences.svelte b/plugins/notification-resources/src/components/settings/ProviderPreferences.svelte new file mode 100644 index 0000000000..f37fa3f103 --- /dev/null +++ b/plugins/notification-resources/src/components/settings/ProviderPreferences.svelte @@ -0,0 +1,72 @@ + + + + +
+
+
+
+ + + +
+ {#if provider.description} + + + {/if} +
+ {#if provider.canDisable} + dispatch('toggle', provider)} /> + {/if} +
+ {#if presenter} + + {/if} +
+ + diff --git a/plugins/notification-resources/src/inboxNotificationsClient.ts b/plugins/notification-resources/src/inboxNotificationsClient.ts index 1e7ea66e24..206f9b376b 100644 --- a/plugins/notification-resources/src/inboxNotificationsClient.ts +++ b/plugins/notification-resources/src/inboxNotificationsClient.ts @@ -34,6 +34,7 @@ import notification, { } from '@hcengineering/notification' import { createQuery, getClient } from '@hcengineering/presentation' import { derived, get, writable } from 'svelte/store' + import { isActivityNotification } from './utils' /** @@ -89,6 +90,10 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient { private _contextByDoc = new Map, DocNotifyContext>() private constructor () { + void this.init() + } + + private async init (): Promise { this.contextsQuery.query( notification.class.DocNotifyContext, { @@ -96,15 +101,14 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient { }, (result: DocNotifyContext[]) => { this.contexts.set(result) - this._contextByDoc = new Map(result.map((updates) => [updates.attachedTo, updates])) + this._contextByDoc = new Map(result.map((updates) => [updates.objectId, updates])) this.contextByDoc.set(this._contextByDoc) } ) this.otherInboxNotificationsQuery.query( - notification.class.InboxNotification, + notification.class.CommonInboxNotification, { - _class: { $ne: notification.class.ActivityInboxNotification }, - archived: { $ne: true }, + archived: false, user: getCurrentAccount()._id }, (result: InboxNotification[]) => { @@ -120,7 +124,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient { this.activityInboxNotificationsQuery.query( notification.class.ActivityInboxNotification, { - archived: { $ne: true }, + archived: false, user: getCurrentAccount()._id }, (result: ActivityInboxNotification[]) => { @@ -242,7 +246,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient { notification.class.InboxNotification, { user: getCurrentAccount()._id, - archived: { $ne: true } + archived: false }, { projection: { _id: 1, _class: 1, space: 1 } } ) @@ -267,8 +271,8 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient { notification.class.InboxNotification, { user: getCurrentAccount()._id, - isViewed: { $ne: true }, - archived: { $ne: true } + isViewed: false, + archived: false }, { projection: { _id: 1, _class: 1, space: 1 } } ) @@ -293,7 +297,7 @@ export class InboxNotificationsClientImpl implements InboxNotificationsClient { { user: getCurrentAccount()._id, isViewed: true, - archived: { $ne: true } + archived: false }, { projection: { _id: 1, _class: 1, space: 1, docNotifyContext: 1 }, diff --git a/plugins/notification-resources/src/utils.ts b/plugins/notification-resources/src/utils.ts index 5b6a75f15b..2d51edccfd 100644 --- a/plugins/notification-resources/src/utils.ts +++ b/plugins/notification-resources/src/utils.ts @@ -83,7 +83,8 @@ export function loadNotificationSettings (): void { providersSettings.set(res) } ) - typeSettingsQuery.query(notification.class.NotificationTypeSetting, {}, (res) => { + + typeSettingsQuery.query(notification.class.NotificationTypeSetting, { space: core.space.Workspace }, (res) => { typesSettings.set(res) }) } @@ -91,11 +92,11 @@ export function loadNotificationSettings (): void { loadNotificationSettings() export async function hasDocNotifyContextPinAction (docNotifyContext: DocNotifyContext): Promise { - return docNotifyContext.isPinned !== true + return !docNotifyContext.isPinned } export async function hasDocNotifyContextUnpinAction (docNotifyContext: DocNotifyContext): Promise { - return docNotifyContext.isPinned === true + return docNotifyContext.isPinned } /** @@ -186,7 +187,7 @@ export async function archiveContextNotifications (doc?: DocNotifyContext): Prom try { const notifications = await ops.findAll( notification.class.InboxNotification, - { docNotifyContext: doc._id, archived: { $ne: true } }, + { docNotifyContext: doc._id, archived: false }, { projection: { _id: 1, _class: 1, space: 1 } } ) @@ -273,9 +274,9 @@ async function updateMeInCollaborators ( /** * @public */ -export async function unsubscribe (object: DocNotifyContext): Promise { +export async function unsubscribe (context: DocNotifyContext): Promise { const client = getClient() - await updateMeInCollaborators(client, object.attachedToClass, object.attachedTo, OpWithMe.Remove) + await updateMeInCollaborators(client, context.objectClass, context.objectId, OpWithMe.Remove) } /** @@ -586,50 +587,39 @@ export async function selectInboxContext ( ): Promise { const client = getClient() const hierarchy = client.getHierarchy() + const { objectId, objectClass } = context if (isMentionNotification(notification) && isActivityMessageClass(notification.mentionedInClass)) { const selectedMsg = notification.mentionedIn as Ref void navigateToInboxDoc( linkProviders, - context.attachedTo, - context.attachedToClass, - isActivityMessageClass(context.attachedToClass) ? (context.attachedTo as Ref) : undefined, + objectId, + objectClass, + isActivityMessageClass(objectClass) ? (objectId as Ref) : undefined, selectedMsg ) return } - if (hierarchy.isDerived(context.attachedToClass, activity.class.ActivityMessage)) { + if (hierarchy.isDerived(objectClass, activity.class.ActivityMessage)) { const message = (notification as WithLookup)?.$lookup?.attachedTo - if (context.attachedToClass === chunter.class.ThreadMessage) { + if (objectClass === chunter.class.ThreadMessage) { const thread = await client.findOne( chunter.class.ThreadMessage, { - _id: context.attachedTo as Ref + _id: objectId as Ref }, { projection: { _id: 1, attachedTo: 1 } } ) - void navigateToInboxDoc( - linkProviders, - context.attachedTo, - context.attachedToClass, - thread?.attachedTo, - thread?._id - ) + void navigateToInboxDoc(linkProviders, objectId, objectClass, thread?.attachedTo, thread?._id) return } if (isReactionMessage(message)) { - void navigateToInboxDoc( - linkProviders, - context.attachedTo, - context.attachedToClass, - undefined, - context.attachedTo as Ref - ) + void navigateToInboxDoc(linkProviders, objectId, objectClass, undefined, objectId as Ref) return } @@ -637,18 +627,18 @@ export async function selectInboxContext ( void navigateToInboxDoc( linkProviders, - context.attachedTo, - context.attachedToClass, - selectedMsg !== undefined ? (context.attachedTo as Ref) : undefined, - selectedMsg ?? (context.attachedTo as Ref) + objectId, + objectClass, + selectedMsg !== undefined ? (objectId as Ref) : undefined, + selectedMsg ?? (objectId as Ref) ) return } void navigateToInboxDoc( linkProviders, - context.attachedTo, - context.attachedToClass, + objectId, + objectClass, undefined, (notification as ActivityInboxNotification)?.attachedTo ) diff --git a/plugins/notification/package.json b/plugins/notification/package.json index 31d57b7d21..8c29582966 100644 --- a/plugins/notification/package.json +++ b/plugins/notification/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@hcengineering/activity": "^0.6.0", + "@hcengineering/contact": "^0.6.24", "@hcengineering/core": "^0.6.32", "@hcengineering/platform": "^0.6.11", "@hcengineering/preference": "^0.6.13", diff --git a/plugins/notification/src/index.ts b/plugins/notification/src/index.ts index fe282ea777..393a9b8260 100644 --- a/plugins/notification/src/index.ts +++ b/plugins/notification/src/index.ts @@ -24,6 +24,7 @@ import { Markup, Mixin, Ref, + Space, Timestamp, Tx, TxOperations @@ -34,6 +35,8 @@ import { Preference } from '@hcengineering/preference' import { IntegrationType } from '@hcengineering/setting' import { AnyComponent, Location, ResolvedLocation } from '@hcengineering/ui' import { Action } from '@hcengineering/view' +import { PersonSpace } from '@hcengineering/contact' + import { Readable, Writable } from './types' export * from './types' @@ -113,6 +116,7 @@ export interface NotificationTemplate { export interface NotificationContent { title: IntlString body: IntlString + data?: Markup intlParams: Record intlParamsNotLocalized?: Record } @@ -160,6 +164,8 @@ export interface NotificationProvider extends Doc { canDisable: boolean ignoreAll?: boolean order: number + presenter?: AnyComponent + isAvailableFn?: Resource<() => boolean> } export interface NotificationProviderDefaults extends Doc { @@ -223,7 +229,7 @@ export interface NotificationContextPresenter extends Class { /** * @public */ -export interface InboxNotification extends Doc { +export interface InboxNotification extends Doc { user: Ref isViewed: boolean @@ -232,9 +238,10 @@ export interface InboxNotification extends Doc { // For browser notifications title?: IntlString body?: IntlString + data?: Markup intlParams?: Record intlParamsNotLocalized?: Record - archived?: boolean + archived: boolean } export interface ActivityInboxNotification extends InboxNotification { @@ -269,14 +276,14 @@ export type DisplayInboxNotification = DisplayActivityInboxNotification | InboxN /** * @public */ -export interface DocNotifyContext extends Doc { +export interface DocNotifyContext extends Doc { user: Ref - // Context - attachedTo: Ref - attachedToClass: Ref> + objectId: Ref + objectClass: Ref> + objectSpace: Ref - isPinned?: boolean + isPinned: boolean lastViewedTimestamp?: Timestamp lastUpdateTimestamp?: Timestamp } @@ -419,7 +426,8 @@ const notification = plugin(notificationId, { CommonNotificationCollectionAdded: '' as IntlString, CommonNotificationCollectionRemoved: '' as IntlString, SoundNotificationsDescription: '' as IntlString, - Sound: '' as IntlString + Sound: '' as IntlString, + NoAccessToObject: '' as IntlString }, function: { Notify: '' as Resource, diff --git a/plugins/onboard-resources/src/components/Auth.svelte b/plugins/onboard-resources/src/components/Auth.svelte index 0d46f6a7e6..7d47d5bff7 100644 --- a/plugins/onboard-resources/src/components/Auth.svelte +++ b/plugins/onboard-resources/src/components/Auth.svelte @@ -1,6 +1,6 @@ + +{#if enabled} + {#if isLoading} +
+ +
+ {:else if info} +
+
+ + + {info.name} (@{info.username}) + + {#if isConnectionEstablished} + + + {/if} +
+ {#if connectionError} + + + {/if} +
+
+ + + {#if codeError} + + + {:else if isCodeValid} + + + {/if} +
+ {:else} + + + {/if} +{/if} + + diff --git a/plugins/telegram-resources/src/components/icons/TelegramColor.svelte b/plugins/telegram-resources/src/components/icons/TelegramColor.svelte index 18a879f9fb..040d911743 100644 --- a/plugins/telegram-resources/src/components/icons/TelegramColor.svelte +++ b/plugins/telegram-resources/src/components/icons/TelegramColor.svelte @@ -14,10 +14,15 @@ // limitations under the License. --> + + diff --git a/plugins/telegram-resources/src/index.ts b/plugins/telegram-resources/src/index.ts index 295e56af08..4c829f2a43 100644 --- a/plugins/telegram-resources/src/index.ts +++ b/plugins/telegram-resources/src/index.ts @@ -24,9 +24,10 @@ import Reconnect from './components/Reconnect.svelte' import IconTelegram from './components/icons/TelegramColor.svelte' import TelegramMessageCreated from './components/activity/TelegramMessageCreated.svelte' import MessagePresenter from './components/MessagePresenter.svelte' +import NotificationProviderPresenter from './components/NotificationProviderPresenter.svelte' import telegram from './plugin' -import { getCurrentEmployeeTG, getIntegrationOwnerTG } from './utils' +import { getCurrentEmployeeTG, getIntegrationOwnerTG, isTelegramNotificationsAvailable } from './utils' import SharedMessages from './components/SharedMessages.svelte' export default async (): Promise => ({ @@ -36,14 +37,16 @@ export default async (): Promise => ({ Reconnect, IconTelegram, SharedMessages, - MessagePresenter + MessagePresenter, + NotificationProviderPresenter }, activity: { TelegramMessageCreated }, function: { GetCurrentEmployeeTG: getCurrentEmployeeTG, - GetIntegrationOwnerTG: getIntegrationOwnerTG + GetIntegrationOwnerTG: getIntegrationOwnerTG, + IsTelegramNotificationsAvailable: isTelegramNotificationsAvailable }, handler: { DisconnectHandler: async () => { diff --git a/plugins/telegram-resources/src/plugin.ts b/plugins/telegram-resources/src/plugin.ts index 76385ba750..1765a3cd1b 100644 --- a/plugins/telegram-resources/src/plugin.ts +++ b/plugins/telegram-resources/src/plugin.ts @@ -35,7 +35,12 @@ export default mergeIds(telegramId, telegram, { Cancel: '' as IntlString, Share: '' as IntlString, PublishSelected: '' as IntlString, - MessagesSelected: '' as IntlString + MessagesSelected: '' as IntlString, + ConnectBotInfoStart: '' as IntlString, + ConnectBotInfoEnd: '' as IntlString, + ConnectBotError: '' as IntlString, + TestConnection: '' as IntlString, + Connected: '' as IntlString }, component: { MessagePresenter: '' as AnyComponent diff --git a/plugins/telegram-resources/src/utils.ts b/plugins/telegram-resources/src/utils.ts index 6e6e99f8b3..b0c20f3d02 100644 --- a/plugins/telegram-resources/src/utils.ts +++ b/plugins/telegram-resources/src/utils.ts @@ -20,6 +20,9 @@ import { getClient } from '@hcengineering/presentation' import setting from '@hcengineering/setting' import { type TemplateDataProvider } from '@hcengineering/templates' import { get } from 'svelte/store' +import { getMetadata } from '@hcengineering/platform' + +import telegram from './plugin' export async function getCurrentEmployeeTG (): Promise { const me = getCurrentAccount() as PersonAccount @@ -44,3 +47,9 @@ export async function getIntegrationOwnerTG (provider: TemplateDataProvider): Pr } } } + +export function isTelegramNotificationsAvailable (): boolean { + const botEndpoint = getMetadata(telegram.metadata.BotUrl) ?? '' + + return botEndpoint !== '' +} diff --git a/plugins/telegram/src/index.ts b/plugins/telegram/src/index.ts index bc34ab429d..83dd982395 100644 --- a/plugins/telegram/src/index.ts +++ b/plugins/telegram/src/index.ts @@ -14,9 +14,9 @@ // import { ChannelItem } from '@hcengineering/contact' -import type { AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core' -import { NotificationType } from '@hcengineering/notification' -import type { Plugin } from '@hcengineering/platform' +import { Account, AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core' +import { InboxNotification, NotificationProvider, NotificationType } from '@hcengineering/notification' +import type { IntlString, Plugin } from '@hcengineering/platform' import { Metadata, plugin } from '@hcengineering/platform' import type { Handler, IntegrationType } from '@hcengineering/setting' import { TemplateField } from '@hcengineering/templates' @@ -58,6 +58,16 @@ export interface SharedTelegramMessages extends AttachedDoc { messages: SharedTelegramMessage[] } +export interface TelegramNotificationRecord { + notificationId: Ref + workspace: string + account: Ref + title: string + quote?: string + body: string + sender: string +} + /** * @public */ @@ -91,6 +101,28 @@ export default plugin(telegramId, { IntegrationOwnerTG: '' as Ref }, metadata: { - TelegramURL: '' as Metadata + TelegramURL: '' as Metadata, + BotUrl: '' as Metadata + }, + providers: { + TelegramNotificationProvider: '' as Ref + }, + string: { + BotDescription: '' as IntlString, + BotShortDescription: '' as IntlString, + WelcomeMessage: '' as IntlString, + TestMessage: '' as IntlString, + ConnectMessage: '' as IntlString, + StopMessage: '' as IntlString, + StartBot: '' as IntlString, + ConnectAccount: '' as IntlString, + ShowCommandsDetails: '' as IntlString, + TurnNotificationsOff: '' as IntlString, + ConnectedDescriptionHtml: '' as IntlString, + AccountAlreadyConnectedHtml: '' as IntlString, + AccountConnectedHtml: '' as IntlString, + AccountAlreadyConnected: '' as IntlString, + InvalidCode: '' as IntlString, + SomethingWentWrong: '' as IntlString } }) diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index ed8e8cdc50..292131de8d 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -88,7 +88,6 @@ export default mergeIds(viewId, view, { AfterDate: '' as IntlString, BetweenDates: '' as IntlString, SaveAs: '' as IntlString, - And: '' as IntlString, Between: '' as IntlString, ShowColors: '' as IntlString, Show: '' as IntlString, diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index fc8463d468..df90297256 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -209,7 +209,8 @@ const view = plugin(viewId, { Unpin: '' as IntlString, Join: '' as IntlString, Leave: '' as IntlString, - Copied: '' as IntlString + Copied: '' as IntlString, + And: '' as IntlString }, icon: { Table: '' as Asset, diff --git a/plugins/workbench-resources/src/connect.ts b/plugins/workbench-resources/src/connect.ts index 62faafa29a..df9a1e25ac 100644 --- a/plugins/workbench-resources/src/connect.ts +++ b/plugins/workbench-resources/src/connect.ts @@ -168,6 +168,9 @@ export async function connect (title: string): Promise { return } try { + if (event === ClientConnectEvent.Connected) { + setMetadata(presentation.metadata.SessionId, data) + } if ((_clientSet && event === ClientConnectEvent.Connected) || event === ClientConnectEvent.Refresh) { void ctx.with('refresh client', {}, async () => { await refreshClient(tokenChanged) diff --git a/pods/account/package.json b/pods/account/package.json index 3d3e661abe..5ae1ba8424 100644 --- a/pods/account/package.json +++ b/pods/account/package.json @@ -14,7 +14,7 @@ "_phase:bundle": "rushx bundle", "_phase:docker-build": "rushx docker:build", "_phase:docker-staging": "rushx docker:staging", - "bundle": "mkdir -p bundle && esbuild src/__start.ts --bundle --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --minify --platform=node > bundle/bundle.js", + "bundle": "mkdir -p bundle && esbuild src/__start.ts --sourcemap=inline --external:*.node --external:snappy --bundle --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --minify --platform=node > bundle/bundle.js", "docker:build": "../../common/scripts/docker_build.sh hardcoreeng/account", "docker:tbuild": "docker build -t hardcoreeng/account . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/account", "docker:abuild": "docker build -t hardcoreeng/account . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/account", diff --git a/pods/authProviders/package.json b/pods/authProviders/package.json index beca9b14b9..2ded298ef8 100644 --- a/pods/authProviders/package.json +++ b/pods/authProviders/package.json @@ -42,7 +42,8 @@ "@types/koa-router": "^7.4.8", "@types/koa": "^2.15.0", "@types/koa-session": "^6.4.5", - "@types/passport-github2": "^1.2.9" + "@types/passport-github2": "^1.2.9", + "@types/querystringify": "^2.0.2" }, "dependencies": { "mongodb": "^6.8.0", @@ -54,6 +55,7 @@ "koa-passport": "^6.0.0", "koa": "^2.15.3", "koa-router": "^12.0.1", - "koa-session": "^6.4.0" + "koa-session": "^6.4.0", + "querystringify": "^2.2.0" } } diff --git a/pods/authProviders/src/github.ts b/pods/authProviders/src/github.ts index 2e073b3cf7..237aae04b8 100644 --- a/pods/authProviders/src/github.ts +++ b/pods/authProviders/src/github.ts @@ -1,8 +1,9 @@ -import { joinWithProvider, loginWithProvider } from '@hcengineering/account' +import { joinWithProvider, loginWithProvider, type LoginInfo } from '@hcengineering/account' import { BrandingMap, concatLink, MeasureContext } from '@hcengineering/core' import Router from 'koa-router' import { Db } from 'mongodb' import { Strategy as GitHubStrategy } from 'passport-github2' +import qs from 'querystringify' import { Passport } from '.' import { getBranding, getHost, safeParseAuthState } from './utils' @@ -66,10 +67,11 @@ export function registerGithub ( const [first, last] = ctx.state.user.displayName?.split(' ') ?? [ctx.state.user.username, ''] measureCtx.info('Provider auth handler', { email, type: 'github' }) if (email !== undefined) { + let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) if (state.inviteId != null && state.inviteId !== '') { - const loginInfo = await joinWithProvider( + loginInfo = await joinWithProvider( measureCtx, db, productId, @@ -82,20 +84,18 @@ export function registerGithub ( githubId: ctx.state.user.id } ) - if (ctx.session != null) { - ctx.session.loginInfo = loginInfo - } } else { - const loginInfo = await loginWithProvider(measureCtx, db, productId, null, email, first, last, { + loginInfo = await loginWithProvider(measureCtx, db, productId, null, email, first, last, { githubId: ctx.state.user.id }) - if (ctx.session != null) { - ctx.session.loginInfo = loginInfo - } } - measureCtx.info('Success auth, redirect', { email, type: 'github' }) + + const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') + const query = encodeURIComponent(qs.stringify({ token: loginInfo.token })) + + measureCtx.info('Success auth, redirect', { email, type: 'github', target: origin }) // Successful authentication, redirect to your application - ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login/auth')) + ctx.redirect(`${origin}?${query}`) } } catch (err: any) { measureCtx.error('failed to auth', { err, type: 'github', user: ctx.state?.user }) diff --git a/pods/authProviders/src/google.ts b/pods/authProviders/src/google.ts index c0237ab5fd..cfda04830c 100644 --- a/pods/authProviders/src/google.ts +++ b/pods/authProviders/src/google.ts @@ -1,8 +1,9 @@ -import { joinWithProvider, loginWithProvider } from '@hcengineering/account' +import { joinWithProvider, LoginInfo, loginWithProvider } from '@hcengineering/account' import { BrandingMap, concatLink, MeasureContext } from '@hcengineering/core' import Router from 'koa-router' import { Db } from 'mongodb' import { Strategy as GoogleStrategy } from 'passport-google-oauth20' +import qs from 'querystringify' import { Passport } from '.' import { getBranding, getHost, safeParseAuthState } from './utils' @@ -71,10 +72,11 @@ export function registerGoogle ( measureCtx.info('Provider auth handler', { email, type: 'google' }) if (email !== undefined) { try { + let loginInfo: LoginInfo const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) if (state.inviteId != null && state.inviteId !== '') { - const loginInfo = await joinWithProvider( + loginInfo = await joinWithProvider( measureCtx, db, productId, @@ -84,19 +86,16 @@ export function registerGoogle ( last, state.inviteId as any ) - if (ctx.session != null) { - ctx.session.loginInfo = loginInfo - } } else { - const loginInfo = await loginWithProvider(measureCtx, db, productId, null, email, first, last) - if (ctx.session != null) { - ctx.session.loginInfo = loginInfo - } + loginInfo = await loginWithProvider(measureCtx, db, productId, null, email, first, last) } + const origin = concatLink(branding?.front ?? frontUrl, '/login/auth') + const query = encodeURIComponent(qs.stringify({ token: loginInfo.token })) + // Successful authentication, redirect to your application - measureCtx.info('Success auth, redirect', { email, type: 'google' }) - ctx.redirect(concatLink(branding?.front ?? frontUrl, '/login/auth')) + measureCtx.info('Success auth, redirect', { email, type: 'google', target: origin }) + ctx.redirect(`${origin}?${query}`) } catch (err: any) { measureCtx.error('failed to auth', { err, type: 'google', user: ctx.state?.user }) } diff --git a/pods/authProviders/src/index.ts b/pods/authProviders/src/index.ts index ae35c64fb4..a66177059e 100644 --- a/pods/authProviders/src/index.ts +++ b/pods/authProviders/src/index.ts @@ -44,7 +44,6 @@ export function registerProviders ( app.keys = [serverSecret] app.use(session({}, app)) - app.use(passport.initialize()) app.use(passport.session()) @@ -69,12 +68,6 @@ export function registerProviders ( if (value !== undefined) res.push(value) } - router.get('auth', '/auth', (ctx) => { - if (ctx.session?.loginInfo != null) { - ctx.body = JSON.stringify(ctx.session.loginInfo) - } - }) - router.get('providers', '/providers', (ctx) => { ctx.body = JSON.stringify(res) }) diff --git a/pods/authProviders/src/token.ts b/pods/authProviders/src/token.ts index 2c5c79ad3c..568160e7ac 100644 --- a/pods/authProviders/src/token.ts +++ b/pods/authProviders/src/token.ts @@ -2,6 +2,7 @@ import { getAccountInfoByToken } from '@hcengineering/account' import { BrandingMap, concatLink, MeasureContext } from '@hcengineering/core' import Router from 'koa-router' import { Db } from 'mongodb' +import qs from 'querystringify' import { Strategy as CustomStrategy } from 'passport-custom' import { Passport } from '.' import { getBranding, getHost, safeParseAuthState } from './utils' @@ -49,13 +50,12 @@ export function registerToken ( const state = safeParseAuthState(ctx.query?.state) const branding = getBranding(brandings, state?.branding) - if (ctx.session != null) { - ctx.session.loginInfo = user - } + const origin = concatLink(branding?.front ?? frontUrl, '/onboard/auth') + const query = encodeURIComponent(qs.stringify({ token: user.token })) - measureCtx.info('Success auth, redirect', { email: user.email, type: 'token' }) + measureCtx.info('Success auth, redirect', { email: user.email, type: 'token', target: origin }) // Successful authentication, redirect to your application - ctx.redirect(concatLink(branding?.front ?? frontUrl, '/onboard/auth')) + ctx.redirect(`${origin}?${query}`) } await next() } diff --git a/pods/backup/package.json b/pods/backup/package.json index 86c36c2c6f..13f45b9e88 100644 --- a/pods/backup/package.json +++ b/pods/backup/package.json @@ -14,7 +14,7 @@ "_phase:bundle": "rushx bundle", "_phase:docker-build": "rushx docker:build", "_phase:docker-staging": "rushx docker:staging", - "bundle": "mkdir -p bundle && esbuild src/index.ts --bundle --sourcemap=inline --minify --platform=node > bundle/bundle.js", + "bundle": "mkdir -p bundle && esbuild src/index.ts --bundle --sourcemap=inline --platform=node --external:*.node --external:bufferutil --external:snappy > bundle/bundle.js", "docker:build": "../../common/scripts/docker_build.sh hardcoreeng/backup", "docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/backup staging", "docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/backup", diff --git a/pods/collaborator/package.json b/pods/collaborator/package.json index feebcac7c0..31898da99f 100644 --- a/pods/collaborator/package.json +++ b/pods/collaborator/package.json @@ -13,7 +13,7 @@ "_phase:bundle": "rushx bundle", "_phase:docker-build": "rushx docker:build", "_phase:docker-staging": "rushx docker:staging", - "bundle": "mkdir -p bundle && esbuild src/__start.ts --bundle --platform=node --keep-names > bundle/bundle.js", + "bundle": "mkdir -p bundle && esbuild src/__start.ts --bundle --platform=node --sourcemap=inline --keep-names --external:*.node --external:bufferutil --external:snappy > bundle/bundle.js", "docker:build": "../../common/scripts/docker_build.sh hardcoreeng/collaborator", "docker:tbuild": "docker build -t hardcoreeng/collaborator . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/collaborator", "docker:abuild": "docker build -t hardcoreeng/collaborator . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/collaborator", diff --git a/pods/server/package.json b/pods/server/package.json index 609152ce69..9f70800e66 100644 --- a/pods/server/package.json +++ b/pods/server/package.json @@ -15,7 +15,7 @@ "_phase:bundle": "rushx bundle", "_phase:docker-build": "rushx docker:build", "_phase:docker-staging": "rushx docker:staging", - "bundle": "mkdir -p bundle && esbuild src/__start.ts --minify --bundle --keep-names --platform=node --external:*.node --external:bufferutil --external:utf-8-validate --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --define:process.env.GIT_REVISION=$(../../common/scripts/git_version.sh) --outfile=bundle/bundle.js --log-level=error --sourcemap=external", + "bundle": "mkdir -p bundle && esbuild src/__start.ts --sourcemap=inline --bundle --keep-names --platform=node --external:*.node --external:bufferutil --external:snappy --external:utf-8-validate --define:process.env.MODEL_VERSION=$(node ../../common/scripts/show_version.js) --define:process.env.GIT_REVISION=$(../../common/scripts/git_version.sh) --outfile=bundle/bundle.js --log-level=error --sourcemap=external", "docker:build": "../../common/scripts/docker_build.sh hardcoreeng/transactor", "docker:tbuild": "docker build -t hardcoreeng/transactor . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/transactor", "docker:abuild": "docker build -t hardcoreeng/transactor . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/transactor", @@ -71,6 +71,8 @@ "@hcengineering/analytics-service": "^0.6.0", "@hcengineering/contact": "^0.6.24", "@hcengineering/notification": "^0.6.23", - "@hcengineering/server-notification": "^0.6.1" + "@hcengineering/server-notification": "^0.6.1", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot-resources": "^0.6.0" } } diff --git a/pods/server/src/__start.ts b/pods/server/src/__start.ts index 8ea676b062..026305cf34 100644 --- a/pods/server/src/__start.ts +++ b/pods/server/src/__start.ts @@ -15,6 +15,7 @@ import serverNotification from '@hcengineering/server-notification' import serverToken from '@hcengineering/server-token' import { serverFactories } from '@hcengineering/server-ws/src/factories' import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service' +import serverAiBot from '@hcengineering/server-ai-bot' import { join } from 'path' import { start } from '.' const serverFactory = serverFactories[(process.env.SERVER_PROVIDER as string) ?? 'ws'] ?? serverFactories.ws @@ -50,6 +51,7 @@ setMetadata(notification.metadata.PushPublicKey, config.pushPublicKey) setMetadata(serverNotification.metadata.PushPrivateKey, config.pushPrivateKey) setMetadata(serverNotification.metadata.PushSubject, config.pushSubject) setMetadata(serverCore.metadata.ElasticIndexName, config.elasticIndexName) +setMetadata(serverAiBot.metadata.SupportWorkspaceId, process.env.SUPPORT_WORKSPACE) const shutdown = start(config.url, { fullTextUrl: config.elasticUrl, diff --git a/pods/server/src/server.ts b/pods/server/src/server.ts index 8cac008e5f..870d96f0eb 100644 --- a/pods/server/src/server.ts +++ b/pods/server/src/server.ts @@ -22,6 +22,8 @@ import { type Token } from '@hcengineering/server-token' import { ClientSession, start as startJsonRpc, type ServerFactory, type Session } from '@hcengineering/server-ws' import { createServerPipeline, registerServerPlugins, registerStringLoaders } from '@hcengineering/server-pipeline' +import { serverAiBotId } from '@hcengineering/server-ai-bot' +import { createAIBotAdapter } from '@hcengineering/server-ai-bot-resources' registerStringLoaders() @@ -53,7 +55,20 @@ export function start ( const externalStorage = buildStorageFromConfig(opt.storageConfig, dbUrl) - const pipelineFactory = createServerPipeline(metrics, dbUrl, { ...opt, externalStorage }) + const pipelineFactory = createServerPipeline( + metrics, + dbUrl, + { ...opt, externalStorage }, + { + serviceAdapters: { + [serverAiBotId]: { + factory: createAIBotAdapter, + db: '%ai-bot', + url: dbUrl + } + } + } + ) const sessionFactory = (token: Token, pipeline: Pipeline): Session => { if (token.extra?.mode === 'backup') { return new BackupClientSession(token, pipeline) diff --git a/products/tracker/src/platform.ts b/products/tracker/src/platform.ts index f628ee5d0b..5a1328da7c 100644 --- a/products/tracker/src/platform.ts +++ b/products/tracker/src/platform.ts @@ -66,6 +66,7 @@ export async function configurePlatform() { setMetadata(presentation.metadata.RequiredVersion, config.MODEL_VERSION) } setMetadata(telegram.metadata.TelegramURL, process.env.TELEGRAM_URL ?? 'http://localhost:8086') + setMetadata(telegram.metadata.BotUrl, process.TELEGRAM_BOT_URL ?? 'http://localhost:4020') setMetadata(gmail.metadata.GmailURL, process.env.GMAIL_URL ?? 'http://localhost:8087') setMetadata(uiPlugin.metadata.DefaultApplication, workbench.component.WorkbenchApp) diff --git a/qms-tests/sanity/tests/documents/REQ-03.spec.ts b/qms-tests/sanity/tests/documents/REQ-03.spec.ts index 3154e6d645..dce0d5329d 100644 --- a/qms-tests/sanity/tests/documents/REQ-03.spec.ts +++ b/qms-tests/sanity/tests/documents/REQ-03.spec.ts @@ -25,6 +25,13 @@ test.describe('ISO 13485, 4.2.4 Control of documents, ensure that the current re await (await page.goto(`${PlatformURI}/${HomepageURI}`))?.finished() }) + test.afterEach(async ({ browser }) => { + const contexts = browser.contexts() + for (const context of contexts) { + await context.close() + } + }) + test('TESTS-325. Create a Several documents with Minor & Major versions', async ({ page, browser }) => { await allure.description( 'Requirement\nUsers need to make a resolve all comments and done documents for the Effective status' diff --git a/qms-tests/sanity/tests/documents/REQ-05.spec.ts b/qms-tests/sanity/tests/documents/REQ-05.spec.ts index f5a7c19572..11f9021ba1 100644 --- a/qms-tests/sanity/tests/documents/REQ-05.spec.ts +++ b/qms-tests/sanity/tests/documents/REQ-05.spec.ts @@ -16,6 +16,13 @@ test.describe('ISO 13485, 4.2.4 Control of documents', () => { await (await page.goto(`${PlatformURI}/${HomepageURI}`))?.finished() }) + test.afterEach(async ({ browser }) => { + const contexts = browser.contexts() + for (const context of contexts) { + await context.close() + } + }) + test('TESTS-298. Create a new Category from top right corner', async ({ page }) => { await allure.description('Requirement\nUsers need to create a new category') await allure.tms('TESTS-298', 'https://tracex.hc.engineering/workbench/platform/tracker/TESTS-298') diff --git a/qms-tests/sanity/tests/documents/REQ-14.spec.ts b/qms-tests/sanity/tests/documents/REQ-14.spec.ts new file mode 100644 index 0000000000..ab44785598 --- /dev/null +++ b/qms-tests/sanity/tests/documents/REQ-14.spec.ts @@ -0,0 +1,129 @@ +import { test } from '@playwright/test' +import { + attachScreenshot, + DocumentURI, + generateId, + getSecondPage, + HomepageURI, + PlatformSetting, + PlatformURI +} from '../utils' +import { allure } from 'allure-playwright' +import { DocumentsPage } from '../model/documents/documents-page' +import { DocumentDetails, DocumentRights, DocumentStatus, NewDocument } from '../model/types' +import { DocumentContentPage } from '../model/documents/document-content-page' +import { prepareDocumentStep } from './common-documents-steps' +import { DocumentHistoryPage } from '../model/documents/document-history-page' + +test.use({ + storageState: PlatformSetting +}) + +test.describe('QMS. Documents tests for Control of documents ISO 13485, 4.2.4 FS-150', () => { + test.beforeEach(async ({ page }) => { + await (await page.goto(`${PlatformURI}/${HomepageURI}`))?.finished() + }) + + test.afterEach(async ({ browser }) => { + const contexts = browser.contexts() + for (const context of contexts) { + await context.close() + } + }) + + test('TESTS-384. Create a new doc version and previous one then marked to “Obsolete”', async ({ page, browser }) => { + await allure.description('Requirement\nUsers need to create a new document and new version document') + await allure.tms('TESTS-384', 'https://front.hc.engineering/workbench/platform/tracker/TESTS-384') + const userSecondPage = await getSecondPage(browser) + const completeDocument: NewDocument = { + template: 'HR (HR)', + title: `Complete document-${generateId()}`, + description: `Complete document description-${generateId()}` + } + const reviewer = 'Dirak Kainin' + const documentDetails: DocumentDetails = { + type: 'HR', + category: 'Human Resources', + version: 'v0.1', + status: DocumentStatus.DRAFT, + owner: 'Appleseed John', + author: 'Appleseed John' + } + await prepareDocumentStep(page, completeDocument) + + const documentContentPage = new DocumentContentPage(page) + + await test.step('2. Send for Approval', async () => { + await documentContentPage.buttonSendForApproval.click() + await documentContentPage.fillSelectApproversForm([reviewer]) + await documentContentPage.checkDocumentStatus(DocumentStatus.IN_APPROVAL) + await documentContentPage.checkDocument({ + ...documentDetails, + status: DocumentStatus.IN_APPROVAL, + version: 'v0.1' + }) + await documentContentPage.checkCurrentRights(DocumentRights.VIEWING) + }) + + await test.step('3. Approve document', async () => { + const documentsPageSecond = new DocumentsPage(userSecondPage) + await (await userSecondPage.goto(`${PlatformURI}/${DocumentURI}`))?.finished() + await documentsPageSecond.openDocument(completeDocument.title) + + const documentContentPageSecond = new DocumentContentPage(userSecondPage) + await documentContentPageSecond.confirmApproval() + + await documentContentPageSecond.checkDocumentStatus(DocumentStatus.EFFECTIVE) + await documentContentPageSecond.checkDocument({ + ...documentDetails, + status: DocumentStatus.EFFECTIVE, + version: 'v0.1' + }) + await documentContentPageSecond.checkCurrentRights(DocumentRights.VIEWING) + + await attachScreenshot('TEST-384_approve_document.png', page) + }) + + await test.step('4. Check document', async () => { + await documentContentPage.checkDocumentStatus(DocumentStatus.EFFECTIVE) + await documentContentPage.checkDocument({ + ...documentDetails, + status: DocumentStatus.EFFECTIVE, + version: 'v0.1' + }) + await documentContentPage.checkCurrentRights(DocumentRights.VIEWING) + + await attachScreenshot('TESTS-384_check_document.png', page) + }) + await test.step('5. Check History tab', async () => { + await documentContentPage.buttonHistoryTab.first().click() + const documentHistoryPage = new DocumentHistoryPage(page) + await documentHistoryPage.checkHistoryEventExist('New document creation') + await attachScreenshot('TESTS-384_check_history_tab.png', page) + }) + await test.step('6. Send for Approval v0.2', async () => { + await documentContentPage.sendForApproval( + 'Minor', + 'v0.2', + 'Reason 0.2', + 'impact 0.2', + 'v0.1', + 'v0.2', + userSecondPage, + completeDocument, + documentDetails + ) + }) + await test.step('7. Check archived status', async () => { + const documentContentPageSecond = new DocumentContentPage(userSecondPage) + await documentContentPage.clickPreviousVersionHeader(userSecondPage, completeDocument, 'v0.2') + await documentContentPage.clickPreviousVersionHeader(userSecondPage, completeDocument, 'v0.1') + await documentContentPageSecond.checkDocument({ + ...documentDetails, + status: DocumentStatus.ARCHIVED, + version: 'v0.1' + }) + await attachScreenshot('TESTS-384_archived_status.png', page) + }) + }) +}) diff --git a/qms-tests/sanity/tests/model/documents/document-content-page.ts b/qms-tests/sanity/tests/model/documents/document-content-page.ts index 1f447b2a5a..c2059843b5 100644 --- a/qms-tests/sanity/tests/model/documents/document-content-page.ts +++ b/qms-tests/sanity/tests/model/documents/document-content-page.ts @@ -236,7 +236,7 @@ export class DocumentContentPage extends DocumentCommonPage { } async selectRelease (version: string): Promise { - await this.page.getByText('Release').click() + await this.page.getByText('Release', { exact: true }).click() if (version === 'Major') { await this.page.getByText('Major').click() } @@ -459,6 +459,11 @@ export class DocumentContentPage extends DocumentCommonPage { await documentHistoryPage.checkHistoryEventExist(reason) } + async clickPreviousVersionHeader (userPage: Page, completeDocument: NewDocument, prevVersion: string): Promise { + const documentContentPageSecond = new DocumentContentPage(userPage) + await documentContentPageSecond.clickDocumentHeader(completeDocument.title + ' ' + prevVersion) + } + async addMessageToTheSectionTitle (title: string, message: string, closePopup: boolean = true): Promise { const locator = await this.getSectionLocator(title) const parentLocator = diff --git a/qms-tests/sanity/tests/model/types.ts b/qms-tests/sanity/tests/model/types.ts index 8e6c698c3c..92e1009118 100644 --- a/qms-tests/sanity/tests/model/types.ts +++ b/qms-tests/sanity/tests/model/types.ts @@ -32,7 +32,8 @@ export enum DocumentStatus { REVIEWED = 'Reviewed', REJECTED = 'Rejected', APPROVED = 'Approved', - EFFECTIVE = 'Effective' + EFFECTIVE = 'Effective', + ARCHIVED = 'Archived' } export interface DocumentDetails { diff --git a/rush.json b/rush.json index 792cd39999..7c72bc0043 100644 --- a/rush.json +++ b/rush.json @@ -2091,6 +2091,31 @@ "packageName": "@hcengineering/server-analytics-collector-resources", "projectFolder": "server-plugins/analytics-collector-resources", "shouldPublish": false + }, + { + "packageName": "@hcengineering/pod-ai-bot", + "projectFolder": "services/ai-bot/pod-ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/ai-bot", + "projectFolder": "plugins/ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/model-server-ai-bot", + "projectFolder": "models/server-ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/server-ai-bot", + "projectFolder": "server-plugins/ai-bot", + "shouldPublish": false + }, + { + "packageName": "@hcengineering/server-ai-bot-resources", + "projectFolder": "server-plugins/ai-bot-resources", + "shouldPublish": false } ] } diff --git a/server-plugins/activity-resources/src/index.ts b/server-plugins/activity-resources/src/index.ts index d2da99eea2..e8f2e096a9 100644 --- a/server-plugins/activity-resources/src/index.ts +++ b/server-plugins/activity-resources/src/index.ts @@ -13,23 +13,30 @@ // limitations under the License. // -import activity, { ActivityMessage, ActivityMessageControl, DocUpdateMessage, Reaction } from '@hcengineering/activity' +import activity, { + ActivityMessage, + ActivityMessageControl, + DocAttributeUpdates, + DocUpdateMessage, + Reaction +} from '@hcengineering/activity' import core, { Account, AttachedDoc, Class, + Collection, Data, Doc, + Hierarchy, + matchQuery, MeasureContext, Ref, + Space, Tx, - TxCUD, TxCollectionCUD, TxCreateDoc, - TxProcessor, - matchQuery, - Hierarchy, - Space + TxCUD, + TxProcessor } from '@hcengineering/core' import { ActivityControl, DocObjectCache } from '@hcengineering/server-activity' import type { TriggerControl } from '@hcengineering/server-core' @@ -43,7 +50,7 @@ import { PersonAccount } from '@hcengineering/contact' import { NotificationContent } from '@hcengineering/notification' import { getResource, translate } from '@hcengineering/platform' -import { getDocUpdateAction, getTxAttributesUpdates } from './utils' +import { getAttrName, getCollectionAttribute, getDocUpdateAction, getTxAttributesUpdates } from './utils' import { ReferenceTrigger } from './references' export async function OnReactionChanged (originTx: Tx, control: TriggerControl): Promise { @@ -143,8 +150,7 @@ export async function createReactionNotifications ( tx, parentMessage, [docUpdateMessage], - { isOwn: true, isSpace: false, shouldUpdateTimestamp: false }, - new Map() + { isOwn: true, isSpace: false, shouldUpdateTimestamp: false } ) ) @@ -257,12 +263,11 @@ export async function generateDocUpdateMessages ( originTx?: TxCUD, objectCache?: DocObjectCache ): Promise[]> { - const { hierarchy } = control - if (tx.space === core.space.DerivedTx) { return res } + const { hierarchy } = control const etx = TxProcessor.extractTx(tx) as TxCUD if ( @@ -314,6 +319,7 @@ export async function generateDocUpdateMessages ( let doc = objectCache?.docs?.get(tx.objectId) if (doc === undefined) { doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] + objectCache?.docs?.set(tx.objectId, doc) } return await ctx.with( 'pushDocUpdateMessages', @@ -342,6 +348,7 @@ export async function generateDocUpdateMessages ( let doc = objectCache?.docs?.get(tx.objectId) if (doc === undefined) { doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] + objectCache?.docs?.set(tx.objectId, doc) } if (doc !== undefined) { return await ctx.with( @@ -369,33 +376,35 @@ export async function generateDocUpdateMessages ( } async function ActivityMessagesHandler (tx: TxCUD, control: TriggerControl): Promise { - if (tx.space === core.space.DerivedTx) { - return [] - } - if (control.hierarchy.isDerived(tx.objectClass, activity.class.ActivityMessage)) { return [] } + const cache: DocObjectCache = { + docs: new Map(), + transactions: new Map() + } const txes = await control.ctx.with( 'generateDocUpdateMessages', {}, - async (ctx) => await generateDocUpdateMessages(ctx, tx, control) + async (ctx) => await generateDocUpdateMessages(ctx, tx, control, [], undefined, cache) ) - if (txes.length === 0) { - return [] - } - const messages = txes.map((messageTx) => TxProcessor.createDoc2Doc(messageTx.tx as TxCreateDoc)) const notificationTxes = await control.ctx.with( 'createCollaboratorNotifications', {}, - async (ctx) => await createCollaboratorNotifications(ctx, tx, control, messages) + async (ctx) => + await createCollaboratorNotifications(ctx, tx, control, messages, undefined, cache.docs as Map, Doc>) ) - return [...txes, ...notificationTxes] + const result = [...txes, ...notificationTxes] + + if (result.length > 0) { + await control.apply(result) + } + return [] } async function OnDocRemoved (originTx: TxCUD, control: TriggerControl): Promise { @@ -443,6 +452,7 @@ async function ReactionNotificationContentProvider ( return { title: activity.string.ReactionNotificationTitle, body: activity.string.ReactionNotificationBody, + data: reaction.emoji, intlParams: { title: text, reaction: reaction.emoji @@ -450,6 +460,70 @@ async function ReactionNotificationContentProvider ( } } +async function getAttributesUpdatesText ( + attributeUpdates: DocAttributeUpdates, + objectClass: Ref>, + hierarchy: Hierarchy +): Promise { + const attrName = await getAttrName(attributeUpdates, objectClass, hierarchy) + + if (attrName === undefined) { + return undefined + } + + if (attributeUpdates.added.length > 0) { + return await translate(activity.string.NewObject, { object: attrName }) + } + if (attributeUpdates.removed.length > 0) { + return await translate(activity.string.RemovedObject, { object: attrName }) + } + + if (attributeUpdates.set.length > 0) { + const values = attributeUpdates.set + const isUnset = values.length > 0 && !values.some((value) => value !== null && value !== '') + + if (isUnset) { + return await translate(activity.string.UnsetObject, { object: attrName }) + } else { + return await translate(activity.string.ChangedObject, { object: attrName }) + } + } + + return undefined +} + +export async function DocUpdateMessageTextPresenter (doc: DocUpdateMessage, control: TriggerControl): Promise { + const { hierarchy } = control + const { attachedTo, attachedToClass, objectClass, objectId, action, updateCollection, attributeUpdates } = doc + const isOwn = attachedTo === objectId + + const collectionAttribute = getCollectionAttribute(hierarchy, attachedToClass, updateCollection) + const clazz = hierarchy.getClass(objectClass) + const objectName = (collectionAttribute?.type as Collection)?.itemLabel ?? clazz.label + const collectionName = collectionAttribute?.label + + const name = + isOwn || collectionName === undefined ? await translate(objectName, {}) : await translate(collectionName, {}) + + if (action === 'create') { + return await translate(activity.string.NewObject, { object: name }) + } + + if (action === 'remove') { + return await translate(activity.string.RemovedObject, { object: name }) + } + + if (action === 'update' && attributeUpdates !== undefined) { + const text = await getAttributesUpdatesText(attributeUpdates, objectClass, hierarchy) + + if (text !== undefined) { + return text + } + } + + return await translate(activity.string.UpdatedObject, { object: name }) +} + export * from './references' // eslint-disable-next-line @typescript-eslint/explicit-function-return-type @@ -461,6 +535,7 @@ export default async () => ({ OnReactionChanged }, function: { - ReactionNotificationContentProvider + ReactionNotificationContentProvider, + DocUpdateMessageTextPresenter } }) diff --git a/server-plugins/activity-resources/src/references.ts b/server-plugins/activity-resources/src/references.ts index f65d49b876..407141e9c8 100644 --- a/server-plugins/activity-resources/src/references.ts +++ b/server-plugins/activity-resources/src/references.ts @@ -46,7 +46,7 @@ import { } from '@hcengineering/text' import { StorageAdapter, TriggerControl } from '@hcengineering/server-core' import activity, { ActivityMessage, ActivityReference, UserMentionInfo } from '@hcengineering/activity' -import contact, { Person, PersonAccount } from '@hcengineering/contact' +import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact' import { getCommonNotificationTxes, getPushCollaboratorTx, @@ -54,7 +54,8 @@ import { isShouldNotifyTx, NotifyResult, applyNotificationProviders, - getNotificationContent + getNotificationContent, + toReceiverInfo } from '@hcengineering/server-notification-resources' async function getPersonAccount (person: Ref, control: TriggerControl): Promise { @@ -118,6 +119,14 @@ export async function getPersonNotificationTxes ( const doc = (await control.findAll(reference.srcDocClass, { _id: reference.srcDocId }))[0] + const receiverPerson = ( + await control.findAll(contact.mixin.Employee, { _id: receiver.person as Ref, active: true }, { limit: 1 }) + )[0] + if (receiverPerson === undefined) return res + + const receiverSpace = (await control.findAll(contact.class.PersonSpace, { person: receiver.person }, { limit: 1 }))[0] + if (receiverSpace === undefined) return res + const collaboratorsTx = await getCollaboratorsTxes(reference, control, receiver, doc) res.push(...collaboratorsTx) @@ -157,23 +166,26 @@ export async function getPersonNotificationTxes ( mentionedIn: reference.attachedDocId ?? reference.srcDocId, mentionedInClass: reference.attachedDocClass ?? reference.srcDocClass, user: receiver._id, - isViewed: false + isViewed: false, + archived: false } const sender = ( await control.modelDb.findAll(contact.class.PersonAccount, { _id: senderId as Ref }, { limit: 1 }) )[0] - const receiverPerson = (await control.findAll(contact.class.Person, { _id: receiver.person }, { limit: 1 }))[0] + const senderPerson = sender !== undefined ? (await control.findAll(contact.class.Person, { _id: sender.person }, { limit: 1 }))[0] : undefined - const receiverInfo = { + const receiverInfo = toReceiverInfo(control.hierarchy, { _id: receiver._id, account: receiver, - person: receiverPerson - } + person: receiverPerson, + space: receiverSpace._id + }) + if (receiverInfo === undefined) return res const senderInfo = { _id: senderId, @@ -199,7 +211,7 @@ export async function getPersonNotificationTxes ( senderInfo, reference.srcDocId, reference.srcDocClass, - space, + doc.space, originTx.modifiedOn, notifyResult, notification.class.MentionInboxNotification @@ -209,7 +221,7 @@ export async function getPersonNotificationTxes ( const context = ( await control.findAll( notification.class.DocNotifyContext, - { attachedTo: reference.srcDocId, user: receiver._id }, + { objectId: reference.srcDocId, user: receiver._id }, { projection: { _id: 1 } } ) )[0] @@ -221,7 +233,7 @@ export async function getPersonNotificationTxes ( docNotifyContext: context._id, _id: generateId(), _class: notification.class.CommonInboxNotification, - space, + space: receiverSpace._id, modifiedOn: originTx.modifiedOn, modifiedBy: sender._id } diff --git a/server-plugins/activity-resources/src/utils.ts b/server-plugins/activity-resources/src/utils.ts index 632f5ce0e8..1617adcd1c 100644 --- a/server-plugins/activity-resources/src/utils.ts +++ b/server-plugins/activity-resources/src/utils.ts @@ -1,7 +1,9 @@ import { Account, AttachedDoc, + type Attribute, Class, + Collection, Doc, Hierarchy, Mixin, @@ -20,6 +22,7 @@ import { ActivityControl, DocObjectCache, getAllObjectTransactions } from '@hcen import { getDocCollaborators } from '@hcengineering/server-notification-resources' import notification from '@hcengineering/notification' import { TriggerControl } from '@hcengineering/server-core' +import { translate } from '@hcengineering/platform' function getAvailableAttributesKeys (tx: TxCUD, hierarchy: Hierarchy): string[] { if (hierarchy.isDerived(tx._class, core.class.TxUpdateDoc)) { @@ -247,14 +250,12 @@ export async function getTxAttributesUpdates ( const hierarchy = control.hierarchy - const filterSet = new Set() - for (const c of controlRules ?? []) { - for (const f of c.skipFields ?? []) { - filterSet.add(f) - } - } + const allowedFields = new Set(controlRules?.flatMap((it) => it.allowedFields ?? []) ?? []) + const skipFields = new Set(controlRules?.flatMap((it) => it.skipFields ?? []) ?? []) - const keys = getAvailableAttributesKeys(tx, hierarchy).filter((it) => !filterSet.has(it)) + const keys = getAvailableAttributesKeys(tx, hierarchy).filter( + (it) => !skipFields.has(it) && (allowedFields.size === 0 || allowedFields.has(it)) + ) if (keys.length === 0) { return [] @@ -265,14 +266,7 @@ export async function getTxAttributesUpdates ( const isMixin = hierarchy.isDerived(tx._class, core.class.TxMixin) const mixin = isMixin ? (tx as TxMixin).mixin : undefined - const { doc, prevDoc } = await getDocDiff( - control, - updateObject._class, - updateObject._id, - originTx._id, - mixin, - objectCache - ) + let docDiff: { doc?: Doc, prevDoc?: Doc } | undefined for (const key of keys) { let attrValue = modifiedAttributes[key] @@ -299,14 +293,25 @@ export async function getTxAttributesUpdates ( continue } - if (Array.isArray(attrValue) && doc != null) { - const diff = await getAttributeDiff(control, doc, prevDoc, key, attrClass, isMixin) + if ( + hierarchy.isDerived(attrClass, core.class.TypeMarkup) || + hierarchy.isDerived(attrClass, core.class.TypeCollaborativeMarkup) || + mixin === notification.mixin.Collaborators + ) { + if (docDiff === undefined) { + docDiff = await getDocDiff(control, updateObject._class, updateObject._id, originTx._id, mixin, objectCache) + } + } + + if (Array.isArray(attrValue) && docDiff?.doc !== undefined) { + const diff = await getAttributeDiff(control, docDiff.doc, docDiff.prevDoc, key, attrClass, isMixin) added.push(...diff.added) removed.push(...diff.removed) attrValue = [] } - if (prevDoc !== undefined) { + if (docDiff?.prevDoc !== undefined) { + const { prevDoc } = docDiff const rawPrevValue = isMixin ? (hierarchy.as(prevDoc, attrClass) as any)[key] : (prevDoc as any)[key] if (Array.isArray(rawPrevValue)) { @@ -345,3 +350,59 @@ function getHiddenAttrs (hierarchy: Hierarchy, _class: Ref>): Set attr.hidden === true).map(([k]) => k) ) } + +export async function getAttrName ( + attributeUpdates: DocAttributeUpdates, + objectClass: Ref>, + hierarchy: Hierarchy +): Promise { + const { attrKey, attrClass, isMixin } = attributeUpdates + let attrObjectClass = objectClass + + try { + if (isMixin) { + const keyedAttribute = [...hierarchy.getAllAttributes(attrClass).entries()] + .filter(([, value]) => value.hidden !== true) + .map(([key, attr]) => ({ key, attr })) + .find(({ key }) => key === attrKey) + if (keyedAttribute === undefined) { + return undefined + } + attrObjectClass = keyedAttribute.attr.attributeOf + } + + const attribute = hierarchy.getAttribute(attrObjectClass, attrKey) + + const label = attribute.shortLabel ?? attribute.label + + if (label === undefined) { + return undefined + } + + return await translate(label, {}) + } catch (e) { + console.error(e) + return undefined + } +} + +export function getCollectionAttribute ( + hierarchy: Hierarchy, + objectClass: Ref>, + collection?: string +): Attribute> | undefined { + if (collection === undefined) { + return undefined + } + + const descendants = hierarchy.getDescendants(objectClass) + + for (const descendant of descendants) { + const collectionAttribute = hierarchy.findAttribute(descendant, collection) + if (collectionAttribute !== undefined) { + return collectionAttribute + } + } + + return undefined +} diff --git a/server-plugins/activity/src/index.ts b/server-plugins/activity/src/index.ts index 5be21e4bb8..f8773449b6 100644 --- a/server-plugins/activity/src/index.ts +++ b/server-plugins/activity/src/index.ts @@ -15,7 +15,7 @@ import { Plugin, Resource, plugin } from '@hcengineering/platform' import type { TriggerFunc } from '@hcengineering/server-core' -import { NotificationContentProvider } from '@hcengineering/server-notification' +import { NotificationContentProvider, Presenter } from '@hcengineering/server-notification' export * from './types' export * from './utils' @@ -36,6 +36,7 @@ export default plugin(serverActivityId, { ReferenceTrigger: '' as Resource }, function: { - ReactionNotificationContentProvider: '' as Resource + ReactionNotificationContentProvider: '' as Resource, + DocUpdateMessageTextPresenter: '' as Resource } }) diff --git a/server-plugins/ai-bot-resources/.eslintrc.js b/server-plugins/ai-bot-resources/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/server-plugins/ai-bot-resources/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/server-plugins/ai-bot-resources/.npmignore b/server-plugins/ai-bot-resources/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/server-plugins/ai-bot-resources/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/server-plugins/ai-bot-resources/.prettierignore b/server-plugins/ai-bot-resources/.prettierignore new file mode 100644 index 0000000000..21c8f7d149 --- /dev/null +++ b/server-plugins/ai-bot-resources/.prettierignore @@ -0,0 +1 @@ +tsdoc-metadata.json \ No newline at end of file diff --git a/server-plugins/ai-bot-resources/config/rig.json b/server-plugins/ai-bot-resources/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/server-plugins/ai-bot-resources/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/server-plugins/ai-bot-resources/jest.config.js b/server-plugins/ai-bot-resources/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/server-plugins/ai-bot-resources/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/server-plugins/ai-bot-resources/package.json b/server-plugins/ai-bot-resources/package.json new file mode 100644 index 0000000000..4c28e91d80 --- /dev/null +++ b/server-plugins/ai-bot-resources/package.json @@ -0,0 +1,55 @@ +{ + "name": "@hcengineering/server-ai-bot-resources", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/activity": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/mongo": "^0.6.1", + "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-activity-resources": "^0.6.0", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-templates": "^0.6.0", + "@hcengineering/templates": "^0.6.11", + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", + "mongodb": "^6.8.0" + } +} diff --git a/server-plugins/ai-bot-resources/src/adapter.ts b/server-plugins/ai-bot-resources/src/adapter.ts new file mode 100644 index 0000000000..0ad6f9f5fb --- /dev/null +++ b/server-plugins/ai-bot-resources/src/adapter.ts @@ -0,0 +1,75 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { MeasureContext, WorkspaceId } from '@hcengineering/core' +import { getMongoClient, MongoClientReference } from '@hcengineering/mongo' +import { AIBotServiceAdapter, WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' +import { Collection, Db, MongoClient } from 'mongodb' + +class AIBotAdapter implements AIBotServiceAdapter { + private readonly workspacesInfoCollection: Collection + private readonly db: Db + closed = false + + constructor ( + private readonly _client: MongoClientReference, + private readonly client: MongoClient, + private readonly _metrics: MeasureContext, + private readonly dbName: string + ) { + this.db = client.db(dbName) + this.workspacesInfoCollection = this.db.collection('workspacesInfo') + } + + async processWorkspace (workspace: WorkspaceId): Promise { + if (this.closed) { + return + } + const existsRecord = await this.workspacesInfoCollection.findOne({ + workspace: workspace.name, + productId: workspace.productId + }) + + if (existsRecord != null && !existsRecord.active) { + await this.workspacesInfoCollection.updateOne( + { workspace: workspace.name, productId: workspace.productId }, + { $set: { active: true } } + ) + } else if (existsRecord == null) { + const record: WorkspaceInfoRecord = { + workspace: workspace.name, + productId: workspace.productId, + active: true + } + + await this.workspacesInfoCollection.insertOne(record) + } + } + + async close (): Promise { + this.closed = true + this._client.close() + } + + metrics (): MeasureContext { + return this._metrics + } +} + +export async function createAIBotAdapter (url: string, db: string, metrics: MeasureContext): Promise { + const _client = getMongoClient(url) + + return new AIBotAdapter(_client, await _client.getClient(), metrics, db) +} diff --git a/server-plugins/ai-bot-resources/src/index.ts b/server-plugins/ai-bot-resources/src/index.ts new file mode 100644 index 0000000000..8e89b65ee7 --- /dev/null +++ b/server-plugins/ai-bot-resources/src/index.ts @@ -0,0 +1,454 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { + AccountRole, + AttachedDoc, + Data, + Doc, + Ref, + toWorkspaceString, + Tx, + TxCollectionCUD, + TxCreateDoc, + TxCUD, + TxProcessor, + TxUpdateDoc, + UserStatus +} from '@hcengineering/core' +import { TriggerControl } from '@hcengineering/server-core' +import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' +import aiBot, { aiBotAccountEmail, AIBotResponseEvent } from '@hcengineering/ai-bot' +import serverAIBot, { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/server-ai-bot' +import contact, { PersonAccount } from '@hcengineering/contact' +import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' +import { getMetadata } from '@hcengineering/platform' +import analytics from '@hcengineering/analytics-collector' + +async function processWorkspace (control: TriggerControl): Promise { + const adapter = control.serviceAdaptersManager.getAdapter(serverAiBotId) as AIBotServiceAdapter | undefined + + if (adapter !== undefined) { + await adapter.processWorkspace(control.workspace) + } else { + console.error('Cannot find server adapter: ', serverAiBotId) + } +} + +async function isDirectAvailable (direct: DirectMessage, control: TriggerControl): Promise { + const { members } = direct + + if (!members.includes(aiBot.account.AIBot)) { + return false + } + + const personAccounts = await control.modelDb.findAll(contact.class.PersonAccount, { + _id: { $in: members as Ref[] } + }) + const persons = new Set(personAccounts.map((account) => account.person)) + + return persons.size === 2 +} + +async function isAvailableDoc (doc: Doc, control: TriggerControl): Promise { + if (doc._class === chunter.class.DirectMessage) { + return await isDirectAvailable(doc as DirectMessage, control) + } + + return true +} + +async function getMessageDoc (message: ChatMessage, control: TriggerControl): Promise { + if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + const thread = message as ThreadMessage + const _id = thread.objectId + const _class = thread.objectClass + + return (await control.findAll(_class, { _id }))[0] + } else { + const _id = message.attachedTo + const _class = message.attachedToClass + + return (await control.findAll(_class, { _id }))[0] + } +} + +async function getMessageData ( + doc: Doc, + message: ChatMessage, + control: TriggerControl +): Promise | undefined> { + if (!(await isAvailableDoc(doc, control))) { + return + } + + return { + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: doc.space, + collection: message.collection, + messageClass: message._class, + message: message.message, + user: message.createdBy ?? message.modifiedBy + } +} + +async function getThreadMessageData ( + doc: Doc, + message: ThreadMessage, + control: TriggerControl +): Promise | undefined> { + if (!(await isAvailableDoc(doc, control))) { + return + } + + return { + objectId: message.attachedTo, + objectClass: message.attachedToClass, + objectSpace: message.space, + collection: message.collection, + messageClass: message._class, + message: message.message, + user: message.createdBy ?? message.modifiedBy + } +} + +// Note: temporally commented until open ai will be added +// async function createAIBotEvent (message: ChatMessage, control: TriggerControl): Promise { +// const doc = await getMessageDoc(message, control) +// +// if (doc === undefined) { +// return [] +// } +// +// let data: Data | undefined +// +// if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { +// data = await getThreadMessageData(doc, message as ThreadMessage, control) +// } else { +// data = await getMessageData(doc, message, control) +// } +// +// if (data === undefined) { +// return [] +// } +// +// const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotResponseEvent, message.space, data) +// +// await processWorkspace(control) +// +// return [eventTx] +// return [] +// } + +async function getThreadParent (control: TriggerControl, message: ChatMessage): Promise | undefined> { + if (!control.hierarchy.isDerived(message.attachedToClass, chunter.class.ChatMessage)) { + return undefined + } + + const parentInfo = ( + await control.findAll(message.attachedToClass, { + _id: message.attachedTo as Ref, + [aiBot.mixin.TransferredMessage]: { $exists: true } + }) + )[0] + + if (parentInfo !== undefined) { + return control.hierarchy.as(parentInfo, aiBot.mixin.TransferredMessage).messageId + } + + return message.attachedTo as Ref +} + +function getSupportWorkspaceId (): string | undefined { + const supportWorkspaceId = getMetadata(serverAIBot.metadata.SupportWorkspaceId) + + if (supportWorkspaceId === '') { + return undefined + } + + return supportWorkspaceId +} + +async function onBotDirectMessageSend (control: TriggerControl, message: ChatMessage): Promise { + const supportWorkspaceId = getSupportWorkspaceId() + + if (supportWorkspaceId === undefined) { + return [] + } + + const direct = (await getMessageDoc(message, control)) as DirectMessage + + if (direct === undefined) { + return [] + } + + const isAvailable = await isDirectAvailable(direct, control) + + if (!isAvailable) { + return [] + } + + const account = control.modelDb.findAllSync(contact.class.PersonAccount, { + _id: (message.createdBy ?? message.modifiedBy) as Ref + })[0] + + if (account === undefined || account.role !== AccountRole.Owner) { + return [] + } + + let data: Data | undefined + + if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + data = await getThreadMessageData(direct, message as ThreadMessage, control) + } else { + data = await getMessageData(direct, message, control) + } + + if (data === undefined) { + return [] + } + + const eventTx = control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + messageClass: data.messageClass, + message: message.message, + collection: data.collection, + toWorkspace: supportWorkspaceId, + toEmail: account.email, + fromWorkspace: toWorkspaceString(control.workspace), + messageId: message._id, + parentMessageId: await getThreadParent(control, message) + }) + + await processWorkspace(control) + + return [eventTx] +} + +async function onSupportWorkspaceMessage (control: TriggerControl, message: ChatMessage): Promise { + const supportWorkspaceId = getSupportWorkspaceId() + + if (supportWorkspaceId === undefined) { + return [] + } + + if (toWorkspaceString(control.workspace) !== supportWorkspaceId) { + return [] + } + + const channel = await getMessageDoc(message, control) + + if (channel === undefined) { + return [] + } + + if (!control.hierarchy.hasMixin(channel, analytics.mixin.AnalyticsChannel)) { + return [] + } + + const mixin = control.hierarchy.as(channel, analytics.mixin.AnalyticsChannel) + const { workspace, email } = mixin + + let data: Data | undefined + + if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + data = await getThreadMessageData(channel, message as ThreadMessage, control) + } else { + data = await getMessageData(channel, message, control) + } + + if (data === undefined) { + return [] + } + + await processWorkspace(control) + + return [ + control.txFactory.createTxCreateDoc(aiBot.class.AIBotTransferEvent, message.space, { + messageClass: data.messageClass, + message: message.message, + collection: data.collection, + toEmail: email, + toWorkspace: workspace, + fromWorkspace: toWorkspaceString(control.workspace), + messageId: message._id, + parentMessageId: await getThreadParent(control, message) + }) + ] +} + +export async function OnMessageSend ( + originTx: TxCollectionCUD, + control: TriggerControl +): Promise { + const { hierarchy } = control + const tx = TxProcessor.extractTx(originTx) as TxCreateDoc + if (tx._class !== core.class.TxCreateDoc || !hierarchy.isDerived(tx.objectClass, chunter.class.ChatMessage)) { + return [] + } + + if (tx.modifiedBy === aiBot.account.AIBot || tx.modifiedBy === core.account.System) { + return [] + } + + const isThread = hierarchy.isDerived(tx.objectClass, chunter.class.ThreadMessage) + const message = TxProcessor.createDoc2Doc(tx) + + const docClass = isThread ? (message as ThreadMessage).objectClass : message.attachedToClass + + if (!hierarchy.isDerived(docClass, chunter.class.ChunterSpace)) { + return [] + } + + const res: Tx[] = [] + + if (docClass === chunter.class.DirectMessage) { + const txes = await onBotDirectMessageSend(control, message) + res.push(...txes) + } + + if (docClass === chunter.class.Channel) { + const txes = await onSupportWorkspaceMessage(control, message) + res.push(...txes) + } + + return res +} + +export async function OnMention (tx: TxCreateDoc, control: TriggerControl): Promise { + // Note: temporally commented until open ai will be added + // if (tx.objectClass !== notification.class.MentionInboxNotification || tx._class !== core.class.TxCreateDoc) { + // return [] + // } + // + // const mention = TxProcessor.createDoc2Doc(tx) + // + // if (mention.user !== aiBot.account.AIBot) { + // return [] + // } + // + // if (!control.hierarchy.isDerived(mention.mentionedInClass, chunter.class.ChatMessage)) { + // return [] + // } + // + // const message = ( + // await control.findAll(mention.mentionedInClass, { _id: mention.mentionedIn as Ref }) + // )[0] + // + // if (message === undefined) { + // return [] + // } + // + // await createAIBotEvent(message, control) + + return [] +} + +export async function OnMessageNotified ( + tx: TxCreateDoc, + control: TriggerControl +): Promise { + // Note: temporally commented until open ai will be added + // if (tx.objectClass !== notification.class.ActivityInboxNotification || tx._class !== core.class.TxCreateDoc) { + // return [] + // } + // + // const doc = TxProcessor.createDoc2Doc(tx) + // + // if (doc.user !== aiBot.account.AIBot) { + // return [] + // } + // + // if (!control.hierarchy.isDerived(doc.attachedToClass, chunter.class.ChatMessage)) { + // return [] + // } + // + // const personAccount = await control.modelDb.findOne(contact.class.PersonAccount, { email: aiBotAccountEmail }) + // + // if (personAccount === undefined) { + // return [] + // } + // + // const message = ( + // await control.findAll(doc.attachedToClass, { _id: doc.attachedTo as Ref }) + // )[0] + // + // if (message === undefined) { + // return [] + // } + // + // if (isDocMentioned(personAccount.person, message.message)) { + // return await createAIBotEvent(message, control) + // } + // + // if (!control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { + // return [] + // } + // + // const thread = message as ThreadMessage + // TODO: do we really need to find parent??? + // const parent = (await control.findAll(thread.attachedToClass, { _id: thread.attachedTo }))[0] + // + // if (parent === undefined) { + // return [] + // } + // + // if (parent.createdBy === aiBot.account.AIBot) { + // return await createAIBotEvent(message, control) + // } + + return [] +} + +export async function OnUserStatus (originTx: Tx, control: TriggerControl): Promise { + const tx = TxProcessor.extractTx(originTx) as TxCUD + + if ( + tx.objectClass !== core.class.UserStatus || + ![core.class.TxCreateDoc, core.class.TxUpdateDoc].includes(tx._class) + ) { + return [] + } + + if (tx._class === core.class.TxUpdateDoc) { + const updateTx = tx as TxUpdateDoc + const val = updateTx.operations.online + if (val !== true) { + return [] + } + } + + const account = control.modelDb.findAllSync(contact.class.PersonAccount, { email: aiBotAccountEmail })[0] + + if (account !== undefined) { + return [] + } + + await processWorkspace(control) + + return [] +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export default async () => ({ + trigger: { + OnMessageSend, + OnMention, + OnMessageNotified, + OnUserStatus + } +}) + +export * from './adapter' diff --git a/server-plugins/ai-bot-resources/tsconfig.json b/server-plugins/ai-bot-resources/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/server-plugins/ai-bot-resources/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/server-plugins/ai-bot/.eslintrc.js b/server-plugins/ai-bot/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/server-plugins/ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/server-plugins/ai-bot/.npmignore b/server-plugins/ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/server-plugins/ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/server-plugins/ai-bot/config/rig.json b/server-plugins/ai-bot/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/server-plugins/ai-bot/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/server-plugins/ai-bot/jest.config.js b/server-plugins/ai-bot/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/server-plugins/ai-bot/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/server-plugins/ai-bot/package.json b/server-plugins/ai-bot/package.json new file mode 100644 index 0000000000..ceee584341 --- /dev/null +++ b/server-plugins/ai-bot/package.json @@ -0,0 +1,45 @@ +{ + "name": "@hcengineering/server-ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@types/jest": "^29.5.5", + "@types/node": "~20.11.16", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/core": "^0.6.32", + "@hcengineering/server-core": "^0.6.1" + } +} diff --git a/server-plugins/ai-bot/src/index.ts b/server-plugins/ai-bot/src/index.ts new file mode 100644 index 0000000000..29380a1f13 --- /dev/null +++ b/server-plugins/ai-bot/src/index.ts @@ -0,0 +1,33 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import type { Metadata, Plugin, Resource } from '@hcengineering/platform' +import { plugin } from '@hcengineering/platform' +import { TriggerFunc } from '@hcengineering/server-core' + +export * from './types' +export const serverAiBotId = 'server-ai-bot' as Plugin + +export default plugin(serverAiBotId, { + metadata: { + SupportWorkspaceId: '' as Metadata + }, + trigger: { + OnMessageSend: '' as Resource, + OnMention: '' as Resource, + OnUserStatus: '' as Resource, + OnMessageNotified: '' as Resource + } +}) diff --git a/server-plugins/ai-bot/src/types.ts b/server-plugins/ai-bot/src/types.ts new file mode 100644 index 0000000000..89d45b440a --- /dev/null +++ b/server-plugins/ai-bot/src/types.ts @@ -0,0 +1,29 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { ServiceAdapter } from '@hcengineering/server-core' +import { WorkspaceId } from '@hcengineering/core' + +export interface AIBotServiceAdapter extends ServiceAdapter { + processWorkspace: (workspace: WorkspaceId) => Promise +} + +export interface WorkspaceInfoRecord { + workspace: string + productId: string + active: boolean + avatarPath?: string + avatarLastModified?: number +} diff --git a/server-plugins/ai-bot/tsconfig.json b/server-plugins/ai-bot/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/server-plugins/ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/server-plugins/chunter-resources/src/index.ts b/server-plugins/chunter-resources/src/index.ts index ab757ee382..3d5434a826 100644 --- a/server-plugins/chunter-resources/src/index.ts +++ b/server-plugins/chunter-resources/src/index.ts @@ -45,7 +45,7 @@ import core, { TxUpdateDoc, UserStatus } from '@hcengineering/core' -import notification, { Collaborators, DocNotifyContext, NotificationContent } from '@hcengineering/notification' +import notification, { DocNotifyContext, NotificationContent } from '@hcengineering/notification' import { getMetadata, IntlString, translate } from '@hcengineering/platform' import serverCore, { TriggerControl } from '@hcengineering/server-core' import { @@ -84,7 +84,7 @@ export async function channelTextPresenter (doc: Doc): Promise { return await translate(chunter.string.Direct, {}) } - return `${channel.name}` + return `#${channel.name}` } export async function ChatMessageTextPresenter (doc: ChatMessage): Promise { @@ -288,13 +288,6 @@ export async function ChunterTrigger (tx: TxCUD, control: TriggerControl): res.push( ...(await control.ctx.with('OnThreadMessageDeleted', {}, async (ctx) => await OnThreadMessageDeleted(tx, control))) ) - res.push( - ...(await control.ctx.with( - 'OnCollaboratorsChanged', - {}, - async (ctx) => await OnCollaboratorsChanged(tx as TxMixin, control) - )) - ) res.push( ...(await control.ctx.with('OnChatMessageCreated', {}, async (ctx) => await OnChatMessageCreated(tx, control))) ) @@ -350,6 +343,7 @@ export async function getChunterNotificationContent ( return { title, body, + data: message, intlParams, intlParamsNotLocalized } @@ -380,91 +374,6 @@ function combineAttributes (attributes: any[], key: string, operator: string, ar ).filter((v) => v != null) } -async function OnChannelMembersChanged (tx: TxUpdateDoc, control: TriggerControl): Promise { - const changedAttributes = Object.entries(tx.operations) - .flatMap(([id, val]) => (['$push', '$pull'].includes(id) ? Object.keys(val) : id)) - .filter((id) => !id.startsWith('$')) - - if (!changedAttributes.includes('members')) { - return [] - } - - const added = combineAttributes([tx.operations], 'members', '$push', '$each') - const removed = combineAttributes([tx.operations], 'members', '$pull', '$in') - - const res: Tx[] = [] - const allContexts = await control.findAll(notification.class.DocNotifyContext, { attachedTo: tx.objectId }) - - if (removed.length > 0) { - res.push( - control.txFactory.createTxMixin(tx.objectId, tx.objectClass, tx.objectSpace, notification.mixin.Collaborators, { - $pull: { - collaborators: { $in: removed } - } - }) - ) - } - - if (added.length > 0) { - res.push( - control.txFactory.createTxMixin(tx.objectId, tx.objectClass, tx.objectSpace, notification.mixin.Collaborators, { - $push: { - collaborators: { $each: added, $position: 0 } - } - }) - ) - } - - for (const addedMember of added) { - const context = allContexts.find(({ user }) => user === addedMember) - - if (context === undefined) { - const createTx = control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, tx.objectSpace, { - attachedTo: tx.objectId, - attachedToClass: tx.objectClass, - user: addedMember, - lastViewedTimestamp: tx.modifiedOn - }) - - await control.apply([createTx]) - } else { - const updateTx = control.txFactory.createTxUpdateDoc(context._class, context.space, context._id, { - lastViewedTimestamp: tx.modifiedOn - }) - - res.push(updateTx) - } - } - - const contextsToRemove = allContexts.filter(({ user }) => removed.includes(user)) - - for (const context of contextsToRemove) { - res.push(control.txFactory.createTxRemoveDoc(context._class, context.space, context._id)) - } - - return res -} - -async function OnCollaboratorsChanged (tx: TxMixin, control: TriggerControl): Promise { - if (tx._class !== core.class.TxMixin || tx.mixin !== notification.mixin.Collaborators) return [] - - if (!control.hierarchy.isDerived(tx.objectClass, chunter.class.Channel)) return [] - - const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }))[0] as Channel | undefined - - if (doc === undefined) return [] - if (doc.private) return [] - - const added = combineAttributes([tx.attributes], 'collaborators', '$push', '$each') - const res: Tx[] = [] - - for (const addedMember of added) { - res.push(...joinChannel(control, doc, addedMember)) - } - - return res -} - async function hideOldDirects ( directs: DocNotifyContext[], control: TriggerControl, @@ -553,7 +462,7 @@ export async function updateChatInfo (control: TriggerControl, status: UserStatu const contexts = await control.findAll(notification.class.DocNotifyContext, { user: account._id, - isPinned: { $ne: true } + isPinned: false }) if (contexts.length === 0) return @@ -561,14 +470,14 @@ export async function updateChatInfo (control: TriggerControl, status: UserStatu const { hierarchy } = control const res: Tx[] = [] - const directContexts = contexts.filter(({ attachedToClass }) => - hierarchy.isDerived(attachedToClass, chunter.class.DirectMessage) + const directContexts = contexts.filter(({ objectClass }) => + hierarchy.isDerived(objectClass, chunter.class.DirectMessage) ) const activityContexts = contexts.filter( - ({ attachedToClass }) => - !hierarchy.isDerived(attachedToClass, chunter.class.DirectMessage) && - !hierarchy.isDerived(attachedToClass, chunter.class.Channel) && - !hierarchy.isDerived(attachedToClass, chunter.class.Channel) + ({ objectClass }) => + !hierarchy.isDerived(objectClass, chunter.class.DirectMessage) && + !hierarchy.isDerived(objectClass, chunter.class.Channel) && + !hierarchy.isDerived(objectClass, activity.class.ActivityMessage) ) const directTxes = await hideOldDirects(directContexts, control, date) @@ -647,12 +556,21 @@ async function OnContextUpdate (tx: TxUpdateDoc, control: Trig return [] } +async function JoinChannelTypeMatch (originTx: Tx, _: Doc, user: Ref): Promise { + if (originTx.modifiedBy === user) return false + if (originTx._class !== core.class.TxUpdateDoc) return false + + const tx = originTx as TxUpdateDoc + const added = combineAttributes([tx.operations], 'members', '$push', '$each') + + return added.includes(user) +} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { ChunterTrigger, OnChatMessageRemoved, - OnChannelMembersChanged, ChatNotificationsHandler, OnUserStatus, OnContextUpdate @@ -662,6 +580,7 @@ export default async () => ({ ChannelHTMLPresenter: channelHTMLPresenter, ChannelTextPresenter: channelTextPresenter, ChunterNotificationContentProvider: getChunterNotificationContent, - ChatMessageTextPresenter + ChatMessageTextPresenter, + JoinChannelTypeMatch } }) diff --git a/server-plugins/chunter/src/index.ts b/server-plugins/chunter/src/index.ts index 4c800ff204..d32a731e4f 100644 --- a/server-plugins/chunter/src/index.ts +++ b/server-plugins/chunter/src/index.ts @@ -16,7 +16,7 @@ import type { Plugin, Resource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import { ObjectDDParticipantFunc, TriggerFunc } from '@hcengineering/server-core' -import { NotificationContentProvider, Presenter } from '@hcengineering/server-notification' +import { NotificationContentProvider, Presenter, TypeMatchFunc } from '@hcengineering/server-notification' /** * @public @@ -30,7 +30,6 @@ export default plugin(serverChunterId, { trigger: { ChunterTrigger: '' as Resource, OnChatMessageRemoved: '' as Resource, - OnChannelMembersChanged: '' as Resource, ChatNotificationsHandler: '' as Resource, OnUserStatus: '' as Resource, OnContextUpdate: '' as Resource @@ -40,6 +39,7 @@ export default plugin(serverChunterId, { ChannelHTMLPresenter: '' as Resource, ChannelTextPresenter: '' as Resource, ChunterNotificationContentProvider: '' as Resource, - ChatMessageTextPresenter: '' as Resource + ChatMessageTextPresenter: '' as Resource, + JoinChannelTypeMatch: '' as TypeMatchFunc } }) diff --git a/server-plugins/collaboration/src/fulltext.ts b/server-plugins/collaboration/src/fulltext.ts index 9560271f9c..7a9bcf9d00 100644 --- a/server-plugins/collaboration/src/fulltext.ts +++ b/server-plugins/collaboration/src/fulltext.ts @@ -35,6 +35,7 @@ import { FullTextPipelineStage, IndexedDoc, StorageAdapter, + collabStageId, contentStageId, docKey, docUpdKey, @@ -46,7 +47,7 @@ import { */ export class CollaborativeContentRetrievalStage implements FullTextPipelineStage { require = [] - stageId = contentStageId + stageId = collabStageId extra = ['content', 'base64'] @@ -59,8 +60,6 @@ export class CollaborativeContentRetrievalStage implements FullTextPipelineStage textLimit = 100 * 1024 - stageValue: boolean | string = true - constructor ( readonly storageAdapter: StorageAdapter | undefined, readonly workspace: WorkspaceId, diff --git a/server-plugins/contact-resources/src/index.ts b/server-plugins/contact-resources/src/index.ts index d42140bf38..836648cc4f 100644 --- a/server-plugins/contact-resources/src/index.ts +++ b/server-plugins/contact-resources/src/index.ts @@ -26,7 +26,8 @@ import contact, { formatName, getFirstName, getLastName, - getName + getName, + PersonSpace } from '@hcengineering/contact' import core, { Account, @@ -41,7 +42,8 @@ import core, { TxProcessor, TxRemoveDoc, TxUpdateDoc, - concatLink + concatLink, + TxCUD } from '@hcengineering/core' import notification, { Collaborators } from '@hcengineering/notification' import { getMetadata } from '@hcengineering/platform' @@ -87,6 +89,10 @@ export async function OnEmployeeCreate (tx: Tx, control: TriggerControl): Promis if (acc === undefined) return [] const spaces = await control.findAll(core.class.Space, { autoJoin: true }) const result: Tx[] = [] + + const txes = await createPersonSpace(acc._id, mixinTx.objectId, control) + result.push(...txes) + for (const space of spaces) { if (space.members.includes(acc._id)) continue const pushTx = control.txFactory.createTxUpdateDoc(space._class, space.space, space._id, { @@ -99,6 +105,35 @@ export async function OnEmployeeCreate (tx: Tx, control: TriggerControl): Promis return result } +async function createPersonSpace ( + account: Ref, + person: Ref, + control: TriggerControl +): Promise[]> { + const personSpace = (await control.findAll(contact.class.PersonSpace, { person }, { limit: 1 })).shift() + if (personSpace !== undefined) { + if (personSpace.members.includes(account)) return [] + return [ + control.txFactory.createTxUpdateDoc(personSpace._class, personSpace.space, personSpace._id, { + $push: { + members: account + } + }) + ] + } + + return [ + control.txFactory.createTxCreateDoc(contact.class.PersonSpace, core.space.Space, { + name: 'Personal space', + description: '', + private: true, + archived: false, + person, + members: [account] + }) + ] +} + export async function OnPersonAccountCreate (tx: Tx, control: TriggerControl): Promise { const acc = TxProcessor.createDoc2Doc(tx as TxCreateDoc) const person = ( @@ -106,7 +141,12 @@ export async function OnPersonAccountCreate (tx: Tx, control: TriggerControl): P )[0] if (person === undefined) return [] const spaces = await control.findAll(core.class.Space, { autoJoin: true }) + const result: Tx[] = [] + const txes = await createPersonSpace(acc._id, person._id, control) + + result.push(...txes) + for (const space of spaces) { if (space.members.includes(acc._id)) continue const pushTx = control.txFactory.createTxUpdateDoc(space._class, space.space, space._id, { diff --git a/server-plugins/gmail-resources/src/index.ts b/server-plugins/gmail-resources/src/index.ts index bc7f5c03a9..8ad1e0d1d6 100644 --- a/server-plugins/gmail-resources/src/index.ts +++ b/server-plugins/gmail-resources/src/index.ts @@ -30,8 +30,12 @@ import { } from '@hcengineering/core' import gmail, { Message } from '@hcengineering/gmail' import { TriggerControl } from '@hcengineering/server-core' -import notification, { BaseNotificationType, InboxNotification, NotificationType } from '@hcengineering/notification' -import serverNotification, { NotificationProviderFunc, UserInfo } from '@hcengineering/server-notification' +import { BaseNotificationType, InboxNotification, NotificationType } from '@hcengineering/notification' +import serverNotification, { + NotificationProviderFunc, + ReceiverInfo, + SenderInfo +} from '@hcengineering/server-notification' import { getContentByTemplate } from '@hcengineering/server-notification-resources' import { getMetadata } from '@hcengineering/platform' @@ -74,46 +78,6 @@ export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise }) res.push(tx) } - if (message.incoming) { - const docs = await control.findAll(notification.class.DocNotifyContext, { - attachedTo: channel._id, - user: message.modifiedBy - }) - for (const doc of docs) { - // TODO: push inbox notification - // res.push( - // control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, { - // $push: { - // txes: { - // _id: tx._id as Ref>, - // modifiedOn: tx.modifiedOn, - // modifiedBy: tx.modifiedBy, - // isNew: true - // } - // } - // }) - // ) - res.push( - control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, { - lastUpdateTimestamp: tx.modifiedOn - }) - ) - } - if (docs.length === 0) { - res.push( - control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, channel.space, { - user: tx.modifiedBy, - attachedTo: channel._id, - attachedToClass: channel._class, - lastUpdateTimestamp: tx.modifiedOn - // TODO: push inbox notification - // txes: [ - // { _id: tx._id as Ref>, modifiedOn: tx.modifiedOn, modifiedBy: tx.modifiedBy, isNew: true } - // ] - }) - ) - } - } } return res @@ -166,8 +130,8 @@ async function notifyByEmail ( control: TriggerControl, type: Ref, doc: Doc | undefined, - sender: UserInfo, - receiver: UserInfo, + sender: SenderInfo, + receiver: ReceiverInfo, data: InboxNotification ): Promise { const account = receiver.account @@ -191,26 +155,14 @@ const SendEmailNotifications: NotificationProviderFunc = async ( types: BaseNotificationType[], object: Doc, data: InboxNotification, - receiver: UserInfo, - sender: UserInfo + receiver: ReceiverInfo, + sender: SenderInfo ): Promise => { if (types.length === 0) { return [] } - if (receiver.person === undefined) { - return [] - } - - const isEmployee = control.hierarchy.hasMixin(receiver.person, contact.mixin.Employee) - - if (!isEmployee) { - return [] - } - - const employee = control.hierarchy.as(receiver.person, contact.mixin.Employee) - - if (!employee.active) { + if (!receiver.person.active) { return [] } diff --git a/server-plugins/love-resources/src/index.ts b/server-plugins/love-resources/src/index.ts index 4a9d1483d5..4f973b29b1 100644 --- a/server-plugins/love-resources/src/index.ts +++ b/server-plugins/love-resources/src/index.ts @@ -84,7 +84,8 @@ async function createUserInfo (acc: Ref, control: TriggerControl): Prom name: person !== undefined ? getName(control.hierarchy, person, control.branding?.lastNameFirst) : account.email, room: room?._id ?? love.ids.Reception, x: 0, - y: 0 + y: 0, + sessionId: null }) const ptx = control.txFactory.createTxApplyIf( core.space.Workspace, @@ -93,7 +94,7 @@ async function createUserInfo (acc: Ref, control: TriggerControl): Prom [ { _class: love.class.ParticipantInfo, - query: { person } + query: { person: personId } } ], [tx], diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index 2d6012e6b3..eb87ef3110 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -76,7 +76,8 @@ import serverNotification, { getPersonAccountById, NOTIFICATION_BODY_SIZE, NOTIFICATION_TITLE_SIZE, - UserInfo + ReceiverInfo, + SenderInfo } from '@hcengineering/server-notification' import serverView from '@hcengineering/server-view' import { stripTags } from '@hcengineering/text' @@ -86,6 +87,8 @@ import webpush, { WebPushError } from 'web-push' import { Content, NotifyParams, NotifyResult } from './types' import { + createPullCollaboratorsTx, + createPushCollaboratorsTx, getHTMLPresenter, getNotificationContent, getTextPresenter, @@ -96,6 +99,7 @@ import { isUserEmployeeInFieldValue, isUserInFieldValue, replaceAll, + toReceiverInfo, updateNotifyContextsSpace } from './utils' @@ -121,8 +125,8 @@ export async function getCommonNotificationTxes ( control: TriggerControl, doc: Doc, data: Partial>, - receiver: UserInfo, - sender: UserInfo, + receiver: ReceiverInfo, + sender: SenderInfo, attachedTo: Ref, attachedToClass: Ref>, space: Ref, @@ -135,7 +139,7 @@ export async function getCommonNotificationTxes ( } const res: Tx[] = [] - const notifyContexts = await control.findAll(notification.class.DocNotifyContext, { attachedTo }) + const notifyContexts = await control.findAll(notification.class.DocNotifyContext, { objectId: attachedTo }) const notificationTx = await pushInboxNotifications( control, @@ -267,13 +271,13 @@ async function getValueCollaborators (value: any, attr: AnyAttribute, control: T } async function getKeyCollaborators ( - doc: Doc, + docClass: Ref>, value: any, field: string, control: TriggerControl ): Promise[] | undefined> { if (value !== undefined && value !== null) { - const attr = control.hierarchy.findAttribute(doc._class, field) + const attr = control.hierarchy.findAttribute(docClass, field) if (attr !== undefined) { return await getValueCollaborators(value, attr, control) } @@ -295,7 +299,7 @@ export async function getDocCollaborators ( const newCollaborators = await ctx.with( 'getKeyCollaborators', {}, - async () => await getKeyCollaborators(doc, value, field, control) + async () => await getKeyCollaborators(doc._class, value, field, control) ) if (newCollaborators !== undefined) { for (const newCollaborator of newCollaborators) { @@ -306,84 +310,44 @@ export async function getDocCollaborators ( return Array.from(collaborators.values()) } -function getDocNotifyContext ( - docNotifyContexts: DocNotifyContext[], - targetUser: Ref, - attachedTo: Ref, - res: Tx[] -): DocNotifyContext | undefined { - const context = docNotifyContexts.find((context) => context.user === targetUser && context.attachedTo === attachedTo) - - if (context !== undefined) { - return context - } - - const contextTx = (res as TxCUD[]).find((tx) => { - if (tx._class === core.class.TxCreateDoc && tx.objectClass === notification.class.DocNotifyContext) { - const createTx = tx as TxCreateDoc - - return createTx.attributes.attachedTo === attachedTo && createTx.attributes.user === targetUser - } - - return false - }) as TxCreateDoc | undefined - - if (contextTx !== undefined) { - return TxProcessor.createDoc2Doc(contextTx) - } - - return undefined -} - export async function pushInboxNotifications ( control: TriggerControl, res: Tx[], - target: UserInfo, - attachedTo: Ref, - attachedToClass: Ref>, - space: Ref, + receiver: ReceiverInfo, + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, contexts: DocNotifyContext[], data: Partial>, _class: Ref>, modifiedOn: Timestamp, shouldUpdateTimestamp = true ): Promise | undefined> { - const account = target.account - - if (account === undefined) { - return - } - const context = getDocNotifyContext(contexts, account._id, attachedTo, res) + const context = contexts.find((context) => context.user === receiver._id && context.objectId === objectId) let docNotifyContextId: Ref if (context === undefined) { - const createContextTx = control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, space, { - user: account._id, - attachedTo, - attachedToClass, - lastUpdateTimestamp: shouldUpdateTimestamp ? modifiedOn : undefined - }) - await control.apply([createContextTx]) - if (target.account?.email !== undefined) { - control.operationContext.derived.targets['docNotifyContext' + createContextTx._id] = (it) => { - if (it._id === createContextTx._id) { - return [target.account?.email as string] - } - } - } - docNotifyContextId = createContextTx.objectId + docNotifyContextId = await createNotifyContext( + control, + objectId, + objectClass, + objectSpace, + receiver, + shouldUpdateTimestamp ? modifiedOn : undefined + ) } else { docNotifyContextId = context._id } const notificationData = { - user: account._id, + user: receiver._id, isViewed: false, docNotifyContext: docNotifyContextId, + archived: false, ...data } - const notificationTx = control.txFactory.createTxCreateDoc(_class, space, notificationData) + const notificationTx = control.txFactory.createTxCreateDoc(_class, receiver.space, notificationData) res.push(notificationTx) return notificationTx @@ -470,7 +434,7 @@ async function mentionInboxNotificationToText ( return await commonInboxNotificationToText(doc) } -async function getTranslatedNotificationContent ( +export async function getTranslatedNotificationContent ( data: Data, _class: Ref>, control: TriggerControl @@ -488,12 +452,12 @@ async function getTranslatedNotificationContent ( export async function createPushFromInbox ( control: TriggerControl, - target: UserInfo, + receiver: ReceiverInfo, attachedTo: Ref, attachedToClass: Ref>, data: Data, _class: Ref>, - sender: UserInfo, + sender: SenderInfo, _id: Ref, cache: Map, Doc> = new Map, Doc>() ): Promise { @@ -522,9 +486,9 @@ export async function createPushFromInbox ( } const path = [workbenchId, control.workspace.workspaceUrl, notificationId, encodeObjectURI(id, attachedToClass)] - await createPushNotification(control, target._id as Ref, title, body, _id, senderPerson, path) - return control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, core.space.Workspace, { - user: target._id, + await createPushNotification(control, receiver._id as Ref, title, body, _id, senderPerson, path) + return control.txFactory.createTxCreateDoc(notification.class.BrowserNotification, receiver.space, { + user: receiver._id, status: NotificationStatus.New, title, body, @@ -604,18 +568,14 @@ export async function pushActivityInboxNotifications ( originTx: TxCUD, control: TriggerControl, res: Tx[], - target: UserInfo, - sender: UserInfo, + receiver: ReceiverInfo, + sender: SenderInfo, object: Doc, docNotifyContexts: DocNotifyContext[], activityMessage: ActivityMessage, shouldUpdateTimestamp: boolean ): Promise | undefined> { - if (target.account === undefined) { - return - } - - const content = await getNotificationContent(originTx, target.account, sender, object, control) + const content = await getNotificationContent(originTx, receiver.account, sender, object, control, activityMessage) const data: Partial> = { ...content, attachedTo: activityMessage._id, @@ -625,10 +585,10 @@ export async function pushActivityInboxNotifications ( return await pushInboxNotifications( control, res, - target, + receiver, activityMessage.attachedTo, activityMessage.attachedToClass, - activityMessage.space, + object.space, docNotifyContexts, data, notification.class.ActivityInboxNotification, @@ -645,8 +605,8 @@ export async function applyNotificationProviders ( control: TriggerControl, res: Tx[], object: Doc, - receiver: UserInfo, - sender: UserInfo + receiver: ReceiverInfo, + sender: SenderInfo ): Promise { const resources = await control.modelDb.findAll(serverNotification.class.NotificationProviderResources, {}) for (const [provider, types] of notifyResult.entries()) { @@ -662,7 +622,6 @@ export async function applyNotificationProviders ( sender, data._id ) - // console.log('Push takes', Date.now() - now, 'ms') if (pushTx !== undefined) { res.push(pushTx) } @@ -683,13 +642,40 @@ export async function applyNotificationProviders ( } } +async function createNotifyContext ( + control: TriggerControl, + objectId: Ref, + objectClass: Ref>, + objectSpace: Ref, + receiver: ReceiverInfo, + updateTimestamp?: Timestamp +): Promise> { + const createTx = control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, receiver.space, { + user: receiver._id, + objectId, + objectClass, + objectSpace, + isPinned: false, + lastUpdateTimestamp: updateTimestamp + }) + await control.apply([createTx]) + if (receiver.account?.email !== undefined) { + control.operationContext.derived.targets['docNotifyContext' + createTx._id] = (it) => { + if (it._id === createTx._id) { + return [receiver.account?.email] + } + } + } + return createTx.objectId +} + export async function getNotificationTxes ( control: TriggerControl, object: Doc, tx: TxCUD, originTx: TxCUD, - receiver: UserInfo, - sender: UserInfo, + receiver: ReceiverInfo, + sender: SenderInfo, params: NotifyParams, docNotifyContexts: DocNotifyContext[], activityMessages: ActivityMessage[] @@ -741,6 +727,21 @@ export async function getNotificationTxes ( sender ) } + } else { + const context = docNotifyContexts.find( + (context) => context.objectId === message.attachedTo && context.user === receiver.account._id + ) + + if (context === undefined) { + await createNotifyContext( + control, + message.attachedTo, + message.attachedToClass, + message.space, + receiver, + params.shouldUpdateTimestamp ? originTx.modifiedOn : undefined + ) + } } } return res @@ -786,6 +787,39 @@ async function updateContextsTimestamp ( await control.apply(res) } +async function removeContexts ( + contexts: DocNotifyContext[], + unsubscribe: Ref[], + control: TriggerControl +): Promise { + if (contexts.length === 0) return + if (unsubscribe.length === 0) return + + const unsubscribeAccounts = await control.modelDb.findAll(contact.class.PersonAccount, { + _id: { $in: unsubscribe } + }) + const res: Tx[] = [] + + for (const context of contexts) { + const account = unsubscribeAccounts.find(({ _id }) => _id === context.user) + if (account === undefined) continue + + const removeTx = control.txFactory.createTxRemoveDoc(context._class, context.space, context._id) + + res.push(removeTx) + + if (account.email !== undefined) { + control.operationContext.derived.targets['docNotifyContext' + removeTx._id] = (it) => { + if (it._id === removeTx._id) { + return [account.email] + } + } + } + } + + await control.apply(res) +} + export async function createCollabDocInfo ( ctx: MeasureContext, collaborators: Ref[], @@ -795,7 +829,7 @@ export async function createCollabDocInfo ( object: Doc, activityMessages: ActivityMessage[], params: NotifyParams, - cache: Map, Doc> + unsubscribe: Ref[] = [] ): Promise { let res: Tx[] = [] @@ -803,9 +837,10 @@ export async function createCollabDocInfo ( return res } - const notifyContexts = await control.findAllCtx(ctx, notification.class.DocNotifyContext, { attachedTo: object._id }) + const notifyContexts = await control.findAllCtx(ctx, notification.class.DocNotifyContext, { objectId: object._id }) await updateContextsTimestamp(notifyContexts, originTx.modifiedOn, control, originTx.modifiedBy) + await removeContexts(notifyContexts, unsubscribe, control) const docMessages = activityMessages.filter((message) => message.attachedTo === object._id) if (docMessages.length === 0) { @@ -831,12 +866,15 @@ export async function createCollabDocInfo ( {}, async (ctx) => await getUsersInfo(ctx, [...Array.from(targets), originTx.modifiedBy as Ref], control) ) - const sender = usersInfo.find(({ _id }) => _id === originTx.modifiedBy) ?? { + const sender: SenderInfo = usersInfo.find(({ _id }) => _id === originTx.modifiedBy) ?? { _id: originTx.modifiedBy } for (const target of targets) { - const info = usersInfo.find(({ _id }) => _id === target) + const info: ReceiverInfo | undefined = toReceiverInfo( + control.hierarchy, + usersInfo.find(({ _id }) => _id === target) + ) if (info === undefined) continue @@ -856,7 +894,7 @@ export async function createCollabDocInfo ( const id = generateId() as string control.operationContext.derived.targets[id] = (it) => { if (ids.has(it._id)) { - return [info.account?.email as string] + return [info.account?.email] } } } @@ -884,6 +922,50 @@ export function getMixinTx ( ) } +async function getTxCollabs ( + ctx: MeasureContext, + tx: TxCUD, + control: TriggerControl, + doc: Doc +): Promise<{ + added: Ref[] + removed: Ref[] + result: Ref[] + }> { + const { hierarchy } = control + const mixin = hierarchy.classHierarchyMixin( + doc._class, + notification.mixin.ClassCollaborators + ) + if (mixin === undefined) return { added: [], removed: [], result: [] } + + if (tx._class === core.class.TxCreateDoc) { + const collabs = await getDocCollaborators(ctx, doc, mixin, control) + return { added: collabs, removed: [], result: collabs } + } + + if (tx._class === core.class.TxRemoveDoc) { + if (hierarchy.hasMixin(doc, notification.mixin.Collaborators)) { + return { added: [], removed: [], result: hierarchy.as(doc, notification.mixin.Collaborators).collaborators ?? [] } + } + + return { added: [], removed: [], result: [] } + } + + if ([core.class.TxUpdateDoc, core.class.TxMixin].includes(tx._class)) { + const collabs = new Set(hierarchy.as(doc, notification.mixin.Collaborators).collaborators ?? []) + const ops = isMixinTx(tx) ? tx.attributes : (tx as TxUpdateDoc).operations + const newCollaborators = (await getNewCollaborators(ops, mixin, doc._class, control)).filter((p) => !collabs.has(p)) + const isSpace = control.hierarchy.isDerived(doc._class, core.class.Space) + const removedCollabs = isSpace ? await getRemovedMembers(ops, mixin, (doc as Space)._class, control) : [] + const result = [...collabs, ...newCollaborators].filter((p) => !removedCollabs.includes(p)) + + return { added: newCollaborators, removed: removedCollabs, result } + } + + return { added: [], removed: [], result: [] } +} + async function getSpaceCollabTxes ( control: TriggerControl, doc: Doc, @@ -916,8 +998,7 @@ async function getSpaceCollabTxes ( originTx, doc, activityMessages, - { isSpace: true, isOwn: false, shouldUpdateTimestamp: true }, - cache + { isSpace: true, isOwn: false, shouldUpdateTimestamp: true } ) } } @@ -960,8 +1041,7 @@ async function createCollaboratorDoc ( originTx, doc, activityMessage, - { isOwn: true, isSpace: false, shouldUpdateTimestamp: true }, - cache + { isOwn: true, isSpace: false, shouldUpdateTimestamp: true } ) ) res.push(mixinTx) @@ -1046,7 +1126,7 @@ async function updateCollaboratorsMixin ( if (newCollabs.length > 0) { const docNotifyContexts = await control.findAllCtx(ctx, notification.class.DocNotifyContext, { user: { $in: newCollabs }, - attachedTo: tx.objectId + objectId: tx.objectId }) const infos = await ctx.with( @@ -1054,11 +1134,13 @@ async function updateCollaboratorsMixin ( {}, async (ctx) => await getUsersInfo(ctx, [...newCollabs, originTx.modifiedBy] as Ref[], control) ) - const sender = infos.find(({ _id }) => _id === originTx.modifiedBy) ?? { _id: originTx.modifiedBy } + const sender: SenderInfo = infos.find(({ _id }) => _id === originTx.modifiedBy) ?? { _id: originTx.modifiedBy } for (const collab of newCollabs) { - const target = infos.find(({ _id }) => _id === collab) - + const target = toReceiverInfo( + hierarchy, + infos.find(({ _id }) => _id === collab) + ) if (target === undefined) continue for (const message of activityMessages) { @@ -1132,8 +1214,7 @@ async function collectionCollabDoc ( tx, doc, activityMessages, - { isOwn: false, isSpace: false, shouldUpdateTimestamp: true }, - cache + { isOwn: false, isSpace: false, shouldUpdateTimestamp: true } ) ) ) @@ -1152,7 +1233,7 @@ async function removeCollaboratorDoc (tx: TxRemoveDoc, control: TriggerCont const res: Tx[] = [] const notifyContexts = await control.findAll( notification.class.DocNotifyContext, - { attachedTo: tx.objectId }, + { objectId: tx.objectId }, { projection: { _id: 1, @@ -1195,7 +1276,7 @@ async function removeCollaboratorDoc (tx: TxRemoveDoc, control: TriggerCont async function getNewCollaborators ( ops: DocumentUpdate | MixinUpdate, mixin: ClassCollaborators, - doc: Doc, + docClass: Ref>, control: TriggerControl ): Promise[]> { const newCollaborators = new Set>() @@ -1206,7 +1287,7 @@ async function getNewCollaborators ( if (typeof value !== 'string') { value = value.$each } - const newCollabs = await getKeyCollaborators(doc, value, key, control) + const newCollabs = await getKeyCollaborators(docClass, value, key, control) if (newCollabs !== undefined) { for (const newCollab of newCollabs) { newCollaborators.add(newCollab) @@ -1219,7 +1300,7 @@ async function getNewCollaborators ( if (key.startsWith('$')) continue if (mixin.fields.includes(key)) { const value = (ops as any)[key] - const newCollabs = await getKeyCollaborators(doc, value, key, control) + const newCollabs = await getKeyCollaborators(docClass, value, key, control) if (newCollabs !== undefined) { for (const newCollab of newCollabs) { newCollaborators.add(newCollab) @@ -1230,6 +1311,30 @@ async function getNewCollaborators ( return Array.from(newCollaborators.values()) } +async function getRemovedMembers ( + ops: DocumentUpdate | MixinUpdate, + mixin: ClassCollaborators, + docClass: Ref>, + control: TriggerControl +): Promise[]> { + const removedCollaborators: Ref[] = [] + if (ops.$pull !== undefined && 'members' in ops.$pull) { + const key = 'members' + if (mixin.fields.includes(key)) { + let value = (ops.$pull as any)[key] + if (typeof value !== 'string') { + value = value.$in + } + const collabs = await getKeyCollaborators(docClass, value, key, control) + if (collabs !== undefined) { + removedCollaborators.push(...collabs) + } + } + } + + return Array.from(new Set(removedCollaborators).values()) +} + async function updateCollaboratorDoc ( ctx: MeasureContext, tx: TxUpdateDoc | TxMixin, @@ -1245,31 +1350,28 @@ async function updateCollaboratorDoc ( const doc = await ctx.with( 'find-doc', { _class: tx.objectClass }, - async () => (await control.findAllCtx(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] + async () => + cache.get(tx.objectId) ?? (await control.findAllCtx(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0] ) if (doc === undefined) return [] + cache.set(doc._id, doc) const params: NotifyParams = { isOwn: true, isSpace: false, shouldUpdateTimestamp: true } if (hierarchy.hasMixin(doc, notification.mixin.Collaborators)) { // we should handle change field and subscribe new collaborators - const collabMixin = hierarchy.as(doc, notification.mixin.Collaborators) - const collabs = new Set(collabMixin.collaborators) - const ops = isMixinTx(tx) ? tx.attributes : tx.operations - const newCollaborators = await ctx.with('get-new-collaborators', {}, async () => - (await getNewCollaborators(ops, mixin, doc, control)).filter((p) => !collabs.has(p)) + const collabsInfo = await ctx.with( + 'get-tx-collaborators', + {}, + async () => await getTxCollabs(ctx, tx, control, doc) ) - if (newCollaborators.length > 0) { - res.push( - control.txFactory.createTxMixin(tx.objectId, tx.objectClass, tx.objectSpace, notification.mixin.Collaborators, { - $push: { - collaborators: { - $each: newCollaborators, - $position: 0 - } - } - }) - ) + if (collabsInfo.added.length > 0) { + res.push(createPushCollaboratorsTx(control, tx.objectId, tx.objectClass, tx.objectSpace, collabsInfo.added)) } + + if (collabsInfo.removed.length > 0) { + res.push(createPullCollaboratorsTx(control, tx.objectId, tx.objectClass, tx.objectSpace, collabsInfo.removed)) + } + res = res.concat( await ctx.with( 'create-collab-docinfo', @@ -1277,14 +1379,14 @@ async function updateCollaboratorDoc ( async () => await createCollabDocInfo( ctx, - [...collabMixin.collaborators, ...newCollaborators] as Ref[], + collabsInfo.result as Ref[], control, tx, originTx, doc, activityMessages, params, - cache + collabsInfo.removed as Ref[] ) ) ) @@ -1304,8 +1406,7 @@ async function updateCollaboratorDoc ( originTx, doc, activityMessages, - params, - cache + params ) ) } @@ -1436,6 +1537,59 @@ async function applyUserTxes ( return res } +async function updateCollaborators (ctx: MeasureContext, control: TriggerControl, tx: TxCUD): Promise { + if (tx._class !== core.class.TxUpdateDoc && tx._class !== core.class.TxMixin) return [] + + const hierarchy = control.hierarchy + + const mixin = hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators) + if (mixin === undefined) return [] + + const { objectClass, objectId, objectSpace } = tx + const ops = isMixinTx(tx) ? tx.attributes : (tx as TxUpdateDoc).operations + const addedCollaborators = await getNewCollaborators(ops, mixin, objectClass, control) + const isSpace = control.hierarchy.isDerived(objectClass, core.class.Space) + const removedCollaborators = isSpace + ? await getRemovedMembers(ops, mixin, objectClass as Ref>, control) + : [] + + if (removedCollaborators.length === 0 && addedCollaborators.length === 0) return [] + + const doc = (await control.findAll(objectClass, { _id: objectId }, { limit: 1 }))[0] + if (doc === undefined) return [] + + const res: Tx[] = [] + const currentCollaborators = new Set(hierarchy.as(doc, notification.mixin.Collaborators).collaborators ?? []) + const toAdd = addedCollaborators.filter((p) => !currentCollaborators.has(p)) + + if (toAdd.length === 0 && removedCollaborators.length === 0) return [] + + if (toAdd.length > 0) { + res.push(createPushCollaboratorsTx(control, objectId, objectClass, objectSpace, toAdd)) + } + + if (removedCollaborators.length > 0) { + res.push(createPullCollaboratorsTx(control, objectId, objectClass, objectSpace, removedCollaborators)) + } + + if (hierarchy.classHierarchyMixin(objectClass, activity.mixin.ActivityDoc) === undefined) return res + + const contexts = await control.findAll(notification.class.DocNotifyContext, { attachedTo: objectId }) + const addedInfo = await getUsersInfo(ctx, toAdd as Ref[], control) + + for (const addedUser of addedInfo) { + const info = toReceiverInfo(hierarchy, addedUser) + if (info === undefined) continue + const context = contexts.find(({ user }) => user === info._id) + if (context !== undefined) continue + await createNotifyContext(control, objectId, objectClass, objectSpace, info) + } + + await removeContexts(contexts, removedCollaborators as Ref[], control) + + return res +} + export async function createCollaboratorNotifications ( ctx: MeasureContext, tx: TxCUD, @@ -1445,7 +1599,12 @@ export async function createCollaboratorNotifications ( cache: Map, Doc> = new Map, Doc>() ): Promise { if (tx.space === core.space.DerivedTx) { - return [] + // do not forgot update collaborators for derived tx + return await ctx.with( + 'updateDerivedCollaborators', + {}, + async () => await updateCollaborators(ctx, control, TxProcessor.extractTx(tx) as TxCUD) + ) } if (activityMessages.length === 0) { @@ -1543,7 +1702,7 @@ async function OnActivityMessageRemove (message: ActivityMessage, control: Trigg return [] } - const contexts = await control.findAll(notification.class.DocNotifyContext, { attachedTo: message.attachedTo }) + const contexts = await control.findAll(notification.class.DocNotifyContext, { objectId: message.attachedTo }) if (contexts.length === 0) return [] const isLastUpdate = contexts.some((context) => { diff --git a/server-plugins/notification-resources/src/utils.ts b/server-plugins/notification-resources/src/utils.ts index 42a582c892..a973aac609 100644 --- a/server-plugins/notification-resources/src/utils.ts +++ b/server-plugins/notification-resources/src/utils.ts @@ -12,7 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { DocUpdateMessage } from '@hcengineering/activity' +import notification, { + BaseNotificationType, + Collaborators, + CommonNotificationType, + NotificationContent, + NotificationProvider, + NotificationType +} from '@hcengineering/notification' +import type { TriggerControl } from '@hcengineering/server-core' import { Analytics } from '@hcengineering/analytics' import contact, { formatName, PersonAccount } from '@hcengineering/contact' import core, { @@ -24,6 +32,7 @@ import core, { matchQuery, MixinUpdate, Ref, + Space, toIdMap, Tx, TxCreateDoc, @@ -34,22 +43,16 @@ import core, { TxUpdateDoc, type MeasureContext } from '@hcengineering/core' -import notification, { - BaseNotificationType, - CommonNotificationType, - NotificationContent, - NotificationProvider, - NotificationType -} from '@hcengineering/notification' import { getResource, IntlString, translate } from '@hcengineering/platform' -import type { TriggerControl } from '@hcengineering/server-core' import serverNotification, { getPersonAccountById, HTMLPresenter, NotificationPresenter, - TextPresenter, - UserInfo + ReceiverInfo, + SenderInfo, + TextPresenter } from '@hcengineering/server-notification' +import { ActivityMessage, DocUpdateMessage } from '@hcengineering/activity' import { NotifyResult } from './types' @@ -157,7 +160,9 @@ export async function isAllowed ( return false } - const typesSettings = await control.queryFind(notification.class.NotificationTypeSetting, {}) + const typesSettings = await control.queryFind(notification.class.NotificationTypeSetting, { + space: core.space.Workspace + }) const setting = typesSettings.find( (it) => it.attachedTo === provider._id && it.type === type._id && it.modifiedBy === receiver ) @@ -300,10 +305,12 @@ export async function updateNotifyContextsSpace ( return [] } - const notifyContexts = await control.findAll(notification.class.DocNotifyContext, { attachedTo: tx.objectId }) + const notifyContexts = await control.findAll(notification.class.DocNotifyContext, { objectId: tx.objectId }) return notifyContexts.map((value) => - control.txFactory.createTxUpdateDoc(value._class, value.space, value._id, { space: updateTx.operations.space }) + control.txFactory.createTxUpdateDoc(value._class, value.space, value._id, { + objectSpace: updateTx.operations.space + }) ) } @@ -319,7 +326,7 @@ export function getTextPresenter (_class: Ref>, hierarchy: Hierarchy) return hierarchy.classHierarchyMixin(_class, serverNotification.mixin.TextPresenter) } -async function getSenderName (control: TriggerControl, sender: UserInfo): Promise { +async function getSenderName (control: TriggerControl, sender: SenderInfo): Promise { if (sender._id === core.account.System) { return await translate(core.string.System, {}) } @@ -340,10 +347,13 @@ async function getFallbackNotificationFullfillment ( object: Doc, originTx: TxCUD, control: TriggerControl, - sender: UserInfo + sender: SenderInfo, + message?: ActivityMessage ): Promise { const title: IntlString = notification.string.CommonNotificationTitle let body: IntlString = notification.string.CommonNotificationBody + let data: string | undefined + const intlParams: Record = {} const intlParamsNotLocalized: Record = {} @@ -353,6 +363,15 @@ async function getFallbackNotificationFullfillment ( intlParams.title = await textPresenterFunc(object, control) } + if (message !== undefined) { + const dataPresenter = getTextPresenter(message._class, control.hierarchy) + + if (dataPresenter !== undefined) { + const textPresenterFunc = await getResource(dataPresenter.presenter) + data = await textPresenterFunc(message, control) + } + } + const tx = TxProcessor.extractTx(originTx) intlParams.senderName = await getSenderName(control, sender) @@ -397,7 +416,7 @@ async function getFallbackNotificationFullfillment ( } } - return { title, body, intlParams, intlParamsNotLocalized } + return { title, body, data, intlParams, intlParamsNotLocalized } } function getNotificationPresenter (_class: Ref>, hierarchy: Hierarchy): NotificationPresenter | undefined { @@ -407,32 +426,36 @@ function getNotificationPresenter (_class: Ref>, hierarchy: Hierarchy export async function getNotificationContent ( originTx: TxCUD, targetUser: PersonAccount, - sender: UserInfo, + sender: SenderInfo, object: Doc, - control: TriggerControl + control: TriggerControl, + message?: ActivityMessage ): Promise { - let { title, body, intlParams, intlParamsNotLocalized } = await getFallbackNotificationFullfillment( + let { title, body, data, intlParams, intlParamsNotLocalized } = await getFallbackNotificationFullfillment( object, originTx, control, - sender + sender, + message ) - const actualTx = TxProcessor.extractTx(originTx) - const notificationPresenter = getNotificationPresenter((actualTx as TxCUD).objectClass, control.hierarchy) + const actualTx = TxProcessor.extractTx(originTx) as TxCUD + const notificationPresenter = getNotificationPresenter(actualTx.objectClass, control.hierarchy) + if (notificationPresenter !== undefined) { const getFuillfillmentParams = await getResource(notificationPresenter.presenter) - const updateIntlParams = await getFuillfillmentParams(object, originTx, targetUser._id, control) - title = updateIntlParams.title - body = updateIntlParams.body + const updateParams = await getFuillfillmentParams(object, originTx, targetUser._id, control) + title = updateParams.title + body = updateParams.body + data = updateParams?.data ?? data intlParams = { ...intlParams, - ...updateIntlParams.intlParams + ...updateParams.intlParams } - if (updateIntlParams.intlParamsNotLocalized != null) { + if (updateParams.intlParamsNotLocalized != null) { intlParamsNotLocalized = { ...intlParamsNotLocalized, - ...updateIntlParams.intlParamsNotLocalized + ...updateParams.intlParamsNotLocalized } } } @@ -440,6 +463,7 @@ export async function getNotificationContent ( const content: NotificationContent = { title, body, + data, intlParams } @@ -454,19 +478,81 @@ export async function getUsersInfo ( ctx: MeasureContext, ids: Ref[], control: TriggerControl -): Promise { +): Promise<(ReceiverInfo | SenderInfo)[]> { + if (ids.length === 0) return [] const accounts = await control.modelDb.findAll(contact.class.PersonAccount, { _id: { $in: ids } }) + const personIds = accounts.map((it) => it.person) + const accountById = toIdMap(accounts) const persons = toIdMap( await ctx.with( - 'query-find', + 'find-persons', {}, - async () => await control.findAll(contact.class.Person, { _id: { $in: accounts.map((it) => it.person) } }) + async () => await control.findAll(contact.class.Person, { _id: { $in: personIds } }) ) ) + const spaces = await ctx.with('find-person-spaces', {}, async () => { + const res = await control.findAll(contact.class.PersonSpace, { person: { $in: personIds } }) - return accounts.map((account) => ({ - _id: account._id, - account, - person: persons.get(account.person) - })) + return new Map(res.map((s) => [s.person, s])) + }) + + return ids.map((_id) => { + const account = accountById.get(_id) + return { + _id, + account, + person: account !== undefined ? persons.get(account.person) : undefined, + space: account !== undefined ? spaces.get(account.person)?._id : undefined + } + }) +} + +export function toReceiverInfo (hierarchy: Hierarchy, info?: SenderInfo | ReceiverInfo): ReceiverInfo | undefined { + if (info === undefined) return undefined + if (info.person === undefined) return undefined + if (info.account === undefined) return undefined + if (!('space' in info)) return undefined + if (info.space === undefined) return undefined + + const isEmployee = hierarchy.hasMixin(info.person, contact.mixin.Employee) + if (!isEmployee) return undefined + + const employee = hierarchy.as(info.person, contact.mixin.Employee) + if (!employee.active) return undefined + + return { + _id: info._id, + account: info.account, + person: employee, + space: info.space + } +} + +export function createPushCollaboratorsTx ( + control: TriggerControl, + objectId: Ref, + objectClass: Ref>, + space: Ref, + collaborators: Ref[] +): TxMixin { + return control.txFactory.createTxMixin(objectId, objectClass, space, notification.mixin.Collaborators, { + $push: { + collaborators: { + $each: collaborators, + $position: 0 + } + } + }) +} + +export function createPullCollaboratorsTx ( + control: TriggerControl, + objectId: Ref, + objectClass: Ref>, + space: Ref, + collaborators: Ref[] +): TxMixin { + return control.txFactory.createTxMixin(objectId, objectClass, space, notification.mixin.Collaborators, { + $pull: { collaborators: { $in: collaborators } } + }) } diff --git a/server-plugins/notification/src/index.ts b/server-plugins/notification/src/index.ts index 331cf45822..9101638965 100644 --- a/server-plugins/notification/src/index.ts +++ b/server-plugins/notification/src/index.ts @@ -14,7 +14,7 @@ // limitations under the License. // -import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact' +import contact, { Employee, Person, PersonAccount, PersonSpace } from '@hcengineering/contact' import { Account, Class, Doc, Mixin, Ref, Tx, TxCUD } from '@hcengineering/core' import { BaseNotificationType, @@ -135,7 +135,14 @@ export interface NotificationPresenter extends Class { presenter: Resource } -export interface UserInfo { +export interface ReceiverInfo { + _id: Ref + account: PersonAccount + person: Employee + space: Ref +} + +export interface SenderInfo { _id: Ref account?: PersonAccount person?: Person @@ -146,8 +153,8 @@ export type NotificationProviderFunc = ( types: BaseNotificationType[], object: Doc, data: InboxNotification, - receiver: UserInfo, - sender: UserInfo + receiver: ReceiverInfo, + sender: SenderInfo ) => Promise export interface NotificationProviderResources extends Doc { @@ -156,7 +163,7 @@ export interface NotificationProviderResources extends Doc { } export const NOTIFICATION_BODY_SIZE = 50 -export const NOTIFICATION_TITLE_SIZE = 30 +export const NOTIFICATION_TITLE_SIZE = 50 /** * @public diff --git a/server-plugins/openai/src/openai.ts b/server-plugins/openai/src/openai.ts index 6b111f8141..31026630bf 100644 --- a/server-plugins/openai/src/openai.ts +++ b/server-plugins/openai/src/openai.ts @@ -20,7 +20,6 @@ import core, { DocumentQuery, DocumentUpdate, docUpdKey, - IndexStageState, MeasureContext, Ref, WorkspaceId @@ -37,7 +36,6 @@ import { FullTextPipelineStage, IndexedDoc, isIndexingRequired, - loadIndexStageStage, RateLimiter } from '@hcengineering/server-core' import got from 'got' @@ -92,12 +90,7 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage { rate = 5 - stageValue: boolean | string = true - limiter = new RateLimiter(this.rate) - - indexState?: IndexStageState - async update (doc: DocIndexState, update: DocumentUpdate): Promise {} constructor ( @@ -156,22 +149,6 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage { console.error(err) this.enabled = false } - - ;[this.stageValue, this.indexState] = await loadIndexStageStage( - ctx, - storage, - this.indexState, - this.stageId, - 'config', - { - enabled: this.enabled, - endpoint: this.endpoint, - field: this.field, - mode: this.model, - copyToState: this.copyToState, - stripNewLines: true - } - ) } async getEmbedding (text: string): Promise { @@ -299,7 +276,7 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage { // No need to index this class, mark embeddings as empty ones. if (!needIndex) { - await pipeline.update(doc._id, this.stageValue, {}) + await pipeline.update(doc._id, true, {}) return } @@ -359,13 +336,13 @@ export class OpenAIEmbeddingsStage implements FullTextPipelineStage { console.error(err) } - await pipeline.update(doc._id, this.stageValue, update) + await pipeline.update(doc._id, true, update) } async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise { // will be handled by field processor for (const doc of docs) { - await pipeline.update(doc._id, this.stageValue, {}) + await pipeline.update(doc._id, true, {}) } } } diff --git a/server-plugins/request-resources/src/index.ts b/server-plugins/request-resources/src/index.ts index 94e5c64bc0..03c5b39d7c 100644 --- a/server-plugins/request-resources/src/index.ts +++ b/server-plugins/request-resources/src/index.ts @@ -24,7 +24,8 @@ import { getNotificationTxes, getCollaborators, getTextPresenter, - getUsersInfo + getUsersInfo, + toReceiverInfo } from '@hcengineering/server-notification-resources' import { PersonAccount } from '@hcengineering/contact' @@ -144,7 +145,7 @@ async function getRequestNotificationTx (tx: TxCollectionCUD, cont if (collaborators.length === 0) return res const notifyContexts = await control.findAll(notification.class.DocNotifyContext, { - attachedTo: doc._id + objectId: doc._id }) const usersInfo = await getUsersInfo(control.ctx, [...collaborators, tx.modifiedBy] as Ref[], control) const senderInfo = usersInfo.find(({ _id }) => _id === tx.modifiedBy) ?? { @@ -152,7 +153,10 @@ async function getRequestNotificationTx (tx: TxCollectionCUD, cont } for (const target of collaborators) { - const targetInfo = usersInfo.find(({ _id }) => _id === target) + const targetInfo = toReceiverInfo( + control.hierarchy, + usersInfo.find(({ _id }) => _id === target) + ) if (targetInfo === undefined) continue const txes = await getNotificationTxes( diff --git a/server-plugins/telegram-resources/package.json b/server-plugins/telegram-resources/package.json index 9bba505746..80f8f91b6c 100644 --- a/server-plugins/telegram-resources/package.json +++ b/server-plugins/telegram-resources/package.json @@ -37,12 +37,19 @@ "@types/jest": "^29.5.5" }, "dependencies": { + "@hcengineering/activity": "^0.6.0", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/chunter": "^0.6.20", "@hcengineering/core": "^0.6.32", + "@hcengineering/notification": "^0.6.23", "@hcengineering/platform": "^0.6.11", "@hcengineering/server-core": "^0.6.1", - "@hcengineering/contact": "^0.6.24", - "@hcengineering/notification": "^0.6.23", + "@hcengineering/server-token": "^0.6.11", + "@hcengineering/server-notification": "^0.6.1", + "@hcengineering/server-telegram": "^0.6.0", + "@hcengineering/server-notification-resources": "^0.6.0", + "@hcengineering/setting": "^0.6.17", "@hcengineering/telegram": "^0.6.21", - "@hcengineering/setting": "^0.6.17" + "@hcengineering/text": "^0.6.5" } } diff --git a/server-plugins/telegram-resources/src/index.ts b/server-plugins/telegram-resources/src/index.ts index a14965d2b2..c39226baac 100644 --- a/server-plugins/telegram-resources/src/index.ts +++ b/server-plugins/telegram-resources/src/index.ts @@ -13,24 +13,34 @@ // limitations under the License. // -import contact, { Channel, ChannelProvider, Contact, Employee, PersonAccount } from '@hcengineering/contact' +import contact, { Channel, ChannelProvider, Contact, Employee, formatName, PersonAccount } from '@hcengineering/contact' import { Account, Class, + concatLink, Doc, DocumentQuery, FindOptions, FindResult, Hierarchy, Ref, + toWorkspaceString, Tx, TxCreateDoc, TxProcessor } from '@hcengineering/core' import { TriggerControl } from '@hcengineering/server-core' -import telegram, { TelegramMessage } from '@hcengineering/telegram' -import notification, { NotificationType } from '@hcengineering/notification' +import telegram, { TelegramMessage, TelegramNotificationRecord } from '@hcengineering/telegram' +import { BaseNotificationType, InboxNotification, NotificationType } from '@hcengineering/notification' import setting, { Integration } from '@hcengineering/setting' +import { NotificationProviderFunc, ReceiverInfo, SenderInfo } from '@hcengineering/server-notification' +import { getMetadata, getResource } from '@hcengineering/platform' +import serverTelegram from '@hcengineering/server-telegram' +import { getTranslatedNotificationContent, getTextPresenter } from '@hcengineering/server-notification-resources' +import { generateToken } from '@hcengineering/server-token' +import chunter, { ChatMessage } from '@hcengineering/chunter' +import { markupToHTML } from '@hcengineering/text' +import activity from '@hcengineering/activity' /** * @public @@ -68,47 +78,6 @@ export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise }) res.push(tx) } - - if (message.incoming) { - const docs = await control.findAll(notification.class.DocNotifyContext, { - attachedTo: channel._id, - user: message.modifiedBy - }) - for (const doc of docs) { - // TODO: push inbox notifications - // res.push( - // control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, { - // $push: { - // txes: { - // _id: tx._id as Ref>, - // modifiedOn: tx.modifiedOn, - // modifiedBy: tx.modifiedBy, - // isNew: true - // } - // } - // }) - // ) - res.push( - control.txFactory.createTxUpdateDoc(doc._class, doc.space, doc._id, { - lastUpdateTimestamp: tx.modifiedOn - }) - ) - } - if (docs.length === 0) { - res.push( - control.txFactory.createTxCreateDoc(notification.class.DocNotifyContext, channel.space, { - user: tx.modifiedBy, - attachedTo: channel._id, - attachedToClass: channel._class, - lastUpdateTimestamp: tx.modifiedOn - // TODO: push inbox notifications - // txes: [ - // { _id: tx._id as Ref>, modifiedOn: tx.modifiedOn, modifiedBy: tx.modifiedBy, isNew: true } - // ] - }) - ) - } - } } return res @@ -173,6 +142,95 @@ async function getContactChannel ( return res?.value ?? '' } +async function getTranslatedData ( + data: InboxNotification, + doc: Doc, + control: TriggerControl +): Promise<{ + title: string + quote: string | undefined + body: string + }> { + const { hierarchy } = control + + let { title, body } = await getTranslatedNotificationContent(data, data._class, control) + let quote: string | undefined + + if (hierarchy.isDerived(doc._class, chunter.class.ChatMessage)) { + const chatMessage = doc as ChatMessage + title = '' + quote = markupToHTML(chatMessage.message) + } else if (hierarchy.isDerived(doc._class, activity.class.ActivityMessage)) { + const resource = getTextPresenter(doc._class, control.hierarchy) + + if (resource !== undefined) { + const fn = await getResource(resource.presenter) + const textData = await fn(doc, control) + if (textData !== undefined && textData !== '') { + title = '' + quote = markupToHTML(textData) + } + } + } + body = data.data !== undefined ? `${markupToHTML(data.data)}` : body + + return { + title, + quote, + body + } +} + +const SendTelegramNotifications: NotificationProviderFunc = async ( + control: TriggerControl, + types: BaseNotificationType[], + doc: Doc, + data: InboxNotification, + receiver: ReceiverInfo, + sender: SenderInfo +): Promise => { + if (types.length === 0) { + return [] + } + + const botUrl = getMetadata(serverTelegram.metadata.BotUrl) + + if (botUrl === undefined || botUrl === '') { + console.log('Please provide telegram bot service url to enable telegram notifications.') + return [] + } + + if (!receiver.person.active) { + return [] + } + + try { + const { title, body, quote } = await getTranslatedData(data, doc, control) + const record: TelegramNotificationRecord = { + notificationId: data._id, + account: receiver._id, + workspace: toWorkspaceString(control.workspace), + sender: data.intlParams?.senderName?.toString() ?? formatName(sender.person?.name ?? 'System'), + title, + quote, + body + } + + await fetch(concatLink(botUrl, '/notify'), { + method: 'POST', + headers: { + Authorization: 'Bearer ' + generateToken(receiver.account.email, control.workspace), + 'Content-Type': 'application/json' + }, + body: JSON.stringify([record]) + }) + } catch (err) { + console.log('Could not send telegram notification', err) + } + + return [] +} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ trigger: { @@ -182,6 +240,7 @@ export default async () => ({ IsIncomingMessage, FindMessages, GetCurrentEmployeeTG, - GetIntegrationOwnerTG + GetIntegrationOwnerTG, + SendTelegramNotifications } }) diff --git a/server-plugins/telegram/src/index.ts b/server-plugins/telegram/src/index.ts index 1cafa9cf90..1b57fc9fbd 100644 --- a/server-plugins/telegram/src/index.ts +++ b/server-plugins/telegram/src/index.ts @@ -14,10 +14,9 @@ // limitations under the License. // -import type { Plugin, Resource } from '@hcengineering/platform' -import { plugin } from '@hcengineering/platform' +import { Metadata, Plugin, Resource, plugin } from '@hcengineering/platform' import { ObjectDDParticipantFunc, TriggerFunc } from '@hcengineering/server-core' -import { TypeMatchFunc } from '@hcengineering/server-notification' +import { NotificationProviderFunc, TypeMatchFunc } from '@hcengineering/server-notification' import { TemplateFieldServerFunc } from '@hcengineering/server-templates' /** @@ -29,6 +28,9 @@ export const serverTelegramId = 'server-telegram' as Plugin * @public */ export default plugin(serverTelegramId, { + metadata: { + BotUrl: '' as Metadata + }, trigger: { OnMessageCreate: '' as Resource }, @@ -36,6 +38,7 @@ export default plugin(serverTelegramId, { IsIncomingMessage: '' as TypeMatchFunc, FindMessages: '' as Resource, GetCurrentEmployeeTG: '' as Resource, - GetIntegrationOwnerTG: '' as Resource + GetIntegrationOwnerTG: '' as Resource, + SendTelegramNotifications: '' as Resource } }) diff --git a/server-plugins/time-resources/src/index.ts b/server-plugins/time-resources/src/index.ts index 199a18c2e8..0301f5bb3b 100644 --- a/server-plugins/time-resources/src/index.ts +++ b/server-plugins/time-resources/src/index.ts @@ -44,7 +44,7 @@ import { jsonToMarkup, nodeDoc, nodeParagraph, nodeText } from '@hcengineering/t import tracker, { Issue, IssueStatus, Project, TimeSpendReport } from '@hcengineering/tracker' import serverTime, { OnToDo, ToDoFactory } from '@hcengineering/server-time' import time, { ProjectToDo, ToDo, ToDoPriority, TodoAutomationHelper, WorkSlot } from '@hcengineering/time' -import { UserInfo } from '@hcengineering/server-notification' +import { ReceiverInfo, SenderInfo } from '@hcengineering/server-notification' /** * @public @@ -186,9 +186,23 @@ export async function OnToDoCreate (tx: TxCUD, control: TriggerControl): Pr } const object = (await control.findAll(todo.attachedToClass, { _id: todo.attachedTo }))[0] - if (object === undefined) return [] + const person = ( + await control.findAll(contact.mixin.Employee, { _id: account.person as Ref, active: true }, { limit: 1 }) + )[0] + if (person === undefined) return [] + + const personSpace = (await control.findAll(contact.class.PersonSpace, { person: account.person }, { limit: 1 }))[0] + if (personSpace === undefined) return [] + + const receiverInfo: ReceiverInfo = { + _id: account._id, + account, + person, + space: personSpace._id + } + const senderAccount = await control.modelDb.findOne(contact.class.PersonAccount, { _id: tx.modifiedBy as Ref }) @@ -197,13 +211,12 @@ export async function OnToDoCreate (tx: TxCUD, control: TriggerControl): Pr ? (await control.findAll(contact.class.Person, { _id: senderAccount.person }))[0] : undefined - const senderInfo: UserInfo = { + const senderInfo: SenderInfo = { _id: tx.modifiedBy, account: senderAccount, person: senderPerson } - const res: Tx[] = [] const notifyResult = await isShouldNotifyTx(control, createTx, tx, todo, account, true, false) const content = await getNotificationContent(tx, account, senderInfo, todo, control) const data: Partial> = { @@ -215,30 +228,18 @@ export async function OnToDoCreate (tx: TxCUD, control: TriggerControl): Pr messageHtml: jsonToMarkup(nodeDoc(nodeParagraph(nodeText(todo.title)))) } - const person = (await control.modelDb.findAll(contact.class.Person, { _id: account.person }))[0] - - const receiverInfo: UserInfo = { - _id: account._id, - account, - person - } - - res.push( - ...(await getCommonNotificationTxes( - control, - object, - data, - receiverInfo, - senderInfo, - object._id, - object._class, - object.space, - createTx.modifiedOn, - notifyResult - )) + return await getCommonNotificationTxes( + control, + object, + data, + receiverInfo, + senderInfo, + object._id, + object._class, + object.space, + createTx.modifiedOn, + notifyResult ) - - return res } /** diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 863208e1ac..a2c621bbef 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -1953,7 +1953,7 @@ async function createPersonAccount ( const ops = new TxOperations(connection, core.account.System) const name = combineName(account.first, account.last) - // Check if EmployeeAccount is not exists + // Check if PersonAccount is not exists if (shouldReplaceCurrent) { const currentAccount = await ops.findOne(contact.class.PersonAccount, {}) if (currentAccount !== undefined) { diff --git a/server/core/src/adapter.ts b/server/core/src/adapter.ts index 578cecd513..6f11b7854f 100644 --- a/server/core/src/adapter.ts +++ b/server/core/src/adapter.ts @@ -112,6 +112,7 @@ export interface DbAdapter { query: DocumentQuery, options?: ServerFindOptions ) => Promise> + tx: (ctx: MeasureContext, ...tx: Tx[]) => Promise find: (ctx: MeasureContext, domain: Domain, recheck?: boolean) => StorageIterator diff --git a/server/core/src/fulltext.ts b/server/core/src/fulltext.ts index 9e18f1c55f..b770983662 100644 --- a/server/core/src/fulltext.ts +++ b/server/core/src/fulltext.ts @@ -14,9 +14,11 @@ // limitations under the License. // +import { Analytics } from '@hcengineering/analytics' import core, { type AttachedDoc, type Class, + type Collection, type Doc, type DocIndexState, type DocumentQuery, @@ -47,7 +49,6 @@ import { createStateDoc } from './indexer/utils' import { getScoringConfig, mapSearchResultDoc } from './mapper' import { type StorageAdapter } from './storage' import type { FullTextAdapter, IndexedDoc, ServerStorage, WithFind } from './types' -import { Analytics } from '@hcengineering/analytics' /** * @public @@ -102,7 +103,8 @@ export class FullTextIndex implements WithFind { attachedTo, attachedToClass, space: tx.objectSpace, - removed: false + removed: false, + needIndex: true }) stDocs.set(cud.objectId as Ref, { create: stDoc, updated: false, removed: false }) } else { @@ -143,7 +145,7 @@ export class FullTextIndex implements WithFind { const ids: Set> = new Set>() const baseClass = this.hierarchy.getBaseClass(_class) - let classes = this.hierarchy.getDescendants(baseClass) + let classes = this.hierarchy.getDescendants(baseClass).filter((it) => !this.hierarchy.isMixin(it)) const attrs = this.hierarchy.getAllAttributes(_class) @@ -173,7 +175,8 @@ export class FullTextIndex implements WithFind { } if (attr.type._class === core.class.Collection) { // we need attached documents to be in classes - const dsc = this.hierarchy.getDescendants(attr.attributeOf) + const coll = attr.type as Collection + const dsc = this.hierarchy.getDescendants(coll.of).filter((it) => !this.hierarchy.isMixin(it)) classes = classes.concat(dsc) } } diff --git a/server/core/src/indexer/content.ts b/server/core/src/indexer/content.ts index 079a4892c0..f93f1208eb 100644 --- a/server/core/src/indexer/content.ts +++ b/server/core/src/indexer/content.ts @@ -56,8 +56,6 @@ export class ContentRetrievalStage implements FullTextPipelineStage { textLimit = 100 * 1024 - stageValue: boolean | string = true - constructor ( readonly storageAdapter: StorageAdapter | undefined, readonly workspace: WorkspaceId, diff --git a/server/core/src/indexer/field.ts b/server/core/src/indexer/field.ts index ff3949ec57..d7e9536196 100644 --- a/server/core/src/indexer/field.ts +++ b/server/core/src/indexer/field.ts @@ -22,7 +22,6 @@ import core, { type DocIndexState, type DocumentQuery, type DocumentUpdate, - type IndexStageState, type MeasureContext, type Ref } from '@hcengineering/core' @@ -36,15 +35,7 @@ import { type FullTextPipeline, type FullTextPipelineStage } from './types' -import { - collectPropagate, - docKey, - docUpdKey, - getContent, - getCustomAttrKeys, - isFullTextAttribute, - loadIndexStageStage -} from './utils' +import { collectPropagate, docKey, docUpdKey, getContent, getCustomAttrKeys, isFullTextAttribute } from './utils' import { Analytics } from '@hcengineering/analytics' /** @@ -61,46 +52,9 @@ export class IndexedFieldStage implements FullTextPipelineStage { updateFields: DocUpdateHandler[] = [] enabled = true - - stageValue: boolean | string = true - - indexState?: IndexStageState - constructor (private readonly dbStorage: ServerStorage) {} - async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise { - const indexablePropogate = ( - await pipeline.model.findAll(core.class.Class, { - [core.mixin.FullTextSearchContext]: { $exists: true } - }) - ) - .map((it) => pipeline.hierarchy.as(it, core.mixin.FullTextSearchContext)) - .filter((it) => it.propagate != null || it.parentPropagate) - .map((it) => - JSON.stringify({ - id: it._id, - propogate: it.propagate, - parentPropgate: it.parentPropagate - }) - ) - - const forceIndexing = ( - await pipeline.model.findAll(core.class.Class, { [core.mixin.FullTextSearchContext + '.forceIndex']: true }) - ).map((it) => it._id) - - indexablePropogate.sort() - ;[this.stageValue, this.indexState] = await loadIndexStageStage( - ctx, - storage, - this.indexState, - this.stageId, - 'config', - { - classes: indexablePropogate, - forceIndex: forceIndexing - } - ) - } + async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise {} async search ( _classes: Ref>[], @@ -234,7 +188,7 @@ export class IndexedFieldStage implements FullTextPipelineStage { } } - await pipeline.update(docState._id, this.stageValue, docUpdate) + await pipeline.update(docState._id, true, docUpdate) } catch (err: any) { Analytics.handleError(err) continue @@ -272,7 +226,7 @@ export class IndexedFieldStage implements FullTextPipelineStage { await pipeline.update(attachedTo, false, parentDocUpdate) } } - await pipeline.update(doc._id, this.stageValue, {}) + await pipeline.update(doc._id, true, {}) } } } diff --git a/server/core/src/indexer/fulltextPush.ts b/server/core/src/indexer/fulltextPush.ts index 683fcdf58b..6b70480c3c 100644 --- a/server/core/src/indexer/fulltextPush.ts +++ b/server/core/src/indexer/fulltextPush.ts @@ -13,37 +13,40 @@ // limitations under the License. // +import { Analytics } from '@hcengineering/analytics' import core, { + type AnyAttribute, type ArrOf, + type Branding, type Class, type Doc, type DocIndexState, type DocumentQuery, type DocumentUpdate, extractDocKey, + getFullTextContext, type Hierarchy, isFullTextAttribute, type MeasureContext, + RateLimiter, type Ref, - type WorkspaceId, - getFullTextContext, - type Branding + toIdMap, + type WorkspaceId } from '@hcengineering/core' import { jsonToText, markupToJSON } from '@hcengineering/text' import { type DbAdapter } from '../adapter' import { updateDocWithPresenter } from '../mapper' import { type FullTextAdapter, type IndexedDoc, type ServerStorage } from '../types' -import { summaryStageId } from './summary' import { contentStageId, type DocUpdateHandler, fieldStateId, type FullTextPipeline, type FullTextPipelineStage, - fullTextPushStageId + fullTextPushStageId, + summaryStageId } from './types' import { collectPropagate, collectPropagateClasses, docKey, isCustomAttr } from './utils' -import { Analytics } from '@hcengineering/analytics' /** * @public @@ -62,8 +65,6 @@ export class FullTextPushStage implements FullTextPipelineStage { field_enabled = '_use' - stageValue: boolean | string = true - constructor ( private readonly dbStorage: ServerStorage, readonly fulltextAdapter: FullTextAdapter, @@ -103,10 +104,16 @@ export class FullTextPushStage implements FullTextPipelineStage { return { docs: [], pass: true } } + allAttrs = new WeakMap>, Map>() + async collect (toIndex: DocIndexState[], pipeline: FullTextPipeline, ctx: MeasureContext): Promise { const bulk: IndexedDoc[] = [] const part = [...toIndex] + + const parentsMap = new Map, DocIndexState>() + + const pushQueue = new RateLimiter(5) while (part.length > 0) { const toIndexPart = part.splice(0, 50) @@ -126,14 +133,31 @@ export class FullTextPushStage implements FullTextPipelineStage { }) ) + // spaces + const spaceDocs = toIdMap( + await ctx.with( + 'find-spaces', + {}, + async (ctx) => + await this.dbStorage.findAll(ctx, core.class.DocIndexState, { + _id: { + $in: toIndexPart.map( + (doc) => + (doc.attributes[docKey('space', { _class: doc.objectClass })] ?? doc.space) as Ref + ) + } + }) + ) + ) + for (const doc of toIndexPart) { if (pipeline.cancelling) { return } const elasticDoc = createElasticDoc(doc) try { - await ctx.with('updateDoc2Elastic', {}, async () => { - updateDoc2Elastic(doc.attributes, elasticDoc, undefined, undefined, pipeline.hierarchy) + ctx.withSync('updateDoc2Elastic', {}, (ctx) => { + updateDoc2Elastic(this.allAttrs, ctx, doc.attributes, elasticDoc, undefined, undefined, pipeline.hierarchy) }) // Include all child attributes @@ -142,8 +166,17 @@ export class FullTextPushStage implements FullTextPipelineStage { for (const c of childDocs) { const fctx = getFullTextContext(pipeline.hierarchy, c.objectClass) if (fctx.parentPropagate ?? true) { - await ctx.with('updateDoc2Elastic', {}, async () => { - updateDoc2Elastic(c.attributes, elasticDoc, c._id, undefined, pipeline.hierarchy, true) + ctx.withSync('updateDoc2Elastic', {}, (ctx) => { + updateDoc2Elastic( + this.allAttrs, + ctx, + c.attributes, + elasticDoc, + c._id, + undefined, + pipeline.hierarchy, + true + ) }) } } @@ -153,18 +186,34 @@ export class FullTextPushStage implements FullTextPipelineStage { const propagate: Ref>[] = collectPropagate(pipeline, doc.attachedToClass) if (propagate.some((it) => pipeline.hierarchy.isDerived(doc.objectClass, it))) { // We need to include all parent content into this one. - ;[parentDoc] = await ctx.with( - 'find-parent', - {}, - async (ctx) => - await this.dbStorage.findAll(ctx, core.class.DocIndexState, { - _id: doc.attachedTo as Ref - }) - ) + parentDoc = + parentsMap.get(doc.attachedTo as Ref) ?? + (await ctx.with('find-parent', {}, async (ctx) => + ( + await this.dbStorage.findAll( + ctx, + core.class.DocIndexState, + { + _id: doc.attachedTo as Ref + }, + { limit: 1 } + ) + ).shift() + )) if (parentDoc !== undefined) { + parentsMap.set(parentDoc._id, parentDoc) const ppdoc = parentDoc - await ctx.with('updateDoc2Elastic', {}, async () => { - updateDoc2Elastic(ppdoc.attributes, elasticDoc, ppdoc._id, undefined, pipeline.hierarchy, true) + ctx.withSync('updateDoc2Elastic', {}, (ctx) => { + updateDoc2Elastic( + this.allAttrs, + ctx, + ppdoc.attributes, + elasticDoc, + ppdoc._id, + undefined, + pipeline.hierarchy, + true + ) }) const collectClasses = collectPropagateClasses(pipeline, parentDoc.objectClass) @@ -175,21 +224,25 @@ export class FullTextPushStage implements FullTextPipelineStage { { attachedTo: parentDoc._id, objectClass: { $in: collectClasses } } ) for (const c of collections) { - await ctx.with('updateDoc2Elastic', {}, async () => { - updateDoc2Elastic(c.attributes, elasticDoc, c._id, undefined, pipeline.hierarchy, true) + ctx.withSync('updateDoc2Elastic', {}, (ctx) => { + updateDoc2Elastic( + this.allAttrs, + ctx, + c.attributes, + elasticDoc, + c._id, + undefined, + pipeline.hierarchy, + true + ) }) } } } } } - const [spaceDoc] = await ctx.with( - 'find-space', - {}, - async (ctx) => - await this.dbStorage.findAll(ctx, core.class.DocIndexState, { - _id: (doc.attributes[docKey('space', { _class: doc.objectClass })] ?? doc.space) as Ref - }) + const spaceDoc = spaceDocs.get( + (doc.attributes[docKey('space', { _class: doc.objectClass })] ?? doc.space) as Ref ) await updateDocWithPresenter(pipeline.hierarchy, doc, elasticDoc, { parentDoc, spaceDoc }, this.branding) @@ -210,15 +263,37 @@ export class FullTextPushStage implements FullTextPipelineStage { } } // Perform bulk update to elastic - try { - await this.fulltextAdapter.updateMany(bulk) - for (const doc of toIndex) { - await pipeline.update(doc._id, true, {}) + + void pushQueue.add(async () => { + try { + try { + await ctx.with('push-elastic', {}, async () => { + await this.fulltextAdapter.updateMany(bulk) + }) + } catch (err: any) { + Analytics.handleError(err) + // Try to push one by one + await ctx.with('push-elastic-by-one', {}, async () => { + for (const d of bulk) { + try { + await this.fulltextAdapter.update(d.id, d) + } catch (err2: any) { + Analytics.handleError(err2) + } + } + }) + } + if (!pipeline.cancelling) { + for (const doc of toIndexPart) { + await pipeline.update(doc._id, true, {}) + } + } + } catch (err: any) { + Analytics.handleError(err) } - } catch (err: any) { - Analytics.handleError(err) - } + }) } + await pushQueue.waitProcessing() } async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise { @@ -246,6 +321,8 @@ export function createElasticDoc (upd: DocIndexState): IndexedDoc { return doc } function updateDoc2Elastic ( + allAttrs: WeakMap>, Map>, + ctx: MeasureContext, attributes: Record, doc: IndexedDoc, docIdOverride?: Ref, @@ -264,10 +341,25 @@ function updateDoc2Elastic ( let vv: any = v if (vv != null && extra.includes('base64')) { - vv = Buffer.from(v, 'base64').toString() + ctx.withSync('buffer-from', {}, () => { + vv = Buffer.from(v, 'base64').toString() + }) } try { - const attribute = hierarchy?.getAttribute(_class ?? doc._class[0], attr) + let attrs = allAttrs.get(_class ?? doc._class[0]) + + if (attrs === undefined) { + attrs = new Map() + if (attrs !== undefined) { + allAttrs.set(_class ?? doc._class[0], attrs) + } + } + + const attribute = attrs?.get(attr) ?? hierarchy?.findAttribute(_class ?? doc._class[0], attr) + if (attribute !== undefined) { + attrs.set(attr, attribute) + allAttrs.set(_class ?? doc._class[0], attrs) + } if (attribute !== undefined && vv != null) { if ( isFullTextAttribute(attribute) || @@ -283,7 +375,9 @@ function updateDoc2Elastic ( attribute.type._class === core.class.TypeMarkup || attribute.type._class === core.class.TypeCollaborativeMarkup ) { - vvv = jsonToText(markupToJSON(vv)) + ctx.withSync('markup-to-json-text', {}, () => { + vvv = jsonToText(markupToJSON(vv)) + }) } if (!(doc.fulltextSummary ?? '').includes(vvv)) { doc.fulltextSummary = (doc.fulltextSummary ?? '') + vvv + '\n' @@ -321,8 +415,8 @@ function updateDoc2Elastic ( const spaceKey = docKey('space', { _class: core.class.Doc }) if (doc[spaceKey] !== undefined) { - const existsingSpace = Array.isArray(doc.space) ? doc.space : [doc.space] + const existingSpaces = Array.isArray(doc.space) ? doc.space : [doc.space] const newSpaces = Array.isArray(doc[spaceKey]) ? doc[spaceKey] : [doc[spaceKey]] - doc.space = [...existsingSpace, ...newSpaces].filter((it, idx, arr) => arr.indexOf(it) === idx) + doc.space = [...existingSpaces, ...newSpaces].filter((it, idx, arr) => arr.indexOf(it) === idx) } } diff --git a/server/core/src/indexer/indexer.ts b/server/core/src/indexer/indexer.ts index 40889eb95f..0fb0146a5b 100644 --- a/server/core/src/indexer/indexer.ts +++ b/server/core/src/indexer/indexer.ts @@ -25,14 +25,12 @@ import core, { type MeasureContext, type ModelDb, type Ref, - SortingOrder, TxFactory, type WorkspaceId, _getOperator, docKey, groupByArray, - setObjectValue, - toFindResult + setObjectValue } from '@hcengineering/core' import { type DbAdapter } from '../adapter' import { RateLimiter } from '../limitter' @@ -74,9 +72,6 @@ export class FullTextIndexPipeline implements FullTextPipeline { indexing: Promise | undefined - // Temporary skipped items - skipped = new Map, number>() - indexId = indexCounter++ updateTriggerTimer: any @@ -99,7 +94,6 @@ export class FullTextIndexPipeline implements FullTextPipeline { async cancel (): Promise { this.cancelling = true clearTimeout(this.updateBroadcast) - clearTimeout(this.skippedReiterationTimeout) clearInterval(this.updateTriggerTimer) // We need to upload all bulk changes. await this.processUpload(this.metrics) @@ -114,7 +108,8 @@ export class FullTextIndexPipeline implements FullTextPipeline { await this.storage.tx( this.metrics, ops.createTxUpdateDoc(doc._class, doc.space, doc._id, { - removed: true + removed: true, + needIndex: true }) ) } @@ -153,7 +148,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { } } - updateDoc (doc: DocIndexState, tx: DocumentUpdate, updateDate: boolean): DocIndexState { + updateDoc (doc: DocIndexState, tx: DocumentUpdate, finish: boolean): DocIndexState { for (const key in tx) { if (key.startsWith('$')) { const operator = _getOperator(key) @@ -168,7 +163,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { doc.space = doc.attributes[spaceKey] } - if (updateDate) { + if (finish) { doc.modifiedBy = core.account.System doc.modifiedOn = Date.now() } @@ -213,6 +208,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { // We need to clear only first state, to prevent multiple index operations to happen. ;(upd as any)['stages.' + this.stages[0].stageId] = false + upd.needIndex = true this.updateOps.set(u[0], upd) } } @@ -225,7 +221,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { // Update are commulative async update ( docId: Ref, - mark: boolean | string, + mark: boolean, update: DocumentUpdate, flush?: boolean ): Promise { @@ -233,7 +229,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { if (udoc !== undefined) { await this.stageUpdate(udoc, update) - udoc = this.updateDoc(udoc, update, mark !== false) + udoc = this.updateDoc(udoc, update, mark) this.toIndex.set(docId, udoc) } @@ -241,7 +237,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { udoc = this.extraIndex.get(docId) if (udoc !== undefined) { await this.stageUpdate(udoc, update) - udoc = this.updateDoc(udoc, update, mark !== false) + udoc = this.updateDoc(udoc, update, mark) this.extraIndex.set(docId, udoc) } } @@ -268,6 +264,12 @@ export class FullTextIndexPipeline implements FullTextPipeline { // Filter unsupported stages udoc.stages = update.stages + const stg = Object.values(udoc.stages) + if (!stg.includes(false) && stg.length === this.stages.length) { + // Check if all marks are true, we need to clear needIndex. + udoc.needIndex = false + } + if (Object.keys(update).length > 0) { this.currentStages[stageId] = (this.currentStages[stageId] ?? 0) + 1 this.stageChanged++ @@ -284,14 +286,48 @@ export class FullTextIndexPipeline implements FullTextPipeline { await this.flush(flush ?? false) } + // Update are commulative + async updateNeedIndex (docId: Ref, value: boolean, flush?: boolean): Promise { + const update = { needIndex: value } + let udoc = this.toIndex.get(docId) + if (udoc !== undefined) { + await this.stageUpdate(udoc, update) + + udoc = this.updateDoc(udoc, update, true) + this.toIndex.set(docId, udoc) + } + + if (udoc === undefined) { + udoc = this.extraIndex.get(docId) + if (udoc !== undefined) { + await this.stageUpdate(udoc, update) + udoc = this.updateDoc(udoc, update, true) + this.extraIndex.set(docId, udoc) + } + } + + if (udoc === undefined) { + // Some updated, document, let's load it. + udoc = (await this.storage.load(this.metrics, DOMAIN_DOC_INDEX_STATE, [docId])).shift() as DocIndexState + } + + const current = this.pending.get(docId) + if (current === undefined) { + this.pending.set(docId, update) + } else { + this.pending.set(docId, { ...current, ...update }) + } + + await this.flush(flush ?? false) + } + triggerCounts = 0 triggerIndexing = (): void => {} - skippedReiterationTimeout: any currentStages: Record = {} - private filterCurrentStages (udoc: DocIndexState): Record { - const result: Record = {} + private filterCurrentStages (udoc: DocIndexState): Record { + const result: Record = {} for (const [k, v] of Object.entries(udoc.stages ?? {})) { if (this.currentStages[k] !== undefined) { result[k] = v @@ -327,70 +363,9 @@ export class FullTextIndexPipeline implements FullTextPipeline { broadcastClasses = new Set>>() updateBroadcast: any = undefined - indexesCreated = false - async doIndexing (): Promise { // Check model is upgraded to support indexer. - if (!this.indexesCreated) { - this.indexesCreated = true - // We need to be sure we have individual indexes per stage. - const oldStagesRegex = [/fld-v.*/, /cnt-v.*/, /fts-v.*/, /sum-v.*/, /emb-v.*/] - - const deletePattern: RegExp[] = [] - const keepPattern: RegExp[] = [] - for (const st of this.stages) { - if (this.cancelling) { - return - } - const regexp = oldStagesRegex.find((r) => r.test(st.stageId)) - if (regexp !== undefined) { - deletePattern.push(regexp) - keepPattern.push(new RegExp(st.stageId)) - } - } - const helper = this.storage.helper() - if (deletePattern.length > 0) { - try { - const existingIndexes = await helper.listIndexes(DOMAIN_DOC_INDEX_STATE) - for (const existingIndex of existingIndexes) { - if (existingIndex.name !== undefined) { - const name: string = existingIndex.name - if (deletePattern.some((it) => it.test(name)) && !keepPattern.some((it) => it.test(name))) { - await helper.dropIndex(DOMAIN_DOC_INDEX_STATE, name) - } - } - } - } catch (err: any) { - console.error(err) - } - } - - for (const st of this.stages) { - if (this.cancelling) { - return - } - await this.storage.helper().createIndex( - DOMAIN_DOC_INDEX_STATE, - { - keys: { - ['stages.' + st.stageId]: 1 - } - }, - { name: 'stages.' + st.stageId + '_1' } - ) - } - } - - try { - this.hierarchy.getClass(core.class.DocIndexState) - } catch (err: any) { - this.metrics.warn('Models is not upgraded to support indexer', { - indexId: this.indexId, - workspace: this.workspace.name - }) - return - } await this.metrics.with('init-states', {}, async () => { await this.initStates() }) @@ -403,10 +378,6 @@ export class FullTextIndexPipeline implements FullTextPipeline { await this.initializeStages() }) - await this.metrics.with('process-remove', { workspace: this.workspace.name }, async () => { - await this.processRemove() - }) - const _classes = await this.metrics.with( 'processIndex', { workspace: this.workspace.name }, @@ -442,14 +413,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { this.triggerIndexing = () => { this.triggerCounts++ resolve(null) - clearTimeout(this.skippedReiterationTimeout) } - this.skippedReiterationTimeout = setTimeout(() => { - // Force skipped reiteration, just decrease by -1 - for (const [s, v] of Array.from(this.skipped.entries())) { - this.skipped.set(s, v - 1) - } - }, 60000) }) } } @@ -458,199 +422,174 @@ export class FullTextIndexPipeline implements FullTextPipeline { } private async processIndex (ctx: MeasureContext): Promise>[]> { - let idx = 0 const _classUpdate = new Set>>() - for (const st of this.stages) { - if (this.cancelling) { - return [] - } - idx++ - await rateLimiter.exec(async () => { - while (true) { - try { - if (this.cancelling) { - return Array.from(_classUpdate.values()) - } - if (!st.enabled) { - break - } - await ctx.with('flush', {}, async () => { - await this.flush(true) - }) - const toSkip = Array.from(this.skipped.entries()) - .filter((it) => it[1] > 3) - .map((it) => it[0]) - - const q: DocumentQuery = { - [`stages.${st.stageId}`]: { $ne: st.stageValue }, - removed: false - } - if (toSkip.length > 0) { - q._id = { $nin: toSkip } - } - let result = await ctx.with( - 'get-to-index', - {}, - async (ctx) => - await this.storage.findAll(ctx, core.class.DocIndexState, q, { - sort: { modifiedOn: SortingOrder.Descending }, - limit: globalIndexer.processingSize, - skipClass: true, - skipSpace: true - }) - ) - const toRemove: DocIndexState[] = [] - // Check and remove missing class documents. - result = toFindResult( - result.filter((doc) => { - const _class = this.model.findObject(doc.objectClass) - if (_class === undefined) { - // no _class present, remove doc - toRemove.push(doc) - return false - } - return true - }), - result.total - ) - - if (toRemove.length > 0) { - try { - await this.storage.clean( - this.metrics, - DOMAIN_DOC_INDEX_STATE, - toRemove.map((it) => it._id) - ) - } catch (err: any) { - Analytics.handleError(err) - // QuotaExceededError, ignore - } - } - - if (result.length > 0) { - this.metrics.info('Full text: Indexing', { - indexId: this.indexId, - stageId: st.stageId, - workspace: this.workspace.name, - ...this.currentStages - }) - } else { - // Nothing to index, check on next cycle. - break - } - - this.toIndex = new Map(result.map((it) => [it._id, it])) - - this.extraIndex.clear() - this.stageChanged = 0 - // Find documents matching query - const toIndex = this.matchStates(st) - - if (toIndex.length > 0) { - // Do Indexing - this.currentStage = st - - await ctx.with('collect-' + st.stageId, {}, async (ctx) => { - await st.collect(toIndex, this, ctx) - }) - if (this.cancelling) { - break - } - - toIndex.forEach((it) => _classUpdate.add(it.objectClass)) - - // go with next stages if they accept it - for (const nst of this.stages.slice(idx)) { - const toIndex2 = this.matchStates(nst) - if (toIndex2.length > 0) { - this.currentStage = nst - await ctx.with('collect-' + nst.stageId, {}, async (ctx) => { - await nst.collect(toIndex2, this, ctx) - }) - } - if (this.cancelling) { - break - } - } - } else { - break - } - - // Check items with not updated state. - for (const d of toIndex) { - if (d.stages?.[st.stageId] === false) { - this.skipped.set(d._id, (this.skipped.get(d._id) ?? 0) + 1) - } else { - this.skipped.delete(d._id) - } - } - } catch (err: any) { - Analytics.handleError(err) - this.metrics.error('error during index', { error: err }) + await rateLimiter.exec(async () => { + while (true) { + try { + if (this.cancelling) { + return Array.from(_classUpdate.values()) } + await ctx.with('flush', {}, async () => { + await this.flush(true) + }) + + let result: DocIndexState[] | undefined = await ctx.with('get-indexable', {}, async () => { + const q: DocumentQuery = { + needIndex: true + } + return await this.storage.findAll(ctx, core.class.DocIndexState, q, { + limit: globalIndexer.processingSize, + skipClass: true, + skipSpace: true + }) + }) + if (result === undefined) { + // No more results + break + } + + await this.processRemove(result) + result = result.filter((it) => !it.removed) + const toRemove: DocIndexState[] = [] + // Check and remove missing class documents. + result = result.filter((doc) => { + const _class = this.model.findObject(doc.objectClass) + if (_class === undefined) { + // no _class present, remove doc + toRemove.push(doc) + return false + } + return true + }) + + if (toRemove.length > 0) { + try { + await this.storage.clean( + this.metrics, + DOMAIN_DOC_INDEX_STATE, + toRemove.map((it) => it._id) + ) + } catch (err: any) { + Analytics.handleError(err) + // QuotaExceededError, ignore + } + } + + if (result.length > 0) { + this.metrics.info('Full text: Indexing', { + indexId: this.indexId, + workspace: this.workspace.name, + ...this.currentStages + }) + } else { + // Nothing to index, check on next cycle. + break + } + const retry: DocIndexState[] = [] + + await this.processStages(result, ctx, _classUpdate) + + // Force clear needIndex, it will be re trigger if some propogate will happen next. + if (!this.cancelling) { + for (const u of result) { + const stg = Object.values(u.stages) + if (!stg.includes(false) && stg.length === this.stages.length) { + // Check if all marks are true, we need to clear needIndex. + u.needIndex = false + await this.updateNeedIndex(u._id, false) + } else { + // Mark as retry on + retry.push(u) + } + } + } + if (retry.length > 0) { + await this.processStages(retry, ctx, _classUpdate) + if (!this.cancelling) { + for (const u of retry) { + // Since retry is happen, it shoudl be marked already. + u.needIndex = false + await this.updateNeedIndex(u._id, false) + } + } + } + } catch (err: any) { + Analytics.handleError(err) + this.metrics.error('error during index', { error: err }) } - }) - } + } + }) return Array.from(_classUpdate.values()) } - private async processRemove (): Promise { - let total = 0 - while (true) { - const result = await this.storage.findAll( - this.metrics, - core.class.DocIndexState, - { - removed: true - }, - { - limit: 1000, - projection: { - _id: 1, - stages: 1, - objectClass: 1 - }, - skipSpace: true, - skipClass: true - } - ) - - this.toIndex = new Map(result.map((it) => [it._id, it])) - + private async processStages ( + result: DocIndexState[], + ctx: MeasureContext, + _classUpdate: Set>> + ): Promise { + this.toIndex = new Map(result.map((it) => [it._id, it])) + for (const st of this.stages) { this.extraIndex.clear() + this.stageChanged = 0 + // Find documents matching query + const toIndex = this.matchStates(st) - const toIndex = Array.from(this.toIndex.values()) - const toRemoveIds = [] - for (const st of this.stages) { - if (toIndex.length > 0) { - // Do Indexing - this.currentStage = st - await st.remove(toIndex, this) - } else { + if (toIndex.length > 0) { + // Do Indexing + this.currentStage = st + + await ctx.with('collect-' + st.stageId, {}, async (ctx) => { + await st.collect(toIndex, this, ctx) + }) + if (this.cancelling) { break } - } - // If all stages are complete, remove document - const allStageIds = this.stages.map((it) => it.stageId) - for (const doc of toIndex) { - if (allStageIds.every((it) => doc.stages[it])) { - toRemoveIds.push(doc._id) - } - } - await this.flush(true) - if (toRemoveIds.length > 0) { - await this.storage.clean(this.metrics, DOMAIN_DOC_INDEX_STATE, toRemoveIds) - total += toRemoveIds.length - this.metrics.info('indexer', { - _classes: Array.from(groupByArray(toIndex, (it) => it.objectClass).keys()), - total, - count: toRemoveIds.length - }) + toIndex.forEach((it) => _classUpdate.add(it.objectClass)) + } else { + continue + } + } + } + + private async processRemove (docs: DocIndexState[]): Promise { + let total = 0 + this.toIndex = new Map(docs.map((it) => [it._id, it])) + + this.extraIndex.clear() + + const toIndex = Array.from(this.toIndex.values()).filter((it) => it.removed) + if (toIndex.length === 0) { + return + } + const toRemoveIds = [] + for (const st of this.stages) { + if (toIndex.length > 0) { + // Do Indexing + this.currentStage = st + await st.remove(toIndex, this) } else { break } } + // If all stages are complete, remove document + const allStageIds = this.stages.map((it) => it.stageId) + for (const doc of toIndex) { + if (allStageIds.every((it) => doc.stages[it])) { + toRemoveIds.push(doc._id) + } + } + + await this.flush(true) + if (toRemoveIds.length > 0) { + await this.storage.clean(this.metrics, DOMAIN_DOC_INDEX_STATE, toRemoveIds) + total += toRemoveIds.length + this.metrics.info('indexer', { + _classes: Array.from(groupByArray(toIndex, (it) => it.objectClass).keys()), + total, + count: toRemoveIds.length + }) + } } private async initStates (): Promise { @@ -665,7 +604,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { const require = [...st.require].filter((it) => this.stages.find((q) => q.stageId === it && q.enabled)) for (const o of this.toIndex.values()) { // We need to contain all state values - if (require.every((it) => o.stages?.[it])) { + if (require.every((it) => o.stages?.[it]) && !(o.stages?.[st.stageId] ?? false)) { toIndex.push(o) } } diff --git a/server/core/src/indexer/summary.ts b/server/core/src/indexer/summary.ts index ac70196ff5..77693ed988 100644 --- a/server/core/src/indexer/summary.ts +++ b/server/core/src/indexer/summary.ts @@ -13,6 +13,7 @@ // limitations under the License. // +import { Analytics } from '@hcengineering/analytics' import core, { type AnyAttribute, type Class, @@ -21,12 +22,11 @@ import core, { type DocumentQuery, type DocumentUpdate, extractDocKey, + getFullTextContext, type Hierarchy, - type IndexStageState, isFullTextAttribute, type MeasureContext, - type Ref, - getFullTextContext + type Ref } from '@hcengineering/core' import { translate } from '@hcengineering/platform' import { jsonToText, markupToJSON } from '@hcengineering/text' @@ -37,15 +37,10 @@ import { type DocUpdateHandler, fieldStateId, type FullTextPipeline, - type FullTextPipelineStage + type FullTextPipelineStage, + summaryStageId } from './types' -import { collectPropagate, collectPropagateClasses, isCustomAttr, loadIndexStageStage } from './utils' -import { Analytics } from '@hcengineering/analytics' - -/** - * @public - */ -export const summaryStageId = 'sum-v5' +import { collectPropagate, collectPropagateClasses, isCustomAttr } from './utils' /** * @public @@ -65,35 +60,12 @@ export class FullSummaryStage implements FullTextPipelineStage { fieldFilter: ((attr: AnyAttribute, value: string) => boolean)[] = [] - stageValue: boolean | string = true - - indexState?: IndexStageState - // Summary should be not a bigger what 1mb of data. summaryLimit = 1024 * 1024 constructor (private readonly dbStorage: ServerStorage) {} - async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise { - const indexable = ( - await pipeline.model.findAll(core.class.Class, { [core.mixin.FullTextSearchContext]: { $exists: true } }) - ) - .map((it) => pipeline.hierarchy.as(it, core.mixin.FullTextSearchContext)) - .filter((it) => it.fullTextSummary) - .map((it) => it._id + (it.propagateClasses ?? []).join('|')) - indexable.sort() - ;[this.stageValue, this.indexState] = await loadIndexStageStage( - ctx, - storage, - this.indexState, - this.stageId, - 'config', - { - classes: indexable, - matchExtra: this.matchExtra - } - ) - } + async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise {} async search ( _classes: Ref>[], @@ -107,7 +79,7 @@ export class FullSummaryStage implements FullTextPipelineStage { async collect (toIndex: DocIndexState[], pipeline: FullTextPipeline, metrics: MeasureContext): Promise { const part = [...toIndex] while (part.length > 0) { - const toIndexPart = part.splice(0, 1000) + const toIndexPart = part.splice(0, 100) const kids = toIndexPart.map((it) => it._id) const allChildDocs = await metrics.with( @@ -128,7 +100,7 @@ export class FullSummaryStage implements FullTextPipelineStage { // No need to index this class, mark embeddings as empty ones. if (!needIndex) { - await pipeline.update(doc._id, this.stageValue, {}) + await pipeline.update(doc._id, true, {}) continue } @@ -201,7 +173,7 @@ export class FullSummaryStage implements FullTextPipelineStage { update.fullSummary = embeddingText - await pipeline.update(doc._id, this.stageValue, update) + await pipeline.update(doc._id, true, update) } } } @@ -209,7 +181,7 @@ export class FullSummaryStage implements FullTextPipelineStage { async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise { // will be handled by field processor for (const doc of docs) { - await pipeline.update(doc._id, this.stageValue, {}) + await pipeline.update(doc._id, true, {}) } } } diff --git a/server/core/src/indexer/types.ts b/server/core/src/indexer/types.ts index 3ce2f3c952..e1afcba2a6 100644 --- a/server/core/src/indexer/types.ts +++ b/server/core/src/indexer/types.ts @@ -35,7 +35,7 @@ export interface FullTextPipeline { model: ModelDb update: ( docId: Ref, - mark: boolean | string, + mark: boolean, update: DocumentUpdate, flush?: boolean ) => Promise @@ -75,9 +75,6 @@ export interface FullTextPipelineStage { updateFields: DocUpdateHandler[] enabled: boolean - - stageValue: boolean | string - initialize: (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline) => Promise // Collect all changes related to bulk of document states @@ -109,6 +106,16 @@ export const fieldStateId = 'fld-v15' */ export const fullTextPushStageId = 'fts-v17' +/** + * @public + */ +export const summaryStageId = 'sum-v5' + +/** + * @public + */ +export const collabStageId = 'collab-v1' + /** * @public */ diff --git a/server/core/src/indexer/utils.ts b/server/core/src/indexer/utils.ts index 4fbc8a687b..c315787084 100644 --- a/server/core/src/indexer/utils.ts +++ b/server/core/src/indexer/utils.ts @@ -20,18 +20,12 @@ import core, { type Doc, type DocIndexState, type FullTextSearchContext, - generateId, getFullTextContext, type Hierarchy, - type IndexStageState, - type MeasureContext, type Obj, type Ref, - type Space, - TxFactory + type Space } from '@hcengineering/core' -import { deepEqual } from 'fast-equals' -import { type DbAdapter } from '../adapter' import plugin from '../plugin' import { type FullTextPipeline } from './types' @@ -81,59 +75,6 @@ export function createStateDoc ( } } -/** - * @public - */ -export async function loadIndexStageStage ( - ctx: MeasureContext, - storage: DbAdapter, - state: IndexStageState | undefined, - stageId: string, - field: string, - newValue: any -): Promise<[boolean | string, IndexStageState]> { - if (state === undefined) { - ;[state] = await storage.findAll(ctx, core.class.IndexStageState, { stageId }, { limit: 1 }) - } - const attributes: Record = state?.attributes ?? {} - - let result: boolean | string | undefined = attributes?.index !== undefined ? `${attributes?.index as number}` : true - - if (!deepEqual(attributes[field], newValue)) { - // Not match, - const newIndex = ((attributes.index as number) ?? 0) + 1 - result = `${newIndex}` - - const ops = new TxFactory(core.account.System, true) - const data = { - stageId, - attributes: { - [field]: newValue, - index: newIndex - } - } - if (state === undefined) { - const id: Ref = generateId() - await storage.tx(ctx, ops.createTxCreateDoc(core.class.IndexStageState, plugin.space.DocIndexState, data, id)) - state = { - ...data, - _class: core.class.IndexStageState, - _id: id, - space: plugin.space.DocIndexState, - modifiedBy: core.account.System, - modifiedOn: Date.now() - } - } else { - await storage.tx( - ctx, - ops.createTxUpdateDoc(core.class.IndexStageState, plugin.space.DocIndexState, state._id, data) - ) - state = { ...state, ...data, modifiedOn: Date.now() } - } - } - return [result, state] -} - /** * @public */ diff --git a/server/core/src/server/storage.ts b/server/core/src/server/storage.ts index 9e320e291c..12a43e2b46 100644 --- a/server/core/src/server/storage.ts +++ b/server/core/src/server/storage.ts @@ -98,7 +98,6 @@ export class TServerStorage implements ServerStorage { branding: Branding | null domainInfo = new Map() - statsCtx: MeasureContext emptyAdapter = new DummyDbAdapter() @@ -125,7 +124,6 @@ export class TServerStorage implements ServerStorage { this.branding = options.branding this.setModel(model) - this.statsCtx = metrics.newChild('stats-' + this.workspaceId.name, {}) } async initDomainInfo (): Promise { diff --git a/server/mongo/src/storage.ts b/server/mongo/src/storage.ts index a011c93566..3fa7f6ec37 100644 --- a/server/mongo/src/storage.ts +++ b/server/mongo/src/storage.ts @@ -731,7 +731,7 @@ abstract class MongoAdapterBase implements DbAdapter { const coll = this.collection(domain) const mongoQuery = this.translateQuery(_class, query, options) - if (options?.limit === 1) { + if (options?.limit === 1 || typeof query._id === 'string') { // Skip sort/projection/etc. return await ctx.with( 'find-one', @@ -748,11 +748,11 @@ abstract class MongoAdapterBase implements DbAdapter { const doc = await coll.findOne(mongoQuery, findOptions) let total = -1 - if (options.total === true) { + if (options?.total === true) { total = await coll.countDocuments(mongoQuery) } if (doc != null) { - return toFindResult([doc as unknown as T], total) + return toFindResult([this.stripHash(doc as unknown as T) as T], total) } return toFindResult([], total) }, @@ -783,7 +783,6 @@ abstract class MongoAdapterBase implements DbAdapter { cursor = cursor.limit(options.limit ?? 1) } } - // Error in case of timeout try { let res: T[] = [] @@ -1520,8 +1519,7 @@ class MongoTxAdapter extends MongoAdapterBase implements TxAdapter { (tx._class === core.class.TxCreateDoc || tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxRemoveDoc) && - ((tx as TxCUD).objectClass === 'contact:class:PersonAccount' || - (tx as TxCUD).objectClass === 'contact:class:EmployeeAccount') + (tx as TxCUD).objectClass === 'contact:class:PersonAccount' ) } model.forEach((tx) => (tx.modifiedBy === core.account.System && !isPersonAccount(tx) ? systemTx : userTx).push(tx)) diff --git a/server/server-pipeline/package.json b/server/server-pipeline/package.json index a35d5dc2be..1d4f8164e9 100644 --- a/server/server-pipeline/package.json +++ b/server/server-pipeline/package.json @@ -172,6 +172,8 @@ "@hcengineering/products": "^0.1.0", "@hcengineering/training": "^0.1.0", "@hcengineering/github": "^0.6.0", - "@hcengineering/github-assets": "^0.6.0" + "@hcengineering/github-assets": "^0.6.0", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-ai-bot-resources": "^0.6.0" } } diff --git a/server/server-pipeline/src/serverPlugins.ts b/server/server-pipeline/src/serverPlugins.ts index 9108f3c0f3..cf647657fe 100644 --- a/server/server-pipeline/src/serverPlugins.ts +++ b/server/server-pipeline/src/serverPlugins.ts @@ -27,6 +27,7 @@ import { serverTimeId } from '@hcengineering/server-time' import { serverTrackerId } from '@hcengineering/server-tracker' import { serverTrainingId } from '@hcengineering/server-training' import { serverViewId } from '@hcengineering/server-view' +import { serverAiBotId } from '@hcengineering/server-ai-bot' export function registerServerPlugins (): void { addLocation(serverActivityId, () => import('@hcengineering/server-activity-resources')) @@ -57,4 +58,5 @@ export function registerServerPlugins (): void { addLocation(serverDocumentsId, () => import('@hcengineering/server-controlled-documents-resources')) addLocation(serverTrainingId, () => import('@hcengineering/server-training-resources')) addLocation(serverGithubId, () => import('@hcengineering/server-github-resources')) + addLocation(serverAiBotId, () => import('@hcengineering/server-ai-bot-resources')) } diff --git a/server/server/src/apm.ts b/server/server/src/apm.ts index a2a915bf07..41407c404c 100644 --- a/server/server/src/apm.ts +++ b/server/server/src/apm.ts @@ -79,11 +79,29 @@ export class APMMeasureContext implements MeasureContext { if (value instanceof Promise) { value = await value } - c.end() return value } catch (err: any) { c.error(err) throw err + } finally { + c.end() + } + } + + withSync( + name: string, + params: ParamsType, + op: (ctx: MeasureContext) => T, + fullParams?: FullParamsType | (() => FullParamsType) + ): T { + const c = this.newChild(name, params) + try { + return op(c) + } catch (err: any) { + c.error(err) + throw err + } finally { + c.end() } } diff --git a/server/tool/src/index.ts b/server/tool/src/index.ts index 58c7b4f186..139968640d 100644 --- a/server/tool/src/index.ts +++ b/server/tool/src/index.ts @@ -332,7 +332,7 @@ export async function upgradeModel ( (it) => it.modifiedBy !== core.account.System || (it as TxCUD).objectClass === contact.class.Person || - (it as TxCUD).objectClass === 'contact:class:EmployeeAccount' + (it as TxCUD).objectClass === 'contact:class:PersonAccount' ) ) ] @@ -348,7 +348,7 @@ export async function upgradeModel ( ) const upgradeIndexes = async (): Promise => { - ctx.info('Migrate to sparse indexes') + ctx.info('Migrate indexes') // Create update indexes await createUpdateIndexes( ctx, @@ -385,7 +385,7 @@ export async function upgradeModel ( await tryMigrate(migrateClient, coreId, [ { - state: 'indexes-v2', + state: 'indexes-v3', func: upgradeIndexes } ]) diff --git a/server/translate/src/retranslate.ts b/server/translate/src/retranslate.ts index f661b0a60d..e94cfebdcc 100644 --- a/server/translate/src/retranslate.ts +++ b/server/translate/src/retranslate.ts @@ -19,7 +19,6 @@ import { DocIndexState, DocumentQuery, DocumentUpdate, - IndexStageState, MeasureContext, Ref, WorkspaceId @@ -33,12 +32,10 @@ import { extractDocKey, fieldStateId, FullTextPipeline, - IndexedDoc, - loadIndexStageStage + IndexedDoc } from '@hcengineering/server-core' import got from 'got' -import translatePlugin from './plugin' import { translateStateId, TranslationStage } from './types' /** @@ -59,40 +56,9 @@ export class LibRetranslateStage implements TranslationStage { token: string = '' endpoint: string = '' - stageValue: boolean | string = true - - indexState?: IndexStageState - constructor (readonly workspaceId: WorkspaceId) {} - async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise { - // Just do nothing - try { - const config = await storage.findAll(ctx, translatePlugin.class.TranslateConfiguration, {}) - if (config.length > 0) { - this.enabled = config[0].enabled - this.token = config[0].token - this.endpoint = config[0].endpoint - } else { - this.enabled = false - } - } catch (err: any) { - console.error(err) - this.enabled = false - } - - ;[this.stageValue, this.indexState] = await loadIndexStageStage( - ctx, - storage, - this.indexState, - this.stageId, - 'config', - { - enabled: this.enabled, - endpoint: this.endpoint - } - ) - } + async initialize (ctx: MeasureContext, storage: DbAdapter, pipeline: FullTextPipeline): Promise {} async search ( _classes: Ref>[], @@ -247,13 +213,13 @@ export class LibRetranslateStage implements TranslationStage { return } - await pipeline.update(doc._id, this.stageValue, update, true) + await pipeline.update(doc._id, true, update, true) } async remove (docs: DocIndexState[], pipeline: FullTextPipeline): Promise { // will be handled by field processor for (const doc of docs) { - await pipeline.update(doc._id, this.stageValue, {}) + await pipeline.update(doc._id, true, {}) } } } diff --git a/server/ws/src/server_http.ts b/server/ws/src/server_http.ts index a3e150b78e..c022aac77b 100644 --- a/server/ws/src/server_http.ts +++ b/server/ws/src/server_http.ts @@ -431,18 +431,13 @@ function createWebsocketClientSocket ( }, data: () => data, send: async (ctx: MeasureContext, msg, binary, compression) => { - const sst = Date.now() const smsg = rpcHandler.serialize(msg, binary) - ctx.measure('serialize', Date.now() - sst) ctx.measure('send-data', smsg.length) const st = Date.now() if (ws.readyState !== ws.OPEN || cs.isClosed) { return } - if (ws.bufferedAmount > 16 * 1024) { - ctx.measure('send-bufferAmmount', 1) - } ws.send(smsg, { binary: true, compress: compression }, (err) => { if (err != null) { if (!`${err.message}`.includes('WebSocket is not open')) { diff --git a/server/ws/src/utils.ts b/server/ws/src/utils.ts index 54d26ff7a1..e0ddb2744f 100644 --- a/server/ws/src/utils.ts +++ b/server/ws/src/utils.ts @@ -40,11 +40,8 @@ export function processRequest ( buff: any, handleRequest: HandleRequestFunction ): void { - const st = Date.now() try { const request = cs.readRequest(buff, session.binaryMode) - const ed = Date.now() - context.measure('deserialize', ed - st) handleRequest(context, session, cs, request, workspaceId) } catch (err: any) { if (((err.message as string) ?? '').includes('Data read, but end of buffer not reached')) { diff --git a/services/ai-bot/pod-ai-bot/.eslintrc.js b/services/ai-bot/pod-ai-bot/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/services/ai-bot/pod-ai-bot/.npmignore b/services/ai-bot/pod-ai-bot/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/services/ai-bot/pod-ai-bot/Dockerfile b/services/ai-bot/pod-ai-bot/Dockerfile new file mode 100644 index 0000000000..356a47b67d --- /dev/null +++ b/services/ai-bot/pod-ai-bot/Dockerfile @@ -0,0 +1,9 @@ +FROM node:20-alpine + +WORKDIR /usr/src/app + +COPY bundle/bundle.js ./ +COPY assets/avatar.png ./ + +EXPOSE 4008 +CMD [ "node", "bundle.js" ] diff --git a/services/ai-bot/pod-ai-bot/assets/avatar.png b/services/ai-bot/pod-ai-bot/assets/avatar.png new file mode 100644 index 0000000000..fe75e47ce5 Binary files /dev/null and b/services/ai-bot/pod-ai-bot/assets/avatar.png differ diff --git a/services/ai-bot/pod-ai-bot/config/rig.json b/services/ai-bot/pod-ai-bot/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/services/ai-bot/pod-ai-bot/jest.config.js b/services/ai-bot/pod-ai-bot/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/services/ai-bot/pod-ai-bot/package.json b/services/ai-bot/pod-ai-bot/package.json new file mode 100644 index 0000000000..28e7cb06a1 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/package.json @@ -0,0 +1,84 @@ +{ + "name": "@hcengineering/pod-ai-bot", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "scripts": { + "build": "compile", + "build:watch": "compile", + "test": "jest --passWithNoTests --silent", + "_phase:bundle": "rushx bundle", + "_phase:docker-build": "rushx docker:build", + "_phase:docker-staging": "rushx docker:staging", + "bundle": "mkdir -p bundle && esbuild src/index.ts --bundle --platform=node > bundle/bundle.js", + "docker:build": "../../../common/scripts/docker_build.sh hardcoreeng/ai-bot", + "docker:staging": "../../../common/scripts/docker_tag.sh hardcoreeng/ai-bot staging", + "docker:push": "../../../common/scripts/docker_tag.sh hardcoreeng/ai-bot", + "docker:tbuild": "rush bundle --to @hcengineering/pod-ai-bot && docker build -t hardcoreeng/ai-bot . --platform=linux/amd64 && ../../../common/scripts/docker_tag_push.sh hardcoreeng/ai-bot", + "run-local": "cross-env APP_ID=$(cat ../../../../uberflow_private/appid) PRIVATE_KEY=\"$(cat ../../../../uberflow_private/private-key.pem)\" CLIENT_ID=$(cat ../../../../uberflow_private/client-id) CLIENT_SECRET=$(cat ../../../../uberflow_private/client-secret) SERVER_SECRET=secret ACCOUNTS_URL=http://localhost:3000/ COLLABORATOR_URL=http://localhost:3078 COLLABORATOR_API_URL=http://localhost:3078 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_ENDPOINT=localhost ts-node src/index.ts", + "format": "format src", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "@hcengineering/platform-rig": "^0.6.0", + "@tsconfig/node16": "^1.0.4", + "@types/cors": "^2.8.12", + "@types/express": "^4.17.13", + "@types/jest": "^29.5.5", + "@types/node": "~20.11.16", + "@types/node-fetch": "~2.6.2", + "@types/ws": "^8.5.11", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "@typescript-eslint/parser": "^6.11.0", + "esbuild": "^0.20.0", + "eslint": "^8.54.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-n": "^15.4.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^6.1.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.8.0", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/account": "^0.6.0", + "@hcengineering/ai-bot": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/client": "^0.6.18", + "@hcengineering/client-resources": "^0.6.27", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/core": "^0.6.32", + "@hcengineering/mongo": "^0.6.1", + "@hcengineering/notification": "^0.6.23", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/server-ai-bot": "^0.6.0", + "@hcengineering/server-analytics-collector-resources": "^0.6.0", + "@hcengineering/server-client": "^0.6.0", + "@hcengineering/server-core": "^0.6.1", + "@hcengineering/server-token": "^0.6.11", + "@hcengineering/server-ws": "^0.6.11", + "@hcengineering/setting": "^0.6.17", + "cors": "^2.8.5", + "dotenv": "~16.0.0", + "express": "^4.19.2", + "fast-equals": "^5.0.1", + "form-data": "^4.0.0", + "mongodb": "^6.8.0", + "node-fetch": "^2.6.6", + "ws": "^8.18.0" + } +} diff --git a/services/ai-bot/pod-ai-bot/src/account.ts b/services/ai-bot/pod-ai-bot/src/account.ts new file mode 100644 index 0000000000..186a0b905e --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/account.ts @@ -0,0 +1,101 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { LoginInfo, Workspace, WorkspaceLoginInfo } from '@hcengineering/account' +import aiBot, { aiBotAccountEmail } from '@hcengineering/ai-bot' +import { AccountRole, WorkspaceId } from '@hcengineering/core' + +import config from './config' + +export async function assignBotToWorkspace (workspaceId: WorkspaceId): Promise { + const accountsUrl = config.AccountsURL + const workspace = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'assignWorkspace', + params: [ + aiBotAccountEmail, + workspaceId.name, + AccountRole.User, + undefined, + false, + undefined, + aiBot.account.AIBot + ] + }) + }) + ).json() + + return workspace.result as Workspace +} + +export async function createBotAccount (): Promise { + const accountsUrl = config.AccountsURL + const workspace = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'createAccount', + params: [aiBotAccountEmail, config.Password, config.FirstName, config.LastName] + }) + }) + ).json() + + return workspace.result as Workspace +} + +export async function loginBot (): Promise { + const accountsUrl = config.AccountsURL + const workspace = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'login', + params: [aiBotAccountEmail, config.Password] + }) + }) + ).json() + + return workspace.result as LoginInfo | undefined +} + +export async function getWorkspaceInfo (token: string): Promise { + const accountsUrl = config.AccountsURL + const workspaceInfo = await ( + await fetch(accountsUrl, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + method: 'getWorkspaceInfo', + params: [] + }) + }) + ).json() + + return workspaceInfo.result as WorkspaceLoginInfo +} diff --git a/services/ai-bot/pod-ai-bot/src/config.ts b/services/ai-bot/pod-ai-bot/src/config.ts new file mode 100644 index 0000000000..784a2ac1ab --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/config.ts @@ -0,0 +1,76 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +interface Config { + AccountsURL: string + ConfigurationDB: string + MongoURL: string + ProductID: string + ServerSecret: string + ServiceID: string + SupportWorkspace: string + FirstName: string + LastName: string + AvatarPath: string + AvatarName: string + AvatarContentType: string + Password: string +} + +const envMap: { [key in keyof Config]: string } = { + AccountsURL: 'ACCOUNTS_URL', + ConfigurationDB: 'CONFIGURATION_DB', + MongoURL: 'MONGO_URL', + ProductID: 'PRODUCT_ID', + ServerSecret: 'SERVER_SECRET', + ServiceID: 'SERVICE_ID', + SupportWorkspace: 'SUPPORT_WORKSPACE', + FirstName: 'FIRST_NAME', + LastName: 'LAST_NAME', + AvatarPath: 'AVATAR_PATH', + AvatarName: 'AVATAR_NAME', + AvatarContentType: 'AVATAR_CONTENT_TYPE', + Password: 'PASSWORD' +} + +const config: Config = (() => { + const params: Partial = { + AccountsURL: process.env[envMap.AccountsURL], + ConfigurationDB: process.env[envMap.ConfigurationDB] ?? '%ai-bot', + MongoURL: process.env[envMap.MongoURL], + ProductID: process.env[envMap.ProductID] ?? '', + ServerSecret: process.env[envMap.ServerSecret], + ServiceID: process.env[envMap.ServiceID] ?? 'ai-bot-service', + SupportWorkspace: process.env[envMap.SupportWorkspace], + FirstName: process.env[envMap.FirstName], + LastName: process.env[envMap.LastName], + AvatarPath: process.env[envMap.AvatarPath] ?? './assets/avatar.png', + AvatarName: process.env[envMap.AvatarName] ?? 'huly_ai_bot_avatar', + AvatarContentType: process.env[envMap.AvatarContentType] ?? '.png', + Password: process.env[envMap.Password] ?? 'password' + } + + const missingEnv = (Object.keys(params) as Array) + .filter((key) => params[key] === undefined) + .map((key) => envMap[key]) + + if (missingEnv.length > 0) { + throw Error(`Missing env variables: ${missingEnv.join(', ')}`) + } + + return params as Config +})() + +export default config diff --git a/services/ai-bot/pod-ai-bot/src/controller.ts b/services/ai-bot/pod-ai-bot/src/controller.ts new file mode 100644 index 0000000000..0df29539ef --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/controller.ts @@ -0,0 +1,216 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Db, Collection } from 'mongodb' +import { getWorkspaceId, MeasureContext, systemAccountEmail, toWorkspaceString, WorkspaceId } from '@hcengineering/core' +import { aiBotAccountEmail, AIBotTransferEvent } from '@hcengineering/ai-bot' +import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' +import { getTransactorEndpoint } from '@hcengineering/server-client' +import { generateToken } from '@hcengineering/server-token' +import { WorkspaceLoginInfo } from '@hcengineering/account' + +import { WorkspaceClient } from './workspaceClient' +import { assignBotToWorkspace, getWorkspaceInfo } from './account' + +const POLLING_INTERVAL_MS = 5 * 1000 // 5 seconds +const CLOSE_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes +const ASSIGN_WORKSPACE_DELAY_MS = 5 * 1000 // 5 secs +const MAX_ASSIGN_ATTEMPTS = 5 + +export class AIBotController { + private readonly workspaces: Map = new Map() + private readonly closeWorkspaceTimeouts: Map = new Map() + + private readonly db: Db + private readonly ctx: MeasureContext + private readonly workspacesInfoCollection: Collection + + private readonly intervalId: NodeJS.Timeout + + assignTimeout: NodeJS.Timeout | undefined + assignAttempts = 0 + + constructor (mongoDb: Db, ctx: MeasureContext) { + this.db = mongoDb + this.ctx = ctx + this.workspacesInfoCollection = this.db.collection('workspacesInfo') + + this.intervalId = setInterval(() => { + void this.updateWorkspaceClients() + }, POLLING_INTERVAL_MS) + } + + async updateWorkspaceClients (): Promise { + const activeRecords = await this.workspacesInfoCollection.find({ active: true }).toArray() + + for (const record of activeRecords) { + const id: WorkspaceId = { name: record.workspace, productId: record.productId } + + if (this.workspaces.has(toWorkspaceString(id))) { + continue + } + + await this.initWorkspaceClient(id, record) + } + } + + async closeWorkspaceClient (workspaceId: WorkspaceId): Promise { + const workspace = toWorkspaceString(workspaceId) + + this.ctx.info('Closing workspace client: ', { workspace }) + + const timeoutId = this.closeWorkspaceTimeouts.get(workspace) + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + this.closeWorkspaceTimeouts.delete(workspace) + } + + await this.workspacesInfoCollection.updateOne( + { workspace: workspaceId.name, productId: workspaceId.productId }, + { $set: { active: false } } + ) + + const client = this.workspaces.get(workspace) + + if (client !== undefined) { + await client.close() + this.workspaces.delete(workspace) + } + } + + private async getWorkspaceInfo (ws: WorkspaceId): Promise { + const systemToken = generateToken(systemAccountEmail, ws) + for (let i = 0; i < 5; i++) { + try { + const info = await getWorkspaceInfo(systemToken) + + if (info == null) { + this.ctx.warn('Cannot find workspace info', ws) + await wait(ASSIGN_WORKSPACE_DELAY_MS) + continue + } + + return info + } catch (e) { + this.ctx.error('Error during get workspace info:', { e }) + await wait(ASSIGN_WORKSPACE_DELAY_MS) + } + } + } + + private async assignToWorkspace (ws: WorkspaceId): Promise { + clearTimeout(this.assignTimeout) + try { + const info = await this.getWorkspaceInfo(ws) + + if (info === undefined) { + void this.closeWorkspaceClient(ws) + return + } + + if (info.creating === true) { + this.ctx.info('Workspace is creating -> waiting...', ws) + this.assignTimeout = setTimeout(() => { + void this.assignToWorkspace(ws) + }, ASSIGN_WORKSPACE_DELAY_MS) + return + } + + const result = await assignBotToWorkspace(ws) + this.ctx.info('Assign to workspace result: ', { result, workspace: ws.name }) + } catch (e) { + this.ctx.error('Error during assign workspace:', { e }) + if (this.assignAttempts < MAX_ASSIGN_ATTEMPTS) { + this.assignAttempts++ + this.assignTimeout = setTimeout(() => { + void this.assignToWorkspace(ws) + }, ASSIGN_WORKSPACE_DELAY_MS) + } else { + void this.closeWorkspaceClient(ws) + } + } + } + + async initWorkspaceClient (workspaceId: WorkspaceId, info: WorkspaceInfoRecord): Promise { + const workspace = toWorkspaceString(workspaceId) + + if (!this.workspaces.has(workspace)) { + this.ctx.info('Listen workspace: ', { workspace }) + await this.assignToWorkspace(workspaceId) + const token = generateToken(aiBotAccountEmail, workspaceId) + const endpoint = await getTransactorEndpoint(token) + this.workspaces.set( + workspace, + new WorkspaceClient(endpoint, token, workspaceId, this, this.ctx.newChild(workspace, {}), info) + ) + } + + const timeoutId = this.closeWorkspaceTimeouts.get(workspace) + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + + const newTimeoutId = setTimeout(() => { + void this.closeWorkspaceClient(workspaceId) + }, CLOSE_INTERVAL_MS) + + this.closeWorkspaceTimeouts.set(workspace, newTimeoutId) + } + + async transfer (event: AIBotTransferEvent): Promise { + const workspaceId = getWorkspaceId(event.toWorkspace) + const info = await this.workspacesInfoCollection + .find({ workspace: workspaceId.name, productId: workspaceId.productId }) + .toArray() + + await this.initWorkspaceClient(workspaceId, info[0]) + + const wsClient = this.workspaces.get(event.toWorkspace) + + if (wsClient === undefined) { + return + } + + await wsClient.transfer(event) + } + + async close (): Promise { + clearInterval(this.intervalId) + for (const workspace of this.workspaces.values()) { + await workspace.close() + } + for (const timeoutId of this.closeWorkspaceTimeouts.values()) { + clearTimeout(timeoutId) + } + this.workspaces.clear() + } + + async updateAvatarInfo (workspace: WorkspaceId, path: string, lastModified: number): Promise { + await this.workspacesInfoCollection.updateOne( + { workspace: workspace.name, productId: workspace.productId }, + { $set: { avatarPath: path, avatarLastModified: lastModified } } + ) + } +} + +async function wait (delay: number): Promise { + await new Promise((resolve) => { + setTimeout(() => { + resolve() + }, delay) + }) +} diff --git a/services/ai-bot/pod-ai-bot/src/index.ts b/services/ai-bot/pod-ai-bot/src/index.ts new file mode 100644 index 0000000000..d3533f53b3 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/index.ts @@ -0,0 +1,20 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// +import { config } from 'dotenv' + +import { start } from './start' + +config() +void start() diff --git a/services/ai-bot/pod-ai-bot/src/platform.ts b/services/ai-bot/pod-ai-bot/src/platform.ts new file mode 100644 index 0000000000..8ba2283aea --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/platform.ts @@ -0,0 +1,7 @@ +import { Client } from '@hcengineering/core' +import { createClient, getTransactorEndpoint } from '@hcengineering/server-client' + +export async function connectPlatform (token: string): Promise { + const endpoint = await getTransactorEndpoint(token) + return await createClient(endpoint, token) +} diff --git a/services/ai-bot/pod-ai-bot/src/start.ts b/services/ai-bot/pod-ai-bot/src/start.ts new file mode 100644 index 0000000000..b006e26448 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/start.ts @@ -0,0 +1,63 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { setMetadata } from '@hcengineering/platform' +import serverToken from '@hcengineering/server-token' +import serverAiBot from '@hcengineering/server-ai-bot' +import { MeasureMetricsContext } from '@hcengineering/core' +import serverClient from '@hcengineering/server-client' + +import config from './config' +import { closeDB, getDB } from './storage' +import { AIBotController } from './controller' +import { createBotAccount } from './account' + +export const start = async (): Promise => { + setMetadata(serverToken.metadata.Secret, config.ServerSecret) + setMetadata(serverAiBot.metadata.SupportWorkspaceId, config.SupportWorkspace) + setMetadata(serverClient.metadata.UserAgent, config.ServiceID) + setMetadata(serverClient.metadata.Endpoint, config.AccountsURL) + + const ctx = new MeasureMetricsContext('ai-bot-service', {}) + + ctx.info('AI Bot Service started', { firstName: config.FirstName, lastName: config.LastName }) + + const db = await getDB(config.MongoURL, config.ConfigurationDB) + for (let i = 0; i < 5; i++) { + ctx.info('Creating bot account', { attempt: i }) + try { + await createBotAccount() + break + } catch (e) { + ctx.error('Error during account creation', { error: e }) + } + await new Promise((resolve) => setTimeout(resolve, 3000)) + } + const aiController = new AIBotController(db, ctx) + + const onClose = (): void => { + void aiController.close() + void closeDB() + } + + process.on('SIGINT', onClose) + process.on('SIGTERM', onClose) + process.on('uncaughtException', (e: Error) => { + console.error(e) + }) + process.on('unhandledRejection', (e: Error) => { + console.error(e) + }) +} diff --git a/services/ai-bot/pod-ai-bot/src/storage.ts b/services/ai-bot/pod-ai-bot/src/storage.ts new file mode 100644 index 0000000000..012646bbbd --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/storage.ts @@ -0,0 +1,31 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Db, MongoClient } from 'mongodb' + +let client: MongoClient | undefined + +export const getDB = async (mongoUrl: string, db: string): Promise => { + client = new MongoClient(mongoUrl) + await client.connect() + + return client.db(db) +} + +export const closeDB: () => Promise = async () => { + if (client !== undefined) { + await client.close() + } +} diff --git a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts new file mode 100644 index 0000000000..2655456748 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts @@ -0,0 +1,444 @@ +// +// Copyright © 2024 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import core, { + Account, + Class, + Client, + Doc, + MeasureContext, + Ref, + Space, + Tx, + TxCreateDoc, + TxOperations, + TxProcessor, + WorkspaceId, + Blob +} from '@hcengineering/core' +import aiBot, { AIBotEvent, aiBotAccountEmail, AIBotResponseEvent, AIBotTransferEvent } from '@hcengineering/ai-bot' +import chunter, { ChatMessage, DirectMessage, ThreadMessage } from '@hcengineering/chunter' +import contact, { AvatarType, combineName, getFirstName, getLastName, PersonAccount } from '@hcengineering/contact' +import { generateToken } from '@hcengineering/server-token' +import notification from '@hcengineering/notification' +import { getOrCreateAnalyticsChannel } from '@hcengineering/server-analytics-collector-resources' +import { deepEqual } from 'fast-equals' +import { BlobClient } from '@hcengineering/server-client' +import fs from 'fs' +import { WorkspaceInfoRecord } from '@hcengineering/server-ai-bot' + +import config from './config' +import { loginBot } from './account' +import { AIBotController } from './controller' +import { connectPlatform } from './platform' + +const MAX_LOGIN_DELAY_MS = 15 * 1000 // 15 ses + +export class WorkspaceClient { + client: Client | undefined + opClient: TxOperations | undefined + + blobClient: BlobClient + + loginTimeout: NodeJS.Timeout | undefined + loginDelayMs = 2 * 1000 + + initializePromise: Promise | undefined = undefined + + aiAccount: PersonAccount | undefined + + constructor ( + readonly transactorUrl: string, + readonly token: string, + readonly workspace: WorkspaceId, + readonly controller: AIBotController, + readonly ctx: MeasureContext, + readonly info: WorkspaceInfoRecord | undefined + ) { + this.blobClient = new BlobClient(transactorUrl, token, this.workspace) + this.initializePromise = this.initClient().then(() => { + this.initializePromise = undefined + }) + } + + private async login (): Promise { + this.ctx.info('Logging in: ', this.workspace) + const token = (await loginBot())?.token + + if (token !== undefined) { + return token + } else { + return (await loginBot())?.token + } + } + + private async uploadAvatarFile (client: TxOperations): Promise { + this.ctx.info('Upload avatar file', { workspace: this.workspace.name }) + + try { + await this.checkPersonData(client) + + const stat = fs.statSync(config.AvatarPath) + const lastModified = stat.mtime.getTime() + + if ( + this.info !== undefined && + this.info.avatarPath === config.AvatarPath && + this.info.avatarLastModified === lastModified + ) { + this.ctx.info('Avatar file already uploaded', { workspace: this.workspace.name, path: config.AvatarPath }) + return + } + const data = fs.readFileSync(config.AvatarPath) + + await this.blobClient.upload(this.ctx, config.AvatarName, data.length, config.AvatarContentType, data) + await this.controller.updateAvatarInfo(this.workspace, config.AvatarPath, lastModified) + this.ctx.info('Uploaded avatar file', { workspace: this.workspace.name, path: config.AvatarPath }) + } catch (e) { + this.ctx.error('Failed to upload avatar file', { e }) + } + } + + private async tryLogin (): Promise { + const token = await this.login() + + clearTimeout(this.loginTimeout) + + if (token === undefined) { + this.loginTimeout = setTimeout(() => { + if (this.loginDelayMs < MAX_LOGIN_DELAY_MS) { + this.loginDelayMs += 1000 + } + void this.tryLogin() + + this.ctx.info(`login delay ${this.loginDelayMs} millisecond`) + }, this.loginDelayMs) + } + } + + private async checkPersonData (client: TxOperations): Promise { + const account = await client.findOne(contact.class.PersonAccount, { email: aiBotAccountEmail }) + if (account === undefined) { + this.ctx.error('Cannot find AI PersonAccount', { email: aiBotAccountEmail }) + return + } + this.aiAccount = account + const person = await client.findOne(contact.class.Person, { _id: account.person }) + + if (person === undefined) { + this.ctx.error('Cannot find AI Person ', { _id: account.person }) + return + } + + const firstName = getFirstName(person.name) + const lastName = getLastName(person.name) + + if (lastName !== config.LastName || firstName !== config.FirstName) { + await client.update(person, { + name: combineName(config.FirstName, config.LastName) + }) + } + + if (person.avatar === config.AvatarName) { + return + } + + const exist = await this.blobClient.checkFile(this.ctx, config.AvatarName) + + if (!exist) { + this.ctx.error('Cannot find file', { file: config.AvatarName, workspace: this.workspace.name }) + return + } + + await client.diffUpdate(person, { avatar: config.AvatarName as Ref, avatarType: AvatarType.IMAGE }) + } + + private async initClient (): Promise { + await this.tryLogin() + + const token = generateToken(aiBotAccountEmail, this.workspace) + this.client = await connectPlatform(token) + + if (this.client === undefined) { + this.ctx.error('Cannot connect to platform', this.workspace) + return + } + this.opClient = new TxOperations(this.client, aiBot.account.AIBot) + void this.opClient.findAll(aiBot.class.AIBotEvent, {}).then((res) => { + void this.processEvents(res) + }) + + this.client.notify = (...txes: Tx[]) => { + void this.txHandler(txes) + } + + await this.uploadAvatarFile(this.opClient) + this.ctx.info('Initialized workspace', this.workspace) + } + + async getThreadParent ( + client: TxOperations, + event: AIBotTransferEvent, + _id: Ref, + _class: Ref> + ): Promise { + const parent = await client.findOne(chunter.class.ChatMessage, { + attachedTo: _id, + attachedToClass: _class, + [aiBot.mixin.TransferredMessage]: { + messageId: event.parentMessageId, + parentMessageId: undefined + } + }) + + if (parent !== undefined) { + return parent + } + + return await client.findOne(chunter.class.ChatMessage, { + _id: event.parentMessageId + }) + } + + async createTransferMessage ( + client: TxOperations, + event: AIBotTransferEvent, + _id: Ref, + _class: Ref>, + space: Ref, + message: string + ): Promise { + if (event.messageClass === chunter.class.ChatMessage) { + const ref = await client.addCollection( + chunter.class.ChatMessage, + space, + _id, + _class, + event.collection, + { message } + ) + await client.createMixin(ref, chunter.class.ChatMessage, space, aiBot.mixin.TransferredMessage, { + messageId: event.messageId, + parentMessageId: event.parentMessageId + }) + } else if (event.messageClass === chunter.class.ThreadMessage && event.parentMessageId !== undefined) { + const parent = await this.getThreadParent(client, event, _id, _class) + + if (parent !== undefined) { + const ref = await client.addCollection( + chunter.class.ThreadMessage, + parent.space, + parent._id, + parent._class, + event.collection, + { message, objectId: parent.attachedTo, objectClass: parent.attachedToClass } + ) + await client.createMixin( + ref, + chunter.class.ThreadMessage as Ref>, + space, + aiBot.mixin.TransferredMessage, + { + messageId: event.messageId, + parentMessageId: event.parentMessageId + } + ) + } + } + } + + async processResponseEvent (event: AIBotResponseEvent): Promise { + if (this.opClient === undefined) { + return + } + + if (event.messageClass === chunter.class.ChatMessage) { + await this.opClient.addCollection( + chunter.class.ChatMessage, + event.objectSpace, + event.objectId, + event.objectClass, + event.collection, + { message: 'You said: ' + event.message } + ) + } else if (event.messageClass === chunter.class.ThreadMessage) { + const parent = await this.opClient.findOne(chunter.class.ChatMessage, { + _id: event.objectId as Ref + }) + + if (parent !== undefined) { + await this.opClient.addCollection( + chunter.class.ThreadMessage, + event.objectSpace, + event.objectId, + event.objectClass, + event.collection, + { message: 'You said: ' + event.message, objectId: parent.attachedTo, objectClass: parent.attachedToClass } + ) + } + } + + await this.opClient.remove(event) + } + + async processTransferEvent (event: AIBotTransferEvent): Promise { + if (this.opClient === undefined) { + return + } + + await this.controller.transfer(event) + await this.opClient.remove(event) + } + + async getAccount (email: string): Promise { + if (this.opClient === undefined) { + return + } + + return await this.opClient.findOne(contact.class.PersonAccount, { email }) + } + + async getDirect (_id: Ref): Promise | undefined> { + if (this.opClient === undefined) { + return + } + + const personAccount = await this.opClient.findOne(contact.class.PersonAccount, { _id: _id as Ref }) + + if (personAccount === undefined) { + return + } + + const allAccounts = await this.opClient.findAll(contact.class.PersonAccount, { person: personAccount.person }) + const accIds: Ref[] = [aiBot.account.AIBot, ...allAccounts.map(({ _id }) => _id)].sort() + const existingDms = await this.opClient.findAll(chunter.class.DirectMessage, {}) + + for (const dm of existingDms) { + if (deepEqual(dm.members.sort(), accIds)) { + return dm._id + } + } + + const id = await this.opClient.createDoc(chunter.class.DirectMessage, core.space.Space, { + name: '', + description: '', + private: true, + archived: false, + members: accIds + }) + + if (this.aiAccount === undefined) return id + const space = await this.opClient.findOne(contact.class.PersonSpace, { person: this.aiAccount.person }) + if (space === undefined) return id + await this.opClient.createDoc(notification.class.DocNotifyContext, space._id, { + user: aiBot.account.AIBot, + objectId: id, + objectClass: chunter.class.DirectMessage, + objectSpace: core.space.Space, + isPinned: false + }) + + return id + } + + async transferToSupport (event: AIBotTransferEvent): Promise { + if (this.opClient === undefined) { + return + } + + const channel = await getOrCreateAnalyticsChannel(this.opClient, event.toEmail, event.fromWorkspace) + + if (channel === undefined) { + return + } + + await this.createTransferMessage(this.opClient, event, channel, chunter.class.Channel, channel, event.message) + } + + async transferToUserDirect (event: AIBotTransferEvent): Promise { + if (this.opClient === undefined) { + return + } + + const account = await this.getAccount(event.toEmail) + + if (account === undefined) { + return + } + + const direct = await this.getDirect(account._id) + + if (direct === undefined) { + return + } + + await this.createTransferMessage(this.opClient, event, direct, chunter.class.DirectMessage, direct, event.message) + } + + async transfer (event: AIBotTransferEvent): Promise { + if (this.initializePromise instanceof Promise) { + await this.initializePromise + } + + if (event.toWorkspace === config.SupportWorkspace) { + await this.transferToSupport(event) + } else { + await this.transferToUserDirect(event) + } + } + + async processEvents (events: AIBotEvent[]): Promise { + if (events.length === 0 || this.opClient === undefined) { + return + } + + for (const event of events) { + try { + if (event._class === aiBot.class.AIBotResponseEvent) { + void this.processResponseEvent(event as AIBotResponseEvent) + } else if (event._class === aiBot.class.AIBotTransferEvent) { + void this.processTransferEvent(event as AIBotTransferEvent) + } + } catch (e) { + this.ctx.error('Error processing event: ', { e }) + } + } + } + + async close (): Promise { + clearTimeout(this.loginTimeout) + await this.client?.close() + await this.opClient?.close() + + this.ctx.info('Closed workspace client: ', this.workspace) + } + + private async txHandler (txes: Tx[]): Promise { + if (this.opClient === undefined) { + return + } + + const hierarchy = this.opClient.getHierarchy() + + const resultTxes = txes + .map((a) => TxProcessor.extractTx(a) as TxCreateDoc) + .filter( + (tx) => tx._class === core.class.TxCreateDoc && hierarchy.isDerived(tx.objectClass, aiBot.class.AIBotEvent) + ) + .map((tx) => TxProcessor.createDoc2Doc(tx)) + + await this.processEvents(resultTxes) + } +} diff --git a/services/ai-bot/pod-ai-bot/tsconfig.json b/services/ai-bot/pod-ai-bot/tsconfig.json new file mode 100644 index 0000000000..59e4fd4297 --- /dev/null +++ b/services/ai-bot/pod-ai-bot/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + } +} \ No newline at end of file diff --git a/services/github/pod-github/src/notifications.ts b/services/github/pod-github/src/notifications.ts index 310359a0b1..a7884d06ac 100644 --- a/services/github/pod-github/src/notifications.ts +++ b/services/github/pod-github/src/notifications.ts @@ -1,19 +1,21 @@ import { Account, Doc, Ref, TxOperations } from '@hcengineering/core' import notification, { DocNotifyContext } from '@hcengineering/notification' import { IntlString } from '@hcengineering/platform' +import { PersonSpace } from '@hcengineering/contact' import github from '@hcengineering/github' export async function createNotification ( client: TxOperations, forDoc: Doc, - data: { user: Ref, message: IntlString, props: Record } + data: { user: Ref, space: Ref, message: IntlString, props: Record } ): Promise { - let docNotifyContext = await client.findOne(notification.class.DocNotifyContext, { attachedTo: forDoc._id }) + let docNotifyContext = await client.findOne(notification.class.DocNotifyContext, { objectId: forDoc._id }) if (docNotifyContext?._id === undefined) { - const docNotifyContextId = await client.createDoc(notification.class.DocNotifyContext, forDoc.space, { - attachedTo: forDoc._id, - attachedToClass: forDoc._class, + const docNotifyContextId = await client.createDoc(notification.class.DocNotifyContext, data.space, { + objectId: forDoc._id, + objectClass: forDoc._class, + objectSpace: forDoc.space, user: data.user, isPinned: false }) @@ -21,7 +23,6 @@ export async function createNotification ( } // Check if we had already same notification send, and just unmark it viewed. - const existing = await client.findOne(notification.class.CommonInboxNotification, { user: data.user, message: data.message, @@ -32,12 +33,13 @@ export async function createNotification ( isViewed: false }) } else { - await client.createDoc(notification.class.CommonInboxNotification, forDoc.space, { + await client.createDoc(notification.class.CommonInboxNotification, data.space, { user: data.user, icon: github.icon.Github, message: data.message, props: data.props, isViewed: false, + archived: false, docNotifyContext: docNotifyContext?._id as Ref }) } diff --git a/services/github/pod-github/src/platform.ts b/services/github/pod-github/src/platform.ts index 4bc9f17347..292a7cdc09 100644 --- a/services/github/pod-github/src/platform.ts +++ b/services/github/pod-github/src/platform.ts @@ -409,13 +409,17 @@ export class PlatformWorker { const person = (await client.findOne(contact.class.Person, { _id: account.person })) as Person if (person !== undefined) { if (!revoke) { - await createNotification(client, person, { - user: account._id, - message: github.string.AuthenticatedWithGithub, - props: { - login: update.login - } - }) + const personSpace = await client.findOne(contact.class.PersonSpace, { person: person._id }) + if (personSpace !== undefined) { + await createNotification(client, person, { + user: account._id, + space: personSpace._id, + message: github.string.AuthenticatedWithGithub, + props: { + login: update.login + } + }) + } const githubAccount = (await client.findOne(core.class.Account, { email: 'github:' + update.login @@ -427,23 +431,31 @@ export class PlatformWorker { const dPerson = (await client.findOne(contact.class.Person, { _id: dummyPerson })) as Person if (person !== undefined && dPerson !== undefined) { - await createNotification(client, dPerson, { - user: account._id, - message: github.string.AuthenticatedWithGithubEmployee, - props: { - login: update.login - } - }) + const personSpace = await client.findOne(contact.class.PersonSpace, { person: person._id }) + if (personSpace !== undefined) { + await createNotification(client, dPerson, { + user: githubAccount._id, + space: personSpace._id, + message: github.string.AuthenticatedWithGithubEmployee, + props: { + login: update.login + } + }) + } } } } else { - await createNotification(client, person, { - user: account._id, - message: github.string.AuthenticationRevokedGithub, - props: { - login: update.login - } - }) + const personSpace = await client.findOne(contact.class.PersonSpace, { person: person._id }) + if (personSpace !== undefined) { + await createNotification(client, person, { + user: account._id, + space: personSpace._id, + message: github.string.AuthenticationRevokedGithub, + props: { + login: update.login + } + }) + } } } diff --git a/services/github/pod-github/src/worker.ts b/services/github/pod-github/src/worker.ts index dc6c4f1a74..cf4aff8570 100644 --- a/services/github/pod-github/src/worker.ts +++ b/services/github/pod-github/src/worker.ts @@ -543,11 +543,15 @@ export class GithubWorker implements IntegrationManager { if (accountRef !== undefined) { const person = await this.client.findOne(contact.class.Person, { _id: accountRef.person }) if (person !== undefined) { - await createNotification(this._client, person, { - user: account, - message: github.string.AuthenticatedWithGithubRequired, - props: {} - }) + const personSpace = await this.client.findOne(contact.class.PersonSpace, { person: person._id }) + if (personSpace !== undefined) { + await createNotification(this._client, person, { + user: account, + space: personSpace._id, + message: github.string.AuthenticatedWithGithubRequired, + props: {} + }) + } } } this.ctx.info('get octokit: return bot', { account }) diff --git a/services/rekoni/package.json b/services/rekoni/package.json index a842386a7f..d3291cee99 100644 --- a/services/rekoni/package.json +++ b/services/rekoni/package.json @@ -16,9 +16,11 @@ "_phase:bundle": "rushx bundle", "_phase:docker-build": "rushx docker:build", "_phase:docker-staging": "rushx docker:staging", - "docker:build": "docker build -t hardcoreeng/rekoni-service .", - "docker:staging": "./docker_tag.sh hardcoreeng/rekoni-service staging", - "docker:push": "./docker_tag.sh hardcoreeng/rekoni-service", + "docker:build": "../../common/scripts/docker_build.sh hardcoreeng/rekoni-service", + "docker:tbuild": "docker build -t hardcoreeng/rekoni-service . --platform=linux/amd64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/rekoni-service", + "docker:abuild": "docker build -t hardcoreeng/rekoni-service . --platform=linux/arm64 && ../../common/scripts/docker_tag_push.sh hardcoreeng/rekoni-service", + "docker:staging": "../../common/scripts/docker_tag.sh hardcoreeng/rekoni-service staging", + "docker:push": "../../common/scripts/docker_tag.sh hardcoreeng/rekoni-service", "bundle": "node esbuild.js", "start": "ts-node-dev src/index.ts", "run-local": "cross-env ts-node src/index.ts", diff --git a/tests/sanity/tests/chat/chat.spec.ts b/tests/sanity/tests/chat/chat.spec.ts index 58abaf09a3..44af888e6f 100644 --- a/tests/sanity/tests/chat/chat.spec.ts +++ b/tests/sanity/tests/chat/chat.spec.ts @@ -1,13 +1,14 @@ -import { test, expect } from '@playwright/test' -import { PlatformURI, generateTestData } from '../utils' -import { LeftSideMenuPage } from '../model/left-side-menu-page' -import { ChunterPage } from '../model/chunter-page' -import { ChannelPage } from '../model/channel-page' -import { ApiEndpoint } from '../API/Api' -import { LoginPage } from '../model/login-page' -import { SignUpData } from '../model/common-types' import { faker } from '@faker-js/faker' +import { expect, test } from '@playwright/test' +import { ApiEndpoint } from '../API/Api' +import { ChannelPage } from '../model/channel-page' +import { ChunterPage } from '../model/chunter-page' +import { SignUpData } from '../model/common-types' +import { LeftSideMenuPage } from '../model/left-side-menu-page' +import { LoginPage } from '../model/login-page' +import { SelectWorkspacePage } from '../model/select-workspace-page' import { SignInJoinPage } from '../model/signin-page' +import { PlatformURI, generateTestData } from '../utils' test.describe('channel tests', () => { let leftSideMenuPage: LeftSideMenuPage @@ -36,7 +37,9 @@ test.describe('channel tests', () => { await api.createWorkspaceWithLogin(data.workspaceName, data.userName, '1234') await (await page.goto(`${PlatformURI}`))?.finished() await loginPage.login(data.userName, '1234') - await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() + const swp = new SelectWorkspacePage(page) + await swp.selectWorkspace(data.workspaceName) + // await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() }) test('create new private channel and check if the messages stays on it', async ({ browser, page }) => { @@ -155,10 +158,11 @@ test.describe('channel tests', () => { await channelPageSecond.checkIfChannelTableExist(data.channelName, true) await channelPageSecond.clickJoinChannelButton() await channelPageSecond.clickChooseChannel(data.channelName) + const checkJoinButton = await page2.locator('button[data-id="btnJoin"]').isVisible({ timeout: 1500 }) + if (checkJoinButton) await page2.locator('button[data-id="btnJoin"]').click() await channelPageSecond.checkMessageExist('Test message', true, 'Test message') await channelPageSecond.sendMessage('My dream is to fly') await channelPageSecond.checkMessageExist('My dream is to fly', true, 'My dream is to fly') - await channelPage.clickOnClosePopupButton() await channelPage.checkMessageExist('My dream is to fly', true, 'My dream is to fly') await page2.close() }) @@ -412,6 +416,7 @@ test.describe('channel tests', () => { }) test('Checking backlinks in the Chat', async ({ browser, page }) => { + await api.createAccount(newUser2.email, newUser2.password, newUser2.firstName, newUser2.lastName) await leftSideMenuPage.openProfileMenu() await leftSideMenuPage.inviteToWorkspace() await leftSideMenuPage.getInviteLink() @@ -420,10 +425,10 @@ test.describe('channel tests', () => { const page2 = await browser.newPage() const leftSideMenuPageSecond = new LeftSideMenuPage(page2) const channelPageSecond = new ChannelPage(page2) - await api.createAccount(newUser2.email, newUser2.password, newUser2.firstName, newUser2.lastName) await page2.goto(linkText ?? '') const joinPage = new SignInJoinPage(page2) await joinPage.join(newUser2) + await leftSideMenuPageSecond.clickChunter() await leftSideMenuPage.clickChunter() await channelPage.clickChannel('general') @@ -431,7 +436,6 @@ test.describe('channel tests', () => { await channelPage.sendMention(mentionName) await channelPage.checkMessageExist(`@${mentionName}`, true, `@${mentionName}`) - await leftSideMenuPageSecond.clickChunter() await channelPageSecond.clickChannel('general') await channelPageSecond.checkMessageExist(`@${mentionName}`, true, `@${mentionName}`) await page2.close() diff --git a/tests/sanity/tests/collaborative/issues.spec.ts b/tests/sanity/tests/collaborative/issues.spec.ts index 8ed4cb8897..7b7827c241 100644 --- a/tests/sanity/tests/collaborative/issues.spec.ts +++ b/tests/sanity/tests/collaborative/issues.spec.ts @@ -69,11 +69,7 @@ test.describe('Collaborative test for issue', () => { await issuesPageSecond.openIssueByName(newIssue.title) const issuesDetailsPageSecond = new IssuesDetailsPage(userSecondPage) - await issuesDetailsPageSecond.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPageSecond.checkIssue(newIssue) await closePages() }) @@ -176,7 +172,7 @@ test.describe('Collaborative test for issue', () => { await issuesPageSecond.openIssueByName(issue.title) const issuesDetailsPageSecond = new IssuesDetailsPage(userSecondPage) - await issuesDetailsPageSecond.checkIssue({ ...issue }) + await issuesDetailsPageSecond.checkIssue(issue) }) await closePages() diff --git a/tests/sanity/tests/custom-atributes/custom-attributes.spec.ts b/tests/sanity/tests/custom-atributes/custom-attributes.spec.ts index 0b591306ee..36f2530bb0 100644 --- a/tests/sanity/tests/custom-atributes/custom-attributes.spec.ts +++ b/tests/sanity/tests/custom-atributes/custom-attributes.spec.ts @@ -12,6 +12,7 @@ import { NewCompany } from '../model/recruiting/types' import { NavigationMenuPage } from '../model/recruiting/navigation-menu-page' import { CompaniesPage } from '../model/recruiting/companies-page' import { CompanyDetailsPage } from '../model/recruiting/company-details-page' +import { SelectWorkspacePage } from '../model/select-workspace-page' test.describe('Custom attributes tests', () => { let leftSideMenuPage: LeftSideMenuPage @@ -42,7 +43,9 @@ test.describe('Custom attributes tests', () => { await api.createWorkspaceWithLogin(data.workspaceName, data.userName, '1234') await (await page.goto(`${PlatformURI}`))?.finished() await loginPage.login(data.userName, '1234') - await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() + const swp = new SelectWorkspacePage(page) + await swp.selectWorkspace(data.workspaceName) + // await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() }) test('Check if all custom attributes exists', async ({ browser, page }) => { diff --git a/tests/sanity/tests/documents/documents.spec.ts b/tests/sanity/tests/documents/documents.spec.ts index c2b2099575..50b8acdf5d 100644 --- a/tests/sanity/tests/documents/documents.spec.ts +++ b/tests/sanity/tests/documents/documents.spec.ts @@ -150,4 +150,23 @@ test.describe('Documents tests', () => { await documentContentPage.addLinkToText(contentLink, 'test/link/123456') await documentContentPage.checkLinkInTheText(contentLink, 'test/link/123456') }) + + test('Locked document and checking URL', async ({ page, context }) => { + const newDocument: NewDocument = { + title: `New Document-${generateId()}`, + space: 'Default' + } + + await leftSideMenuPage.clickDocuments() + await documentsPage.clickOnButtonCreateDocument() + await documentsPage.createDocument(newDocument) + await documentsPage.selectMoreActionOfDocument(newDocument.title, 'Lock') + await documentsPage.selectMoreActionOfDocument(newDocument.title, 'Copy document URL to clipboard') + await context.grantPermissions(['clipboard-read']) + const handle = await page.evaluateHandle(() => navigator.clipboard.readText()) + const clipboardContent = await handle.jsonValue() + await page.goto(`${clipboardContent}`) + await documentContentPage.checkDocumentTitle(newDocument.title) + await documentContentPage.checkDocumentLocked() + }) }) diff --git a/tests/sanity/tests/inbox/inbox.spec.ts b/tests/sanity/tests/inbox/inbox.spec.ts index 8e7128a396..5c8aa99a16 100644 --- a/tests/sanity/tests/inbox/inbox.spec.ts +++ b/tests/sanity/tests/inbox/inbox.spec.ts @@ -1,5 +1,5 @@ import { test } from '@playwright/test' -import { PlatformURI, generateTestData } from '../utils' +import { PlatformURI, generateTestData, getTimeForPlanner, attachScreenshot } from '../utils' import { LeftSideMenuPage } from '../model/left-side-menu-page' import { ApiEndpoint } from '../API/Api' import { LoginPage } from '../model/login-page' @@ -12,6 +12,9 @@ import { SignInJoinPage } from '../model/signin-page' import { ChannelPage } from '../model/channel-page' import { UserProfilePage } from '../model/profile/user-profile-page' import { MenuItems, NotificationsPage } from '../model/profile/notifications-page' +import { SelectWorkspacePage } from '../model/select-workspace-page' +import { PlanningPage } from '../model/planning/planning-page' +import { TeamPage } from '../model/team-page' test.describe('Inbox tests', () => { let leftSideMenuPage: LeftSideMenuPage @@ -40,17 +43,15 @@ test.describe('Inbox tests', () => { await api.createWorkspaceWithLogin(data.workspaceName, data.userName, '1234') await (await page.goto(`${PlatformURI}`))?.finished() await loginPage.login(data.userName, '1234') - await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() + const swp = new SelectWorkspacePage(page) + await swp.selectWorkspace(data.workspaceName) + // await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() }) test('User is able to create a task, assign a himself and see it inside the inbox', async ({ page }) => { const newIssue = createNewIssueData(data.firstName, data.lastName) await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await leftSideMenuPage.clickTracker() await leftSideMenuPage.clickNotification() @@ -60,44 +61,28 @@ test.describe('Inbox tests', () => { test('User is able to create a task, assign a himself and open it from inbox', async ({ page }) => { const newIssue = createNewIssueData(data.firstName, data.lastName) await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await leftSideMenuPage.clickTracker() await leftSideMenuPage.clickNotification() await inboxPage.checkIfTaskIsPresentInInbox(newIssue.title) await inboxPage.clickOnToDo(newIssue.title) await inboxPage.clickLeftSidePanelOpen() - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) }) test.skip('User is able to create a task, assign a himself and close it from inbox', async ({ page }) => { const newIssue = createNewIssueData(data.firstName, data.lastName) await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await leftSideMenuPage.clickTracker() await leftSideMenuPage.clickNotification() await inboxPage.checkIfTaskIsPresentInInbox(newIssue.title) await inboxPage.clickOnToDo(newIssue.title) await inboxPage.clickLeftSidePanelOpen() - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await inboxPage.clickCloseLeftSidePanel() // ADD ASSERT ONCE THE ISSUE IS FIXED }) @@ -117,11 +102,7 @@ test.describe('Inbox tests', () => { const newIssue = createNewIssueData(newUser2.firstName, newUser2.lastName) await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await leftSideMenuPageSecond.clickTracker() await leftSideMenuPageSecond.clickNotification() await inboxPageSecond.checkIfTaskIsPresentInInbox(newIssue.title) @@ -144,21 +125,13 @@ test.describe('Inbox tests', () => { const newIssue = createNewIssueData(newUser2.firstName, newUser2.lastName) await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await leftSideMenuPageSecond.clickTracker() await leftSideMenuPageSecond.clickNotification() await inboxPageSecond.checkIfTaskIsPresentInInbox(newIssue.title) await inboxPageSecond.clickOnToDo(newIssue.title) await inboxPageSecond.clickLeftSidePanelOpen() - await issuesDetailsPageSecond.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPageSecond.checkIssue(newIssue) await page2.close() }) test.skip('User is able to create a task, assign a other user and close it from inbox', async ({ page, browser }) => { @@ -177,21 +150,13 @@ test.describe('Inbox tests', () => { const newIssue = createNewIssueData(newUser2.firstName, newUser2.lastName) await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await leftSideMenuPageSecond.clickTracker() await leftSideMenuPageSecond.clickNotification() await inboxPageSecond.checkIfTaskIsPresentInInbox(newIssue.title) await inboxPageSecond.clickOnToDo(newIssue.title) await inboxPageSecond.clickLeftSidePanelOpen() - await issuesDetailsPageSecond.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPageSecond.checkIssue(newIssue) await inboxPage.clickCloseLeftSidePanel() // ADD ASSERT ONCE THE ISSUE IS FIXED await page2.close() @@ -282,11 +247,7 @@ test.describe('Inbox tests', () => { const newIssue = createNewIssueData(newUser2.firstName, newUser2.lastName) await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) await leftSideMenuPageSecond.clickTracker() await leftSideMenuPageSecond.clickNotification() await inboxPageSecond.clickOnInboxFilter('Channels') @@ -297,4 +258,51 @@ test.describe('Inbox tests', () => { await inboxPageSecond.checkIfInboxChatExists('Channel general', false) await page2.close() }) + + test('Checking the ability to receive a task and schedule it', async ({ page, browser }) => { + await leftSideMenuPage.openProfileMenu() + await leftSideMenuPage.inviteToWorkspace() + await leftSideMenuPage.getInviteLink() + const linkText = await page.locator('.antiPopup .link').textContent() + await leftSideMenuPage.clickOnCloseInvite() + + const page2 = await browser.newPage() + await page2.goto(linkText ?? '') + const joinPage = new SignInJoinPage(page2) + await joinPage.join(newUser2) + + const newIssue = createNewIssueData(data.firstName, data.lastName, { + status: 'Todo', + assignee: `${newUser2.lastName} ${newUser2.firstName}`, + estimation: '0' + }) + await prepareNewIssueWithOpenStep(page, newIssue) + await issuesDetailsPage.checkIssue(newIssue) + + const leftSideMenuPageSecond = new LeftSideMenuPage(page2) + const inboxPageSecond = new InboxPage(page2) + const issuesDetailsPageSecond = new IssuesDetailsPage(page2) + const planningPageSecond = new PlanningPage(page2) + await leftSideMenuPageSecond.clickNotification() + await inboxPageSecond.checkIfIssueIsPresentInInbox(newIssue.title) + await inboxPageSecond.clickIssuePresentInInbox(newIssue.title) + await inboxPageSecond.clickLeftSidePanelOpen() + await issuesDetailsPageSecond.checkIssue(newIssue) + await leftSideMenuPageSecond.clickPlanner() + await planningPageSecond.closeNotification() + await planningPageSecond.dragdropTomorrow(newIssue.title, getTimeForPlanner()) + await planningPageSecond.eventInSchedule(newIssue.title).isVisible() + await attachScreenshot('Recive_task_and_scheduled.png', page2) + + await attachScreenshot('Recive_task_and_scheduled-Detail.png', page) + await issuesDetailsPage.checkIssue({ ...newIssue, status: 'In Progress' }) + await leftSideMenuPage.clickTeam() + const teamPage = new TeamPage(page) + await teamPage.checkTeamPageIsOpened() + await teamPage.selectTeam('Default') + await teamPage.buttonNextDay().click() + await attachScreenshot('Recive_task_and_scheduled-Tomorrow.png', page) + await teamPage.getItemByText('Tomorrow', newIssue.title).isVisible() + await page2.close() + }) }) diff --git a/tests/sanity/tests/model/channel-page.ts b/tests/sanity/tests/model/channel-page.ts index 9991085cc1..d4c898ea3d 100644 --- a/tests/sanity/tests/model/channel-page.ts +++ b/tests/sanity/tests/model/channel-page.ts @@ -30,20 +30,20 @@ export class ChannelPage extends CommonPage { readonly addMemberToChannelButton = (userName: string): Locator => this.page.getByText(userName) readonly joinChannelButton = (): Locator => this.page.getByRole('button', { name: 'Join' }) readonly addEmojiButton = (): Locator => - this.page.locator('.activityMessage-actionPopup > button[data-id$="AddReactionAction"]') + this.page.locator('.activityMessage-actionPopup > button[data-id$="AddReactionAction"]').last() readonly selectEmoji = (emoji: string): Locator => this.page.getByText(emoji) readonly saveMessageButton = (): Locator => - this.page.locator('.activityMessage-actionPopup > button[data-id$="SaveForLaterAction"]') + this.page.locator('.activityMessage-actionPopup > button[data-id$="SaveForLaterAction"]').last() readonly pinMessageButton = (): Locator => - this.page.locator('.activityMessage-actionPopup > button[data-id$="PinMessageAction"]') + this.page.locator('.activityMessage-actionPopup > button[data-id$="PinMessageAction"]').last() readonly replyButton = (): Locator => - this.page.locator('.activityMessage-actionPopup > button[data-id$="ReplyToThreadAction"]') + this.page.locator('.activityMessage-actionPopup > button[data-id$="ReplyToThreadAction"]').last() readonly openMoreButton = (): Locator => - this.page.locator('.activityMessage-actionPopup > button[data-id="btnMoreActions"]') + this.page.locator('.activityMessage-actionPopup > button[data-id="btnMoreActions"]').last() readonly messageSaveMarker = (): Locator => this.page.locator('.saveMarker') readonly saveMessageTab = (): Locator => this.page.getByRole('button', { name: 'Saved' }) @@ -66,7 +66,7 @@ export class ChannelPage extends CommonPage { readonly privateOrPublicPopupButton = (change: string): Locator => this.page.locator('div.popup div.menu-item', { hasText: change }) - readonly userAdded = (user: string): Locator => this.page.getByText(user) + readonly userAdded = (user: string): Locator => this.page.locator('.members').getByText(user) private readonly addMemberPreview = (): Locator => this.page.getByRole('button', { name: 'Add members' }) private readonly addButtonPreview = (): Locator => this.page.getByRole('button', { name: 'Add', exact: true }) diff --git a/tests/sanity/tests/model/common-page.ts b/tests/sanity/tests/model/common-page.ts index cd94426f64..0f6083b9b0 100644 --- a/tests/sanity/tests/model/common-page.ts +++ b/tests/sanity/tests/model/common-page.ts @@ -31,7 +31,7 @@ export class CommonPage { tagsStringAddTagButtonSubmit = (): Locator => this.page.locator('div.popup form[id="tags:string:AddTag"] button[type="submit"]') - notifyContainerButton = (): Locator => this.page.locator('div.notify-container button[type="button"].small').nth(0) + notifyContainerButton = (): Locator => this.page.locator('div.notifyPopup button[data-id="btnNotifyClose"]').first() errorSpan = (): Locator => this.page.locator('div.ERROR span') infoSpan = (): Locator => this.page.locator('div.INFO span') popupSubmitButton = (): Locator => this.page.locator('div.popup button[type="submit"]') @@ -165,8 +165,10 @@ export class CommonPage { await this.selectPopupSpanLines(point).click() } - async closeNotification (page: Page): Promise { - await this.notifyContainerButton().click() + async closeNotification (): Promise { + while (await this.notifyContainerButton().isVisible()) { + await this.notifyContainerButton().click() + } } async checkError (page: Page, errorMessage: string): Promise { diff --git a/tests/sanity/tests/model/documents/document-content-page.ts b/tests/sanity/tests/model/documents/document-content-page.ts index 5d6de50ea6..f806ebd5f4 100644 --- a/tests/sanity/tests/model/documents/document-content-page.ts +++ b/tests/sanity/tests/model/documents/document-content-page.ts @@ -19,6 +19,8 @@ export class DocumentContentPage extends CommonPage { readonly buttonMoreActions = (): Locator => this.page.locator('div.hulyHeader-buttonsGroup button#btn-doc-title-open-more') + readonly buttonLockedInTitle = (): Locator => this.page.getByRole('button', { name: 'Locked' }) + readonly popupPanel = (): Locator => this.page.locator('div.popupPanel-title') readonly popupPanelH1 = (): Locator => this.page.locator('div.antiPopup > h1') @@ -26,6 +28,10 @@ export class DocumentContentPage extends CommonPage { await expect(this.buttonDocumentTitle()).toHaveValue(title) } + async checkDocumentLocked (): Promise { + await expect(this.buttonLockedInTitle()).toBeVisible({ timeout: 1000 }) + } + async addContentToTheNewLine (newContent: string): Promise { await expect(this.inputContent()).toBeVisible() await expect(this.inputContent()).toHaveJSProperty('contentEditable', 'true') diff --git a/tests/sanity/tests/model/documents/documents-page.ts b/tests/sanity/tests/model/documents/documents-page.ts index 52fb26d215..7f11d1da24 100644 --- a/tests/sanity/tests/model/documents/documents-page.ts +++ b/tests/sanity/tests/model/documents/documents-page.ts @@ -20,6 +20,9 @@ export class DocumentsPage extends CommonPage { readonly buttonCreateDocument = (): Locator => this.page.locator('div[data-float="navigator"] button[id="new-document"]') + readonly buttonDocument = (name: string): Locator => + this.page.locator('button.hulyNavItem-container > span[class*="label"]', { hasText: name }) + readonly divTeamspacesParent = (): Locator => this.page.locator('div#navGroup-tree-teamspaces').locator('xpath=../button[1]') @@ -97,7 +100,13 @@ export class DocumentsPage extends CommonPage { } async openDocument (name: string): Promise { - await this.page.locator('button.hulyNavItem-container > span[class*="label"]', { hasText: name }).click() + await this.buttonDocument(name).click() + } + + async selectMoreActionOfDocument (name: string, popupItem: string): Promise { + await this.buttonDocument(name).hover() + await this.page.getByRole('button', { name }).getByRole('button').nth(2).click() + await this.selectFromDropdown(this.page, popupItem) } async openDocumentForTeamspace (spaceName: string, documentName: string): Promise { diff --git a/tests/sanity/tests/model/inbox.ts/inbox-page.ts b/tests/sanity/tests/model/inbox.ts/inbox-page.ts index d8ad4a116a..1d456395dd 100644 --- a/tests/sanity/tests/model/inbox.ts/inbox-page.ts +++ b/tests/sanity/tests/model/inbox.ts/inbox-page.ts @@ -12,7 +12,7 @@ export class InboxPage { readonly leftSidePanelOpen = (): Locator => this.page.locator('#btnPAside') readonly leftSidePanelClose = (): Locator => this.page.locator('#btnPClose') readonly inboxChat = (text: string): Locator => this.page.getByText(text) - readonly issueTitle = (issueTitle: string): Locator => this.page.getByTitle(issueTitle) + readonly issueTitle = (issueTitle: string): Locator => this.page.getByText(issueTitle).first() // ACTIONS @@ -48,6 +48,10 @@ export class InboxPage { await expect(this.issueTitle(issueTitle)).toBeVisible() } + async clickIssuePresentInInbox (issueTitle: string): Promise { + await this.issueTitle(issueTitle).click() + } + async checkIfInboxChatExists (text: string, exists: boolean): Promise { if (exists) { await expect(this.inboxChat(text)).toBeVisible() diff --git a/tests/sanity/tests/model/planning/planning-page.ts b/tests/sanity/tests/model/planning/planning-page.ts index 3cf324d284..f1a4a721c7 100644 --- a/tests/sanity/tests/model/planning/planning-page.ts +++ b/tests/sanity/tests/model/planning/planning-page.ts @@ -81,7 +81,7 @@ export class PlanningPage extends CalendarPage { readonly eventInSchedule = (title: string): Locator => this.schedule().locator('div.event-container', { hasText: title }) - async dragdropTomorrow (title: string, time: string): Promise { + async dragdropTomorrow (title: string, time: string, addHalf: boolean = false): Promise { await this.toDosContainer().getByRole('button', { name: title }).hover() await expect(async () => { @@ -90,7 +90,7 @@ export class PlanningPage extends CalendarPage { expect(boundingBox).toBeTruthy() if (boundingBox != null) { await this.page.mouse.move(boundingBox.x + 10, boundingBox.y + 10) - await this.page.mouse.move(boundingBox.x + 10, boundingBox.y + 20) + await this.page.mouse.move(boundingBox.x + 10, boundingBox.y + (addHalf ? 40 : 20)) await this.page.mouse.up() } }).toPass(retryOptions) diff --git a/tests/sanity/tests/model/team-page.ts b/tests/sanity/tests/model/team-page.ts index 8bba4f4e29..e4e93cc5ab 100644 --- a/tests/sanity/tests/model/team-page.ts +++ b/tests/sanity/tests/model/team-page.ts @@ -16,6 +16,9 @@ export class TeamPage extends CommonPage { buttonNextDay = (): Locator => this.page.locator('div.hulyComponent div.hulyHeader-container .actions button[data-id="btnNext"]') + getItemByText = (column: string, title: string): Locator => + this.page.locator('div.hulyComponent div.item', { hasText: column }).locator('div.item', { hasText: title }) + async checkTeamPageIsOpened (): Promise { await expect(this.appHeader()).toBeVisible() } diff --git a/tests/sanity/tests/model/tracker/issues-details-page.ts b/tests/sanity/tests/model/tracker/issues-details-page.ts index e1e77c44a3..270646f99b 100644 --- a/tests/sanity/tests/model/tracker/issues-details-page.ts +++ b/tests/sanity/tests/model/tracker/issues-details-page.ts @@ -1,6 +1,7 @@ import { expect, type Locator, type Page } from '@playwright/test' import { CommonTrackerPage } from './common-tracker-page' import { Issue, NewIssue } from './types' +import { convertEstimation } from '../../tracker/tracker.utils' export class IssuesDetailsPage extends CommonTrackerPage { readonly page: Page @@ -139,10 +140,10 @@ export class IssuesDetailsPage extends CommonTrackerPage { await expect(this.buttonComponent()).toHaveText(data.component) } if (data.milestone != null) { - await expect(this.buttonMilestone()).toHaveText(data.milestone) + await expect(this.buttonMilestone()).toHaveText(data.milestone === 'No Milestone' ? 'Milestone' : data.milestone) } if (data.estimation != null) { - await expect(this.textEstimation()).toHaveText(data.estimation) + await expect(this.textEstimation()).toHaveText(convertEstimation(data.estimation)) } if (data.parentIssue != null) { await expect(this.textParentTitle()).toHaveText(data.parentIssue) diff --git a/tests/sanity/tests/model/tracker/issues-page.ts b/tests/sanity/tests/model/tracker/issues-page.ts index 869789e494..b44f8068d7 100644 --- a/tests/sanity/tests/model/tracker/issues-page.ts +++ b/tests/sanity/tests/model/tracker/issues-page.ts @@ -412,7 +412,7 @@ export class IssuesPage extends CommonTrackerPage { await this.fillNewIssueForm(data) await this.clickButtonCreateIssue() if (closeNotification) { - await this.closeNotification(this.page) + await this.closeNotification() } await attachScreenshot(`createdNewIssue-${data.title}.png`, this.page) } diff --git a/tests/sanity/tests/model/tracker/template-details-page.ts b/tests/sanity/tests/model/tracker/template-details-page.ts index 5d14235475..eb44b9bc75 100644 --- a/tests/sanity/tests/model/tracker/template-details-page.ts +++ b/tests/sanity/tests/model/tracker/template-details-page.ts @@ -1,6 +1,7 @@ import { expect, type Locator } from '@playwright/test' import { CommonTrackerPage } from './common-tracker-page' import { Issue, NewIssue } from './types' +import { convertEstimation } from '../../tracker/tracker.utils' export class TemplateDetailsPage extends CommonTrackerPage { inputTitle = (): Locator => this.page.locator('div.popupPanel-body input[type="text"]') @@ -34,7 +35,7 @@ export class TemplateDetailsPage extends CommonTrackerPage { await expect(this.buttonComponent()).toHaveText(data.component) } if (data.estimation != null) { - await expect(this.buttonEstimation()).toHaveText(data.estimation) + await expect(this.buttonEstimation()).toHaveText(convertEstimation(data.estimation)) } } diff --git a/tests/sanity/tests/planning/plan.spec.ts b/tests/sanity/tests/planning/plan.spec.ts index 606d28f46f..9c0c8988b0 100644 --- a/tests/sanity/tests/planning/plan.spec.ts +++ b/tests/sanity/tests/planning/plan.spec.ts @@ -1,5 +1,5 @@ import { test } from '@playwright/test' -import { generateId, PlatformSetting, PlatformURI, generateTestData } from '../utils' +import { generateId, PlatformSetting, PlatformURI, generateTestData, getTimeForPlanner } from '../utils' import { PlanningPage } from '../model/planning/planning-page' import { NewToDo } from '../model/planning/types' import { PlanningNavigationMenuPage } from '../model/planning/planning-navigation-menu-page' @@ -11,6 +11,7 @@ import { ApiEndpoint } from '../API/Api' import { LoginPage } from '../model/login-page' import { SignInJoinPage } from '../model/signin-page' import { TeamPage } from '../model/team-page' +import { SelectWorkspacePage } from '../model/select-workspace-page' test.use({ storageState: PlatformSetting @@ -178,12 +179,7 @@ test.describe('Planning ToDo tests', () => { } const titleV = `Visible ToDo ${generateId()}` const titleI = `Inisible ToDo ${generateId()}` - - let hour = new Date().getHours() - const ampm = hour < 13 ? 'am' : 'pm' - hour = hour < 1 ? 1 : hour >= 11 && hour < 13 ? 11 : hour >= 22 ? 10 : hour > 12 ? hour - 12 : hour - const timeV = `${hour}${ampm}` - const timeI = `${hour + 1}${ampm}` + const time = getTimeForPlanner() const leftSideMenuPage: LeftSideMenuPage = new LeftSideMenuPage(page) const loginPage: LoginPage = new LoginPage(page) @@ -192,7 +188,9 @@ test.describe('Planning ToDo tests', () => { await api.createWorkspaceWithLogin(data.workspaceName, data.userName, '1234') await (await page.goto(`${PlatformURI}`))?.finished() await loginPage.login(data.userName, '1234') - await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() + const swp = new SelectWorkspacePage(page) + await swp.selectWorkspace(data.workspaceName) + // await (await page.goto(`${PlatformURI}/workbench/${data.workspaceName}`))?.finished() await leftSideMenuPage.clickPlanner() const planningNavigationMenuPage = new PlanningNavigationMenuPage(page) @@ -201,7 +199,7 @@ test.describe('Planning ToDo tests', () => { await planningPage.selectInputToDo().fill(titleV) await planningPage.selectInputToDo().press('Enter') - await planningPage.dragdropTomorrow(titleV, timeV) + await planningPage.dragdropTomorrow(titleV, time) await planningPage.eventInSchedule(titleV).click() await planningPage.buttonPopupCreateVisible().click() await planningPage.buttonPopupVisibleToEveryone().click() @@ -209,7 +207,7 @@ test.describe('Planning ToDo tests', () => { await planningPage.selectInputToDo().fill(titleI) await planningPage.selectInputToDo().press('Enter') - await planningPage.dragdropTomorrow(titleI, timeI) + await planningPage.dragdropTomorrow(titleI, time, true) await planningPage.eventInSchedule(titleI).click() await planningPage.buttonPopupCreateVisible().click() await planningPage.buttonPopupOnlyVisibleToYou().click() diff --git a/tests/sanity/tests/tracker/common-steps.ts b/tests/sanity/tests/tracker/common-steps.ts index ab53e6a6a3..097f960510 100644 --- a/tests/sanity/tests/tracker/common-steps.ts +++ b/tests/sanity/tests/tracker/common-steps.ts @@ -26,7 +26,7 @@ export async function prepareNewIssueWithOpenStep (page: Page, issue: NewIssue): }) } -export function createNewIssueData (firstName: string, lastName: string): NewIssue { +export function createNewIssueData (firstName: string, lastName: string, replace?: object): NewIssue { return { title: faker.lorem.words(3), description: faker.lorem.sentence(), @@ -39,6 +39,7 @@ export function createNewIssueData (firstName: string, lastName: string): NewIss estimation: '2', milestone: 'No Milestone', duedate: 'today', - filePath: 'cat.jpeg' + filePath: 'cat.jpeg', + ...replace } } diff --git a/tests/sanity/tests/tracker/issues.spec.ts b/tests/sanity/tests/tracker/issues.spec.ts index c32a57ab71..fb0c445f07 100644 --- a/tests/sanity/tests/tracker/issues.spec.ts +++ b/tests/sanity/tests/tracker/issues.spec.ts @@ -46,11 +46,7 @@ test.describe('Tracker issue tests', () => { } await prepareNewIssueWithOpenStep(page, newIssue) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) }) test('Edit an issue', async ({ page }) => { @@ -74,34 +70,19 @@ test.describe('Tracker issue tests', () => { await issuesDetailsPage.checkIssue({ ...newIssue, - ...editIssue, - estimation: '1d' + ...editIssue }) - const estimations = new Map([ - ['0', '0h'], - ['1', '1h'], - ['1.25', '1h 15m'], - ['1.259', '1h 15m'], - ['1.26', '1h 15m'], - ['1.27', '1h 16m'], - ['1.5', '1h 30m'], - ['1.75', '1h 45m'], - ['2', '2h'], - ['7', '7h'], - ['8', '1d'], - ['9', '1d 1h'], - ['9.5', '1d 1h 30m'] - ]) + const estimations = ['0', '1', '1.25', '1.259', '1.26', '1.27', '1.5', '1.75', '2', '7', '8', '9', '9.5'] - for (const [input, expected] of estimations.entries()) { + for (const input of estimations) { await issuesDetailsPage.editIssue({ estimation: input }) await issuesDetailsPage.checkIssue({ ...newIssue, ...editIssue, - estimation: expected + estimation: input }) } }) @@ -222,7 +203,7 @@ test.describe('Tracker issue tests', () => { title: `New Issue-${generateId(4)}`, description: 'New Issue', priority: 'Medium', - estimation: '1d', + estimation: '8', component: 'Default component', milestone: 'Edit Milestone' } diff --git a/tests/sanity/tests/tracker/related-issues.spec.ts b/tests/sanity/tests/tracker/related-issues.spec.ts index d23d8cac0a..b9d399f64e 100644 --- a/tests/sanity/tests/tracker/related-issues.spec.ts +++ b/tests/sanity/tests/tracker/related-issues.spec.ts @@ -57,8 +57,6 @@ test.describe('Tracker related issue tests', () => { await issuesDetailsPage.checkIssue({ ...newIssue, ...relatedIssue, - milestone: 'Milestone', - estimation: '1d 4h', relatedIssue: 'TSK' }) }) diff --git a/tests/sanity/tests/tracker/subissues.spec.ts b/tests/sanity/tests/tracker/subissues.spec.ts index 6e8298aa2e..c4d998f68b 100644 --- a/tests/sanity/tests/tracker/subissues.spec.ts +++ b/tests/sanity/tests/tracker/subissues.spec.ts @@ -94,8 +94,6 @@ test.describe('Tracker sub-issues tests', () => { await issuesDetailsPage.checkIssue({ ...newSubIssue, ...editSubIssue, - milestone: 'Milestone', - estimation: '1d', parentIssue: newIssue.title }) }) diff --git a/tests/sanity/tests/tracker/template.spec.ts b/tests/sanity/tests/tracker/template.spec.ts index c379928c09..6b87bc9f49 100644 --- a/tests/sanity/tests/tracker/template.spec.ts +++ b/tests/sanity/tests/tracker/template.spec.ts @@ -41,10 +41,7 @@ test.describe('Tracker template tests', () => { await trackerNavigationMenuPage.openTemplateForProject('Default') await templatePage.createNewTemplate(newTemplate) await templatePage.openTemplate(newTemplate.title) - await templateDetailsPage.checkTemplate({ - ...newTemplate, - estimation: '2h' - }) + await templateDetailsPage.checkTemplate(newTemplate) }) test('Edit a Template', async ({ page }) => { @@ -69,36 +66,21 @@ test.describe('Tracker template tests', () => { await templateDetailsPage.editTemplate(editTemplate) await templateDetailsPage.checkTemplate({ ...newTemplate, - ...editTemplate, - estimation: '1d' + ...editTemplate }) await templateDetailsPage.checkActivityContent(`New template: ${newTemplate.title}`) - const estimations = new Map([ - ['0', '0h'], - ['1', '1h'], - ['1.25', '1h 15m'], - ['1.259', '1h 15m'], - ['1.26', '1h 15m'], - ['1.27', '1h 16m'], - ['1.5', '1h 30m'], - ['1.75', '1h 45m'], - ['2', '2h'], - ['7', '7h'], - ['8', '1d'], - ['9', '1d 1h'], - ['9.5', '1d 1h 30m'] - ]) + const estimations = ['0', '1', '1.25', '1.259', '1.26', '1.27', '1.5', '1.75', '2', '7', '8', '9', '9.5'] - for (const [input, expected] of estimations.entries()) { + for (const input of estimations) { await templateDetailsPage.editTemplate({ estimation: input }) await templateDetailsPage.checkTemplate({ ...newTemplate, ...editTemplate, - estimation: expected + estimation: input }) } }) diff --git a/tests/sanity/tests/tracker/tracker.spec.ts b/tests/sanity/tests/tracker/tracker.spec.ts index 4529f105e3..b119b24fab 100644 --- a/tests/sanity/tests/tracker/tracker.spec.ts +++ b/tests/sanity/tests/tracker/tracker.spec.ts @@ -168,7 +168,7 @@ test.describe('Tracker tests', () => { status: 'Todo', priority: 'Urgent', assignee: 'Appleseed John', - estimation: '1h', + estimation: '1', dueDate: '24' }) }) diff --git a/tests/sanity/tests/tracker/tracker.utils.ts b/tests/sanity/tests/tracker/tracker.utils.ts index a7d271ca0b..c296ba09c0 100644 --- a/tests/sanity/tests/tracker/tracker.utils.ts +++ b/tests/sanity/tests/tracker/tracker.utils.ts @@ -204,7 +204,7 @@ export async function checkIssueDraft (page: Page, props: IssueProps): Promise= 11 && hour < 13 ? 11 : hour >= 22 ? 10 : hour > 12 ? hour - 12 : hour + + return `${hour}${ampm}` +} + // Consistent data export const workspaceName = faker.lorem.word() export const userName = faker.internet.userName() diff --git a/tests/sanity/tests/workspace/create.spec.ts b/tests/sanity/tests/workspace/create.spec.ts index 4e4d2a5936..d51304d485 100644 --- a/tests/sanity/tests/workspace/create.spec.ts +++ b/tests/sanity/tests/workspace/create.spec.ts @@ -82,11 +82,7 @@ test.describe('Workspace tests', () => { await issuesPage.openIssueByName(newIssue.title) const issuesDetailsPage = new IssuesDetailsPage(page) - await issuesDetailsPage.checkIssue({ - ...newIssue, - milestone: 'Milestone', - estimation: '2h' - }) + await issuesDetailsPage.checkIssue(newIssue) }) test('Check validation steps description for the create flow', async ({ page }) => {