Improve support worksapce (#6360)

Signed-off-by: Kristina Fefelova <kristin.fefelova@gmail.com>
This commit is contained in:
Kristina 2024-08-23 11:05:21 +04:00 committed by GitHub
parent 0fe88a8ef0
commit ae60ab3531
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1161 additions and 446 deletions

2
.vscode/launch.json vendored
View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -274,6 +274,7 @@ export async function configurePlatform (): Promise<void> {
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'))

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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'))

View File

@ -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,

View File

@ -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"
}
}

View File

@ -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<ActivityMessageControl<OnboardingChannel>>(
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
}
}
})
}

View File

@ -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<void> {
const channels = await client.find(DOMAIN_SPACE, {
[`${analyticsCollector.mixin.AnalyticsChannel}`]: { $exists: true }
})
async function removeOnboardingChannels (client: MigrationClient): Promise<void> {
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<void>
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<void> {
await tryMigrate(client, analyticsCollectorId, [
{
state: 'remove-analytics-channels-v1',
func: removeAnalyticsChannels
state: 'remove-analytics-channels-v3',
func: removeOnboardingChannels
}
])
},

View File

@ -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<SocialChannelProvider>
@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<InlineButtonAction>
}
@Model(chunter.class.TypingInfo, core.class.Doc, DOMAIN_TRANSIENT)
export class TTypingInfo extends TDoc implements TypingInfo {
objectId!: Ref<Doc>
@ -176,6 +189,7 @@ export function createModel (builder: Builder): void {
TObjectChatPanel,
TChatInfo,
TChannelInfo,
TInlineButton,
TTypingInfo
)
const spaceClasses = [chunter.class.Channel, chunter.class.DirectMessage]

View File

@ -53,8 +53,7 @@ export default mergeIds(chunterId, chunter, {
Chunter: '' as Ref<ActionCategory>
},
activity: {
ChannelCreatedMessage: '' as AnyComponent,
MembersChangedMessage: '' as AnyComponent
ChannelCreatedMessage: '' as AnyComponent
},
string: {
ApplicationLabelChunter: '' as IntlString,

View File

@ -65,6 +65,7 @@ export class TAIBotTransferEvent extends TAIBotEvent implements AIBotTransferEve
toEmail!: string
toWorkspace!: string
fromWorkspace!: string
fromWorkspaceName!: string
fromWorkspaceUrl!: string
messageId!: Ref<ChatMessage>
parentMessageId?: Ref<ChatMessage>

View File

@ -39,6 +39,7 @@ export interface AIBotTransferEvent extends AIBotEvent {
toEmail: string
toWorkspace: string
fromWorkspace: string
fromWorkspaceName: string
fromWorkspaceUrl: string
messageId: Ref<ChatMessage>
parentMessageId?: Ref<ChatMessage>

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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"
}
}

View File

@ -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": "Имя пользователя"
}
}

View File

@ -0,0 +1,4 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/ui/eslint.config.json'],
parserOptions: { tsconfigRootDir: __dirname }
}

View File

@ -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"
}
}
]
}

View File

@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig",
"rigProfile": "ui"
}

View File

@ -0,0 +1,5 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)']
}

View File

@ -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"
}
}

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: [
require('autoprefixer')
]
}

View File

@ -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<Resources> => ({
function: {
AnalyticsCollectorInlineAction
}
})

View File

@ -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<void> => {
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)
}
}

View File

@ -0,0 +1,5 @@
const sveltePreprocess = require('svelte-preprocess')
module.exports = {
preprocess: sveltePreprocess()
};

View File

@ -0,0 +1,9 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/ui/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types"
}
}

View File

@ -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<string>
},
mixin: {
AnalyticsChannel: '' as Ref<Mixin<AnalyticsChannel>>
class: {
OnboardingChannel: '' as Ref<Class<OnboardingChannel>>
},
space: {
GeneralOnboardingChannel: '' as Ref<Channel>
},
function: {
AnalyticsCollectorInlineAction: '' as Resource<InlineButtonAction>
},
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
}
})

View File

@ -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
}

View File

