diff --git a/.vscode/launch.json b/.vscode/launch.json index 5ee24a15d5..5d8c9333ed 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -351,7 +351,7 @@ "request": "launch", "args": ["src/index.ts"], "env": { - "PORT": "4077", + "PORT": "4007", "SECRET": "secret", "MONGO_URL": "mongodb://localhost:27017", "MINIO_ENDPOINT": "localhost", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 340fd45a1f..ba4aecd349 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -83,6 +83,9 @@ dependencies: '@rush-temp/analytics-collector-assets': specifier: file:./projects/analytics-collector-assets.tgz version: file:projects/analytics-collector-assets.tgz(esbuild@0.20.1)(svelte@4.2.12)(ts-node@10.9.2) + '@rush-temp/analytics-collector-resources': + specifier: file:./projects/analytics-collector-resources.tgz + version: file:projects/analytics-collector-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/analytics-service': specifier: file:./projects/analytics-service.tgz version: file:projects/analytics-service.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2) @@ -24112,6 +24115,51 @@ packages: - ts-node dev: false + file:projects/analytics-collector-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-UzpZTJE37q+ijqBeKDNHJ3JCUMXIakaHRSuBcBNGLXvW/wDtOW+zJNpou3I69TVwVZNcT/6/uRVYfFfy9thpow==, tarball: file:projects/analytics-collector-resources.tgz} + id: file:projects/analytics-collector-resources.tgz + name: '@rush-temp/analytics-collector-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) + eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.12)(ts-node@10.9.2) + jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + prettier: 3.2.5 + prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.12) + sass: 1.71.1 + svelte: 4.2.12 + svelte-check: 3.6.9(postcss-load-config@4.0.2)(postcss@8.4.35)(sass@1.71.1)(svelte@4.2.12) + svelte-eslint-parser: 0.33.1(svelte@4.2.12) + svelte-loader: 3.2.0(svelte@4.2.12) + svelte-preprocess: 5.1.3(postcss-load-config@4.0.2)(postcss@8.4.35)(sass@1.71.1)(svelte@4.2.12)(typescript@5.3.3) + 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 + - coffeescript + - esbuild + - less + - node-notifier + - postcss + - postcss-load-config + - pug + - stylus + - sugarss + - supports-color + - ts-node + dev: false + file:projects/analytics-collector.tgz(@types/node@20.11.19)(esbuild@0.20.1)(svelte@4.2.12)(ts-node@10.9.2): resolution: {integrity: sha512-m298Tce6R1UfZPYO43qimcjCqQNDKFQ08UiLhIcwIfk5Tg+Gm67q9Da79dE2fKDxKqIoAIPytneAgtOOXIMVaw==, tarball: file:projects/analytics-collector.tgz} id: file:projects/analytics-collector.tgz @@ -25495,7 +25543,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-+dbbvdgHJD76pLjOUSctj1tb9kC3hWa4CisFJxvSmlObAUII4NI+4EmE4u1x8OWaGEDqRCPCmrGFk1aSiPF5fQ==, tarball: file:projects/desktop.tgz} + resolution: {integrity: sha512-/SB+ER8kNF2XYUvZ61U+U9GSaTya1qsHJWc3FDUdz8d12APYoMYf6c2Ax1+Kqk1GUWMu9+rYRfypLOVyqirjyw==, tarball: file:projects/desktop.tgz} id: file:projects/desktop.tgz name: '@rush-temp/desktop' version: 0.0.0 @@ -27162,7 +27210,7 @@ packages: dev: false file:projects/model-analytics-collector.tgz: - resolution: {integrity: sha512-ht/R4aa2RvdT8odpbznVfFTed3dJGToS1q5tKHqJIF8BQhjNcVh9ZUmAJNnNZNTeD8J6yuKIPq7n/Io2CuTYdg==, tarball: file:projects/model-analytics-collector.tgz} + resolution: {integrity: sha512-bYgqacNTGcL0kAYSi3aFT9mseXoIamKxvjP/7gWyh2Hm61bf9FSeQsgVXUxYBLACWeTWFfZsTie8Go1Hjs0JlQ==, tarball: file:projects/model-analytics-collector.tgz} name: '@rush-temp/model-analytics-collector' version: 0.0.0 dependencies: @@ -29025,7 +29073,7 @@ packages: dev: false file:projects/pod-analytics-collector.tgz(bufferutil@4.0.8)(utf-8-validate@6.0.4): - resolution: {integrity: sha512-15hQZSiqnsgeZDg3n3AE2D2mx6xBToMB+2z+nDKLAKbaa+XAjMyj7J/Z9nLwfjs2EX6Z8x10JoPvs4au/SXxlA==, tarball: file:projects/pod-analytics-collector.tgz} + resolution: {integrity: sha512-PbfH6v1MeYjOCQjFEQ2Lb63CwlQrqeeHkEzZYbPjgMC1x9XoUOzv+2krfWR8RXHCm7pW1TnWn8UHRbaZIUVJzg==, tarball: file:projects/pod-analytics-collector.tgz} id: file:projects/pod-analytics-collector.tgz name: '@rush-temp/pod-analytics-collector' version: 0.0.0 @@ -29049,6 +29097,7 @@ packages: eslint-plugin-promise: 6.1.1(eslint@8.56.0) express: 4.19.2 jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2) + mongodb: 6.8.0 prettier: 3.2.5 puppeteer: 22.14.0(bufferutil@4.0.8)(typescript@5.3.3)(utf-8-validate@6.0.4) ts-jest: 29.1.2(esbuild@0.20.1)(jest@29.7.0)(typescript@5.3.3) @@ -29056,14 +29105,21 @@ packages: 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 + - gcp-metadata + - kerberos + - mongodb-client-encryption - node-notifier + - snappy + - socks - supports-color - utf-8-validate dev: false diff --git a/desktop/package.json b/desktop/package.json index a3f4ecfa41..79eab846c5 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -200,6 +200,7 @@ "@hcengineering/products-resources": "^0.1.0", "@hcengineering/analytics-collector": "^0.6.0", "@hcengineering/analytics-collector-assets": "^0.6.0", + "@hcengineering/analytics-collector-resources": "^0.6.0", "electron-squirrel-startup": "~1.0.0", "dotenv": "~16.0.0", "electron-context-menu": "^4.0.1", diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index d151e0f028..b9fc55bd53 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -274,6 +274,7 @@ export async function configurePlatform (): Promise { addLocation(notificationId, async () => await import('@hcengineering/notification-resources')) addLocation(tagsId, async () => await import('@hcengineering/tags-resources')) addLocation(calendarId, async () => await import('@hcengineering/calendar-resources')) + addLocation(analyticsCollectorId, async () => await import('@hcengineering/analytics-collector-resources')) addLocation(trackerId, async () => await import('@hcengineering/tracker-resources')) addLocation(boardId, async () => await import('@hcengineering/board-resources')) diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index 33fac4a2a1..e7e9d000ac 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -110,7 +110,7 @@ services: - GITHUB_URL=http://localhost:3500 - PRINT_URL=http://localhost:4005 - SIGN_URL=http://localhost:4006 - - ANALYTICS_COLLECTOR_URL=http://localhost:4077 + - ANALYTICS_COLLECTOR_URL=http://localhost:4007 - DESKTOP_UPDATES_URL=https://dist.huly.io - DESKTOP_UPDATES_CHANNEL=dev - BRANDING_URL=http://localhost:8087/branding.json @@ -200,7 +200,7 @@ services: image: hardcoreeng/analytics-collector restart: unless-stopped ports: - - 4077:4007 + - 4007:4007 environment: - SECRET=secret - PORT=4007 diff --git a/dev/local-mongo/docker-compose.yaml b/dev/local-mongo/docker-compose.yaml index eb2ee0d2c0..228b33aec0 100644 --- a/dev/local-mongo/docker-compose.yaml +++ b/dev/local-mongo/docker-compose.yaml @@ -97,7 +97,7 @@ services: - GITHUB_URL=http://localhost:3500 - PRINT_URL=http://localhost:4005 - SIGN_URL=http://localhost:4006 - - ANALYTICS_COLLECTOR_URL=http://localhost:4077 + - ANALYTICS_COLLECTOR_URL=http://localhost:4007 - DESKTOP_UPDATES_URL=https://dist.huly.io - DESKTOP_UPDATES_CHANNEL=dev - BRANDING_URL=http://localhost:8087/branding.json @@ -187,7 +187,7 @@ services: image: hardcoreeng/analytics-collector restart: unless-stopped ports: - - 4077:4007 + - 4007:4007 environment: - SECRET=secret - PORT=4007 diff --git a/dev/prod/package.json b/dev/prod/package.json index ee3cddfae8..6e10a6a868 100644 --- a/dev/prod/package.json +++ b/dev/prod/package.json @@ -216,6 +216,7 @@ "@hcengineering/uploader-resources": "^0.6.0", "@hcengineering/analytics-collector": "^0.6.0", "@hcengineering/analytics-collector-assets": "^0.6.0", + "@hcengineering/analytics-collector-resources": "^0.6.0", "@hcengineering/controlled-documents": "^0.1.0", "@hcengineering/controlled-documents-assets": "^0.1.0", "@hcengineering/controlled-documents-resources": "^0.1.0", diff --git a/dev/prod/public/config.json b/dev/prod/public/config.json index b448809f77..9b3e3ce052 100644 --- a/dev/prod/public/config.json +++ b/dev/prod/public/config.json @@ -12,7 +12,7 @@ "LAST_NAME_FIRST": "true", "PRINT_URL": "http://localhost:4005", "SIGN_URL": "http://localhost:4006", - "ANALYTICS_COLLECTOR_URL": "http://localhost:4077", + "ANALYTICS_COLLECTOR_URL": "http://localhost:4007", "BRANDING_URL": "/branding.json", "VERSION": null, "MODEL_VERSION": null diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 3d238cd38d..c8724610ae 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -364,6 +364,7 @@ export async function configurePlatform() { addLocation(diffviewId, () => import(/* webpackChunkName: "diffview" */ '@hcengineering/diffview-resources')) addLocation(timeId, () => import(/* webpackChunkName: "time" */ '@hcengineering/time-resources')) addLocation(desktopPreferencesId, () => import(/* webpackChunkName: "desktop-preferences" */ '@hcengineering/desktop-preferences-resources')) + addLocation(analyticsCollectorId, async () => await import('@hcengineering/analytics-collector-resources')) addLocation(trackerId, () => import(/* webpackChunkName: "tracker" */ '@hcengineering/tracker-resources')) addLocation(boardId, () => import(/* webpackChunkName: "board" */ '@hcengineering/board-resources')) diff --git a/models/all/src/index.ts b/models/all/src/index.ts index 02ec6770db..78f4f623f5 100644 --- a/models/all/src/index.ts +++ b/models/all/src/index.ts @@ -249,6 +249,17 @@ export default function buildModel (enabled: string[] = ['*'], disabled: string[ [uploaderModel, uploaderId], [notificationModel, notificationId], [preferenceModel, preferenceId], + [ + analyticsCollectorModel, + analyticsCollectorId, + { + label: inventory.string.ConfigLabel, + description: inventory.string.ConfigDescription, + enabled: false, + beta: false, + classFilter: defaultFilter + } + ], [ hrModel, hrId, @@ -348,7 +359,6 @@ export default function buildModel (enabled: string[] = ['*'], disabled: string[ } ], [printModel, printId], - [analyticsCollectorModel, analyticsCollectorId], [driveModel, driveId], [ documentsModel, diff --git a/models/analytics-collector/package.json b/models/analytics-collector/package.json index 68c30d5268..aa1337b10b 100644 --- a/models/analytics-collector/package.json +++ b/models/analytics-collector/package.json @@ -28,18 +28,19 @@ "typescript": "^5.3.3" }, "dependencies": { + "@hcengineering/activity": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", "@hcengineering/attachment": "^0.6.14", "@hcengineering/chunter": "^0.6.20", "@hcengineering/core": "^0.6.32", "@hcengineering/model": "^0.6.11", "@hcengineering/model-activity": "^0.6.0", - "@hcengineering/model-notification": "^0.6.0", "@hcengineering/model-chunter": "^0.6.0", "@hcengineering/model-core": "^0.6.0", + "@hcengineering/model-notification": "^0.6.0", "@hcengineering/model-view": "^0.6.0", "@hcengineering/platform": "^0.6.11", "@hcengineering/ui": "^0.6.15", - "@hcengineering/view": "^0.6.13", - "@hcengineering/analytics-collector": "^0.6.0" + "@hcengineering/view": "^0.6.13" } } diff --git a/models/analytics-collector/src/index.ts b/models/analytics-collector/src/index.ts index 999ef785d4..aaa58ef93f 100644 --- a/models/analytics-collector/src/index.ts +++ b/models/analytics-collector/src/index.ts @@ -13,10 +13,12 @@ // limitations under the License. // -import { type Builder, Mixin } from '@hcengineering/model' -import { type AnalyticsChannel } from '@hcengineering/analytics-collector' +import { type Builder, Model, Prop, ReadOnly, TypeString, UX } from '@hcengineering/model' +import { type OnboardingChannel } from '@hcengineering/analytics-collector' import chunter from '@hcengineering/chunter' import { TChannel } from '@hcengineering/model-chunter' +import activity, { type ActivityMessageControl } from '@hcengineering/activity' +import core from '@hcengineering/core' import analyticsCollector from './plugin' @@ -24,12 +26,66 @@ export { analyticsCollectorId } from '@hcengineering/analytics-collector' export { analyticsCollectorOperation } from './migration' export default analyticsCollector -@Mixin(analyticsCollector.mixin.AnalyticsChannel, chunter.class.Channel) -export class TAnalyticsChannel extends TChannel implements AnalyticsChannel { - workspace!: string - email!: string +@Model(analyticsCollector.class.OnboardingChannel, chunter.class.Channel) +@UX( + analyticsCollector.string.OnboardingChannel, + chunter.icon.Hashtag, + undefined, + undefined, + undefined, + analyticsCollector.string.OnboardingChannels +) +export class TOnboardingChannel extends TChannel implements OnboardingChannel { + @Prop(TypeString(), analyticsCollector.string.UserName) + @ReadOnly() + userName!: string + + @Prop(TypeString(), analyticsCollector.string.Email) + @ReadOnly() + email!: string + + @Prop(TypeString(), analyticsCollector.string.WorkspaceName) + @ReadOnly() + workspaceName!: string + + @Prop(TypeString(), analyticsCollector.string.WorkspaceUrl) + @ReadOnly() + workspaceUrl!: string + + @Prop(TypeString(), analyticsCollector.string.WorkspaceId) + @ReadOnly() + workspaceId!: string } export function createModel (builder: Builder): void { - builder.createModel(TAnalyticsChannel) + builder.createModel(TOnboardingChannel) + + builder.createDoc(activity.class.ActivityExtension, core.space.Model, { + ofClass: analyticsCollector.class.OnboardingChannel, + components: { input: chunter.component.ChatMessageInput } + }) + + builder.createDoc>( + activity.class.ActivityMessageControl, + core.space.Model, + { + objectClass: analyticsCollector.class.OnboardingChannel, + skip: [ + { _class: core.class.TxMixin }, + { _class: core.class.TxCreateDoc, objectClass: { $ne: analyticsCollector.class.OnboardingChannel } }, + { _class: core.class.TxRemoveDoc } + ], + allowedFields: ['members'] + } + ) + + builder.createDoc(activity.class.DocUpdateMessageViewlet, core.space.Model, { + objectClass: analyticsCollector.class.OnboardingChannel, + action: 'update', + config: { + members: { + presenter: chunter.activity.MembersChangedMessage + } + } + }) } diff --git a/models/analytics-collector/src/migration.ts b/models/analytics-collector/src/migration.ts index ee80e23af2..aad168359a 100644 --- a/models/analytics-collector/src/migration.ts +++ b/models/analytics-collector/src/migration.ts @@ -19,15 +19,13 @@ import { type MigrationUpgradeClient, tryMigrate } from '@hcengineering/model' -import analyticsCollector, { analyticsCollectorId } from '@hcengineering/analytics-collector' +import { analyticsCollectorId } from '@hcengineering/analytics-collector' import { DOMAIN_SPACE } from '@hcengineering/model-core' import { DOMAIN_DOC_NOTIFY, DOMAIN_NOTIFICATION } from '@hcengineering/model-notification' import { DOMAIN_ACTIVITY } from '@hcengineering/model-activity' -async function removeAnalyticsChannels (client: MigrationClient): Promise { - const channels = await client.find(DOMAIN_SPACE, { - [`${analyticsCollector.mixin.AnalyticsChannel}`]: { $exists: true } - }) +async function removeOnboardingChannels (client: MigrationClient): Promise { + const channels = await client.find(DOMAIN_SPACE, { 'analytics:mixin:AnalyticsChannel': { $exists: true } }) if (channels.length === 0) { return @@ -40,15 +38,15 @@ async function removeAnalyticsChannels (client: MigrationClient): Promise await client.deleteMany(DOMAIN_ACTIVITY, { attachedTo: { $in: channelsIds } }) await client.deleteMany(DOMAIN_NOTIFICATION, { docNotifyContext: { $in: contextsIds } }) await client.deleteMany(DOMAIN_DOC_NOTIFY, { _id: { $in: contextsIds } }) - await client.deleteMany(DOMAIN_SPACE, { [`${analyticsCollector.mixin.AnalyticsChannel}`]: { $exists: true } }) + await client.deleteMany(DOMAIN_SPACE, { _id: { $in: channelsIds } }) } export const analyticsCollectorOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise { await tryMigrate(client, analyticsCollectorId, [ { - state: 'remove-analytics-channels-v1', - func: removeAnalyticsChannels + state: 'remove-analytics-channels-v3', + func: removeOnboardingChannels } ]) }, diff --git a/models/chunter/src/index.ts b/models/chunter/src/index.ts index dfc8774d67..f8405261a8 100644 --- a/models/chunter/src/index.ts +++ b/models/chunter/src/index.ts @@ -25,7 +25,9 @@ import { type ThreadMessage, type ChatInfo, type ChannelInfo, - type TypingInfo + type InlineButton, + type TypingInfo, + type InlineButtonAction } from '@hcengineering/chunter' import presentation from '@hcengineering/model-presentation' import contact, { type ChannelProvider as SocialChannelProvider, type Person } from '@hcengineering/contact' @@ -54,11 +56,11 @@ import { Hidden } from '@hcengineering/model' import attachment from '@hcengineering/model-attachment' -import core, { TClass, TDoc, TSpace } from '@hcengineering/model-core' +import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core' import notification, { TDocNotifyContext } from '@hcengineering/model-notification' import view from '@hcengineering/model-view' import workbench from '@hcengineering/model-workbench' -import { type IntlString } from '@hcengineering/platform' +import { type IntlString, type Resource } from '@hcengineering/platform' import { TActivityMessage } from '@hcengineering/model-activity' import { type DocNotifyContext } from '@hcengineering/notification' @@ -103,6 +105,9 @@ export class TChatMessage extends TActivityMessage implements ChatMessage { @Prop(TypeRef(contact.class.ChannelProvider), core.string.Object) provider?: Ref + + @Prop(PropCollection(chunter.class.InlineButton), core.string.Object) + inlineButtons?: number } @Model(chunter.class.ThreadMessage, chunter.class.ChatMessage) @@ -157,6 +162,14 @@ export class TChatInfo extends TDoc implements ChatInfo { timestamp!: Timestamp } +@Model(chunter.class.InlineButton, core.class.Doc, DOMAIN_CHUNTER) +export class TInlineButton extends TAttachedDoc implements InlineButton { + name!: string + titleIntl?: IntlString + title?: string + action!: Resource +} + @Model(chunter.class.TypingInfo, core.class.Doc, DOMAIN_TRANSIENT) export class TTypingInfo extends TDoc implements TypingInfo { objectId!: Ref @@ -176,6 +189,7 @@ export function createModel (builder: Builder): void { TObjectChatPanel, TChatInfo, TChannelInfo, + TInlineButton, TTypingInfo ) const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage] diff --git a/models/chunter/src/plugin.ts b/models/chunter/src/plugin.ts index 0e039bbd7c..4a5efef78e 100644 --- a/models/chunter/src/plugin.ts +++ b/models/chunter/src/plugin.ts @@ -53,8 +53,7 @@ export default mergeIds(chunterId, chunter, { Chunter: '' as Ref }, activity: { - ChannelCreatedMessage: '' as AnyComponent, - MembersChangedMessage: '' as AnyComponent + ChannelCreatedMessage: '' as AnyComponent }, string: { ApplicationLabelChunter: '' as IntlString, diff --git a/models/server-ai-bot/src/index.ts b/models/server-ai-bot/src/index.ts index 077c9f9d16..d81b0c21d3 100644 --- a/models/server-ai-bot/src/index.ts +++ b/models/server-ai-bot/src/index.ts @@ -65,6 +65,7 @@ export class TAIBotTransferEvent extends TAIBotEvent implements AIBotTransferEve toEmail!: string toWorkspace!: string fromWorkspace!: string + fromWorkspaceName!: string fromWorkspaceUrl!: string messageId!: Ref parentMessageId?: Ref diff --git a/plugins/ai-bot/src/index.ts b/plugins/ai-bot/src/index.ts index 376004992d..c612e4dd6f 100644 --- a/plugins/ai-bot/src/index.ts +++ b/plugins/ai-bot/src/index.ts @@ -39,6 +39,7 @@ export interface AIBotTransferEvent extends AIBotEvent { toEmail: string toWorkspace: string fromWorkspace: string + fromWorkspaceName: string fromWorkspaceUrl: string messageId: Ref parentMessageId?: Ref diff --git a/plugins/analytics-collector-assets/lang/en.json b/plugins/analytics-collector-assets/lang/en.json index e1a049dade..ffc075b674 100644 --- a/plugins/analytics-collector-assets/lang/en.json +++ b/plugins/analytics-collector-assets/lang/en.json @@ -1,6 +1,6 @@ { "string": { - "AnalyticsChannelDescription": "User: {user}; Workspace id: {workspace}", + "OnboardingChannelDescription": "User: {user}; Workspace: {workspace}", "Error": "Error: ", "InProject": " in project ", "NavigateToSpecial": "{app}: open {special}", @@ -8,6 +8,13 @@ "Set": "Set ", "To": " to ", "Open": "open ", - "Workbench": "Workbench: " + "Workbench": "Workbench: ", + "OnboardingChannel": "Onboarding Channel", + "OnboardingChannels": "Onboarding Channels", + "WorkspaceId": "Workspace id", + "WorkspaceName": "Workspace name", + "WorkspaceUrl": "Workspace url", + "Email": "Email", + "UserName": "User name" } } diff --git a/plugins/analytics-collector-assets/lang/es.json b/plugins/analytics-collector-assets/lang/es.json index 324bdcf600..13f5f142e3 100644 --- a/plugins/analytics-collector-assets/lang/es.json +++ b/plugins/analytics-collector-assets/lang/es.json @@ -1,6 +1,6 @@ { "string": { - "AnalyticsChannelDescription": "User: {user}; Workspace id: {workspace}", + "OnboardingChannelDescription": "User: {user}; Workspace: {workspace}", "Error": "Error: ", "InProject": " in project ", "NavigateToSpecial": "{app}: open {special}", @@ -8,6 +8,13 @@ "Set": "Set ", "To": " to ", "Open": "open ", - "Workbench": "Workbench: " + "Workbench": "Workbench: ", + "OnboardingChannel": "Canal de incorporación", + "OnboardingChannels": "Canales de incorporación", + "WorkspaceId": "Workspace id", + "WorkspaceName": "Workspace name", + "WorkspaceUrl": "Workspace url", + "Email": "Email", + "UserName": "User name" } } diff --git a/plugins/analytics-collector-assets/lang/pt.json b/plugins/analytics-collector-assets/lang/pt.json index 324bdcf600..13f5f142e3 100644 --- a/plugins/analytics-collector-assets/lang/pt.json +++ b/plugins/analytics-collector-assets/lang/pt.json @@ -1,6 +1,6 @@ { "string": { - "AnalyticsChannelDescription": "User: {user}; Workspace id: {workspace}", + "OnboardingChannelDescription": "User: {user}; Workspace: {workspace}", "Error": "Error: ", "InProject": " in project ", "NavigateToSpecial": "{app}: open {special}", @@ -8,6 +8,13 @@ "Set": "Set ", "To": " to ", "Open": "open ", - "Workbench": "Workbench: " + "Workbench": "Workbench: ", + "OnboardingChannel": "Canal de incorporación", + "OnboardingChannels": "Canales de incorporación", + "WorkspaceId": "Workspace id", + "WorkspaceName": "Workspace name", + "WorkspaceUrl": "Workspace url", + "Email": "Email", + "UserName": "User name" } } diff --git a/plugins/analytics-collector-assets/lang/ru.json b/plugins/analytics-collector-assets/lang/ru.json index 324bdcf600..f94f5e1ccb 100644 --- a/plugins/analytics-collector-assets/lang/ru.json +++ b/plugins/analytics-collector-assets/lang/ru.json @@ -1,6 +1,6 @@ { "string": { - "AnalyticsChannelDescription": "User: {user}; Workspace id: {workspace}", + "OnboardingChannelDescription": "User: {user}; Workspace: {workspace}", "Error": "Error: ", "InProject": " in project ", "NavigateToSpecial": "{app}: open {special}", @@ -8,6 +8,13 @@ "Set": "Set ", "To": " to ", "Open": "open ", - "Workbench": "Workbench: " + "Workbench": "Workbench: ", + "OnboardingChannel": "Канал обучения", + "OnboardingChannels": "Каналы обучения", + "WorkspaceId": "Id пространства", + "WorkspaceName": "Имя пространства", + "WorkspaceUrl": "URL пространства", + "Email": "Email", + "UserName": "Имя пользователя" } } diff --git a/plugins/analytics-collector-resources/.eslintrc.js b/plugins/analytics-collector-resources/.eslintrc.js new file mode 100644 index 0000000000..bb8fd7450d --- /dev/null +++ b/plugins/analytics-collector-resources/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/ui/eslint.config.json'], + parserOptions: { tsconfigRootDir: __dirname } +} diff --git a/plugins/analytics-collector-resources/.prettierrc b/plugins/analytics-collector-resources/.prettierrc new file mode 100644 index 0000000000..792942803a --- /dev/null +++ b/plugins/analytics-collector-resources/.prettierrc @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "trailingComma": "none", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "printWidth": 120, + "useTabs": false, + "bracketSpacing": true, + "proseWrap": "preserve", + "plugins": [ + "prettier-plugin-svelte" + ], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] +} \ No newline at end of file diff --git a/plugins/analytics-collector-resources/config/rig.json b/plugins/analytics-collector-resources/config/rig.json new file mode 100644 index 0000000000..bcad6f7c33 --- /dev/null +++ b/plugins/analytics-collector-resources/config/rig.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig", + "rigProfile": "ui" +} diff --git a/plugins/analytics-collector-resources/jest.config.js b/plugins/analytics-collector-resources/jest.config.js new file mode 100644 index 0000000000..3656e284d3 --- /dev/null +++ b/plugins/analytics-collector-resources/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'] +} diff --git a/plugins/analytics-collector-resources/package.json b/plugins/analytics-collector-resources/package.json new file mode 100644 index 0000000000..525ad90342 --- /dev/null +++ b/plugins/analytics-collector-resources/package.json @@ -0,0 +1,51 @@ +{ + "name": "@hcengineering/analytics-collector-resources", + "version": "0.6.0", + "main": "src/index.ts", + "author": "Anticrm Platform Contributors", + "license": "EPL-2.0", + "scripts": { + "build": "compile ui", + "build:docs": "api-extractor run --local", + "format": "format src", + "svelte-check": "do-svelte-check", + "_phase:svelte-check": "do-svelte-check", + "build:watch": "compile ui", + "_phase:build": "compile ui", + "_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", + "eslint-plugin-svelte": "^2.35.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "prettier-plugin-svelte": "^3.2.2", + "sass": "^1.53.0", + "svelte-check": "^3.6.9", + "svelte-eslint-parser": "^0.33.1", + "svelte-loader": "^3.2.0", + "svelte-preprocess": "^5.1.3", + "ts-jest": "^29.1.1", + "typescript": "^5.3.3" + }, + "dependencies": { + "@hcengineering/analytics-collector": "^0.6.0", + "@hcengineering/chunter": "^0.6.20", + "@hcengineering/chunter-resources": "^0.6.0", + "@hcengineering/contact": "^0.6.24", + "@hcengineering/contact-resources": "^0.6.0", + "@hcengineering/core": "^0.6.32", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/presentation": "^0.6.3", + "svelte": "^4.2.12" + } +} diff --git a/plugins/analytics-collector-resources/postcss.config.js b/plugins/analytics-collector-resources/postcss.config.js new file mode 100644 index 0000000000..88752c6cb0 --- /dev/null +++ b/plugins/analytics-collector-resources/postcss.config.js @@ -0,0 +1,5 @@ +module.exports = { + plugins: [ + require('autoprefixer') + ] +} diff --git a/plugins/analytics-collector-resources/src/index.ts b/plugins/analytics-collector-resources/src/index.ts new file mode 100644 index 0000000000..f21bd9da1b --- /dev/null +++ b/plugins/analytics-collector-resources/src/index.ts @@ -0,0 +1,23 @@ +// +// 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 Resources } from '@hcengineering/platform' +import { AnalyticsCollectorInlineAction } from './utils' + +export default async (): Promise => ({ + function: { + AnalyticsCollectorInlineAction + } +}) diff --git a/plugins/analytics-collector-resources/src/utils.ts b/plugins/analytics-collector-resources/src/utils.ts new file mode 100644 index 0000000000..4d90715c6d --- /dev/null +++ b/plugins/analytics-collector-resources/src/utils.ts @@ -0,0 +1,46 @@ +// +// 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 InlineButtonAction } from '@hcengineering/chunter' +import analyticsCollector from '@hcengineering/analytics-collector' +import { getMetadata } from '@hcengineering/platform' +import presentation from '@hcengineering/presentation' +import { concatLink } from '@hcengineering/core' + +export const AnalyticsCollectorInlineAction: InlineButtonAction = async ( + button, + messageId, + channelId +): Promise => { + const url = getMetadata(analyticsCollector.metadata.EndpointURL) ?? '' + const token = getMetadata(presentation.metadata.Token) ?? '' + + if (url === '' || token === '') { + return + } + + try { + await fetch(concatLink(url, 'action'), { + method: 'POST', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ _id: button._id, name: button.name, messageId, channelId }) + }) + } catch (e) { + console.error(e) + } +} diff --git a/plugins/analytics-collector-resources/svelte.config.js b/plugins/analytics-collector-resources/svelte.config.js new file mode 100644 index 0000000000..944a06f73e --- /dev/null +++ b/plugins/analytics-collector-resources/svelte.config.js @@ -0,0 +1,5 @@ +const sveltePreprocess = require('svelte-preprocess') + +module.exports = { + preprocess: sveltePreprocess() +}; \ No newline at end of file diff --git a/plugins/analytics-collector-resources/tsconfig.json b/plugins/analytics-collector-resources/tsconfig.json new file mode 100644 index 0000000000..fb6ccb2d18 --- /dev/null +++ b/plugins/analytics-collector-resources/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/ui/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types" + } +} \ No newline at end of file diff --git a/plugins/analytics-collector/src/index.ts b/plugins/analytics-collector/src/index.ts index d96f62dccf..d1009feae5 100644 --- a/plugins/analytics-collector/src/index.ts +++ b/plugins/analytics-collector/src/index.ts @@ -13,11 +13,12 @@ // limitations under the License. // -import type { IntlString, Metadata, Plugin } from '@hcengineering/platform' +import type { IntlString, Metadata, Plugin, Resource } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' -import type { Mixin, Ref } from '@hcengineering/core' +import type { Class, Ref } from '@hcengineering/core' +import { Channel, InlineButtonAction } from '@hcengineering/chunter' -import { AnalyticsChannel } from './types' +import { OnboardingChannel } from './types' export const analyticsCollectorId = 'analytics' as Plugin @@ -28,18 +29,31 @@ const analyticsCollector = plugin(analyticsCollectorId, { metadata: { EndpointURL: '' as Metadata }, - mixin: { - AnalyticsChannel: '' as Ref> + class: { + OnboardingChannel: '' as Ref> + }, + space: { + GeneralOnboardingChannel: '' as Ref + }, + function: { + AnalyticsCollectorInlineAction: '' as Resource }, string: { - AnalyticsChannelDescription: '' as IntlString, + OnboardingChannelDescription: '' as IntlString, Error: '' as IntlString, InProject: '' as IntlString, Open: '' as IntlString, OpenSpecial: '' as IntlString, Set: '' as IntlString, To: '' as IntlString, - Workbench: '' as IntlString + Workbench: '' as IntlString, + OnboardingChannel: '' as IntlString, + OnboardingChannels: '' as IntlString, + WorkspaceId: '' as IntlString, + WorkspaceName: '' as IntlString, + WorkspaceUrl: '' as IntlString, + Email: '' as IntlString, + UserName: '' as IntlString } }) diff --git a/plugins/analytics-collector/src/types.ts b/plugins/analytics-collector/src/types.ts index 8759474e23..75a86d154c 100644 --- a/plugins/analytics-collector/src/types.ts +++ b/plugins/analytics-collector/src/types.ts @@ -29,7 +29,10 @@ export interface AnalyticEvent { timestamp: number } -export interface AnalyticsChannel extends Channel { - workspace: string +export interface OnboardingChannel extends Channel { + workspaceId: string + workspaceName: string + workspaceUrl: string email: string + userName: string } diff --git a/plugins/analytics-collector/src/utils.ts b/plugins/analytics-collector/src/utils.ts index 2ab19eeb46..9b2c888ea3 100644 --- a/plugins/analytics-collector/src/utils.ts +++ b/plugins/analytics-collector/src/utils.ts @@ -13,6 +13,6 @@ // limitations under the License. // -export function getAnalyticsChannelName (worksapce: string, email: string): string { +export function getOnboardingChannelName (worksapce: string, email: string): string { return `${email}; ${worksapce}` } diff --git a/plugins/chunter-resources/src/channelDataProvider.ts b/plugins/chunter-resources/src/channelDataProvider.ts index 722d2ce49e..37dd22a669 100644 --- a/plugins/chunter-resources/src/channelDataProvider.ts +++ b/plugins/chunter-resources/src/channelDataProvider.ts @@ -30,6 +30,7 @@ import activity, { type ActivityMessage, type ActivityReference } from '@hcengin import attachment from '@hcengineering/attachment' import { combineActivityMessages, sortActivityMessages } from '@hcengineering/activity-resources' import notification, { type DocNotifyContext } from '@hcengineering/notification' +import chunter from '@hcengineering/chunter' export type LoadMode = 'forward' | 'backward' @@ -285,7 +286,7 @@ export class ChannelDataProvider implements IChannelDataProvider { { sort: { createdOn: SortingOrder.Descending }, lookup: { - _id: { attachments: attachment.class.Attachment } + _id: { attachments: attachment.class.Attachment, inlineButtons: chunter.class.InlineButton } } } ) @@ -331,7 +332,7 @@ export class ChannelDataProvider implements IChannelDataProvider { limit: limit ?? this.limit, sort: { createdOn: isBackward ? SortingOrder.Descending : SortingOrder.Ascending }, lookup: { - _id: { attachments: attachment.class.Attachment } + _id: { attachments: attachment.class.Attachment, inlineButtons: chunter.class.InlineButton } } } ) diff --git a/plugins/chunter-resources/src/components/ChannelScrollView.svelte b/plugins/chunter-resources/src/components/ChannelScrollView.svelte index c35d615b5d..0b8864c698 100644 --- a/plugins/chunter-resources/src/components/ChannelScrollView.svelte +++ b/plugins/chunter-resources/src/components/ChannelScrollView.svelte @@ -23,7 +23,8 @@ ActivityExtension as ActivityExtensionComponent, ActivityMessagePresenter, canGroupMessages, - messageInFocus + messageInFocus, + sortActivityMessages } from '@hcengineering/activity-resources' import { Doc, generateId, getDay, Ref, Timestamp } from '@hcengineering/core' import { InboxNotificationsClientImpl } from '@hcengineering/notification-resources' @@ -358,7 +359,7 @@ clearTimeout(messagesToReadAccumulatorTimer) messagesToReadAccumulatorTimer = setTimeout(() => { const messagesToRead = [...messagesToReadAccumulator] - void readChannelMessages(messagesToRead, notifyContext) + void readChannelMessages(sortActivityMessages(messagesToRead), notifyContext) }, 500) } diff --git a/plugins/chunter-resources/src/components/InlineButtons.svelte b/plugins/chunter-resources/src/components/InlineButtons.svelte new file mode 100644 index 0000000000..0af2bd8eb2 --- /dev/null +++ b/plugins/chunter-resources/src/components/InlineButtons.svelte @@ -0,0 +1,49 @@ + + + +{#each inlineButtons as button} + { + void handleInlineButtonClick(button) + }} + /> +{/each} diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte index 565c8ac628..6e37fe54a3 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessagePresenter.svelte @@ -16,18 +16,20 @@ import contact, { Person, PersonAccount } from '@hcengineering/contact' import { personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources' import { Class, Doc, getCurrentAccount, Ref, Space, WithLookup } from '@hcengineering/core' - import { getClient, MessageViewer } from '@hcengineering/presentation' + import presentation, { createQuery, getClient, MessageViewer } from '@hcengineering/presentation' import { AttachmentDocList, AttachmentImageSize } from '@hcengineering/attachment-resources' import { getDocLinkTitle } from '@hcengineering/view-resources' import { Action, Button, IconEdit, ShowMore } from '@hcengineering/ui' import view from '@hcengineering/view' import activity, { ActivityMessage, ActivityMessageViewType, DisplayActivityMessage } from '@hcengineering/activity' import { ActivityDocLink, ActivityMessageTemplate } from '@hcengineering/activity-resources' - import chunter, { ChatMessage, ChatMessageViewlet } from '@hcengineering/chunter' + import chunter, { ChatMessage, ChatMessageViewlet, InlineButton } from '@hcengineering/chunter' import { Attachment } from '@hcengineering/attachment' + import { getMetadata } from '@hcengineering/platform' import ChatMessageHeader from './ChatMessageHeader.svelte' import ChatMessageInput from './ChatMessageInput.svelte' + import InlineButtons from '../InlineButtons.svelte' export let value: WithLookup | undefined export let doc: Doc | undefined = undefined @@ -142,6 +144,8 @@ let attachments: Attachment[] | undefined = undefined $: attachments = value?.$lookup?.attachments as Attachment[] | undefined + let inlineButtons: InlineButton[] = [] + $: inlineButtons = (value?.$lookup?.inlineButtons ?? []) as InlineButton[] $: socialProvider = value?.provider ? client.getModel().findAllSync(contact.class.ChannelProvider, { _id: value.provider })[0] @@ -192,12 +196,14 @@
+
{:else}
+
{/if} {:else if object} diff --git a/plugins/chunter/src/index.ts b/plugins/chunter/src/index.ts index 67f6d08efe..1c87455e66 100644 --- a/plugins/chunter/src/index.ts +++ b/plugins/chunter/src/index.ts @@ -14,9 +14,9 @@ // import { ActivityMessage, ActivityMessageViewlet } from '@hcengineering/activity' -import type { Class, Doc, Markup, Mixin, Ref, Space, Timestamp } from '@hcengineering/core' +import type { AttachedDoc, Class, Doc, Markup, Mixin, Ref, Space, Timestamp } from '@hcengineering/core' import { DocNotifyContext, NotificationType } from '@hcengineering/notification' -import type { Asset, Plugin } from '@hcengineering/platform' +import type { Asset, Plugin, Resource } from '@hcengineering/platform' import { IntlString, plugin } from '@hcengineering/platform' import { AnyComponent } from '@hcengineering/ui' import { Action } from '@hcengineering/view' @@ -54,6 +54,7 @@ export interface ChatMessage extends ActivityMessage { attachments?: number editedOn?: Timestamp provider?: Ref + inlineButtons?: number } /** @@ -91,6 +92,15 @@ export interface ChannelInfo extends DocNotifyContext { hidden: boolean } +export type InlineButtonAction = (button: InlineButton, message: Ref, channel: Ref) => Promise + +export interface InlineButton extends AttachedDoc { + name: string + titleIntl?: IntlString + title?: string + action: Resource +} + /** * @public */ @@ -124,6 +134,9 @@ export default plugin(chunterId, { ChatMessagePreview: '' as AnyComponent, ThreadMessagePreview: '' as AnyComponent }, + activity: { + MembersChangedMessage: '' as AnyComponent + }, class: { ThreadMessage: '' as Ref>, ChunterSpace: '' as Ref>, @@ -132,6 +145,7 @@ export default plugin(chunterId, { ChatMessage: '' as Ref>, ChatMessageViewlet: '' as Ref>, ChatInfo: '' as Ref>, + InlineButton: '' as Ref>, TypingInfo: '' as Ref> }, mixin: { diff --git a/rush.json b/rush.json index 7a79398b44..f4db1abb66 100644 --- a/rush.json +++ b/rush.json @@ -2072,6 +2072,11 @@ "projectFolder": "plugins/analytics-collector-assets", "shouldPublish": false }, + { + "packageName": "@hcengineering/analytics-collector-resources", + "projectFolder": "plugins/analytics-collector-resources", + "shouldPublish": false + }, { "packageName": "@hcengineering/model-analytics-collector", "projectFolder": "models/analytics-collector", diff --git a/server-plugins/ai-bot-resources/src/index.ts b/server-plugins/ai-bot-resources/src/index.ts index 888f0bbbd3..2683aeb511 100644 --- a/server-plugins/ai-bot-resources/src/index.ts +++ b/server-plugins/ai-bot-resources/src/index.ts @@ -35,7 +35,7 @@ import serverAIBot, { AIBotServiceAdapter, serverAiBotId } from '@hcengineering/ import contact, { PersonAccount } from '@hcengineering/contact' import { ActivityInboxNotification, MentionInboxNotification } from '@hcengineering/notification' import { getMetadata } from '@hcengineering/platform' -import analytics from '@hcengineering/analytics-collector' +import analyticsCollector, { OnboardingChannel } from '@hcengineering/analytics-collector' async function processWorkspace (control: TriggerControl): Promise { const adapter = control.serviceAdaptersManager.getAdapter(serverAiBotId) as AIBotServiceAdapter | undefined @@ -228,6 +228,7 @@ async function onBotDirectMessageSend (control: TriggerControl, message: ChatMes toWorkspace: supportWorkspaceId, toEmail: account.email, fromWorkspace: toWorkspaceString(control.workspace), + fromWorkspaceName: control.workspace.workspaceName, fromWorkspaceUrl: control.workspace.workspaceUrl, messageId: message._id, parentMessageId: await getThreadParent(control, message) @@ -248,19 +249,17 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat return } - const channel = await getMessageDoc(message, control) + if (!control.hierarchy.isDerived(message.attachedToClass, analyticsCollector.class.OnboardingChannel)) { + return + } + + const channel = (await getMessageDoc(message, control)) as OnboardingChannel 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 - + const { workspaceId, email } = channel let data: Data | undefined if (control.hierarchy.isDerived(message._class, chunter.class.ThreadMessage)) { @@ -278,9 +277,10 @@ async function onSupportWorkspaceMessage (control: TriggerControl, message: Chat message: message.message, collection: data.collection, toEmail: email, - toWorkspace: workspace, + toWorkspace: workspaceId, fromWorkspace: toWorkspaceString(control.workspace), fromWorkspaceUrl: control.workspace.workspaceUrl, + fromWorkspaceName: control.workspace.workspaceName, messageId: message._id, parentMessageId: await getThreadParent(control, message) }) @@ -317,7 +317,7 @@ export async function OnMessageSend ( await onBotDirectMessageSend(control, message) } - if (docClass === chunter.class.Channel) { + if (docClass === analyticsCollector.class.OnboardingChannel) { await onSupportWorkspaceMessage(control, message) } diff --git a/server-plugins/analytics-collector-resources/src/utils.ts b/server-plugins/analytics-collector-resources/src/utils.ts index a9d157ab63..b73a9c6f4f 100644 --- a/server-plugins/analytics-collector-resources/src/utils.ts +++ b/server-plugins/analytics-collector-resources/src/utils.ts @@ -13,48 +13,85 @@ // limitations under the License. // import chunter, { Channel } from '@hcengineering/chunter' -import core, { AccountRole, MeasureContext, Ref, TxOperations } from '@hcengineering/core' -import analyticsCollector, { getAnalyticsChannelName } from '@hcengineering/analytics-collector' +import core, { MeasureContext, Ref, TxOperations } from '@hcengineering/core' import contact, { Person } from '@hcengineering/contact' +import analyticsCollector, { getOnboardingChannelName, OnboardingChannel } from '@hcengineering/analytics-collector' import { translate } from '@hcengineering/platform' -export async function getOrCreateAnalyticsChannel ( +interface WorkspaceInfo { + workspaceId: string + workspaceName: string + workspaceUrl: string +} + +export async function getOrCreateOnboardingChannel ( ctx: MeasureContext, client: TxOperations, email: string, - workspace: string, - workspaceUrl: string, + workspace: WorkspaceInfo, person?: Person -): Promise | undefined> { - const channel = await client.findOne(chunter.class.Channel, { - [`${analyticsCollector.mixin.AnalyticsChannel}.workspace`]: workspace, - [`${analyticsCollector.mixin.AnalyticsChannel}.email`]: email - }) - - if (channel !== undefined) { - return channel._id - } - - ctx.info('Creating analytics channel', { email, workspace }) - - const accounts = await client.findAll(contact.class.PersonAccount, { role: { $ne: AccountRole.Guest } }) - const _id = await client.createDoc(chunter.class.Channel, core.space.Space, { - name: getAnalyticsChannelName(workspaceUrl, email), - topic: await translate(analyticsCollector.string.AnalyticsChannelDescription, { - user: person?.name ?? email, - workspace: workspaceUrl - }), - description: '', - private: false, - members: accounts.map(({ _id }) => _id), - autoJoin: true, - archived: false - }) - - await client.createMixin(_id, chunter.class.Channel, core.space.Space, analyticsCollector.mixin.AnalyticsChannel, { - workspace, +): Promise<[Ref | undefined, boolean]> { + const channel = await client.findOne(analyticsCollector.class.OnboardingChannel, { + workspaceId: workspace.workspaceId, email }) - return _id + if (channel !== undefined) { + return [channel._id, false] + } + + ctx.info('Creating user onboarding channel', { email, workspace }) + + const _id = await client.createDoc(analyticsCollector.class.OnboardingChannel, core.space.Space, { + name: getOnboardingChannelName(workspace.workspaceUrl, email), + topic: await translate(analyticsCollector.string.OnboardingChannelDescription, { + user: person?.name ?? email, + workspace: workspace.workspaceName + }), + description: '', + private: false, + members: [], + autoJoin: true, + archived: false, + email, + workspaceId: workspace.workspaceId, + workspaceUrl: workspace.workspaceUrl, + workspaceName: workspace.workspaceName, + userName: person?.name ?? email + }) + + return [_id, true] +} + +export async function createGeneralOnboardingChannel ( + ctx: MeasureContext, + client: TxOperations +): Promise { + const channel = await client.findOne(chunter.class.Channel, { + _id: analyticsCollector.space.GeneralOnboardingChannel + }) + + if (channel !== undefined) { + return channel + } + + ctx.info('Creating general onboarding channel') + + const accounts = await client.findAll(contact.class.PersonAccount, {}) + await client.createDoc( + chunter.class.Channel, + core.space.Space, + { + name: 'General Onboarding', + topic: '', + description: '', + private: false, + members: accounts.map(({ _id }) => _id), + autoJoin: true, + archived: false + }, + analyticsCollector.space.GeneralOnboardingChannel + ) + + return await client.findOne(chunter.class.Channel, { _id: analyticsCollector.space.GeneralOnboardingChannel }) } diff --git a/server-plugins/notification-resources/src/index.ts b/server-plugins/notification-resources/src/index.ts index e81ec345c4..ffd3f35da4 100644 --- a/server-plugins/notification-resources/src/index.ts +++ b/server-plugins/notification-resources/src/index.ts @@ -776,7 +776,7 @@ export async function getNotificationTxes ( control, message.attachedTo, message.attachedToClass, - message.space, + object.space, receiver, params.shouldUpdateTimestamp ? originTx.modifiedOn : undefined, tx diff --git a/services/ai-bot/pod-ai-bot/src/platform.ts b/services/ai-bot/pod-ai-bot/src/platform.ts index 8ba2283aea..6c9813b8b3 100644 --- a/services/ai-bot/pod-ai-bot/src/platform.ts +++ b/services/ai-bot/pod-ai-bot/src/platform.ts @@ -1,7 +1,6 @@ import { Client } from '@hcengineering/core' -import { createClient, getTransactorEndpoint } from '@hcengineering/server-client' +import { createClient } from '@hcengineering/server-client' -export async function connectPlatform (token: string): Promise { - const endpoint = await getTransactorEndpoint(token) +export async function connectPlatform (token: string, endpoint: string): Promise { 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 index e19a6e4d70..3d3003a8d7 100644 --- a/services/ai-bot/pod-ai-bot/src/start.ts +++ b/services/ai-bot/pod-ai-bot/src/start.ts @@ -36,7 +36,7 @@ export const start = async (): Promise => { ctx.info('AI Bot Service started', { firstName: config.FirstName, lastName: config.LastName }) - const db = await getDB(config.MongoURL, config.ConfigurationDB) + const db = await getDB() for (let i = 0; i < 5; i++) { ctx.info('Creating bot account', { attempt: i }) try { diff --git a/services/ai-bot/pod-ai-bot/src/storage.ts b/services/ai-bot/pod-ai-bot/src/storage.ts index 012646bbbd..5549accc00 100644 --- a/services/ai-bot/pod-ai-bot/src/storage.ts +++ b/services/ai-bot/pod-ai-bot/src/storage.ts @@ -13,19 +13,24 @@ // limitations under the License. // -import { Db, MongoClient } from 'mongodb' +import { MongoClientReference, getMongoClient } from '@hcengineering/mongo' +import { MongoClient } from 'mongodb' +import config from './config' + +const clientRef: MongoClientReference = getMongoClient(config.MongoURL) let client: MongoClient | undefined -export const getDB = async (mongoUrl: string, db: string): Promise => { - client = new MongoClient(mongoUrl) - await client.connect() +export const getDB = (() => { + return async () => { + if (client === undefined) { + client = await clientRef.getClient() + } - return client.db(db) -} + return client.db(config.ConfigurationDB) + } +})() export const closeDB: () => Promise = async () => { - if (client !== undefined) { - await client.close() - } + clientRef.close() } diff --git a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts index ce9199585a..5986d7ec55 100644 --- a/services/ai-bot/pod-ai-bot/src/workspaceClient.ts +++ b/services/ai-bot/pod-ai-bot/src/workspaceClient.ts @@ -32,9 +32,8 @@ import core, { import aiBot, { AIBotEvent, aiBotAccountEmail, AIBotResponseEvent, AIBotTransferEvent } from '@hcengineering/ai-bot' import chunter, { Channel, 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 { getOrCreateOnboardingChannel } from '@hcengineering/server-analytics-collector-resources' import { deepEqual } from 'fast-equals' import { BlobClient } from '@hcengineering/server-client' import fs from 'fs' @@ -49,15 +48,13 @@ const MAX_LOGIN_DELAY_MS = 15 * 1000 // 15 ses export class WorkspaceClient { client: Client | undefined - opClient: TxOperations | undefined + opClient: Promise | TxOperations blobClient: BlobClient loginTimeout: NodeJS.Timeout | undefined loginDelayMs = 2 * 1000 - initializePromise: Promise | undefined = undefined - channelByKey = new Map>() aiAccount: PersonAccount | undefined rate = new RateLimiter(1) @@ -73,8 +70,9 @@ export class WorkspaceClient { readonly info: WorkspaceInfoRecord | undefined ) { this.blobClient = new BlobClient(transactorUrl, token, this.workspace) - this.initializePromise = this.initClient().then(() => { - this.initializePromise = undefined + this.opClient = this.initClient() + void this.opClient.then((opClient) => { + this.opClient = opClient }) } @@ -126,24 +124,22 @@ export class WorkspaceClient { if (this.loginDelayMs < MAX_LOGIN_DELAY_MS) { this.loginDelayMs += 1000 } - void this.tryLogin() - this.ctx.info(`login delay ${this.loginDelayMs} millisecond`) + void this.tryLogin() }, this.loginDelayMs) } } private async checkPersonData (client: TxOperations): Promise { - const account = await client.findOne(contact.class.PersonAccount, { email: aiBotAccountEmail }) - if (account === undefined) { + this.aiAccount = await client.getModel().findOne(contact.class.PersonAccount, { email: aiBotAccountEmail }) + if (this.aiAccount === 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 }) + const person = await client.findOne(contact.class.Person, { _id: this.aiAccount.person }) if (person === undefined) { - this.ctx.error('Cannot find AI Person ', { _id: account.person }) + this.ctx.error('Cannot find AI Person ', { _id: this.aiAccount.person }) return } @@ -170,25 +166,22 @@ export class WorkspaceClient { await client.diffUpdate(person, { avatar: config.AvatarName as Ref, avatarType: AvatarType.IMAGE }) } - private async initClient (): Promise { + private async initClient (): Promise { await this.tryLogin() - const token = generateToken(aiBotAccountEmail, this.workspace) - this.client = await connectPlatform(token) + this.client = await connectPlatform(this.token, this.transactorUrl) + const opClient = new TxOperations(this.client, aiBot.account.AIBot) - if (this.client === undefined) { - this.ctx.error('Cannot connect to platform', this.workspace) - return - } - this.opClient = new TxOperations(this.client, aiBot.account.AIBot) - await this.uploadAvatarFile(this.opClient) - const events = await this.opClient.findAll(aiBot.class.AIBotTransferEvent, {}) + await this.uploadAvatarFile(opClient) + const events = await opClient.findAll(aiBot.class.AIBotTransferEvent, {}) void this.processEvents(events) this.client.notify = (...txes: Tx[]) => { - void this.txHandler(txes) + void this.txHandler(opClient, txes) } this.ctx.info('Initialized workspace', this.workspace) + + return opClient } async getThreadParent ( @@ -263,12 +256,10 @@ export class WorkspaceClient { } async processResponseEvent (event: AIBotResponseEvent): Promise { - if (this.opClient === undefined) { - return - } + const client = await this.opClient if (event.messageClass === chunter.class.ChatMessage) { - await this.opClient.addCollection( + await client.addCollection( chunter.class.ChatMessage, event.objectSpace, event.objectId, @@ -277,12 +268,12 @@ export class WorkspaceClient { { message: 'You said: ' + event.message } ) } else if (event.messageClass === chunter.class.ThreadMessage) { - const parent = await this.opClient.findOne(chunter.class.ChatMessage, { + const parent = await client.findOne(chunter.class.ChatMessage, { _id: event.objectId as Ref }) if (parent !== undefined) { - await this.opClient.addCollection( + await client.addCollection( chunter.class.ThreadMessage, event.objectSpace, event.objectId, @@ -293,30 +284,24 @@ export class WorkspaceClient { } } - await this.opClient.remove(event) + await client.remove(event) } async processTransferEvent (event: AIBotTransferEvent): Promise { - if (this.opClient === undefined) { - return - } + const client = await this.opClient await this.controller.transfer(event) - await this.opClient.remove(event) + await client.remove(event) } async getAccount (email: string): Promise { - if (this.opClient === undefined) { - return - } + const client = await this.opClient - return await this.opClient.findOne(contact.class.PersonAccount, { email }) + return await client.findOne(contact.class.PersonAccount, { email }) } async getDirect (email: string): Promise | undefined> { - if (this.opClient === undefined) { - return - } + const client = await this.opClient const personAccount = await this.getAccount(email) @@ -324,9 +309,9 @@ export class WorkspaceClient { return } - const allAccounts = await this.opClient.findAll(contact.class.PersonAccount, { person: personAccount.person }) + const allAccounts = await client.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, {}) + const existingDms = await client.findAll(chunter.class.DirectMessage, {}) for (const dm of existingDms) { if (deepEqual(dm.members.sort(), accIds)) { @@ -334,7 +319,7 @@ export class WorkspaceClient { } } - const dmId = await this.opClient.createDoc(chunter.class.DirectMessage, core.space.Space, { + const dmId = await client.createDoc(chunter.class.DirectMessage, core.space.Space, { name: '', description: '', private: true, @@ -343,9 +328,9 @@ export class WorkspaceClient { }) if (this.aiAccount === undefined) return dmId - const space = await this.opClient.findOne(contact.class.PersonSpace, { person: this.aiAccount.person }) + const space = await client.findOne(contact.class.PersonSpace, { person: this.aiAccount.person }) if (space === undefined) return dmId - await this.opClient.createDoc(notification.class.DocNotifyContext, space._id, { + await client.createDoc(notification.class.DocNotifyContext, space._id, { user: aiBot.account.AIBot, objectId: dmId, objectClass: chunter.class.DirectMessage, @@ -357,18 +342,18 @@ export class WorkspaceClient { } async transferToSupport (event: AIBotTransferEvent, channelRef?: Ref): Promise { - if (this.opClient === undefined) return + const client = await this.opClient const key = `${event.toEmail}-${event.fromWorkspace}` const channel = channelRef ?? this.channelByKey.get(key) ?? - (await getOrCreateAnalyticsChannel( - this.ctx, - this.opClient, - event.toEmail, - event.fromWorkspace, - event.fromWorkspaceUrl - )) + ( + await getOrCreateOnboardingChannel(this.ctx, client, event.toEmail, { + workspaceId: event.fromWorkspace, + workspaceName: event.fromWorkspaceName, + workspaceUrl: event.fromWorkspaceUrl + }) + )[0] if (channel === undefined) { return @@ -376,14 +361,10 @@ export class WorkspaceClient { this.channelByKey.set(key, channel) - await this.createTransferMessage(this.opClient, event, channel, chunter.class.Channel, channel, event.message) + await this.createTransferMessage(client, event, channel, chunter.class.Channel, channel, event.message) } async transferToUserDirect (event: AIBotTransferEvent): Promise { - if (this.opClient === undefined) { - return - } - const direct = this.directByEmail.get(event.toEmail) ?? (await this.getDirect(event.toEmail)) if (direct === undefined) { @@ -391,8 +372,9 @@ export class WorkspaceClient { } this.directByEmail.set(event.toEmail, direct) + const client = await this.opClient - await this.createTransferMessage(this.opClient, event, direct, chunter.class.DirectMessage, direct, event.message) + await this.createTransferMessage(client, event, direct, chunter.class.DirectMessage, direct, event.message) } getChannelRef (email: string, workspace: string): Ref | undefined { @@ -402,17 +384,13 @@ export class WorkspaceClient { } async transfer (event: AIBotTransferEvent): Promise { - if (this.initializePromise instanceof Promise) { - await this.initializePromise - } - if (event.toWorkspace === config.SupportWorkspace) { const channel = this.getChannelRef(event.toEmail, event.fromWorkspace) if (channel !== undefined) { await this.transferToSupport(event, channel) } else { - // If we dont have AnalyticsChannel we should call it sync to prevent multiple channel for the same user and workspace + // If we dont have OnboardingChannel we should call it sync to prevent multiple channel for the same user and workspace await this.rate.add(async () => { await this.transferToSupport(event) }) @@ -449,18 +427,24 @@ export class WorkspaceClient { async close (): Promise { clearTimeout(this.loginTimeout) - await this.client?.close() - await this.opClient?.close() + + if (this.client !== undefined) { + await this.client.close() + } + + if (this.opClient instanceof Promise) { + void this.opClient.then((opClient) => { + void opClient.close() + }) + } else { + 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() + private async txHandler (client: TxOperations, txes: Tx[]): Promise { + const hierarchy = client.getHierarchy() const resultTxes = txes .map((a) => TxProcessor.extractTx(a) as TxCreateDoc) diff --git a/services/analytics-collector/pod-analytics-collector/package.json b/services/analytics-collector/pod-analytics-collector/package.json index 6d06711246..4cf7c44b44 100644 --- a/services/analytics-collector/pod-analytics-collector/package.json +++ b/services/analytics-collector/pod-analytics-collector/package.json @@ -53,9 +53,11 @@ "typescript": "^5.3.3" }, "dependencies": { - "@hcengineering/analytics": "^0.6.0", - "@hcengineering/analytics-service": "^0.6.0", "@hcengineering/account": "^0.6.0", + "@hcengineering/analytics": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", + "@hcengineering/analytics-collector-assets": "^0.6.0", + "@hcengineering/analytics-service": "^0.6.0", "@hcengineering/chunter": "^0.6.20", "@hcengineering/chunter-assets": "^0.6.18", "@hcengineering/client": "^0.6.18", @@ -73,6 +75,7 @@ "@hcengineering/lead-assets": "^0.6.0", "@hcengineering/love": "^0.6.0", "@hcengineering/love-assets": "^0.6.0", + "@hcengineering/mongo": "^0.6.1", "@hcengineering/notification": "^0.6.23", "@hcengineering/notification-assets": "^0.6.17", "@hcengineering/platform": "^0.6.11", @@ -80,6 +83,8 @@ "@hcengineering/preference-assets": "^0.6.0", "@hcengineering/recruit": "^0.6.29", "@hcengineering/recruit-assets": "^0.6.23", + "@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/setting": "^0.6.17", @@ -93,13 +98,10 @@ "@hcengineering/view-assets": "^0.6.11", "@hcengineering/workbench": "^0.6.16", "@hcengineering/workbench-assets": "^0.6.14", - "@hcengineering/analytics-collector": "^0.6.0", - "@hcengineering/analytics-collector-assets": "^0.6.0", - "@hcengineering/server-analytics-collector-resources": "^0.6.0", - "@hcengineering/server-client": "^0.6.0", "cors": "^2.8.5", "dotenv": "~16.0.0", "express": "^4.19.2", + "mongodb": "^6.8.0", "puppeteer": "^22.6.1", "ws": "^8.18.0" } diff --git a/services/analytics-collector/pod-analytics-collector/src/collector.ts b/services/analytics-collector/pod-analytics-collector/src/collector.ts index 45b55f91c8..b73682a69f 100644 --- a/services/analytics-collector/pod-analytics-collector/src/collector.ts +++ b/services/analytics-collector/pod-analytics-collector/src/collector.ts @@ -14,84 +14,57 @@ // import { generateToken, Token } from '@hcengineering/server-token' import { AnalyticEvent } from '@hcengineering/analytics-collector' -import { - AccountRole, - getWorkspaceId, - MeasureContext, - Timestamp, - toWorkspaceString, - WorkspaceId -} from '@hcengineering/core' +import { AccountRole, getWorkspaceId, MeasureContext, toWorkspaceString, WorkspaceId } from '@hcengineering/core' import { Person } from '@hcengineering/contact' +import { Db, Collection } from 'mongodb' import { WorkspaceClient } from './workspaceClient' import config from './config' import { getWorkspaceInfo } from './account' +import { SupportWsClient } from './supportWsClient' +import { Action, OnboardingMessage } from './types' -const clearEventsTimeout = 10 * 60 * 1000 // 10 hour -const eventsTimeToLive = 60 * 60 * 1000 // 1 hour const closeWorkspaceTimeout = 10 * 60 * 1000 // 10 minutes export class Collector { private readonly workspaces: Map = new Map() private readonly closeWorkspaceTimeouts: Map = new Map() private readonly createdWorkspaces: Set = new Set() - private readonly workspaceUrlById = new Map() - supportClient: WorkspaceClient | undefined = undefined - eventsByEmail = new Map() + private readonly onboardingMessagesCollection: Collection - periodicTimer: NodeJS.Timeout + supportClient: SupportWsClient | undefined = undefined persons = new Map() - constructor (private readonly ctx: MeasureContext) { - this.periodicTimer = setInterval(() => { - void this.clearEvents() - }, clearEventsTimeout) - } - - async clearEvents (): Promise { - const now = Date.now() - - for (const [key, events] of this.eventsByEmail.entries()) { - const firstValidIndex = events.findIndex((event) => now - event.timestamp < eventsTimeToLive) - - if (firstValidIndex === -1) { - this.eventsByEmail.delete(key) - } else { - this.eventsByEmail.set(key, events.slice(firstValidIndex)) - } - } - } - - async closeWorkspaceClient (workspaceId: WorkspaceId): Promise { - this.ctx.info('Closing workspace client', { workspace: toWorkspaceString(workspaceId) }) - const workspace = toWorkspaceString(workspaceId) - const timeoutId = this.closeWorkspaceTimeouts.get(workspace) - - if (timeoutId !== undefined) { - clearTimeout(timeoutId) - this.closeWorkspaceTimeouts.delete(workspace) - } - - const client = this.workspaces.get(workspace) - - if (client !== undefined) { - await client.close() - this.workspaces.delete(workspace) - } + constructor ( + private readonly ctx: MeasureContext, + private readonly db: Db + ) { + this.onboardingMessagesCollection = this.db.collection('messages') + this.supportClient = this.getSupportWorkspaceClient() } getWorkspaceClient (workspaceId: WorkspaceId): WorkspaceClient { const workspace = toWorkspaceString(workspaceId) - const wsClient = this.workspaces.get(workspace) ?? new WorkspaceClient(this.ctx, workspaceId) + + let wsClient: WorkspaceClient if (!this.workspaces.has(workspace)) { this.ctx.info('Creating workspace client', { workspace, allClients: Array.from(this.workspaces.keys()) }) - this.workspaces.set(workspace, wsClient) + const client = new WorkspaceClient(this.ctx, workspaceId) + this.workspaces.set(workspace, client) + wsClient = client + } else { + wsClient = this.workspaces.get(workspace) as WorkspaceClient } + this.setWorkspaceCloseTimeout(workspace) + + return wsClient + } + + setWorkspaceCloseTimeout (workspace: string): void { const timeoutId = this.closeWorkspaceTimeouts.get(workspace) if (timeoutId !== undefined) { @@ -99,12 +72,47 @@ export class Collector { } const newTimeoutId = setTimeout(() => { - void this.closeWorkspaceClient(workspaceId) + void this.closeWorkspaceClient(workspace) }, closeWorkspaceTimeout) this.closeWorkspaceTimeouts.set(workspace, newTimeoutId) + } - return wsClient + getSupportWorkspaceClient (): SupportWsClient { + let client: SupportWsClient + + if (this.supportClient !== undefined) { + client = this.supportClient + } else { + client = new SupportWsClient(this.ctx, getWorkspaceId(config.SupportWorkspace)) + this.supportClient = client + } + + this.setWorkspaceCloseTimeout(config.SupportWorkspace) + + return client + } + + async closeWorkspaceClient (workspace: string): Promise { + this.ctx.info('Closing workspace client', { workspace }) + const timeoutId = this.closeWorkspaceTimeouts.get(workspace) + + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + this.closeWorkspaceTimeouts.delete(workspace) + } + + if (workspace === config.SupportWorkspace) { + await this.supportClient?.close() + this.supportClient = undefined + } else { + const client = this.workspaces.get(workspace) + + if (client !== undefined) { + await client.close() + this.workspaces.delete(workspace) + } + } } collect (events: AnalyticEvent[], token: Token): void { @@ -112,18 +120,7 @@ export class Collector { return } - const existingEvents = this.eventsByEmail.get(token.email) ?? [] - - this.eventsByEmail.set(token.email, existingEvents.concat(events)) - void this.pushEvents(events, token) - } - - getSupportWorkspaceClient (): WorkspaceClient { - if (this.supportClient === undefined) { - this.supportClient = new WorkspaceClient(this.ctx, getWorkspaceId(config.SupportWorkspace)) - } - - return this.supportClient + void this.pushEventsToSupport(events, token) } async isWorkspaceCreated (token: Token): Promise { @@ -140,10 +137,6 @@ export class Collector { return false } - if (info?.workspaceUrl != null) { - this.workspaceUrlById.set(ws, info.workspaceUrl) - } - if (info?.creating === true) { return false } @@ -181,7 +174,7 @@ export class Collector { return person } - async pushEvents (events: AnalyticEvent[], token: Token): Promise { + async pushEventsToSupport (events: AnalyticEvent[], token: Token): Promise { const isCreated = await this.isWorkspaceCreated(token) if (!isCreated) { @@ -196,36 +189,32 @@ export class Collector { const client = this.getSupportWorkspaceClient() - await client.pushEvents( - events, - token.email, - token.workspace, - person, - this.workspaceUrlById.get(toWorkspaceString(token.workspace)) - ) + await client.pushEvents(events, token.email, token.workspace, person, this.onboardingMessagesCollection) } - getEvents (start?: Timestamp, end?: Timestamp): AnalyticEvent[] { - const events = Array.from(this.eventsByEmail.values()) - .flat() - .sort((e1, e2) => e1.timestamp - e2.timestamp) + async processAction (action: Action, token: Token): Promise { + const ws = toWorkspaceString(token.workspace) - if (start === undefined && end === undefined) { - return events + if (ws !== config.SupportWorkspace) { + return } - if (start === undefined && end !== undefined) { - return events.filter((e) => e.timestamp <= end) - } + const client = this.getSupportWorkspaceClient() - if (end === undefined && start !== undefined) { - return events.filter((e) => e.timestamp >= start) - } - - return events.filter((e) => e.timestamp >= (start ?? 0) && e.timestamp <= (end ?? 0)) + await client.processAction(action, token.email, this.onboardingMessagesCollection) } async close (): Promise { - clearInterval(this.periodicTimer) + for (const [, client] of this.workspaces) { + await client.close() + } + + this.workspaces.clear() + this.closeWorkspaceTimeouts.clear() + + if (this.supportClient !== undefined) { + await this.supportClient.close() + this.supportClient = undefined + } } } diff --git a/services/analytics-collector/pod-analytics-collector/src/config.ts b/services/analytics-collector/pod-analytics-collector/src/config.ts index f0f6375969..bb8aed909c 100644 --- a/services/analytics-collector/pod-analytics-collector/src/config.ts +++ b/services/analytics-collector/pod-analytics-collector/src/config.ts @@ -15,7 +15,8 @@ export interface Config { Port: number - DbURL: string + MongoUrl: string + MongoDb: string Secret: string ServiceID: string SupportWorkspace: string @@ -28,7 +29,8 @@ const parseNumber = (str: string | undefined): number | undefined => (str !== un const config: Config = (() => { const params: Partial = { Port: parseNumber(process.env.PORT) ?? 4007, - DbURL: process.env.MONGO_URL, + MongoUrl: process.env.MONGO_URL, + MongoDb: process.env.MONGO_DB ?? '%analytics-collector', Secret: process.env.SECRET, ServiceID: process.env.SERVICE_ID ?? 'analytics-collector-service', SupportWorkspace: process.env.SUPPORT_WORKSPACE, diff --git a/services/analytics-collector/pod-analytics-collector/src/format.ts b/services/analytics-collector/pod-analytics-collector/src/format.ts index 887e77b11c..8a31d75e57 100644 --- a/services/analytics-collector/pod-analytics-collector/src/format.ts +++ b/services/analytics-collector/pod-analytics-collector/src/format.ts @@ -398,3 +398,22 @@ function parseHash (hash: string): string { } return decodeURIComponent(hash) } + +export function getOnboardingMessage (email: string, workspace: string, name: string): Markup { + const nodes: MarkupNode[] = [ + toText('New user for onboarding: '), + toText('name', 'bold'), + toText(' - '), + toText(name), + toText(', '), + toText('email', 'bold'), + toText(' - '), + toText(email), + toText(', '), + toText('workspace', 'bold'), + toText(' - '), + toText(workspace) + ] + + return toMarkup(nodes) +} diff --git a/services/analytics-collector/pod-analytics-collector/src/main.ts b/services/analytics-collector/pod-analytics-collector/src/main.ts index d4073b4b38..f537d872ac 100644 --- a/services/analytics-collector/pod-analytics-collector/src/main.ts +++ b/services/analytics-collector/pod-analytics-collector/src/main.ts @@ -25,6 +25,7 @@ import config from './config' import { createServer, listen } from './server' import { Collector } from './collector' import { registerLoaders } from './loaders' +import { closeDB, getDB } from './storage' const ctx = new MeasureMetricsContext( 'analytics-collector-service', @@ -47,19 +48,20 @@ export const main = async (): Promise => { ctx.info('Analytics service started', { accountsUrl: config.AccountsUrl, - dbUrl: config.DbURL, supportWorkspace: config.SupportWorkspace }) registerLoaders() - const collector = new Collector(ctx) + const db = await getDB() + const collector = new Collector(ctx, db) const app = createServer(collector) const server = listen(app, config.Port) const shutdown = (): void => { void collector.close() + void closeDB() server.close(() => process.exit()) } diff --git a/services/analytics-collector/pod-analytics-collector/src/server.ts b/services/analytics-collector/pod-analytics-collector/src/server.ts index 71610c1318..8bd259d3b0 100644 --- a/services/analytics-collector/pod-analytics-collector/src/server.ts +++ b/services/analytics-collector/pod-analytics-collector/src/server.ts @@ -21,6 +21,7 @@ import { AnalyticEvent } from '@hcengineering/analytics-collector' import { ApiError } from './error' import { Collector } from './collector' +import { Action } from './types' const extractCookieToken = (cookie?: string): Token | null => { if (cookie === undefined || cookie === null) { @@ -106,18 +107,6 @@ function isContentValid (body: any[]): boolean { }) } -function handleGetEventsRequest (req: Request, res: Response, collector: Collector): void { - const qStart = req.query.start as string - const qEnd = req.query.end as string - - const start = qStart != null ? parseInt(qStart) : undefined - const end = qEnd != null ? parseInt(qEnd) : undefined - - res.status(200) - res.contentType('application/json') - res.send(collector.getEvents(start, end)) -} - export function createServer (collector: Collector): Express { const app = express() app.use(cors()) @@ -143,10 +132,32 @@ export function createServer (collector: Collector): Express { }) ) - app.get( - '/events', - wrapRequest(async (req, res) => { - handleGetEventsRequest(req, res, collector) + app.post( + '/action', + wrapRequest(async (req, res, token) => { + if (req.body == null || Array.isArray(req.body)) { + throw new ApiError(400) + } + + const name = req.body.name + const messageId = req.body.messageId + const channelId = req.body.channelId + const _id = req.body._id + + if (name == null || messageId == null || channelId == null || _id == null) { + throw new ApiError(400) + } + + const action: Action = { + _id, + name, + messageId, + channelId + } + await collector.processAction(action, token) + + res.status(200) + res.json({}) }) ) diff --git a/services/analytics-collector/pod-analytics-collector/src/storage.ts b/services/analytics-collector/pod-analytics-collector/src/storage.ts new file mode 100644 index 0000000000..d7f406ef60 --- /dev/null +++ b/services/analytics-collector/pod-analytics-collector/src/storage.ts @@ -0,0 +1,36 @@ +// +// 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 { MongoClientReference, getMongoClient } from '@hcengineering/mongo' +import { MongoClient } from 'mongodb' + +import config from './config' + +const clientRef: MongoClientReference = getMongoClient(config.MongoUrl) +let client: MongoClient | undefined + +export const getDB = (() => { + return async () => { + if (client === undefined) { + client = await clientRef.getClient() + } + + return client.db(config.MongoDb) + } +})() + +export const closeDB: () => Promise = async () => { + clientRef.close() +} diff --git a/services/analytics-collector/pod-analytics-collector/src/supportWsClient.ts b/services/analytics-collector/pod-analytics-collector/src/supportWsClient.ts new file mode 100644 index 0000000000..c1ef1053db --- /dev/null +++ b/services/analytics-collector/pod-analytics-collector/src/supportWsClient.ts @@ -0,0 +1,288 @@ +// +// 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, { + generateId, + getWorkspaceId, + RateLimiter, + Ref, + systemAccountEmail, + toWorkspaceString, + TxOperations, + WorkspaceId, + Tx, + Doc, + TxUpdateDoc, + TxProcessor +} from '@hcengineering/core' +import chunter, { Channel, ChatMessage } from '@hcengineering/chunter' +import contact, { Person } from '@hcengineering/contact' +import { + createGeneralOnboardingChannel, + getOrCreateOnboardingChannel +} from '@hcengineering/server-analytics-collector-resources' +import analyticsCollector, { AnalyticEvent, OnboardingChannel } from '@hcengineering/analytics-collector' +import { generateToken } from '@hcengineering/server-token' + +import { eventToMarkup, getOnboardingMessage } from './format' +import { WorkspaceClient } from './workspaceClient' +import { getWorkspaceInfo } from './account' +import { Action, MessageActions, OnboardingMessage } from './types' +import { WorkspaceInfo } from '@hcengineering/account' +import { Collection } from 'mongodb' + +export class SupportWsClient extends WorkspaceClient { + channelIdByKey = new Map>() + + rate = new RateLimiter(1) + + generalChannel: Channel | undefined = undefined + + async initClient (): Promise { + const client = await super.initClient() + + this.generalChannel = await createGeneralOnboardingChannel(this.ctx, client) + if (this.client != null) { + this.client.notify = (...txes) => { + this.handleTx(client, ...txes) + } + } + + return client + } + + private handleTx (client: TxOperations, ...txes: Tx[]): void { + for (const tx of txes) { + const etx = TxProcessor.extractTx(tx) + switch (etx._class) { + case core.class.TxUpdateDoc: { + this.txUpdateDoc(client, tx as TxUpdateDoc) + break + } + } + } + } + + private txUpdateDoc (client: TxOperations, tx: TxUpdateDoc): void { + const hierarchy = client.getHierarchy() + + if ( + hierarchy.isDerived(tx.objectClass, chunter.class.Channel) && + tx.objectId === analyticsCollector.space.GeneralOnboardingChannel + ) { + if (this.generalChannel == null) { + return + } + this.generalChannel = TxProcessor.updateDoc2Doc(this.generalChannel, tx as TxUpdateDoc) + } + } + + private async getOrCreateOnboardingChannel ( + client: TxOperations, + workspace: string, + email: string, + person: Person + ): Promise<{ + channelId: Ref | undefined + isCreated: boolean + workspace?: WorkspaceInfo + }> { + const key = `${email}-${workspace}` + + if (this.channelIdByKey.has(key)) { + return { + channelId: this.channelIdByKey.get(key), + isCreated: false + } + } + + const info = await getWorkspaceInfo(generateToken(systemAccountEmail, getWorkspaceId(workspace))) + + if (info === undefined) { + this.ctx.error('Failed to get workspace info', { workspace }) + return { + channelId: undefined, + isCreated: false + } + } + + const [channel, isCreated] = await getOrCreateOnboardingChannel( + this.ctx, + client, + email, + { + workspaceId: workspace, + workspaceName: info?.workspaceName ?? '', + workspaceUrl: info?.workspaceUrl ?? '' + }, + person + ) + + if (channel !== undefined) { + this.channelIdByKey.set(key, channel) + } + + return { + channelId: channel, + isCreated, + workspace: info + } + } + + async handleAcceptAction ( + action: Action, + email: string, + onboardingMessages: Collection + ): Promise { + if (action.channelId !== analyticsCollector.space.GeneralOnboardingChannel) { + return + } + const client = await this.opClient + const account = await client.getModel().findOne(contact.class.PersonAccount, { email }) + + if (account === undefined) { + return + } + + if (this.generalChannel === undefined) { + return + } + + if (!this.generalChannel.members.includes(account._id)) { + return + } + + const message = (await onboardingMessages.findOne({ messageId: action.messageId })) ?? undefined + + if (message === undefined) { + return + } + + await client.updateDoc(analyticsCollector.class.OnboardingChannel, core.space.Space, message.channelId, { + $push: { members: account._id } + }) + + await onboardingMessages.deleteOne({ messageId: action.messageId }) + await client.removeCollection( + chunter.class.InlineButton, + analyticsCollector.space.GeneralOnboardingChannel, + action._id, + action.messageId, + chunter.class.ChatMessage, + 'inlineButtons' + ) + } + + async processAction (action: Action, email: string, onboardingMessages: Collection): Promise { + switch (action.name) { + case MessageActions.Accept: + await this.handleAcceptAction(action, email, onboardingMessages) + break + default: + } + } + + async processEvents ( + events: AnalyticEvent[], + email: string, + workspace: WorkspaceId, + person: Person, + onboardingMessages: Collection + ): Promise { + const client = await this.opClient + const op = client.apply(generateId(), 'processEvents') + const wsString = toWorkspaceString(workspace) + const { + channelId, + isCreated, + workspace: workspaceInfo + } = await this.getOrCreateOnboardingChannel(op, wsString, email, person) + + if (channelId === undefined) { + return + } + + if (isCreated) { + const messageId = generateId() + await op.addCollection( + chunter.class.ChatMessage, + analyticsCollector.space.GeneralOnboardingChannel, + analyticsCollector.space.GeneralOnboardingChannel, + chunter.class.Channel, + 'messages', + { message: getOnboardingMessage(email, workspaceInfo?.workspaceUrl ?? wsString, person.name) }, + messageId + ) + + await op.addCollection( + chunter.class.InlineButton, + analyticsCollector.space.GeneralOnboardingChannel, + messageId, + chunter.class.ChatMessage, + 'inlineButtons', + { + name: MessageActions.Accept, + title: 'Accept', + action: analyticsCollector.function.AnalyticsCollectorInlineAction + } + ) + + await onboardingMessages.insertOne({ messageId, channelId }) + } + + const hierarchy = client.getHierarchy() + + for (const event of events) { + const markup = await eventToMarkup(event, hierarchy) + + if (markup === undefined) { + continue + } + + await op.addCollection( + chunter.class.ChatMessage, + channelId, + channelId, + chunter.class.Channel, + 'messages', + { message: markup }, + undefined, + event.timestamp + ) + } + + await op.commit() + } + + async pushEvents ( + events: AnalyticEvent[], + email: string, + workspace: WorkspaceId, + person: Person, + onboardingMessages: Collection + ): Promise { + const wsString = toWorkspaceString(workspace) + const channelKey = `${email}-${wsString}` + + if (this.channelIdByKey.has(channelKey)) { + await this.processEvents(events, email, workspace, person, onboardingMessages) + } else { + // If we dont have OnboardingChannel we should call it sync to prevent multiple channels for the same user and workspace + await this.rate.add(async () => { + await this.processEvents(events, email, workspace, person, onboardingMessages) + }) + } + } +} diff --git a/services/analytics-collector/pod-analytics-collector/src/types.ts b/services/analytics-collector/pod-analytics-collector/src/types.ts new file mode 100644 index 0000000000..52290e0509 --- /dev/null +++ b/services/analytics-collector/pod-analytics-collector/src/types.ts @@ -0,0 +1,19 @@ +import { ChatMessage, InlineButton, Channel } from '@hcengineering/chunter' +import { Ref } from '@hcengineering/core' +import { OnboardingChannel } from '@hcengineering/analytics-collector' + +export interface Action { + _id: Ref + name: string + messageId: Ref + channelId: Ref +} + +export enum MessageActions { + Accept = 'accept' +} + +export interface OnboardingMessage { + messageId: Ref + channelId: Ref +} diff --git a/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts b/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts index d25648cf46..a2795eadf1 100644 --- a/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts +++ b/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts @@ -13,164 +13,43 @@ // limitations under the License. // -import core, { - Client, - MeasureContext, - RateLimiter, - Ref, - systemAccountEmail, - toWorkspaceString, - TxOperations, - WorkspaceId -} from '@hcengineering/core' +import core, { Client, MeasureContext, systemAccountEmail, TxOperations, WorkspaceId } from '@hcengineering/core' import { generateToken } from '@hcengineering/server-token' -import chunter, { Channel } from '@hcengineering/chunter' import contact, { Person, PersonAccount } from '@hcengineering/contact' -import { AnalyticEvent } from '@hcengineering/analytics-collector' -import { getOrCreateAnalyticsChannel } from '@hcengineering/server-analytics-collector-resources' import { connectPlatform } from './platform' -import { eventToMarkup } from './format' export class WorkspaceClient { client: Client | undefined - opClient: TxOperations | undefined + opClient: Promise | TxOperations initializePromise: Promise | undefined = undefined - channelIdByKey = new Map>() - - rate = new RateLimiter(1) - constructor ( readonly ctx: MeasureContext, readonly workspace: WorkspaceId ) { - this.initializePromise = this.initClient().then(() => { - this.initializePromise = undefined + this.opClient = this.initClient() + void this.opClient.then((opClient) => { + this.opClient = opClient }) } - private async initClient (): Promise { + protected async initClient (): Promise { const token = generateToken(systemAccountEmail, this.workspace) this.client = await connectPlatform(token) - if (this.client === undefined) { - return - } - - this.opClient = new TxOperations(this.client, core.account.System) + return new TxOperations(this.client, core.account.System) } async getAccount (email: string): Promise { - if (this.initializePromise instanceof Promise) { - await this.initializePromise - } - - if (this.opClient === undefined) { - return - } - - return await this.opClient.findOne(contact.class.PersonAccount, { email }) + const opClient = await this.opClient + return await opClient.getModel().findOne(contact.class.PersonAccount, { email }) } async getPerson (account: PersonAccount): Promise { - if (this.initializePromise instanceof Promise) { - await this.initializePromise - } - - if (this.opClient === undefined) { - return - } - - return await this.opClient.findOne(contact.class.Person, { _id: account.person }) - } - - async getChannel ( - client: TxOperations, - workspace: string, - workspaceName: string, - email: string, - person?: Person - ): Promise | undefined> { - const key = `${email}-${workspace}` - if (this.channelIdByKey.has(key)) { - return this.channelIdByKey.get(key) - } - - const channel = await getOrCreateAnalyticsChannel(this.ctx, client, email, workspace, workspaceName, person) - - if (channel !== undefined) { - this.channelIdByKey.set(key, channel) - } - - return channel - } - - async processEvents ( - client: TxOperations, - events: AnalyticEvent[], - email: string, - workspace: WorkspaceId, - person?: Person, - wsUrl?: string, - channelRef?: Ref - ): Promise { - const wsString = toWorkspaceString(workspace) - const channel = channelRef ?? (await this.getChannel(client, wsString, wsUrl ?? wsString, email, person)) - - if (channel === undefined) { - return - } - - for (const event of events) { - const markup = await eventToMarkup(event, client.getHierarchy()) - - if (markup === undefined) { - continue - } - - await client.addCollection( - chunter.class.ChatMessage, - channel, - channel, - chunter.class.Channel, - 'messages', - { message: markup }, - undefined, - event.timestamp - ) - } - } - - async pushEvents ( - events: AnalyticEvent[], - email: string, - workspace: WorkspaceId, - person?: Person, - wsUrl?: string - ): Promise { - if (this.initializePromise instanceof Promise) { - await this.initializePromise - } - - if (this.opClient === undefined) { - return - } - - const wsString = toWorkspaceString(workspace) - const channelKey = `${email}-${wsString}` - - if (this.channelIdByKey.has(channelKey)) { - const channel = this.channelIdByKey.get(channelKey) - await this.processEvents(this.opClient, events, email, workspace, person, wsUrl, channel) - } else { - // If we dont have AnalyticsChannel we should call it sync to prevent multiple channels for the same user and workspace - await this.rate.add(async () => { - if (this.opClient === undefined) return - await this.processEvents(this.opClient, events, email, workspace, person, wsUrl) - }) - } + const opClient = await this.opClient + return await opClient.findOne(contact.class.Person, { _id: account.person }) } async close (): Promise { @@ -178,7 +57,14 @@ export class WorkspaceClient { return } - await this.opClient?.close() - await this.client.close() + await this.client?.close() + + if (this.opClient instanceof Promise) { + void this.opClient.then((opClient) => { + void opClient.close() + }) + } else { + await this.opClient.close() + } } }