@ -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}`
}

View File

@ -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 }
}
}
)

View File

@ -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)
}

View File

@ -0,0 +1,49 @@
<!--
// 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.
-->
<script lang="ts">
import { ModernButton } from '@hcengineering/ui'
import chunter, { ChatMessage, InlineButton } from '@hcengineering/chunter'
import { createQuery } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
export let value: ChatMessage
export let inlineButtons: InlineButton[] = []
const query = createQuery()
$: if ((value.inlineButtons ?? 0) > 0 && inlineButtons.length === 0) {
query.query(chunter.class.InlineButton, { attachedTo: value._id, space: value.space }, (res) => {
inlineButtons = res
})
} else {
query.unsubscribe()
}
async function handleInlineButtonClick (button: InlineButton): Promise<void> {
const resource = await getResource(button.action)
await resource(button, value._id, value.attachedTo)
}
</script>
{#each inlineButtons as button}
<ModernButton
title={button.title}
label={button.titleIntl}
size="small"
on:click={() => {
void handleInlineButtonClick(button)
}}
/>
{/each}

View File

@ -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<ChatMessage> | 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 @@
<div class="clear-mins">
<MessageViewer message={value.message} />
<AttachmentDocList {value} {attachments} imageSize={attachmentImageSize} {videoPreload} />
<InlineButtons {value} {inlineButtons} />
</div>
</ShowMore>
{:else}
<div class="clear-mins">
<MessageViewer message={value.message} />
<AttachmentDocList {value} {attachments} imageSize={attachmentImageSize} {videoPreload} />
<InlineButtons {value} {inlineButtons} />
</div>
{/if}
{:else if object}

View File

@ -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<SocialChannelProvider>
inlineButtons?: number
}
/**
@ -91,6 +92,15 @@ export interface ChannelInfo extends DocNotifyContext {
hidden: boolean
}
export type InlineButtonAction = (button: InlineButton, message: Ref<ChatMessage>, channel: Ref<Doc>) => Promise<void>
export interface InlineButton extends AttachedDoc {
name: string
titleIntl?: IntlString
title?: string
action: Resource<InlineButtonAction>
}
/**
* @public
*/
@ -124,6 +134,9 @@ export default plugin(chunterId, {
ChatMessagePreview: '' as AnyComponent,
ThreadMessagePreview: '' as AnyComponent
},
activity: {
MembersChangedMessage: '' as AnyComponent
},
class: {
ThreadMessage: '' as Ref<Class<ThreadMessage>>,
ChunterSpace: '' as Ref<Class<ChunterSpace>>,
@ -132,6 +145,7 @@ export default plugin(chunterId, {
ChatMessage: '' as Ref<Class<ChatMessage>>,
ChatMessageViewlet: '' as Ref<Class<ChatMessageViewlet>>,
ChatInfo: '' as Ref<Class<ChatInfo>>,
InlineButton: '' as Ref<Class<InlineButton>>,
TypingInfo: '' as Ref<Class<TypingInfo>>
},
mixin: {

View File

@ -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",

View File

@ -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<void> {
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<AIBotResponseEvent> | 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)
}

View File

@ -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<Ref<Channel> | 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<OnboardingChannel> | 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<Channel | undefined> {
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 })
}

View File

@ -776,7 +776,7 @@ export async function getNotificationTxes (
control,
message.attachedTo,
message.attachedToClass,
message.space,
object.space,
receiver,
params.shouldUpdateTimestamp ? originTx.modifiedOn : undefined,
tx

View File

@ -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<Client> {
const endpoint = await getTransactorEndpoint(token)
export async function connectPlatform (token: string, endpoint: string): Promise<Client> {
return await createClient(endpoint, token)
}

View File

@ -36,7 +36,7 @@ export const start = async (): Promise<void> => {
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 {

View File

@ -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<Db> => {
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<void> = async () => {
if (client !== undefined) {
await client.close()
}
clientRef.close()
}

View File

@ -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> | TxOperations
blobClient: BlobClient
loginTimeout: NodeJS.Timeout | undefined
loginDelayMs = 2 * 1000
initializePromise: Promise<void> | undefined = undefined
channelByKey = new Map<string, Ref<Channel>>()
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<void> {
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<Blob>, avatarType: AvatarType.IMAGE })
}
private async initClient (): Promise<void> {
private async initClient (): Promise<TxOperations> {
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<void> {
if (this.opClient === undefined) {
return
}
const client = await this.opClient
if (event.messageClass === chunter.class.ChatMessage) {
await this.opClient.addCollection<Doc, ChatMessage>(
await client.addCollection<Doc, ChatMessage>(
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<ChatMessage>(chunter.class.ChatMessage, {
const parent = await client.findOne<ChatMessage>(chunter.class.ChatMessage, {
_id: event.objectId as Ref<ChatMessage>
})
if (parent !== undefined) {
await this.opClient.addCollection<Doc, ThreadMessage>(
await client.addCollection<Doc, ThreadMessage>(
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<void> {
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<PersonAccount | undefined> {
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<Ref<DirectMessage> | 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<Account>[] = [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<DirectMessage>(chunter.class.DirectMessage, core.space.Space, {
const dmId = await client.createDoc<DirectMessage>(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<Channel>): Promise<void> {
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<void> {
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<Channel> | undefined {
@ -402,17 +384,13 @@ export class WorkspaceClient {
}
async transfer (event: AIBotTransferEvent): Promise<void> {
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<void> {
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<void> {
if (this.opClient === undefined) {
return
}
const hierarchy = this.opClient.getHierarchy()
private async txHandler (client: TxOperations, txes: Tx[]): Promise<void> {
const hierarchy = client.getHierarchy()
const resultTxes = txes
.map((a) => TxProcessor.extractTx(a) as TxCreateDoc<AIBotEvent>)

View File

@ -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"
}

View File

@ -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<string, WorkspaceClient> = new Map<string, WorkspaceClient>()
private readonly closeWorkspaceTimeouts: Map<string, NodeJS.Timeout> = new Map<string, NodeJS.Timeout>()
private readonly createdWorkspaces: Set<string> = new Set<string>()
private readonly workspaceUrlById = new Map<string, string>()
supportClient: WorkspaceClient | undefined = undefined
eventsByEmail = new Map<string, AnalyticEvent[]>()
private readonly onboardingMessagesCollection: Collection<OnboardingMessage>
periodicTimer: NodeJS.Timeout
supportClient: SupportWsClient | undefined = undefined
persons = new Map<string, Person>()
constructor (private readonly ctx: MeasureContext) {
this.periodicTimer = setInterval(() => {
void this.clearEvents()
}, clearEventsTimeout)
}
async clearEvents (): Promise<void> {
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<void> {
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<OnboardingMessage>('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<void> {
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<boolean> {
@ -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<void> {
async pushEventsToSupport (events: AnalyticEvent[], token: Token): Promise<void> {
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<void> {
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<void> {
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
}
}
}

View File

@ -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<Config> = {
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,

View File

@ -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)
}

View File

@ -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<void> => {
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())
}

View File

@ -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({})
})
)

View File

@ -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<void> = async () => {
clientRef.close()
}

View File

@ -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<string, Ref<OnboardingChannel>>()
rate = new RateLimiter(1)
generalChannel: Channel | undefined = undefined
async initClient (): Promise<TxOperations> {
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<Doc>)
break
}
}
}
}
private txUpdateDoc (client: TxOperations, tx: TxUpdateDoc<Doc>): 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<Channel>)
}
}
private async getOrCreateOnboardingChannel (
client: TxOperations,
workspace: string,
email: string,
person: Person
): Promise<{
channelId: Ref<OnboardingChannel> | 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<OnboardingMessage>
): Promise<void> {
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<OnboardingMessage>): Promise<void> {
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<OnboardingMessage>
): Promise<void> {
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<ChatMessage>()
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<OnboardingMessage>
): Promise<void> {
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)
})
}
}
}

View File

@ -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<InlineButton>
name: string
messageId: Ref<ChatMessage>
channelId: Ref<Channel>
}
export enum MessageActions {
Accept = 'accept'
}
export interface OnboardingMessage {
messageId: Ref<ChatMessage>
channelId: Ref<OnboardingChannel>
}

View File

@ -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> | TxOperations
initializePromise: Promise<void> | undefined = undefined
channelIdByKey = new Map<string, Ref<Channel>>()
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<void> {
protected async initClient (): Promise<TxOperations> {
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<PersonAccount | undefined> {
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<Person | undefined> {
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<Ref<Channel> | 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<Channel>
): Promise<void> {
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<void> {
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<void> {
@ -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()
}
}
}