diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 35bd13c2ef..53022a30ba 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -466,6 +466,9 @@ importers: '@rush-temp/inbox-resources': specifier: file:./projects/inbox-resources.tgz version: file:projects/inbox-resources.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)) + '@rush-temp/integration-client': + specifier: file:./projects/integration-client.tgz + version: file:projects/integration-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)) '@rush-temp/inventory': specifier: file:./projects/inventory.tgz version: file:projects/inventory.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)) @@ -4743,6 +4746,10 @@ packages: resolution: {integrity: sha512-Qv3JD4silIEc3DbDzvxoBF+bW3a5/+oZXYBudlOu0Rjl83i4Rq5XOV9wWSa74ob9XlyVPvEl80OuqcEEydbS/g==, tarball: file:projects/inbox.tgz} version: 0.0.0 + '@rush-temp/integration-client@file:projects/integration-client.tgz': + resolution: {integrity: sha512-r46KMTTe/Xy/8c7L81e3fmLX/ESI5ChiCmz+KTBVnHqeuM788FYZVdJhb+JebbXoi+lSHhqE0kWm825RhPiUEA==, tarball: file:projects/integration-client.tgz} + version: 0.0.0 + '@rush-temp/inventory-assets@file:projects/inventory-assets.tgz': resolution: {integrity: sha512-Ad5kbORRxeNRLA+TZJOkkE657ctwFxnyGpP24QsqtwWYpi0asjNkE/niPY9GdM5nqRIkNFG+Jh375imRx38XlQ==, tarball: file:projects/inventory-assets.tgz} version: 0.0.0 @@ -5312,7 +5319,7 @@ packages: version: 0.0.0 '@rush-temp/pod-gmail@file:projects/pod-gmail.tgz': - resolution: {integrity: sha512-ZLnW1O8HTDfGRQLJGyMs1GO63xr5jnqZOZ36/0AjopTYWrT0xzP77bRh/J6XmjOUhDza7oGHisztULcWkyMHQg==, tarball: file:projects/pod-gmail.tgz} + resolution: {integrity: sha512-HAat9sFoOjj5Shx3UlRU9ACh2mH4gLlp8CEkxv3gbuoQwVQsgJ0VLCe4FwUjj9DPHP8cpBTek6h75DOXbbvN3g==, tarball: file:projects/pod-gmail.tgz} version: 0.0.0 '@rush-temp/pod-inbound-mail@file:projects/pod-inbound-mail.tgz': @@ -5896,7 +5903,7 @@ packages: version: 0.0.0 '@rush-temp/telegram-resources@file:projects/telegram-resources.tgz': - resolution: {integrity: sha512-AfBy+LDwX0PQwsO8zX0oY5lwb4VhN/KMduePtl13BpiTpHRfJH+/9CQ+zOnu/2t6UEP9yMqrr+trSIGCwX/OMg==, tarball: file:projects/telegram-resources.tgz} + resolution: {integrity: sha512-oNF++UNPuNik4Ak7sGISighoWJtiPGG+x9+Bop7xHYpPk+j4QTobQlqDKBSAXJdXYE2fe4U64QL5vvIeZT/dDQ==, tarball: file:projects/telegram-resources.tgz} version: 0.0.0 '@rush-temp/telegram@file:projects/telegram.tgz': @@ -20698,6 +20705,36 @@ snapshots: - supports-color - ts-node + '@rush-temp/integration-client@file:projects/integration-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))': + dependencies: + '@types/jest': 29.5.12 + '@types/node': 22.15.29 + '@types/uuid': 8.3.4 + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3) + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3) + cross-env: 7.0.3 + esbuild: 0.24.2 + eslint: 8.56.0 + eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.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@8.56.0)(typescript@5.8.3) + eslint-plugin-import: 2.29.1(eslint@8.56.0) + eslint-plugin-n: 15.7.0(eslint@8.56.0) + eslint-plugin-promise: 6.1.1(eslint@8.56.0) + jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)) + jest-fetch-mock: 3.0.3(encoding@0.1.13) + prettier: 3.2.5 + ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3) + typescript: 5.8.3 + uuid: 8.3.2 + transitivePeerDependencies: + - '@babel/core' + - '@jest/types' + - babel-jest + - babel-plugin-macros + - encoding + - node-notifier + - supports-color + - ts-node + '@rush-temp/inventory-assets@file:projects/inventory-assets.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))': dependencies: '@types/jest': 29.5.12 diff --git a/dev/tool/src/calendar.ts b/dev/tool/src/calendar.ts index 3015e1acfe..19444f3bef 100644 --- a/dev/tool/src/calendar.ts +++ b/dev/tool/src/calendar.ts @@ -1,4 +1,5 @@ import { type AccountClient } from '@hcengineering/account-client' +import { calendarIntegrationKind } from '@hcengineering/calendar' import { type PersonId, type WorkspaceInfoWithStatus, @@ -55,8 +56,6 @@ interface WorkspaceInfoProvider { getWorkspaceInfo: (workspaceUuid: WorkspaceUuid) => Promise } -const CALENDAR_INTEGRATION = 'google-calendar' - export async function performCalendarAccountMigrations (db: Db, region: string | null, kvsUrl: string): Promise { console.log('Start calendar migrations') const token = generateToken(systemAccountUuid, undefined, { service: 'tool', admin: 'true' }) @@ -124,14 +123,14 @@ async function migrateCalendarIntegrations ( } // Check/create integration in account const existing = await accountClient.getIntegration({ - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: ws.uuid, socialId: personId }) if (existing == null) { await accountClient.createIntegration({ - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: ws.uuid, socialId: personId }) @@ -139,7 +138,7 @@ async function migrateCalendarIntegrations ( const existingToken = await accountClient.getIntegrationSecret({ key: token.email, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, socialId: personId, workspaceUuid: ws.uuid }) @@ -152,7 +151,7 @@ async function migrateCalendarIntegrations ( if (existingToken == null) { await accountClient.addIntegrationSecret({ key: newToken.email, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, socialId: personId, secret: JSON.stringify(newToken), workspaceUuid: newToken.workspace @@ -164,7 +163,7 @@ async function migrateCalendarIntegrations ( } await accountClient.updateIntegrationSecret({ key: newToken.email, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, socialId: personId, secret: JSON.stringify(updatedToken), workspaceUuid: newToken.workspace @@ -194,7 +193,7 @@ async function migrateCalendarHistory ( const calendarHistory = db.collection('calendarHistories') const calendarHistories = await calendarHistory.find({}).toArray() - const kvsClient = getKvsClient(CALENDAR_INTEGRATION, kvsUrl, token) + const kvsClient = getKvsClient(calendarIntegrationKind, kvsUrl, token) for (const history of [...calendarHistories, ...allHistories]) { try { @@ -212,8 +211,8 @@ async function migrateCalendarHistory ( const key = history.calendarId != null - ? `${CALENDAR_INTEGRATION}:eventHistory:${ws.uuid}:${personId}:${history.email}:${history.calendarId}` - : `${CALENDAR_INTEGRATION}:calendarsHistory:${ws.uuid}:${personId}:${history.email}` + ? `${calendarIntegrationKind}:eventHistory:${ws.uuid}:${personId}:${history.email}:${history.calendarId}` + : `${calendarIntegrationKind}:calendarsHistory:${ws.uuid}:${personId}:${history.email}` await kvsClient.setValue(key, history.historyId) } catch (e) { @@ -229,7 +228,7 @@ async function migrateCalendarHistory ( continue } - const key = `${CALENDAR_INTEGRATION}:calendarSync:${ws.uuid}` + const key = `${calendarIntegrationKind}:calendarSync:${ws.uuid}` await kvsClient.setValue(key, history.timestamp) } console.log('Finished migrating gmail history, count:', allHistories.length) diff --git a/dev/tool/src/github.ts b/dev/tool/src/github.ts index 6496af53c3..973f940bc6 100644 --- a/dev/tool/src/github.ts +++ b/dev/tool/src/github.ts @@ -11,7 +11,8 @@ import core, { type Ref, type Tx, type TxCUD, - type WorkspaceUuid + type WorkspaceUuid, + type IntegrationKind } from '@hcengineering/core' import { getAccountsFromTxes, getSocialKeyByOldEmail } from '@hcengineering/model-core' import { getAccountClient } from '@hcengineering/server-client' @@ -149,13 +150,15 @@ export async function performGithubAccountMigrations ( } const sid = socialKeyByAccount[it.accountId] + const kind: IntegrationKind = 'github' as any + const userKind: IntegrationKind = 'github-user' as any const person = sid !== undefined ? await accountClient.findSocialIdBySocialKey(sid) : undefined if (person !== undefined) { // Check/create integeration in account const existing = await githubAccountClient.getIntegration({ - kind: 'github', + kind, workspaceUuid: ws?.uuid, socialId: person }) @@ -163,7 +166,7 @@ export async function performGithubAccountMigrations ( if (existing == null) { try { await githubAccountClient.createIntegration({ - kind: 'github', + kind, workspaceUuid: ws?.uuid, socialId: person, data: { @@ -191,7 +194,7 @@ export async function performGithubAccountMigrations ( const { _id, accounts, ...data } = u const existing = await githubAccountClient.getIntegration({ - kind: 'github-user', + kind: userKind, workspaceUuid: null, socialId: person }) @@ -199,7 +202,7 @@ export async function performGithubAccountMigrations ( if (existing == null) { try { await githubAccountClient.createIntegration({ - kind: 'github-user', + kind: userKind, workspaceUuid: null, socialId: person, data: { @@ -208,7 +211,7 @@ export async function performGithubAccountMigrations ( }) // Check/create integeration in account await githubAccountClient.addIntegrationSecret({ - kind: 'github-user', + kind: userKind, workspaceUuid: null, socialId: person, key: u._id, // github login diff --git a/dev/tool/src/gmail.ts b/dev/tool/src/gmail.ts index 0046854ec3..400290645c 100644 --- a/dev/tool/src/gmail.ts +++ b/dev/tool/src/gmail.ts @@ -2,6 +2,7 @@ import core, { type AccountUuid, DOMAIN_MODEL_TX, type Doc, + type IntegrationKind, MeasureMetricsContext, type PersonId, type Ref, @@ -67,7 +68,7 @@ interface WorkspaceInfoProvider { getWorkspaceInfo: (workspaceUuid: WorkspaceUuid) => Promise } -const GMAIL_INTEGRATION = 'gmail' +const GMAIL_INTEGRATION: IntegrationKind = 'gmail' as any const TOKEN_TYPE = 'token' export async function performGmailAccountMigrations ( diff --git a/models/bitrix/src/index.ts b/models/bitrix/src/index.ts index 071002797a..fd78aec317 100644 --- a/models/bitrix/src/index.ts +++ b/models/bitrix/src/index.ts @@ -34,7 +34,8 @@ import { type BitrixEntityMapping, type BitrixFieldMapping, type BitrixSyncDoc, - type Fields + type Fields, + bitrixIntegrationKind } from '@hcengineering/bitrix' import { type AnyAttribute, @@ -112,7 +113,8 @@ export function createModel (builder: Builder): void { icon: bitrix.component.BitrixIcon, allowMultiple: false, createComponent: bitrix.component.BitrixConnect, - configureComponent: bitrix.component.BitrixConfigure + configureComponent: bitrix.component.BitrixConfigure, + kind: bitrixIntegrationKind }, bitrix.integrationType.Bitrix ) diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts index 7b40678431..3fe91ae1ee 100644 --- a/models/calendar/src/index.ts +++ b/models/calendar/src/index.ts @@ -69,6 +69,7 @@ import { type AnyComponent } from '@hcengineering/ui/src/types' import workbench from '@hcengineering/model-workbench' import { WidgetType } from '@hcengineering/workbench' import preference, { TPreference } from '@hcengineering/model-preference' +import { calendarIntegrationKind } from '@hcengineering/calendar' import calendar from './plugin' @@ -248,8 +249,11 @@ export function createModel (builder: Builder): void { allowMultiple: true, createComponent: calendar.component.IntegrationConnect, onDisconnect: calendar.handler.DisconnectHandler, + onDisconnectAll: calendar.handler.DisconnectAllHandler, reconnectComponent: calendar.component.IntegrationConnect, - configureComponent: calendar.component.IntegrationConfigure + configureComponent: calendar.component.IntegrationConfigure, + stateComponent: calendar.component.IntegrationState, + kind: calendarIntegrationKind }, calendar.integrationType.Calendar ) diff --git a/models/gmail/src/index.ts b/models/gmail/src/index.ts index 4133a0060c..9f3ad61beb 100644 --- a/models/gmail/src/index.ts +++ b/models/gmail/src/index.ts @@ -16,7 +16,13 @@ import activity from '@hcengineering/activity' import { type Channel } from '@hcengineering/contact' import { type Class, type Domain, IndexKind, type Ref, type Timestamp, type Type } from '@hcengineering/core' -import type { Message, NewMessage, SharedMessage, SharedMessages } from '@hcengineering/gmail' +import { + type Message, + type NewMessage, + type SharedMessage, + type SharedMessages, + gmailIntegrationKind +} from '@hcengineering/gmail' import { ArrOf, type Builder, @@ -154,11 +160,14 @@ export function createModel (builder: Builder): void { label: gmail.string.IntegrationLabel, description: gmail.string.IntegrationDescription, icon: gmail.component.IconGmail, - allowMultiple: false, // enable in future + allowMultiple: true, createComponent: gmail.component.Connect, onDisconnect: gmail.handler.DisconnectHandler, + onDisconnectAll: gmail.handler.DisconnectAllHandler, reconnectComponent: gmail.component.Connect, - configureComponent: gmail.component.Configure + configureComponent: gmail.component.Configure, + stateComponent: gmail.component.IntegrationState, + kind: gmailIntegrationKind }, gmail.integrationType.Gmail ) diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 7de63d5cdc..60ee4a9207 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -21,7 +21,8 @@ import { type AccountUuid, type Blob, type ClassCollaborators, - type Ref + type Ref, + type IntegrationKind } from '@hcengineering/core' import exportPlugin from '@hcengineering/export' import { Mixin, Model, UX, type Builder } from '@hcengineering/model' @@ -92,6 +93,8 @@ export class TIntegrationType extends TDoc implements IntegrationType { reconnectComponent?: AnyComponent onDisconnect!: Handler configureComponent?: AnyComponent + kind!: IntegrationKind + stateComponent?: AnyComponent } @Mixin(setting.mixin.Editable, core.class.Class) @@ -157,10 +160,6 @@ export function createModel (builder: Builder): void { fields: ['modifiedBy'] }) - builder.mixin(setting.class.Integration, core.class.Class, view.mixin.ObjectPanel, { - component: setting.component.IntegrationPanel - }) - builder.createDoc( setting.class.SettingsCategory, core.space.Model, diff --git a/models/setting/src/plugin.ts b/models/setting/src/plugin.ts index 6c87f0512d..a9550a6b9b 100644 --- a/models/setting/src/plugin.ts +++ b/models/setting/src/plugin.ts @@ -46,7 +46,6 @@ export default mergeIds(settingId, setting, { CreateMixin: '' as AnyComponent, InviteSetting: '' as AnyComponent, ArrayEditor: '' as AnyComponent, - IntegrationPanel: '' as AnyComponent, Configure: '' as AnyComponent, SettingsWidget: '' as AnyComponent }, diff --git a/models/telegram/src/index.ts b/models/telegram/src/index.ts index e396ce35fa..18c09eb1d8 100644 --- a/models/telegram/src/index.ts +++ b/models/telegram/src/index.ts @@ -33,11 +33,12 @@ import attachment from '@hcengineering/model-attachment' import contact from '@hcengineering/model-contact' import core, { TAttachedDoc } from '@hcengineering/model-core' import setting from '@hcengineering/setting' -import type { - NewTelegramMessage, - SharedTelegramMessage, - SharedTelegramMessages, - TelegramMessage +import { + type NewTelegramMessage, + type SharedTelegramMessage, + type SharedTelegramMessages, + type TelegramMessage, + telegramIntegrationKind } from '@hcengineering/telegram' import templates from '@hcengineering/templates' import view from '@hcengineering/view' @@ -158,10 +159,14 @@ export function createModel (builder: Builder): void { description: telegram.string.TelegramIntegrationDesc, descriptionComponent: telegram.component.TelegramIntegrationDescription, icon: telegram.component.IconTelegram, - allowMultiple: false, + allowMultiple: true, createComponent: telegram.component.Connect, reconnectComponent: telegram.component.Reconnect, - onDisconnect: telegram.handler.DisconnectHandler + configureComponent: telegram.component.Configure, + stateComponent: telegram.component.StateComponent, + onDisconnect: telegram.handler.DisconnectHandler, + onDisconnectAll: telegram.handler.DisconnectAllHandler, + kind: telegramIntegrationKind }, telegram.integrationType.Telegram ) diff --git a/packages/account-client/src/types.ts b/packages/account-client/src/types.ts index 33dee51422..e5a0b7c198 100644 --- a/packages/account-client/src/types.ts +++ b/packages/account-client/src/types.ts @@ -10,7 +10,8 @@ import { type WorkspaceMode, Person, WorkspaceInfo, - AccountInfo + AccountInfo, + IntegrationKind } from '@hcengineering/core' export interface LoginInfo { @@ -92,9 +93,10 @@ export interface MailboxInfo { export interface Integration { socialId: PersonId - kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. workspaceUuid: WorkspaceUuid | null data?: Record + disabled?: boolean } export interface SocialId extends SocialIdBase { @@ -106,7 +108,7 @@ export type IntegrationKey = Omit export interface IntegrationSecret { socialId: PersonId - kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. workspaceUuid: WorkspaceUuid | null key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc. secret: string diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index a68cba0c57..c0e9f25bd4 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -903,3 +903,8 @@ export interface ClassCollaborators extends Doc { export interface Collaborator extends AttachedDoc { collaborator: AccountUuid } + +/** + * @public + */ +export type IntegrationKind = string & { __IntegrationKind: true } diff --git a/packages/integration-client/.eslintrc.js b/packages/integration-client/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/packages/integration-client/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/integration-client/.npmignore b/packages/integration-client/.npmignore new file mode 100644 index 0000000000..e3ec093c38 --- /dev/null +++ b/packages/integration-client/.npmignore @@ -0,0 +1,4 @@ +* +!/lib/** +!CHANGELOG.md +/lib/**/__tests__/ diff --git a/packages/integration-client/config/rig.json b/packages/integration-client/config/rig.json new file mode 100644 index 0000000000..0110930f55 --- /dev/null +++ b/packages/integration-client/config/rig.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + "rigPackageName": "@hcengineering/platform-rig" +} diff --git a/packages/integration-client/jest.config.js b/packages/integration-client/jest.config.js new file mode 100644 index 0000000000..2cfd408b67 --- /dev/null +++ b/packages/integration-client/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + roots: ["./src"], + coverageReporters: ["text-summary", "html"] +} diff --git a/packages/integration-client/jest.setup.js b/packages/integration-client/jest.setup.js new file mode 100644 index 0000000000..78d57e3d23 --- /dev/null +++ b/packages/integration-client/jest.setup.js @@ -0,0 +1,2 @@ +// Set up fetch mock +require('jest-fetch-mock').enableMocks() diff --git a/packages/integration-client/package.json b/packages/integration-client/package.json new file mode 100644 index 0000000000..fc3bfac94c --- /dev/null +++ b/packages/integration-client/package.json @@ -0,0 +1,62 @@ +{ + "name": "@hcengineering/integration-client", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.6.0", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "esbuild": "^0.24.2", + "@typescript-eslint/parser": "^6.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "jest-fetch-mock": "^3.0.3", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5", + "@types/uuid": "^8.3.1" + }, + "dependencies": { + "@hcengineering/account-client": "^0.6.0", + "@hcengineering/core": "^0.6.32", + "@hcengineering/platform": "^0.6.11", + "@hcengineering/kvs-client": "^0.6.0", + "uuid": "^8.3.2" + }, + "repository": "https://github.com/hcengineering/platform", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/packages/integration-client/src/__tests__/client.test.ts b/packages/integration-client/src/__tests__/client.test.ts new file mode 100644 index 0000000000..76713533fb --- /dev/null +++ b/packages/integration-client/src/__tests__/client.test.ts @@ -0,0 +1,550 @@ +// +// Copyright © 2025 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 { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core' +import { Integration, IntegrationKey, type AccountClient } from '@hcengineering/account-client' +import { IntegrationClientImpl } from '../client' +import { IntegrationClient } from '../types' + +// Mock the events module before importing anything else +const mockEvents = { + on: jest.fn(), + off: jest.fn(), + emit: jest.fn() +} + +jest.mock('../events', () => ({ + getIntegrationEventBus: () => mockEvents +})) + +// Mock account client +const mockAccountClient: jest.Mocked = { + listIntegrations: jest.fn(), + getIntegration: jest.fn(), + createIntegration: jest.fn(), + updateIntegration: jest.fn(), + deleteIntegration: jest.fn() +} as any + +const integrationKind: IntegrationKind = 'gmail' as IntegrationKind +const serviceName = 'gmail-service' +const testSocialId: PersonId = 'social-123' as PersonId +const testWorkspaceId: WorkspaceUuid = 'workspace-456' as WorkspaceUuid + +describe('IntegrationClientImpl', () => { + let client: IntegrationClient + + beforeEach(() => { + jest.clearAllMocks() + + // Reset mock event handlers + mockEvents.on.mockReturnValue(() => {}) + mockEvents.off.mockImplementation(() => {}) + mockEvents.emit.mockImplementation(() => {}) + + client = new IntegrationClientImpl(mockAccountClient, integrationKind, serviceName) + }) + + describe('getIntegrations', () => { + it('should return integrations from account client', async () => { + const mockIntegrations: Integration[] = [ + { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: { test: true } + } + ] + + mockAccountClient.listIntegrations.mockResolvedValue(mockIntegrations) + + const result = await client.getIntegrations() + + expect(mockAccountClient.listIntegrations).toHaveBeenCalledWith({ kind: integrationKind }) + expect(result).toEqual(mockIntegrations) + }) + + it('should return empty array when account client returns null', async () => { + mockAccountClient.listIntegrations.mockResolvedValue([]) + + const result = await client.getIntegrations() + + expect(result).toEqual([]) + }) + }) + + describe('getConnection', () => { + it('should return integration if it is already a connection', async () => { + const connectionIntegration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: { isConnection: true } + } + + const result = await client.getConnection(connectionIntegration) + + expect(result).toBe(connectionIntegration) + expect(mockAccountClient.getIntegration).not.toHaveBeenCalled() + }) + + it('should fetch connection for non-connection integration', async () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: { test: true } + } + + const connectionIntegration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: { isConnection: true } + } + + mockAccountClient.getIntegration.mockResolvedValue(connectionIntegration) + + const result = await client.getConnection(integration) + + expect(mockAccountClient.getIntegration).toHaveBeenCalledWith({ + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null + }) + expect(result).toBe(connectionIntegration) + }) + + it('should emit error event and rethrow when getIntegration fails', async () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: { test: true } + } + + const error = new Error('Connection failed') + mockAccountClient.getIntegration.mockRejectedValue(error) + + await expect(client.getConnection(integration)).rejects.toThrow('Connection failed') + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:error', + expect.objectContaining({ + operation: 'connect', + error: 'Connection failed', + socialId: testSocialId, + timestamp: expect.any(Number) + }) + ) + }) + }) + + describe('integrate', () => { + it('should create new integration when none exists', async () => { + const connection: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: { _id: 'conn-123', other: 'data' } + } + + mockAccountClient.getIntegration.mockResolvedValue(null) + mockAccountClient.createIntegration.mockResolvedValue(undefined) + + const result = await client.integrate(connection, testWorkspaceId) + + expect(mockAccountClient.createIntegration).toHaveBeenCalledWith( + expect.objectContaining({ + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: expect.objectContaining({ + connectionId: 'conn-123', + _id: expect.any(String), + other: 'data' + }) + }) + ) + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:created', + expect.objectContaining({ + integration: expect.any(Object), + timestamp: expect.any(Number) + }) + ) + + expect(result).toEqual( + expect.objectContaining({ + socialId: testSocialId, + workspaceUuid: testWorkspaceId + }) + ) + }) + + it('should return existing integration when it already exists', async () => { + const connection: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: { _id: 'conn-123' } + } + + const existingIntegration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: { existing: true } + } + + mockAccountClient.getIntegration.mockResolvedValue(existingIntegration) + + const result = await client.integrate(connection, testWorkspaceId) + + expect(mockAccountClient.createIntegration).not.toHaveBeenCalled() + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:updated', + expect.objectContaining({ + integration: expect.any(Object), + timestamp: expect.any(Number) + }) + ) + expect(result).toBe(existingIntegration) + }) + + it('should emit error event when integration fails', async () => { + const connection: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: {} + } + + const error = new Error('Integration failed') + mockAccountClient.getIntegration.mockRejectedValue(error) + + await expect(client.integrate(connection, testWorkspaceId)).rejects.toThrow('Integration failed') + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:error', + expect.objectContaining({ + operation: 'integrate', + error: 'Integration failed', + workspaceUuid: testWorkspaceId, + timestamp: expect.any(Number) + }) + ) + }) + }) + + describe('connect', () => { + it('should create new connection when none exists', async () => { + mockAccountClient.getIntegration.mockResolvedValue(null) + mockAccountClient.createIntegration.mockResolvedValue(undefined) + + const data = { test: 'data' } + const result = await client.connect(testSocialId, data) + + expect(mockAccountClient.createIntegration).toHaveBeenCalledWith({ + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data + }) + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'connection:created', + expect.objectContaining({ + integration: expect.objectContaining({ + socialId: testSocialId, + workspaceUuid: null + }), + timestamp: expect.any(Number) + }) + ) + + expect(result).toEqual( + expect.objectContaining({ + socialId: testSocialId, + workspaceUuid: null, + data + }) + ) + }) + + it('should return existing connection when it already exists', async () => { + const existingConnection: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: { existing: true } + } + + mockAccountClient.getIntegration.mockResolvedValue(existingConnection) + + const result = await client.connect(testSocialId) + + expect(mockAccountClient.createIntegration).not.toHaveBeenCalled() + expect(mockAccountClient.updateIntegration).not.toHaveBeenCalled() + expect(result).toBe(existingConnection) + }) + + it('should update existing connection when data is different', async () => { + const existingConnection: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: { existing: true, version: 1 } + } + + const newData = { existing: true, version: 2, newField: 'value' } + const updatedConnection = { + ...existingConnection, + data: newData + } + + mockAccountClient.getIntegration.mockResolvedValue(existingConnection) + mockAccountClient.updateIntegration.mockResolvedValue(undefined) + + const result = await client.connect(testSocialId, newData) + + expect(mockAccountClient.createIntegration).not.toHaveBeenCalled() + expect(mockAccountClient.updateIntegration).toHaveBeenCalledWith(updatedConnection) + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'connection:updated', + expect.objectContaining({ + integration: expect.objectContaining({ + data: newData + }), + timestamp: expect.any(Number) + }) + ) + + expect(result).toEqual(updatedConnection) + }) + + it('should not update existing connection when data is the same', async () => { + const existingData = { existing: true, version: 1 } + const existingConnection: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: existingData + } + + mockAccountClient.getIntegration.mockResolvedValue(existingConnection) + + const result = await client.connect(testSocialId, existingData) + + expect(mockAccountClient.createIntegration).not.toHaveBeenCalled() + expect(mockAccountClient.updateIntegration).not.toHaveBeenCalled() + expect(mockEvents.emit).not.toHaveBeenCalledWith('connection:updated', expect.any(Object)) + expect(result).toBe(existingConnection) + }) + + it('should emit error event when connect fails', async () => { + const error = new Error('Connect failed') + mockAccountClient.getIntegration.mockRejectedValue(error) + + await expect(client.connect(testSocialId)).rejects.toThrow('Connect failed') + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:error', + expect.objectContaining({ + operation: 'connect', + error: 'Connect failed', + socialId: testSocialId, + timestamp: expect.any(Number) + }) + ) + }) + }) + + describe('updateConfig', () => { + it('should update integration config', async () => { + const integrationKey: IntegrationKey = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId + } + + const existingIntegration: Integration = { + ...integrationKey, + data: { config: { old: true }, other: 'data' } + } + + const newConfig = { new: true } + const refreshFn = jest.fn().mockResolvedValue(undefined) + + mockAccountClient.getIntegration.mockResolvedValue(existingIntegration) + mockAccountClient.updateIntegration.mockResolvedValue(undefined) + + await client.updateConfig(integrationKey, newConfig, refreshFn) + + expect(mockAccountClient.updateIntegration).toHaveBeenCalledWith({ + ...existingIntegration, + data: { + config: newConfig, + other: 'data' + } + }) + + expect(refreshFn).toHaveBeenCalled() + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:updated', + expect.objectContaining({ + integration: existingIntegration, + oldConfig: { old: true }, + newConfig, + timestamp: expect.any(Number) + }) + ) + }) + + it('should throw error when integration not found', async () => { + const integrationKey: IntegrationKey = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId + } + + mockAccountClient.getIntegration.mockResolvedValue(null) + + await expect(client.updateConfig(integrationKey, {})).rejects.toThrow('Integration not found') + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:error', + expect.objectContaining({ + operation: 'updateConfig', + integrationKey, + timestamp: expect.any(Number) + }) + ) + }) + }) + + describe('removeIntegration', () => { + it('should return undefined when socialId is null', async () => { + const result = await client.removeIntegration(null, testWorkspaceId) + expect(result).toBeUndefined() + }) + + it('should remove integration and keep connection', async () => { + const integrations: Integration[] = [ + { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + ] + + const remainingIntegrations: Integration[] = [ + { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, // connection + data: {} + }, + { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: 'other-workspace' as WorkspaceUuid, + data: {} + } + ] + + mockAccountClient.listIntegrations + .mockResolvedValueOnce(integrations) + .mockResolvedValueOnce(remainingIntegrations) + + const result = await client.removeIntegration(testSocialId, testWorkspaceId) + + expect(mockAccountClient.deleteIntegration).toHaveBeenCalledWith({ + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId + }) + + expect(result).toEqual({ connectionRemoved: false }) + }) + + it('should remove integration and connection when it was the last one', async () => { + const integrations: Integration[] = [ + { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + ] + + const remainingIntegrations: Integration[] = [ + { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, // connection only + data: {} + } + ] + + mockAccountClient.listIntegrations + .mockResolvedValueOnce(integrations) + .mockResolvedValueOnce(remainingIntegrations) + .mockResolvedValueOnce([]) // after removing connection + + const result = await client.removeIntegration(testSocialId, testWorkspaceId) + + expect(result).toEqual({ connectionRemoved: true }) + }) + }) + + describe('removeConnection', () => { + it('should remove all integrations for the connection', async () => { + const connection: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: {} + } + + const integrations: Integration[] = [ + connection, + { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + ] + + mockAccountClient.listIntegrations.mockResolvedValue(integrations) + + await client.removeConnection(connection) + + expect(mockAccountClient.deleteIntegration).toHaveBeenCalledTimes(2) + expect(mockEvents.emit).toHaveBeenCalledWith( + 'integration:deleted', + expect.objectContaining({ + integration: expect.any(Object), + timestamp: expect.any(Number) + }) + ) + }) + }) +}) diff --git a/packages/integration-client/src/__tests__/events.test.ts b/packages/integration-client/src/__tests__/events.test.ts new file mode 100644 index 0000000000..d0ccd606c2 --- /dev/null +++ b/packages/integration-client/src/__tests__/events.test.ts @@ -0,0 +1,314 @@ +// +// Copyright © 2025 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 { IntegrationEventEmitter, getIntegrationEventBus, onIntegrationEvent } from '../events' + +describe('IntegrationEventEmitter', () => { + let emitter: IntegrationEventEmitter + + beforeEach(() => { + emitter = new IntegrationEventEmitter() + }) + + describe('on', () => { + it('should register event listener and return unsubscribe function', () => { + const callback = jest.fn() + const unsubscribe = emitter.on('test-event', callback) + + expect(typeof unsubscribe).toBe('function') + expect(emitter.listenerCount('test-event')).toBe(1) + }) + + it('should support multiple listeners for the same event', () => { + const callback1 = jest.fn() + const callback2 = jest.fn() + + emitter.on('test-event', callback1) + emitter.on('test-event', callback2) + + expect(emitter.listenerCount('test-event')).toBe(2) + }) + }) + + describe('emit', () => { + it('should call all registered listeners with event data', () => { + const callback1 = jest.fn() + const callback2 = jest.fn() + const testData = { test: 'data' } + + emitter.on('test-event', callback1) + emitter.on('test-event', callback2) + + emitter.emit('test-event', testData) + + expect(callback1).toHaveBeenCalledWith(testData) + expect(callback2).toHaveBeenCalledWith(testData) + }) + + it('should not throw when emitting event with no listeners', () => { + expect(() => { + emitter.emit('non-existent-event', {}) + }).not.toThrow() + }) + + it('should continue calling other listeners if one throws an error', () => { + const callback1 = jest.fn(() => { + throw new Error('Test error') + }) + const callback2 = jest.fn() + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + + emitter.on('test-event', callback1) + emitter.on('test-event', callback2) + + emitter.emit('test-event', {}) + + expect(callback1).toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + expect(consoleSpy).toHaveBeenCalledWith("Error in event listener for 'test-event':", expect.any(Error)) + + consoleSpy.mockRestore() + }) + }) + + describe('off', () => { + it('should remove specific listener', () => { + const callback1 = jest.fn() + const callback2 = jest.fn() + + emitter.on('test-event', callback1) + emitter.on('test-event', callback2) + + expect(emitter.listenerCount('test-event')).toBe(2) + + emitter.off('test-event', callback1) + + expect(emitter.listenerCount('test-event')).toBe(1) + + emitter.emit('test-event', {}) + expect(callback1).not.toHaveBeenCalled() + expect(callback2).toHaveBeenCalled() + }) + + it('should remove all listeners for event when no callback specified', () => { + const callback1 = jest.fn() + const callback2 = jest.fn() + + emitter.on('test-event', callback1) + emitter.on('test-event', callback2) + + expect(emitter.listenerCount('test-event')).toBe(2) + + emitter.off('test-event') + + expect(emitter.listenerCount('test-event')).toBe(0) + }) + + it('should not throw when removing non-existent listener', () => { + const callback = jest.fn() + expect(() => { + emitter.off('non-existent-event', callback) + }).not.toThrow() + }) + + it('should clean up event when last listener is removed', () => { + const callback = jest.fn() + emitter.on('test-event', callback) + + expect(emitter.listenerCount('test-event')).toBe(1) + + emitter.off('test-event', callback) + + expect(emitter.listenerCount('test-event')).toBe(0) + }) + }) + + describe('unsubscribe function', () => { + it('should remove listener when called', () => { + const callback = jest.fn() + const unsubscribe = emitter.on('test-event', callback) + + expect(emitter.listenerCount('test-event')).toBe(1) + + unsubscribe() + + expect(emitter.listenerCount('test-event')).toBe(0) + }) + + it('should clean up event when last listener unsubscribes', () => { + const callback1 = jest.fn() + const callback2 = jest.fn() + + const unsubscribe1 = emitter.on('test-event', callback1) + const unsubscribe2 = emitter.on('test-event', callback2) + + expect(emitter.listenerCount('test-event')).toBe(2) + + unsubscribe1() + expect(emitter.listenerCount('test-event')).toBe(1) + + unsubscribe2() + expect(emitter.listenerCount('test-event')).toBe(0) + }) + + it('should be safe to call multiple times', () => { + const callback = jest.fn() + const unsubscribe = emitter.on('test-event', callback) + + unsubscribe() + expect(() => { + unsubscribe() + }).not.toThrow() + expect(emitter.listenerCount('test-event')).toBe(0) + }) + }) + + describe('removeAllListeners', () => { + it('should remove all listeners for specific event', () => { + emitter.on('event1', jest.fn()) + emitter.on('event1', jest.fn()) + emitter.on('event2', jest.fn()) + + expect(emitter.listenerCount('event1')).toBe(2) + expect(emitter.listenerCount('event2')).toBe(1) + + emitter.removeAllListeners('event1') + + expect(emitter.listenerCount('event1')).toBe(0) + expect(emitter.listenerCount('event2')).toBe(1) + }) + + it('should remove all listeners for all events when no event specified', () => { + emitter.on('event1', jest.fn()) + emitter.on('event2', jest.fn()) + + expect(emitter.listenerCount('event1')).toBe(1) + expect(emitter.listenerCount('event2')).toBe(1) + + emitter.removeAllListeners() + + expect(emitter.listenerCount('event1')).toBe(0) + expect(emitter.listenerCount('event2')).toBe(0) + }) + }) + + describe('listenerCount', () => { + it('should return correct listener count', () => { + expect(emitter.listenerCount('test-event')).toBe(0) + + emitter.on('test-event', jest.fn()) + expect(emitter.listenerCount('test-event')).toBe(1) + + emitter.on('test-event', jest.fn()) + expect(emitter.listenerCount('test-event')).toBe(2) + }) + + it('should return 0 for non-existent events', () => { + expect(emitter.listenerCount('non-existent')).toBe(0) + }) + }) +}) + +describe('getIntegrationEventBus', () => { + afterEach(() => { + // Reset the global instance after each test + const eventBus = getIntegrationEventBus() + eventBus.removeAllListeners() + }) + + it('should return singleton instance', () => { + const eventBus1 = getIntegrationEventBus() + const eventBus2 = getIntegrationEventBus() + + expect(eventBus1).toBe(eventBus2) + expect(eventBus1).toBeInstanceOf(IntegrationEventEmitter) + }) + + it('should maintain state across calls', () => { + const eventBus1 = getIntegrationEventBus() + eventBus1.on('test-event', jest.fn()) + + const eventBus2 = getIntegrationEventBus() + expect(eventBus2.listenerCount('test-event')).toBe(1) + }) +}) + +describe('onIntegrationEvent', () => { + let mockCallback: jest.Mock + let mockFilter: jest.Mock + + beforeEach(() => { + mockCallback = jest.fn() + mockFilter = jest.fn() + // Clear any existing listeners + const eventBus = getIntegrationEventBus() + eventBus.removeAllListeners() + }) + + afterEach(() => { + const eventBus = getIntegrationEventBus() + eventBus.removeAllListeners() + }) + + it('should register event listener without filter', () => { + const unsubscribe = onIntegrationEvent('test-event', mockCallback) + const eventBus = getIntegrationEventBus() + + expect(eventBus.listenerCount('test-event')).toBe(1) + expect(typeof unsubscribe).toBe('function') + + const testData = { test: 'data' } + eventBus.emit('test-event', testData) + + expect(mockCallback).toHaveBeenCalledWith(testData) + }) + + it('should register event listener with filter', () => { + mockFilter.mockReturnValue(true) + + onIntegrationEvent('test-event', mockCallback, mockFilter) + const eventBus = getIntegrationEventBus() + + const testData = { test: 'data' } + eventBus.emit('test-event', testData) + + expect(mockFilter).toHaveBeenCalledWith(testData) + expect(mockCallback).toHaveBeenCalledWith(testData) + }) + + it('should not call callback when filter returns false', () => { + mockFilter.mockReturnValue(false) + + onIntegrationEvent('test-event', mockCallback, mockFilter) + const eventBus = getIntegrationEventBus() + + const testData = { test: 'data' } + eventBus.emit('test-event', testData) + + expect(mockFilter).toHaveBeenCalledWith(testData) + expect(mockCallback).not.toHaveBeenCalled() + }) + + it('should return working unsubscribe function', () => { + const unsubscribe = onIntegrationEvent('test-event', mockCallback) + const eventBus = getIntegrationEventBus() + + expect(eventBus.listenerCount('test-event')).toBe(1) + + unsubscribe() + + expect(eventBus.listenerCount('test-event')).toBe(0) + }) +}) diff --git a/packages/integration-client/src/__tests__/index.test.ts b/packages/integration-client/src/__tests__/index.test.ts new file mode 100644 index 0000000000..0ea0954d69 --- /dev/null +++ b/packages/integration-client/src/__tests__/index.test.ts @@ -0,0 +1,55 @@ +// +// Copyright © 2025 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 * as integrationClient from '../index' + +describe('index exports', () => { + it('should export IntegrationClientImpl from client', () => { + expect(integrationClient.IntegrationClientImpl).toBeDefined() + expect(typeof integrationClient.IntegrationClientImpl).toBe('function') + }) + + it('should export getIntegrationClient from client', () => { + expect(integrationClient.getIntegrationClient).toBeDefined() + expect(typeof integrationClient.getIntegrationClient).toBe('function') + }) + + it('should export utility functions from utils', () => { + expect(integrationClient.isWorkspaceIntegration).toBeDefined() + expect(integrationClient.isConnection).toBeDefined() + expect(integrationClient.isSameIntegrationEvent).toBeDefined() + + expect(typeof integrationClient.isWorkspaceIntegration).toBe('function') + expect(typeof integrationClient.isConnection).toBe('function') + expect(typeof integrationClient.isSameIntegrationEvent).toBe('function') + }) + + it('should export event-related exports from events', () => { + expect(integrationClient.IntegrationEventEmitter).toBeDefined() + expect(integrationClient.getIntegrationEventBus).toBeDefined() + expect(integrationClient.onIntegrationEvent).toBeDefined() + + expect(typeof integrationClient.IntegrationEventEmitter).toBe('function') + expect(typeof integrationClient.getIntegrationEventBus).toBe('function') + expect(typeof integrationClient.onIntegrationEvent).toBe('function') + }) + + it('should not export any undefined values', () => { + const exports = Object.keys(integrationClient) + exports.forEach((exportName) => { + expect((integrationClient as any)[exportName]).toBeDefined() + }) + }) +}) diff --git a/packages/integration-client/src/__tests__/utils.test.ts b/packages/integration-client/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..4e10188d1e --- /dev/null +++ b/packages/integration-client/src/__tests__/utils.test.ts @@ -0,0 +1,197 @@ +// +// Copyright © 2025 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 { Integration } from '@hcengineering/account-client' +import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core' +import { isWorkspaceIntegration, isConnection, isSameIntegrationEvent } from '../utils' +import { IntegrationEventData } from '../types' + +const testSocialId: PersonId = 'social-123' as PersonId +const testWorkspaceId: WorkspaceUuid = 'workspace-456' as WorkspaceUuid +const integrationKind: IntegrationKind = 'gmail' as IntegrationKind + +describe('utils', () => { + describe('isWorkspaceIntegration', () => { + it('should return true for workspace integration', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + + expect(isWorkspaceIntegration(integration)).toBe(true) + }) + + it('should return false for connection (null workspaceUuid)', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: {} + } + + expect(isWorkspaceIntegration(integration)).toBe(false) + }) + + it('should return false for connection (undefined workspaceUuid)', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: undefined, + data: {} + } as any + + expect(isWorkspaceIntegration(integration)).toBe(false) + }) + }) + + describe('isConnection', () => { + it('should return true for connection (null workspaceUuid)', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: {} + } + + expect(isConnection(integration)).toBe(true) + }) + + it('should return true for connection (undefined workspaceUuid)', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: undefined, + data: {} + } as any + + expect(isConnection(integration)).toBe(true) + }) + + it('should return false for workspace integration', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + + expect(isConnection(integration)).toBe(false) + }) + }) + + describe('isSameIntegrationEvent', () => { + it('should return true for matching integration event', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + + const event: IntegrationEventData = { + integration: { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: { different: 'data' } + }, + timestamp: Date.now() + } + + expect(isSameIntegrationEvent(event, integration)).toBe(true) + }) + + it('should return false for different socialId', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + + const event: IntegrationEventData = { + integration: { + socialId: 'different-social' as PersonId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + }, + timestamp: Date.now() + } + + expect(isSameIntegrationEvent(event, integration)).toBe(false) + }) + + it('should return false for different workspaceUuid', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + + const event: IntegrationEventData = { + integration: { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: 'different-workspace' as WorkspaceUuid, + data: {} + }, + timestamp: Date.now() + } + + expect(isSameIntegrationEvent(event, integration)).toBe(false) + }) + + it('should return false when event integration is undefined', () => { + const integration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + } + + const event: IntegrationEventData = { + integration: undefined, + timestamp: Date.now() + } as any + + expect(isSameIntegrationEvent(event, integration)).toBe(false) + }) + + it('should handle connection vs workspace integration comparison', () => { + const connectionIntegration: Integration = { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: null, + data: {} + } + + const workspaceEvent: IntegrationEventData = { + integration: { + socialId: testSocialId, + kind: integrationKind, + workspaceUuid: testWorkspaceId, + data: {} + }, + timestamp: Date.now() + } + + expect(isSameIntegrationEvent(workspaceEvent, connectionIntegration)).toBe(false) + }) + }) +}) diff --git a/packages/integration-client/src/client.ts b/packages/integration-client/src/client.ts new file mode 100644 index 0000000000..4645c5039f --- /dev/null +++ b/packages/integration-client/src/client.ts @@ -0,0 +1,340 @@ +// +// Copyright © 2025 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. +// +// packages/integration/src/account.ts + +import { + getClient as getAccountClientRaw, + Integration, + IntegrationKey, + IntegrationSecret, + type AccountClient +} from '@hcengineering/account-client' +import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core' +import { v4 as uuid } from 'uuid' + +import { IntegrationClient, IntegrationEventData, IntegrationUpdatedData, IntegrationErrorData } from './types' +import { isConnection } from './utils' +import { type EventCallback, getIntegrationEventBus } from './events' + +export function getIntegrationClient ( + accountsUrl: string, + token: string, + integrationKind: IntegrationKind, + serviceName: string +): IntegrationClient { + const client = getAccountClientRaw(accountsUrl, token) + return new IntegrationClientImpl(client, integrationKind, serviceName) +} + +export class IntegrationClientImpl implements IntegrationClient { + private readonly events = getIntegrationEventBus() + + constructor ( + private readonly client: AccountClient, + private readonly integrationKind: IntegrationKind, + private readonly serviceName: string + ) {} + + // Event methods + on(event: string, callback: EventCallback): () => void { + return this.events.on(event, callback) + } + + off (event: string, callback?: EventCallback): void { + this.events.off(event, callback) + } + + private emit(event: string, data: T): void { + this.events.emit(event, data) + } + + async getIntegrations (): Promise { + return (await this.client.listIntegrations({ kind: this.integrationKind })) ?? [] + } + + async getConnection (integration: Integration): Promise { + try { + if (isConnection(integration)) { + return integration + } + const connectionKey = { + socialId: integration.socialId, + kind: integration.kind, + workspaceUuid: null + } + return await this.client.getIntegration(connectionKey) + } catch (error) { + const errorData: IntegrationErrorData = { + operation: 'connect', + error: error instanceof Error ? error.message : String(error), + socialId: integration.socialId, + timestamp: Date.now() + } + this.emit('integration:error', errorData) + throw error + } + } + + async integrate (connection: Integration, workspace: WorkspaceUuid, data?: Record): Promise { + try { + const existingData = data ?? connection.data ?? {} + const integration = { + socialId: connection.socialId, + kind: connection.kind ?? this.integrationKind, + workspaceUuid: workspace, + data: { + ...existingData, + _id: uuid(), + connectionId: connection.data?._id + } + } + + const existingIntegration = await this.client.getIntegration(integration) + if (existingIntegration != null) { + const eventData: IntegrationEventData = { + integration, + timestamp: Date.now() + } + this.emit('integration:updated', eventData) + return existingIntegration + } + + await this.client.createIntegration(integration) + + const eventData: IntegrationEventData = { + integration, + timestamp: Date.now() + } + this.emit('integration:created', eventData) + + return integration + } catch (error) { + const errorData: IntegrationErrorData = { + operation: 'integrate', + error: error instanceof Error ? error.message : String(error), + workspaceUuid: workspace, + timestamp: Date.now() + } + this.emit('integration:error', errorData) + throw error + } + } + + async connect (socialId: PersonId, data?: Record): Promise { + try { + const connection = { + socialId, + kind: this.integrationKind, + workspaceUuid: null, + data + } + const existingConnection = await this.client.getIntegration(connection) + if (existingConnection != null) { + // Check if data needs to be updated + if (data == null) { + return existingConnection + } + const existingData = existingConnection.data ?? {} + const updatedData = { + ...existingData, + ...data + } + const needsUpdate = data != null && JSON.stringify(updatedData) !== JSON.stringify(existingData) + + if (needsUpdate) { + const updatedConnection = { + ...existingConnection, + data: updatedData + } + + await this.client.updateIntegration(updatedConnection) + + const eventData: IntegrationEventData = { + integration: updatedConnection, + timestamp: Date.now() + } + this.emit('connection:updated', eventData) + + return updatedConnection + } + + return existingConnection + } + + await this.client.createIntegration(connection) + + const eventData: IntegrationEventData = { + integration: connection, + timestamp: Date.now() + } + this.emit('connection:created', eventData) + + return connection + } catch (error) { + const errorData: IntegrationErrorData = { + operation: 'connect', + error: error instanceof Error ? error.message : String(error), + socialId, + timestamp: Date.now() + } + this.emit('integration:error', errorData) + throw error + } + } + + async updateConfig ( + integrationKey: IntegrationKey, + config: Record, + refresh?: () => Promise + ): Promise { + try { + const integration = await this.client.getIntegration(integrationKey) + if (integration == null) { + throw new Error(`Integration not found: ${JSON.stringify(integrationKey)}`) + } + + const oldConfig = integration.data?.config + const data = { + ...integration.data, + config + } + + await this.client.updateIntegration({ + ...integration, + data + }) + if (refresh != null) { + await refresh() + } + + const eventData: IntegrationUpdatedData = { + integration, + oldConfig, + newConfig: config, + timestamp: Date.now() + } + this.emit('integration:updated', eventData) + } catch (error) { + const errorData: IntegrationErrorData = { + operation: 'updateConfig', + error: error instanceof Error ? error.message : String(error), + integrationKey, + timestamp: Date.now() + } + this.emit('integration:error', errorData) + throw error + } + } + + async removeIntegration ( + socialId: PersonId | undefined | null, + workspaceUuid: WorkspaceUuid | null | undefined + ): Promise< + | { + connectionRemoved: boolean + } + | undefined + > { + if (socialId == null) return + + try { + const integrations = await this.client.listIntegrations({ + kind: this.integrationKind, + socialId, + workspaceUuid + }) + + for (const integration of integrations) { + await this.client.deleteIntegration({ + socialId, + kind: this.integrationKind, + workspaceUuid: integration.workspaceUuid + }) + + const eventData: IntegrationEventData = { + integration, + timestamp: Date.now() + } + this.emit('integration:deleted', eventData) + } + + // Remove connection if it was the last integration for this socialId + const remainedIntegrations = await this.client.listIntegrations({ + kind: this.integrationKind, + socialId + }) + if (remainedIntegrations.length === 1 && isConnection(remainedIntegrations[0])) { + await this.removeConnection(remainedIntegrations[0]) + return { + connectionRemoved: true + } + } + return { + connectionRemoved: false + } + } catch (error) { + const errorData: IntegrationErrorData = { + operation: 'removeIntegration', + error: error instanceof Error ? error.message : String(error), + socialId, + workspaceUuid, + timestamp: Date.now() + } + this.emit('integration:error', errorData) + throw error + } + } + + async removeConnection (connection: Integration): Promise { + try { + const integrations = await this.client.listIntegrations({ + kind: connection.kind, + socialId: connection.socialId + }) + + for (const integration of integrations) { + await this.client.deleteIntegration({ + socialId: integration.socialId, + kind: integration.kind, + workspaceUuid: integration.workspaceUuid + }) + + const eventData: IntegrationEventData = { + integration, + timestamp: Date.now() + } + this.emit('integration:deleted', eventData) + } + } catch (error) { + const errorData: IntegrationErrorData = { + operation: 'removeConnection', + error: error instanceof Error ? error.message : String(error), + timestamp: Date.now() + } + this.emit('integration:error', errorData) + throw error + } + } + + async setSecret (data: IntegrationSecret): Promise { + const { secret, ...secretKey } = data + const currentSecret = await this.client.getIntegrationSecret(secretKey) + if (currentSecret != null) { + await this.client.updateIntegrationSecret(data) + } else { + await this.client.addIntegrationSecret(data) + } + } +} diff --git a/packages/integration-client/src/events.ts b/packages/integration-client/src/events.ts new file mode 100644 index 0000000000..e95efe92af --- /dev/null +++ b/packages/integration-client/src/events.ts @@ -0,0 +1,125 @@ +// +// Copyright © 2025 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. +// + +export type EventCallback = (data: T) => void + +export interface EventEmitter { + on: (event: string, callback: EventCallback) => () => void + emit: (event: string, data: T) => void + off: (event: string, callback?: EventCallback) => void + removeAllListeners: (event?: string) => void +} + +export class IntegrationEventEmitter implements EventEmitter { + private readonly listeners = new Map>() + + on(event: string, callback: EventCallback): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()) + } + + const eventListeners = this.listeners.get(event) + if (eventListeners === undefined) { + console.error(`Event '${event}' not found in listeners map`) + return () => {} + } + eventListeners.add(callback) + + // Return unsubscribe function + return () => { + eventListeners.delete(callback) + if (eventListeners.size === 0) { + this.listeners.delete(event) + } + } + } + + emit(event: string, data: T): void { + const eventListeners = this.listeners.get(event) + if (eventListeners === undefined) return + + // Convert to array to avoid issues if listeners are modified during iteration + const callbacks = Array.from(eventListeners) + + callbacks.forEach((callback) => { + try { + callback(data) + } catch (error) { + console.error(`Error in event listener for '${event}':`, error) + } + }) + } + + off (event: string, callback?: EventCallback): void { + if (callback == null) { + this.listeners.delete(event) + return + } + + const eventListeners = this.listeners.get(event) + if (eventListeners !== undefined) { + eventListeners.delete(callback) + if (eventListeners.size === 0) { + this.listeners.delete(event) + } + } + } + + removeAllListeners (event?: string): void { + if (event !== undefined) { + this.listeners.delete(event) + } else { + this.listeners.clear() + } + } + + // Utility method to get listener count + listenerCount (event: string): number { + return this.listeners.get(event)?.size ?? 0 + } +} + +const GlobalIntegrationEventBus = { + instance: null as IntegrationEventEmitter | null, + + getInstance (): IntegrationEventEmitter { + if (GlobalIntegrationEventBus.instance == null) { + GlobalIntegrationEventBus.instance = new IntegrationEventEmitter() + } + return GlobalIntegrationEventBus.instance + }, + + resetInstance (): void { + GlobalIntegrationEventBus.instance = null + } +} + +export const getIntegrationEventBus = (): IntegrationEventEmitter => { + return GlobalIntegrationEventBus.getInstance() +} + +export function onIntegrationEvent ( + event: string, + callback: (data: T) => void, + filter?: (data: T) => boolean +): () => void { + const eventBus = getIntegrationEventBus() + + return eventBus.on(event, (data: T) => { + if (filter === undefined || filter(data)) { + callback(data) + } + }) +} diff --git a/packages/integration-client/src/index.ts b/packages/integration-client/src/index.ts new file mode 100644 index 0000000000..9ebaea02a9 --- /dev/null +++ b/packages/integration-client/src/index.ts @@ -0,0 +1,19 @@ +// +// Copyright © 2025 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. +// + +export * from './client' +export * from './types' +export * from './utils' +export * from './events' diff --git a/packages/integration-client/src/types.ts b/packages/integration-client/src/types.ts new file mode 100644 index 0000000000..aaf1c3761f --- /dev/null +++ b/packages/integration-client/src/types.ts @@ -0,0 +1,301 @@ +// +// Copyright © 2025 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 { Integration, IntegrationKey, IntegrationSecret } from '@hcengineering/account-client' +import { IntegrationKind, PersonId, WorkspaceUuid } from '@hcengineering/core' + +export type IntegrationStatus = 'active' | 'inactive' | 'connecting' | 'disconnecting' | 'error' | 'unknown' + +export type ActionKind = 'connect' | 'disconnect' | 'reconnect' | 'update' | 'delete' + +export interface IntegrationClient { + /** + * Get integrations + */ + getIntegrations: () => Promise + + /** + * Get the connection details for a specific integration + */ + getConnection: (integration: Integration) => Promise + + /** + * Integrate a social ID with a workspace + */ + integrate: (connection: Integration, workspace: WorkspaceUuid, data?: Record) => Promise + + /** + * Create a connection for a social ID (without workspace integration) + */ + connect: (socialId: PersonId, data?: Record) => Promise + + /** + * Update configuration for an existing integration + */ + updateConfig: ( + integrationKey: IntegrationKey, + config: Record, + refresh?: () => Promise + ) => Promise + + /** + * Remove integration for a social ID and workspace + */ + removeIntegration: ( + socialId: PersonId | undefined | null, + workspaceUuid: WorkspaceUuid | null | undefined + ) => Promise< + | { + connectionRemoved: boolean + } + | undefined + > + + /** + * Remove connection and all associated integrations + */ + removeConnection: (connection: Integration) => Promise + + /** + * Set or update integration secret + */ + setSecret: (data: IntegrationSecret) => Promise +} + +export interface IntegrationEventData { + integration: Integration + timestamp: number +} + +export interface IntegrationUpdatedData extends IntegrationEventData { + oldConfig?: Record + newConfig: Record +} + +export interface IntegrationErrorData { + operation: string + error: string + integrationKey?: IntegrationKey + socialId?: PersonId + workspaceUuid?: WorkspaceUuid | null + timestamp: number +} + +export interface IntegrationData { + _id: string | undefined + connectionId: string | undefined + config: Record +} + +export interface ActionRequest { + /** + * The type of action to perform + */ + type: ActionKind + + /** + * The ID of the integration to act upon + */ + integrationId: string + + /** + * Additional data required for the action, if any + */ + data?: Record +} + +export interface ActionResponse { + /** + * Whether the action was successful + */ + success: boolean + + /** + * Action result data, if any + */ + data?: Record + + /** + * Error message if the action failed + */ + error?: string + + /** + * Updated integration status after the action + */ + status?: IntegrationStatus +} + +export interface IntegrationInfo { + /** + * Unique identifier for the integration + */ + id: string + + /** + * Integration kind (gmail, slack, github, etc.) + */ + kind: string + + /** + * Current status of the integration + */ + status: IntegrationStatus + + /** + * Last sync timestamp + */ + lastSync?: Date + + /** + * Error information if status is 'error' + */ + error?: { + message: string + code?: string + timestamp: Date + } +} + +/** + * Integration-specific configuration + */ +export type IntegrationConfig = Record + +export interface ServiceClient { + /** + * Connect to an integration service + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @param config - Optional configuration + * @returns Promise that resolves to connection result + * @throws {ConnectionError} If connection fails + */ + connect: (workspace: WorkspaceUuid, integrationId: string, config?: IntegrationConfig) => Promise + + /** + * Disconnect from an integration service + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @param force - Whether to force disconnect even if cleanup fails + * @returns Promise that resolves when disconnected + * @throws {DisconnectionError} If disconnection fails + */ + disconnect: (workspace: WorkspaceUuid, integrationId: string, force?: boolean) => Promise + + /** + * Reconnect to an integration service (disconnect + connect) + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @param credentials - Updated authentication credentials + * @returns Promise that resolves to reconnection result + */ + reconnect: (workspace: WorkspaceUuid, integrationId: string) => Promise + + /** + * Update integration configuration + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @param config - Configuration updates + * @returns Promise that resolves when configuration is updated + * @throws {ConfigurationError} If configuration is invalid + */ + configure: ( + workspace: WorkspaceUuid, + integrationId: string, + config: Partial + ) => Promise + + /** + * Test integration connection + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @returns Promise that resolves to test result + */ + test: (workspace: WorkspaceUuid, integrationId: string) => Promise + + /** + * Get integration information and status + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @returns Promise that resolves to integration info + */ + getInfo: (workspace: WorkspaceUuid, integrationId: string) => Promise + + /** + * Delete an integration permanently + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @param cleanup - Whether to cleanup associated data + * @returns Promise that resolves when integration is deleted + */ + delete: (workspace: WorkspaceUuid, integrationId: string, cleanup?: boolean) => Promise + + /** + * Enable or disable an integration + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @param enabled - Whether to enable or disable + * @returns Promise that resolves when status is updated + */ + setEnabled: (workspace: WorkspaceUuid, integrationId: string, enabled: boolean) => Promise + + /** + * Execute a custom action on the integration + * @param workspace - The workspace UUID + * @param integrationId - The integration identifier + * @param action - Custom action to execute + * @returns Promise that resolves to action result + */ + executeAction: (workspace: WorkspaceUuid, integrationId: string, action: ActionRequest) => Promise + + getIntegrationKind: () => IntegrationKind +} + +/** + * Integration-specific error types + */ +export class IntegrationError extends Error { + constructor ( + message: string, + public code: string, + public integrationId: string, + public workspace: WorkspaceUuid + ) { + super(message) + this.name = 'IntegrationError' + } +} + +export class ConnectionError extends IntegrationError { + constructor (message: string, integrationId: string, workspace: WorkspaceUuid) { + super(message, 'CONNECTION_ERROR', integrationId, workspace) + this.name = 'ConnectionError' + } +} + +export class ConfigurationError extends IntegrationError { + constructor (message: string, integrationId: string, workspace: WorkspaceUuid) { + super(message, 'CONFIGURATION_ERROR', integrationId, workspace) + this.name = 'ConfigurationError' + } +} + +export class DisconnectionError extends IntegrationError { + constructor (message: string, integrationId: string, workspace: WorkspaceUuid) { + super(message, 'DISCONNECTION_ERROR', integrationId, workspace) + this.name = 'DisconnectionError' + } +} diff --git a/packages/integration-client/src/utils.ts b/packages/integration-client/src/utils.ts new file mode 100644 index 0000000000..61ea42d38d --- /dev/null +++ b/packages/integration-client/src/utils.ts @@ -0,0 +1,32 @@ +// +// Copyright © 2025 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 { Integration } from '@hcengineering/account-client' +import { IntegrationEventData } from './types' + +export function isWorkspaceIntegration (integration: Integration): boolean { + return integration.workspaceUuid != null +} + +export function isConnection (integration: Integration): boolean { + return integration.workspaceUuid == null +} + +export function isSameIntegrationEvent (event: IntegrationEventData, integration: Integration): boolean { + return ( + event.integration?.socialId === integration.socialId && + event.integration?.workspaceUuid === integration.workspaceUuid + ) +} diff --git a/packages/integration-client/tsconfig.json b/packages/integration-client/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/packages/integration-client/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json", + + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declarationDir": "./types", + "tsBuildInfoFile": ".build/build.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "lib", "dist", "types", "bundle"] +} \ No newline at end of file diff --git a/packages/retry/src/__test__/decorator.test.ts b/packages/retry/src/__test__/decorator.test.ts index 1e1bdb4679..557661d011 100644 --- a/packages/retry/src/__test__/decorator.test.ts +++ b/packages/retry/src/__test__/decorator.test.ts @@ -16,6 +16,7 @@ import { DelayStrategyFactory } from '../delay' import { Retryable } from '../decorator' import { type RetryOptions } from '../retry' +import { retryAllErrors } from '../retryable' // Instead of mocking withRetry, we'll mock setTimeout to avoid waiting in tests jest.spyOn(global, 'setTimeout').mockImplementation((fn: any) => { @@ -38,6 +39,7 @@ describe('Retryable decorator', () => { maxDelayMs: 100, backoffFactor: 2 }), + isRetryable: retryAllErrors, logger: mockLogger } @@ -88,7 +90,7 @@ describe('Retryable decorator', () => { async testMethod (): Promise { this.callCount++ if (this.callCount === 1) { - throw new Error('First attempt failed') + throw new Error('network error') } return 'success' } @@ -109,7 +111,7 @@ describe('Retryable decorator', () => { async incrementAndGet (): Promise { if (this.counter === 0) { this.counter++ - throw new Error('First attempt failed') + throw new Error('network error') } this.counter++ return this.counter @@ -135,7 +137,8 @@ describe('Retryable decorator', () => { delayStrategy: DelayStrategyFactory.fixed({ delayMs: 10 }), - logger: mockLogger + logger: mockLogger, + isRetryable: retryAllErrors }) async alwaysFailingMethod (): Promise { throw new Error('Persistent failure') @@ -189,7 +192,8 @@ describe('Retryable decorator', () => { delayStrategy: DelayStrategyFactory.fixed({ delayMs: 10 }), - logger: mockLogger + logger: mockLogger, + isRetryable: retryAllErrors }) async unstableMethod (): Promise { callCount++ @@ -228,7 +232,8 @@ describe('Retryable decorator', () => { maxDelayMs: 500, backoffFactor: 2, jitter: 0 // Disable jitter for predictable tests - }) + }), + isRetryable: retryAllErrors }) async delayingMethod (): Promise { callCount++ @@ -266,7 +271,8 @@ describe('Retryable decorator', () => { delayStrategy: DelayStrategyFactory.fixed({ delayMs: 100, jitter: 0 // Disable jitter for predictable tests - }) + }), + isRetryable: retryAllErrors }) async method (): Promise { callCount++ @@ -290,7 +296,8 @@ describe('Retryable decorator', () => { baseDelayMs: 100, maxDelayMs: 1000, jitter: 0 // Disable jitter for predictable tests - }) + }), + isRetryable: retryAllErrors }) async method (): Promise { callCount++ diff --git a/packages/retry/src/__test__/retry.test.ts b/packages/retry/src/__test__/retry.test.ts index 07bc982f3e..7118520e46 100644 --- a/packages/retry/src/__test__/retry.test.ts +++ b/packages/retry/src/__test__/retry.test.ts @@ -150,6 +150,7 @@ describe('withRetry', () => { maxDelayMs: 1000, backoffFactor: 10 // Would normally go 50 -> 500 -> 5000, but should cap at 1000 }), + isRetryable: retryAllErrors, logger: mockLogger } @@ -194,6 +195,7 @@ describe('withRetry', () => { delayMs: 200, jitter: 0 }), + isRetryable: retryAllErrors, logger: mockLogger } @@ -220,6 +222,7 @@ describe('withRetry', () => { maxDelayMs: 10000, jitter: 0 }), + isRetryable: retryAllErrors, logger: mockLogger } @@ -253,6 +256,7 @@ describe('createRetryableFunction', () => { maxDelayMs: 100, backoffFactor: 2 }), + isRetryable: retryAllErrors, logger: mockLogger } @@ -294,12 +298,12 @@ describe('createRetryableFunction', () => { }) it('should propagate the final error if all retries fail', async () => { - const mockError = new Error('persistent failure') + const mockError = new Error('network error') const mockFn = jest.fn().mockRejectedValue(mockError) const retryableFn = createRetryableFunction(mockFn, mockOptions) - await expect(retryableFn()).rejects.toThrow('persistent failure') + await expect(retryableFn()).rejects.toThrow('network error') expect(mockOptions.maxRetries).toBeDefined() expect(mockFn).toHaveBeenCalledTimes(mockOptions.maxRetries ?? -1) @@ -314,7 +318,7 @@ describe('Using retry in class methods', () => { async unstableFunction (): Promise { this.counter++ if (this.counter < 3) { - throw new Error(`Failure attempt ${this.counter}`) + throw new Error(`network error ${this.counter}`) } return 'success' } @@ -461,12 +465,12 @@ describe('withRetry with isRetryable option', () => { expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining('non-retriable error'), expect.any(Object)) }) - it('should use the default retryAllErrors if isRetryable is not provided', async () => { + it('should use the default retryNetworkErrors if isRetryable is not provided', async () => { // All errors should be retried by default const mockOperation = jest .fn() - .mockRejectedValueOnce(new Error('error 1')) - .mockRejectedValueOnce(new Error('error 2')) + .mockRejectedValueOnce(new Error('unreachable')) + .mockRejectedValueOnce(new Error('ECONNREFUSED')) .mockResolvedValueOnce('success') const result = await withRetry(mockOperation, { diff --git a/packages/retry/src/retry.ts b/packages/retry/src/retry.ts index 15f8d4550c..692b602660 100644 --- a/packages/retry/src/retry.ts +++ b/packages/retry/src/retry.ts @@ -13,7 +13,7 @@ // limitations under the License. // import { defaultLogger, type Logger } from './logger' -import { type IsRetryable, retryAllErrors } from './retryable' +import { type IsRetryable, retryNetworkErrors } from './retryable' import { type DelayStrategy, DelayStrategyFactory, sleep } from './delay' /** @@ -35,7 +35,7 @@ export interface RetryOptions { */ export const DEFAULT_RETRY_OPTIONS: RetryOptions = { maxRetries: 5, - isRetryable: retryAllErrors, + isRetryable: retryNetworkErrors, delayStrategy: DelayStrategyFactory.exponentialBackoff({ initialDelayMs: 1000, maxDelayMs: 30000, diff --git a/packages/theme/styles/_colors.scss b/packages/theme/styles/_colors.scss index 7fb6b11954..355ac0de1f 100644 --- a/packages/theme/styles/_colors.scss +++ b/packages/theme/styles/_colors.scss @@ -393,6 +393,18 @@ --calendar-event-back-color: #2B2D41; --calendar-event-border-color: rgba(43, 81, 144, 0.2); --calendar-event-handle-color: #2b5190; + + --theme-label-green-color: rgba(34, 197, 94, .8); + --theme-label-green-bg-color: rgba(34, 197, 94, .08); + --theme-label-green-border-color: rgba(34, 197, 94, .2); + + --theme-label-blue-color: rgba(59, 130, 246, .8); + --theme-label-blue-bg-color: rgba(59, 130, 246, .08); + --theme-label-blue-border-color: rgba(59, 130, 246, .2); + + --theme-label-gray-color: rgba(255, 255, 255, .6); + --theme-label-gray-bg-color: rgba(255, 255, 255, .02); + --theme-label-gray-border-color: rgba(255, 255, 255, .09); } /* Light Theme */ @@ -693,4 +705,16 @@ --theme-button-contrast-disabled: rgba(0, 0, 0, .6); --theme-button-contrast-disabled-color: rgba(255, 255, 255, .5); --theme-button-contrast-border: rgba(0, 0, 0, .2); + + --theme-label-green-color: rgba(22, 130, 60, 0.9); + --theme-label-green-bg-color: rgba(22, 163, 74, .08); + --theme-label-green-border-color: rgba(22, 163, 74, .15); + + --theme-label-blue-color: rgba(37, 99, 235, 0.9); + --theme-label-blue-bg-color: rgba(59, 130, 246, .08); + --theme-label-blue-border-color: rgba(59, 130, 246, .15); + + --theme-label-gray-color: rgba(0, 0, 0, .6); + --theme-label-gray-bg-color: rgba(0, 0, 0, .02); + --theme-label-gray-border-color: rgba(0, 0, 0, .09) } diff --git a/packages/ui/lang/cs.json b/packages/ui/lang/cs.json index dde094aa84..b6cb0310c4 100644 --- a/packages/ui/lang/cs.json +++ b/packages/ui/lang/cs.json @@ -97,6 +97,8 @@ "Language": "Jazyk", "DaysWOValue": "{days, plural, =1 {den} other {dny}}", "WeeksWOValue": "{weeks, plural, =1 {týden} other {týdny}}", - "MonthsWOValue": "{months, plural, =1 {měsíc} other {měsíce}}" + "MonthsWOValue": "{months, plural, =1 {měsíc} other {měsíce}}", + "Filter": "Filtr", + "Clear": "Vymazat" } } diff --git a/packages/ui/lang/de.json b/packages/ui/lang/de.json index d826267973..63f39e69b4 100644 --- a/packages/ui/lang/de.json +++ b/packages/ui/lang/de.json @@ -98,6 +98,8 @@ "Language": "Sprache", "DaysWOValue": "{days, plural, =1 {Tag} other {Tage}}", "WeeksWOValue": "{weeks, plural, =1 {Woche} other {Wochen}}", - "MonthsWOValue": "{months, plural, =1 {Monat} other {Monate}}" + "MonthsWOValue": "{months, plural, =1 {Monat} other {Monate}}", + "Filter": "Filter", + "Clear": "Löschen" } } diff --git a/packages/ui/lang/en.json b/packages/ui/lang/en.json index 8c5e437d86..c9d2949dab 100644 --- a/packages/ui/lang/en.json +++ b/packages/ui/lang/en.json @@ -98,6 +98,8 @@ "Language": "Language", "DaysWOValue": "{days, plural, =1 {day} other {days}}", "WeeksWOValue": "{weeks, plural, =1 {week} other {weeks}}", - "MonthsWOValue": "{months, plural, =1 {month} other {months}}" + "MonthsWOValue": "{months, plural, =1 {month} other {months}}", + "Filter": "Filter", + "Clear": "Clear" } } diff --git a/packages/ui/lang/es.json b/packages/ui/lang/es.json index f7eac6f0ba..f1be25a142 100644 --- a/packages/ui/lang/es.json +++ b/packages/ui/lang/es.json @@ -98,6 +98,8 @@ "Language": "Idioma", "DaysWOValue": "{days, plural, =1 {día} other {días}}", "WeeksWOValue": "{weeks, plural, =1 {semana} other {semanas}}", - "MonthsWOValue": "{months, plural, =1 {mes} other {meses}}" + "MonthsWOValue": "{months, plural, =1 {mes} other {meses}}", + "Filter": "Filtrar", + "Clear": "Limpiar" } } diff --git a/packages/ui/lang/fr.json b/packages/ui/lang/fr.json index 0ee667613e..d42ad1feb3 100644 --- a/packages/ui/lang/fr.json +++ b/packages/ui/lang/fr.json @@ -98,6 +98,8 @@ "Language": "Langue", "DaysWOValue": "{days, plural, =1 {jour} other {jours}}", "WeeksWOValue": "{weeks, plural, =1 {semaine} other {semaines}}", - "MonthsWOValue": "{months, plural, =1 {mois} other {mois}}" + "MonthsWOValue": "{months, plural, =1 {mois} other {mois}}", + "Filter": "Filtrer", + "Clear": "Effacer" } } diff --git a/packages/ui/lang/it.json b/packages/ui/lang/it.json index 4718932e73..ae426e12e8 100644 --- a/packages/ui/lang/it.json +++ b/packages/ui/lang/it.json @@ -98,6 +98,8 @@ "Language": "Lingua", "DaysWOValue": "{days, plural, =1 {giorno} other {giorni}}", "WeeksWOValue": "{weeks, plural, =1 {settimana} other {settimane}}", - "MonthsWOValue": "{months, plural, =1 {mese} other {mesi}}" + "MonthsWOValue": "{months, plural, =1 {mese} other {mesi}}", + "Filter": "Filtra", + "Clear": "Cancella" } } diff --git a/packages/ui/lang/ja.json b/packages/ui/lang/ja.json index 2738f14355..0f69bf5372 100644 --- a/packages/ui/lang/ja.json +++ b/packages/ui/lang/ja.json @@ -98,6 +98,8 @@ "Language": "言語", "DaysWOValue": "{days, plural, =1 {日} other {日}}", "WeeksWOValue": "{weeks, plural, =1 {週} other {週}}", - "MonthsWOValue": "{months, plural, =1 {か月} other {か月}}" + "MonthsWOValue": "{months, plural, =1 {か月} other {か月}}", + "Filter": "フィルター", + "Clear": "クリア" } } diff --git a/packages/ui/lang/pt.json b/packages/ui/lang/pt.json index 2dcde0ab83..b3fe1b0354 100644 --- a/packages/ui/lang/pt.json +++ b/packages/ui/lang/pt.json @@ -98,6 +98,8 @@ "Language": "Idioma", "DaysWOValue": "{days, plural, =1 {dia} other {dias}}", "WeeksWOValue": "{weeks, plural, =1 {semana} other {semanas}}", - "MonthsWOValue": "{months, plural, =1 {mês} other {meses}}" + "MonthsWOValue": "{months, plural, =1 {mês} other {meses}}", + "Filter": "Filtrar", + "Clear": "Limpar" } } diff --git a/packages/ui/lang/ru.json b/packages/ui/lang/ru.json index 60e6cf21fb..5c17874941 100644 --- a/packages/ui/lang/ru.json +++ b/packages/ui/lang/ru.json @@ -98,6 +98,8 @@ "Language": "Язык", "DaysWOValue": "{days, plural, one {день} few {дня} other {дней}}", "WeeksWOValue": "{weeks, plural, one {неделя} few {недели} other {недель}}", - "MonthsWOValue": "{months, plural, one {месяц} few {месяца} other {месяцев}}" + "MonthsWOValue": "{months, plural, one {месяц} few {месяца} other {месяцев}}", + "Filter": "Фильтр", + "Clear": "Очистить" } } diff --git a/packages/ui/lang/zh.json b/packages/ui/lang/zh.json index c3285950d7..f5a33907db 100644 --- a/packages/ui/lang/zh.json +++ b/packages/ui/lang/zh.json @@ -98,6 +98,8 @@ "Language": "语言", "DaysWOValue": "{days, plural, =1 {天} other {天}}", "WeeksWOValue": "{weeks, plural, =1 {周} other {周}}", - "MonthsWOValue": "{months, plural, =1 {月} other {月}}" + "MonthsWOValue": "{months, plural, =1 {月} other {月}}", + "Filter": "过滤", + "Clear": "清除" } } diff --git a/packages/ui/src/components/Button.svelte b/packages/ui/src/components/Button.svelte index fcd411e0fe..571554a874 100644 --- a/packages/ui/src/components/Button.svelte +++ b/packages/ui/src/components/Button.svelte @@ -173,7 +173,7 @@ {/if} {#if loading}
diff --git a/packages/ui/src/components/DropdownLabelsIntl.svelte b/packages/ui/src/components/DropdownLabelsIntl.svelte index 609f31d8fb..8b08b35309 100644 --- a/packages/ui/src/components/DropdownLabelsIntl.svelte +++ b/packages/ui/src/components/DropdownLabelsIntl.svelte @@ -36,6 +36,7 @@ export let size: ButtonSize = 'small' export let justify: 'left' | 'center' = 'center' export let width: string | undefined = undefined + export let minWidth: string | undefined = undefined export let labelDirection: TooltipAlignment | undefined = undefined export let shouldUpdateUndefined: boolean = true export let minW0 = true @@ -86,6 +87,7 @@ {icon} {iconProps} width={width ?? 'min-content'} + {minWidth} {size} {kind} {disabled} diff --git a/packages/ui/src/components/FilterButton.svelte b/packages/ui/src/components/FilterButton.svelte new file mode 100644 index 0000000000..420b1b8d18 --- /dev/null +++ b/packages/ui/src/components/FilterButton.svelte @@ -0,0 +1,87 @@ + + + + + + + {/each} +
+ {:else if view === 'options' && selectedCategory !== null} + + +
+ {#each selectedCategory.options as option (option.id)} + + {/each} + {#if currentActiveFilter !== null} +
+ + {/if} +
+ {/if} + + + diff --git a/packages/ui/src/components/FilterOptionPopup.svelte b/packages/ui/src/components/FilterOptionPopup.svelte new file mode 100644 index 0000000000..a01a511656 --- /dev/null +++ b/packages/ui/src/components/FilterOptionPopup.svelte @@ -0,0 +1,144 @@ + + + +
+ +
+ {#each category.options as option (option.id)} + + {/each} + {#if activeFilter} +
+ + {/if} +
+
+ + diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f1271d7327..031a7dc380 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -58,6 +58,8 @@ export { default as Label } from './components/Label.svelte' export { default as Button } from './components/Button.svelte' export { default as ButtonWithDropdown } from './components/ButtonWithDropdown.svelte' export { default as ButtonGroup } from './components/ButtonGroup.svelte' +export { default as FilterButton } from './components/FilterButton.svelte' +export { default as FilterCategoryPopup } from './components/FilterCategoryPopup.svelte' export { default as Status } from './components/Status.svelte' export { default as StateTag } from './components/StateTag.svelte' export { default as Component } from './components/Component.svelte' diff --git a/packages/ui/src/plugin.ts b/packages/ui/src/plugin.ts index 96bccb1083..e6467131a6 100644 --- a/packages/ui/src/plugin.ts +++ b/packages/ui/src/plugin.ts @@ -145,6 +145,9 @@ export const uis = plugin(uiId, { WeeksWOValue: '' as IntlString, MonthsWOValue: '' as IntlString, + Filter: '' as IntlString, + Clear: '' as IntlString, + ReadOnlyModeWarning: '' as IntlString }, metadata: { diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index eb99966f2e..608d7ca0e8 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -564,3 +564,21 @@ export interface IPanelState extends IHeaderState { panelWidth: number innerWidth: number } + +export interface FilterOption { + id: string + label: IntlString +} + +export interface FilterCategory { + id: string + label: IntlString + options: FilterOption[] +} + +export interface ActiveFilter { + categoryId: string + optionId: string + categoryLabel: IntlString + optionLabel: IntlString +} diff --git a/plugins/bitrix/src/types.ts b/plugins/bitrix/src/types.ts index 601e560435..91e16a50e7 100644 --- a/plugins/bitrix/src/types.ts +++ b/plugins/bitrix/src/types.ts @@ -1,5 +1,5 @@ import { ChannelProvider } from '@hcengineering/contact' -import { AnyAttribute, AttachedDoc, Class, Doc, Mixin, Ref } from '@hcengineering/core' +import { AnyAttribute, AttachedDoc, Class, Doc, IntegrationKind, Mixin, Ref } from '@hcengineering/core' import { ExpertKnowledge, InitialKnowledge, MeaningfullKnowledge } from '@hcengineering/tags' import { ProjectType } from '@hcengineering/task' @@ -375,3 +375,8 @@ string, urlShow: string } > + +/** + * @public + */ +export const bitrixIntegrationKind = 'bitrix' as IntegrationKind diff --git a/plugins/calendar-assets/lang/cs.json b/plugins/calendar-assets/lang/cs.json index 891adaddea..4c42d36517 100644 --- a/plugins/calendar-assets/lang/cs.json +++ b/plugins/calendar-assets/lang/cs.json @@ -121,6 +121,10 @@ "NextToLast": "Předposlední", "Last": "Poslední", "Weekday": "Všední den", - "WeekendDay": "Víkendový den" + "WeekendDay": "Víkendový den", + "NotConnectedIntegration": "Účet {email} není integrován s pracovním prostorem", + "SynchronizedCalendars": "Synchronizované kalendáře", + "Account": "Účet", + "NoCalendars": "Kalendáře pro synchronizaci nejsou vybrány" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/de.json b/plugins/calendar-assets/lang/de.json index 2d9894b341..e88b018ad0 100644 --- a/plugins/calendar-assets/lang/de.json +++ b/plugins/calendar-assets/lang/de.json @@ -121,6 +121,10 @@ "NextToLast": "Vorletzter", "Last": "Letzter", "Weekday": "Wochentag", - "WeekendDay": "Wochenendtag" + "WeekendDay": "Wochenendtag", + "NotConnectedIntegration": "Das Konto {email} ist nicht mit dem Arbeitsbereich integriert", + "SynchronizedCalendars": "Synchronisierte Kalender", + "Account": "Konto", + "NoCalendars": "Keine Kalender zur Synchronisation ausgewählt" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/en.json b/plugins/calendar-assets/lang/en.json index a68208f6e0..84bb5180b8 100644 --- a/plugins/calendar-assets/lang/en.json +++ b/plugins/calendar-assets/lang/en.json @@ -43,7 +43,7 @@ "HideDetails": "Hide details", "ReccuringEvent": "Recurring event", "ExternalParticipants": "External participants", - "IntegrationDescr": "Integrate with Google Calendar", + "IntegrationDescr": "Sync events and tasks with your Google Calendar", "Connect": "Connect", "RedirectGoogle": "You will be redirected to the Google authentication page. Please make sure to select all permissions.", "ConnectCalendar": "Connect Google Calendar account", @@ -121,6 +121,10 @@ "NextToLast": "Next to last", "Last": "Last", "Weekday": "Weekday", - "WeekendDay": "Weekend day" + "WeekendDay": "Weekend day", + "NotConnectedIntegration": "The account {email} is not integrated with the workspace", + "SynchronizedCalendars": "Synchronized calendars", + "Account": "Account", + "NoCalendars": "No calendars selected for synchronization" } } diff --git a/plugins/calendar-assets/lang/es.json b/plugins/calendar-assets/lang/es.json index 1e844973a4..b017c83f03 100644 --- a/plugins/calendar-assets/lang/es.json +++ b/plugins/calendar-assets/lang/es.json @@ -121,6 +121,10 @@ "NextToLast": "Penúltimo", "Last": "Último", "Weekday": "Día laborable", - "WeekendDay": "Día de descanso" + "WeekendDay": "Día de descanso", + "NotConnectedIntegration": "La cuenta {email} no está integrada con el espacio de trabajo", + "SynchronizedCalendars": "Calendarios sincronizados", + "Account": "Cuenta", + "NoCalendars": "No se seleccionaron calendarios para la sincronización" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/fr.json b/plugins/calendar-assets/lang/fr.json index d60fcf5325..0c927a42aa 100644 --- a/plugins/calendar-assets/lang/fr.json +++ b/plugins/calendar-assets/lang/fr.json @@ -121,6 +121,10 @@ "NextToLast": "Avant-dernier", "Last": "Dernier", "Weekday": "Jour de la semaine", - "WeekendDay": "Jour de congé" + "WeekendDay": "Jour de congé", + "NotConnectedIntegration": "Le compte {email} n'est pas intégré au workspace", + "SynchronizedCalendars": "Calendriers synchronisés", + "Account": "Compte", + "NoCalendars": "Aucun calendrier sélectionné pour la synchronisation" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/it.json b/plugins/calendar-assets/lang/it.json index 455d26c5e2..bdfd40af3d 100644 --- a/plugins/calendar-assets/lang/it.json +++ b/plugins/calendar-assets/lang/it.json @@ -121,6 +121,10 @@ "NextToLast": "Penultimo", "Last": "Ultimo", "Weekday": "Giorno lavorativo", - "WeekendDay": "Giorno libero" + "WeekendDay": "Giorno libero", + "NotConnectedIntegration": "L'account {email} non è integrato con il workspace", + "SynchronizedCalendars": "Calendari sincronizzati", + "Account": "Account", + "NoCalendars": "Nessun calendario selezionato per la sincronizzazione" } } diff --git a/plugins/calendar-assets/lang/ja.json b/plugins/calendar-assets/lang/ja.json index 87922765be..42ddae3d9f 100644 --- a/plugins/calendar-assets/lang/ja.json +++ b/plugins/calendar-assets/lang/ja.json @@ -117,6 +117,10 @@ "NextToLast": "最後から2番目", "Last": "最後", "Weekday": "平日", - "WeekendDay": "休日" + "WeekendDay": "休日", + "NotConnectedIntegration": "アカウント {email} はワークスペースに統合されていません", + "SynchronizedCalendars": "同期されたカレンダー", + "Account": "アカウント", + "NoCalendars": "同期するカレンダーが選択されていません" } } diff --git a/plugins/calendar-assets/lang/pt.json b/plugins/calendar-assets/lang/pt.json index a839833e12..03f7dc0b52 100644 --- a/plugins/calendar-assets/lang/pt.json +++ b/plugins/calendar-assets/lang/pt.json @@ -121,6 +121,10 @@ "NextToLast": "Penúltimo", "Last": "Último", "Weekday": "Dia da semana", - "WeekendDay": "Folga" + "WeekendDay": "Folga", + "NotConnectedIntegration": "A conta {email} não está integrada com o espaço de trabalho", + "SynchronizedCalendars": "Calendários sincronizados", + "Account": "Conta", + "NoCalendars": "Nenhum calendário encontrado" } } \ No newline at end of file diff --git a/plugins/calendar-assets/lang/ru.json b/plugins/calendar-assets/lang/ru.json index ed55140673..e33f15c3aa 100644 --- a/plugins/calendar-assets/lang/ru.json +++ b/plugins/calendar-assets/lang/ru.json @@ -121,6 +121,10 @@ "NextToLast": "Предпоследний", "Last": "Последний", "Weekday": "Будний день", - "WeekendDay": "Выходной день" + "WeekendDay": "Выходной день", + "NotConnectedIntegration": "Учетная запись {email} не интегрирована с рабочим пространством", + "SynchronizedCalendars": "Синхронизированные календари", + "Account": "Аккаунт", + "NoCalendars": "Календари для синхронизации не выбраны" } } diff --git a/plugins/calendar-assets/lang/zh.json b/plugins/calendar-assets/lang/zh.json index d17e252afb..ae3876e8cc 100644 --- a/plugins/calendar-assets/lang/zh.json +++ b/plugins/calendar-assets/lang/zh.json @@ -121,6 +121,10 @@ "NextToLast": "倒数第二个", "Last": "最后一个", "Weekday": "工作日", - "WeekendDay": "周末日" + "WeekendDay": "周末日", + "NotConnectedIntegration": "帐户 {email} 未与工作区集成", + "SynchronizedCalendars": "已同步的日历", + "Account": "帐户", + "NoCalendars": "未选择同步日历" } } \ No newline at end of file diff --git a/plugins/calendar-resources/package.json b/plugins/calendar-resources/package.json index bac7a4e659..1cee912ba5 100644 --- a/plugins/calendar-resources/package.json +++ b/plugins/calendar-resources/package.json @@ -56,6 +56,7 @@ "@hcengineering/view-resources": "^0.6.0", "@hcengineering/view": "^0.6.13", "@hcengineering/workbench": "^0.6.16", + "@hcengineering/integration-client": "^0.6.0", "fast-equals": "^5.2.2", "date-fns": "^2.30.0", "date-fns-tz": "^2.0.0" diff --git a/plugins/calendar-resources/src/api.ts b/plugins/calendar-resources/src/api.ts new file mode 100644 index 0000000000..87467ab129 --- /dev/null +++ b/plugins/calendar-resources/src/api.ts @@ -0,0 +1,69 @@ +// +// Copyright © 2025 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 { concatLink } from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import presentation from '@hcengineering/presentation' +import login from '@hcengineering/login' +import { type Integration } from '@hcengineering/account-client' +import { + type IntegrationClient, + getIntegrationClient as getIntegrationClientRaw +} from '@hcengineering/integration-client' +import calendar from './plugin' +import { calendarIntegrationKind } from '@hcengineering/calendar' + +export async function signout (integration: Integration, client: IntegrationClient): Promise { + const url = getMetadata(calendar.metadata.CalendarServiceURL) + const token = getMetadata(presentation.metadata.Token) + if (url === undefined || token === undefined) { + throw new Error('Calendar service URL or token is not defined') + } + const connection = await client.getConnection(integration) + const email = integration.data?.email ?? connection?.data?.email + if (email === undefined) { + throw new Error('Email is not defined in integration') + } + await fetch(concatLink(url, `/signout?value=${email}`), { + method: 'GET', + headers: { + Authorization: 'Bearer ' + token, + 'Content-Type': 'application/json' + } + }) +} + +export async function disconnect (integration: Integration): Promise { + const integrationClient = await getIntegrationClient() + const result = await integrationClient.removeIntegration(integration.socialId, integration.workspaceUuid) + if (result !== undefined && result.connectionRemoved) { + await signout(integration, integrationClient) + } +} + +export async function disconnectAll (integration: Integration): Promise { + const integrationClient = await getIntegrationClient() + await integrationClient.removeConnection(integration) + await signout(integration, integrationClient) +} + +export async function getIntegrationClient (): Promise { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + const token = getMetadata(presentation.metadata.Token) + if (accountsUrl === undefined || token === undefined) { + throw new Error('Accounts URL or token is not defined') + } + return getIntegrationClientRaw(accountsUrl, token, calendarIntegrationKind, 'calendar') +} diff --git a/plugins/calendar-resources/src/components/CalDavAccess.svelte b/plugins/calendar-resources/src/components/CalDavAccess.svelte index 7dbb019db5..55b1b40856 100644 --- a/plugins/calendar-resources/src/components/CalDavAccess.svelte +++ b/plugins/calendar-resources/src/components/CalDavAccess.svelte @@ -6,7 +6,7 @@ getCurrentWorkspaceUuid } from '@hcengineering/presentation' import { Label, Modal, CheckBox, Spinner, Button, IconCopy, EditBox } from '@hcengineering/ui' - import calendar from '@hcengineering/calendar' + import calendar, { caldavIntegrationKind } from '@hcengineering/calendar' import { createEventDispatcher, onMount } from 'svelte' import { slide } from 'svelte/transition' import { quintOut } from 'svelte/easing' @@ -72,7 +72,7 @@ const accountClient = getAccountClient() const integrations = await accountClient.listIntegrations({ socialId: socialId._id, - kind: 'caldav' + kind: caldavIntegrationKind }) for (const integration of integrations) { if (integration.workspaceUuid == null) { @@ -104,7 +104,7 @@ } const accountClient = getAccountClient() const socialId = selectedSocialId._id - const kind = 'caldav' + const kind = caldavIntegrationKind const key = 'password' if (wasAccessEnabled && !accessEnabled) { // Access disabled diff --git a/plugins/calendar-resources/src/components/IntegrationConfigure.svelte b/plugins/calendar-resources/src/components/IntegrationConfigure.svelte index ee1bcde944..8539a3a4ef 100644 --- a/plugins/calendar-resources/src/components/IntegrationConfigure.svelte +++ b/plugins/calendar-resources/src/components/IntegrationConfigure.svelte @@ -17,9 +17,9 @@ import { Calendar } from '@hcengineering/calendar' import { getCurrentAccount } from '@hcengineering/core' import presentation, { Card, createQuery, getClient } from '@hcengineering/presentation' - import { Integration } from '@hcengineering/setting' import { Grid, Label, Toggle, tooltip } from '@hcengineering/ui' import { createEventDispatcher } from 'svelte' + import type { Integration } from '@hcengineering/account-client' import calendar from '../plugin' export let integration: Integration @@ -32,7 +32,7 @@ calendar.class.ExternalCalendar, { createdBy: { $in: getCurrentAccount().socialIds }, - externalUser: integration.value + externalUser: integration.data?.email }, (res) => { calendars = res diff --git a/plugins/calendar-resources/src/components/IntegrationState.svelte b/plugins/calendar-resources/src/components/IntegrationState.svelte new file mode 100644 index 0000000000..bf601338d0 --- /dev/null +++ b/plugins/calendar-resources/src/components/IntegrationState.svelte @@ -0,0 +1,99 @@ + + + +
+
+ {#if integration.workspaceUuid == null} +
+ + +
+ {:else if integration.data?.email !== undefined} +
+
+ {integration.data?.email} +
+
+ {#if syncedCalendars.length === 0} +
+ {calendar.string.NoCalendars} +
+ {:else} + {#each syncedCalendars as calendar (calendar._id)} +
+ {calendar.name} +
+ {/each} + {/if} +
+ {/if} +
+
+ + diff --git a/plugins/calendar-resources/src/components/icons/Calendar.svelte b/plugins/calendar-resources/src/components/icons/Calendar.svelte index 52745da67b..66dd94f5d6 100644 --- a/plugins/calendar-resources/src/components/icons/Calendar.svelte +++ b/plugins/calendar-resources/src/components/icons/Calendar.svelte @@ -18,7 +18,7 @@ xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" - viewBox="0 0 36 36" + viewBox="3 3 30 30" style="enable-background:new 0 0 36 36;" xml:space="preserve" > diff --git a/plugins/calendar-resources/src/index.ts b/plugins/calendar-resources/src/index.ts index 3ec362cd9f..b36480ed6b 100644 --- a/plugins/calendar-resources/src/index.ts +++ b/plugins/calendar-resources/src/index.ts @@ -14,9 +14,9 @@ // import { type ReccuringInstance } from '@hcengineering/calendar' -import { type Doc, type TxOperations, concatLink } from '@hcengineering/core' -import { type Resources, getMetadata } from '@hcengineering/platform' -import presentation, { getClient } from '@hcengineering/presentation' +import { type Doc, type TxOperations } from '@hcengineering/core' +import { type Resources } from '@hcengineering/platform' +import { getClient } from '@hcengineering/presentation' import { closePopup, showPopup } from '@hcengineering/ui' import CalendarView from './components/CalendarView.svelte' import CreateEvent from './components/CreateEvent.svelte' @@ -44,10 +44,13 @@ import ConnectApp from './components/ConnectApp.svelte' import CalendarWidget from './components/CalendarWidget.svelte' import ScheduleNavSection from './components/ScheduleNavSection.svelte' import CalendarSettings from './components/CalendarSettings.svelte' +import IntegrationState from './components/IntegrationState.svelte' import calendar from './plugin' import contact from '@hcengineering/contact' import { deleteObjects } from '@hcengineering/view-resources' import { eventTitleProvider, configureCalDavAccess } from './utils' +import { type Integration } from '@hcengineering/account-client' +import { disconnect, disconnectAll } from './api' export { EventElement, @@ -198,24 +201,19 @@ export default async (): Promise => ({ IntegrationConfigure, ConnectApp, CalendarWidget, - CalendarSettings + CalendarSettings, + IntegrationState }, actionImpl: { SaveEventReminder: saveEventReminder, DeleteRecEvent: deleteRecEvent }, handler: { - DisconnectHandler: async (value: string) => { - const url = getMetadata(calendar.metadata.CalendarServiceURL) - const token = getMetadata(presentation.metadata.Token) - if (url === undefined || token === undefined) return - await fetch(concatLink(url, `/signout?value=${value}`), { - method: 'GET', - headers: { - Authorization: 'Bearer ' + token, - 'Content-Type': 'application/json' - } - }) + DisconnectHandler: async (integration: Integration) => { + await disconnect(integration) + }, + DisconnectAllHandler: async (integration: Integration): Promise => { + await disconnectAll(integration) } }, function: { diff --git a/plugins/calendar/src/index.ts b/plugins/calendar/src/index.ts index 04aab296b5..42702d68cd 100644 --- a/plugins/calendar/src/index.ts +++ b/plugins/calendar/src/index.ts @@ -17,6 +17,7 @@ import type { Class, Client, Doc, + IntegrationKind, Markup, Mixin, PersonId, @@ -173,6 +174,16 @@ export interface Schedule extends Doc { calendar?: Ref } +/** + * @public + */ +export const calendarIntegrationKind = 'google-calendar' as IntegrationKind + +/** + * @public + */ +export const caldavIntegrationKind = 'caldav' as IntegrationKind + /** * @public */ @@ -229,7 +240,8 @@ const calendarPlugin = plugin(calendarId, { DateTimePresenter: '' as AnyComponent, DocReminder: '' as AnyComponent, ConnectApp: '' as AnyComponent, - ScheduleEditor: '' as AnyComponent + ScheduleEditor: '' as AnyComponent, + IntegrationState: '' as AnyComponent }, string: { Title: '' as IntlString, @@ -278,10 +290,15 @@ const calendarPlugin = plugin(calendarId, { CalDavAccessPasswordWarning: '' as IntlString, MeetingScheduledNotification: '' as IntlString, MeetingRescheduledNotification: '' as IntlString, - MeetingCanceledNotification: '' as IntlString + MeetingCanceledNotification: '' as IntlString, + NotConnectedIntegration: '' as IntlString, + SynchronizedCalendars: '' as IntlString, + Account: '' as IntlString, + NoCalendars: '' as IntlString }, handler: { - DisconnectHandler: '' as Handler + DisconnectHandler: '' as Handler, + DisconnectAllHandler: '' as Handler }, integrationType: { Calendar: '' as Ref diff --git a/plugins/gmail-assets/lang/cs.json b/plugins/gmail-assets/lang/cs.json index 1e626bb02a..1ff2a68a9e 100644 --- a/plugins/gmail-assets/lang/cs.json +++ b/plugins/gmail-assets/lang/cs.json @@ -39,6 +39,9 @@ "ConfigDescription": "Rozšíření pro integraci e-mailů Gmail", "GooglePrivacy": "Použití a přenos informací získaných z Google API do jakékoli jiné aplikace bude v souladu s zásadami Google API Services User Data Policy, včetně požadavků na omezené použití.", "EmailNotificationsDescription": "Dostávejte osobní oznámení na e-mail.", - "FailedToConnect": "Nepodařilo se připojit k Gmail" + "FailedToConnect": "Služba Gmail není k dispozici", + "TotalMessages": "Celkový počet zpráv", + "FailedToLoadState": "Nepodařilo se načíst stav Gmailu", + "NotConnectedIntegration": "Účet {email} není integrován s pracovním prostorem" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/de.json b/plugins/gmail-assets/lang/de.json index 7689d8f192..746bc1f91f 100644 --- a/plugins/gmail-assets/lang/de.json +++ b/plugins/gmail-assets/lang/de.json @@ -39,6 +39,9 @@ "ConfigDescription": "Erweiterung für Gmail-Integration", "GooglePrivacy": "Die Nutzung und Übertragung von Informationen, die Huly von Google-APIs erhält, an andere Apps erfolgt gemäß der Google API Services User Data Policy, einschließlich der Anforderungen zur eingeschränkten Nutzung.", "EmailNotificationsDescription": "Erhalten Sie persönliche Benachrichtigungen per E-Mail.", - "FailedToConnect": "Fehler beim Verbinden mit Gmail" + "FailedToConnect": "Gmail-Dienst ist nicht verfügbar", + "TotalMessages": "Gesamtanzahl der Nachrichten", + "FailedToLoadState": "Fehler beim Laden des Gmail-Zustands", + "NotConnectedIntegration": "Das Konto {email} ist nicht mit dem Arbeitsbereich integriert" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/en.json b/plugins/gmail-assets/lang/en.json index a7480da6eb..e2f4d41976 100644 --- a/plugins/gmail-assets/lang/en.json +++ b/plugins/gmail-assets/lang/en.json @@ -2,7 +2,7 @@ "string": { "MessageID": "MessageID", "IntegrationLabel": "Gmail", - "IntegrationDescription": "Integrate with Gmail", + "IntegrationDescription": "Connect your Gmail account to send, receive, and manage emails", "SharedMessages": "shared emails", "To": "To:", "From": "From:", @@ -39,6 +39,9 @@ "ConfigDescription": "Extension for Gmail email integration", "GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to Google API Services User Data Policy, including the Limited Use requirements.", "EmailNotificationsDescription": "Receive personal notifications on email.", - "FailedToConnect": "Failed to connect to Gmail" + "FailedToConnect": "Gmail service is not available", + "TotalMessages": "Total messages", + "FailedToLoadState": "Failed to load Gmail state", + "NotConnectedIntegration": "The account {email} is not integrated with the workspace" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/es.json b/plugins/gmail-assets/lang/es.json index e5c3ecd199..ca284ba94d 100644 --- a/plugins/gmail-assets/lang/es.json +++ b/plugins/gmail-assets/lang/es.json @@ -39,6 +39,9 @@ "ConfigDescription": "Extensión para la integración de correo electrónico de Gmail", "GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to Google API Services User Data Policy, including the Limited Use requirements.", "EmailNotificationsDescription": "Reciba notificaciones personales por correo electrónico.", - "FailedToConnect": "Error al conectar con Gmail" + "FailedToConnect": "El servicio de Gmail no está disponible", + "TotalMessages": "Total de mensajes", + "FailedToLoadState": "Error al cargar el estado de Gmail", + "NotConnectedIntegration": "La cuenta {email} no está integrada con el espacio de trabajo" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/fr.json b/plugins/gmail-assets/lang/fr.json index c05a212fd0..f89c08e743 100644 --- a/plugins/gmail-assets/lang/fr.json +++ b/plugins/gmail-assets/lang/fr.json @@ -39,6 +39,9 @@ "ConfigDescription": "Extension pour l'intégration des emails Gmail", "GooglePrivacy": "L'utilisation et le transfert des informations reçues des API Google par Huly à toute autre application respecteront les règles d'utilisation des données utilisateur des services API Google, y compris les exigences d'utilisation limitée.", "EmailNotificationsDescription": "Recevez des notifications personnelles par e-mail.", - "FailedToConnect": "Impossible de se connecter au compte Gmail" + "FailedToConnect": "Le service Gmail n'est pas disponible", + "TotalMessages": "Total des messages", + "FailedToLoadState": "Échec du chargement de l'état de Gmail", + "NotConnectedIntegration": "Le compte {email} n'est pas intégré à l'espace de travail" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/it.json b/plugins/gmail-assets/lang/it.json index 72c775d58f..5bfedd3ff3 100644 --- a/plugins/gmail-assets/lang/it.json +++ b/plugins/gmail-assets/lang/it.json @@ -39,6 +39,9 @@ "ConfigDescription": "Estensione per l'integrazione delle email di Gmail", "GooglePrivacy": "L'uso e il trasferimento delle informazioni ricevute dalle API di Google a qualsiasi altra app di Huly adempirà alla Politica sui dati degli utenti dei servizi API di Google, inclusi i requisiti di utilizzo limitato.", "EmailNotificationsDescription": "Ricevi notifiche personali sulle email.", - "FailedToConnect": "Impossibile connettere l'account Gmail" + "FailedToConnect": "Il servizio Gmail non è disponibile", + "TotalMessages": "Totale messaggi", + "FailedToLoadState": "Impossibile caricare lo stato di Gmail", + "NotConnectedIntegration": "L'account {email} non è integrato con il workspace" } } diff --git a/plugins/gmail-assets/lang/ja.json b/plugins/gmail-assets/lang/ja.json index 2d6269beec..b2cae2a0b2 100644 --- a/plugins/gmail-assets/lang/ja.json +++ b/plugins/gmail-assets/lang/ja.json @@ -38,6 +38,10 @@ "ConfigLabel": "メール", "ConfigDescription": "Gmailメール連携のための拡張機能", "GooglePrivacy": "Huly による Google API から受信した情報の他のアプリへの使用および転送は、Google API Services User Data Policy(限定的な使用の要件を含む)に準拠します。", - "EmailNotificationsDescription": "メールで個人通知を受け取る" + "EmailNotificationsDescription": "メールで個人通知を受け取る", + "FailedToConnect": "Gmail サービスは利用できません", + "TotalMessages": "メッセージの総数", + "FailedToLoadState": "Gmail の状態の読み込みに失敗しました", + "NotConnectedIntegration": "アカウント {email} はワークスペースに統合されていません" } } diff --git a/plugins/gmail-assets/lang/pt.json b/plugins/gmail-assets/lang/pt.json index 1eea278f1c..7cc1011187 100644 --- a/plugins/gmail-assets/lang/pt.json +++ b/plugins/gmail-assets/lang/pt.json @@ -39,6 +39,9 @@ "ConfigDescription": "Extensão para a integração de email do Gmail", "GooglePrivacy": "Huly’s use and transfer of information received from Google APIs to any other app will adhere to Google API Services User Data Policy, including the Limited Use requirements.", "EmailNotificationsDescription": "Receba notificações pessoais por e-mail.", - "FailedToConnect": "Não foi possível conectar a conta do Gmail" + "FailedToConnect": "O serviço Gmail não está disponível", + "TotalMessages": "Total de mensagens", + "FailedToLoadState": "Falha ao carregar o estado do Gmail", + "NotConnectedIntegration": "A conta {email} não está integrada com o espaço de trabalho" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/ru.json b/plugins/gmail-assets/lang/ru.json index f1ad4201ba..d991d812d2 100644 --- a/plugins/gmail-assets/lang/ru.json +++ b/plugins/gmail-assets/lang/ru.json @@ -39,6 +39,9 @@ "ConfigDescription": "Расширение по работе с Gmail электронной почтой", "GooglePrivacy": "Использование и передача информации, полученной Huly от Google API, будет соответствовать Политике использования данных пользователей Google API, включая требования ограниченного использования.", "EmailNotificationsDescription": "Получайте персональные уведомления на электронную почту.", - "FailedToConnect": "Не удалось подключиться к Gmail" + "FailedToConnect": "Gmail сервис недоступен", + "TotalMessages": "Всего сообщений", + "FailedToLoadState": "Не удалось загрузить состояние Gmail", + "NotConnectedIntegration": "Учетная запись {email} не интегрирована с рабочим пространством" } } \ No newline at end of file diff --git a/plugins/gmail-assets/lang/zh.json b/plugins/gmail-assets/lang/zh.json index 32988675f8..416f151a2b 100644 --- a/plugins/gmail-assets/lang/zh.json +++ b/plugins/gmail-assets/lang/zh.json @@ -39,6 +39,9 @@ "ConfigDescription": "Gmail 邮件集成扩展", "GooglePrivacy": "Huly 从 Google API 接收的信息的使用和传输将遵守 Google API 服务用户数据政策,包括有限使用要求。", "EmailNotificationsDescription": "透過電子郵件接收個人通知。", - "FailedToConnect": "無法連接 Gmail 帳戶。請檢查您的憑證並重試。" + "FailedToConnect": "Gmail 服务不可用", + "TotalMessages": "总消息数", + "FailedToLoadState": "无法加载 Gmail 状态", + "NotConnectedIntegration": "帐户 {email} 未与工作区集成" } } diff --git a/plugins/gmail-resources/package.json b/plugins/gmail-resources/package.json index 3fb728b205..d8dcc18bd5 100644 --- a/plugins/gmail-resources/package.json +++ b/plugins/gmail-resources/package.json @@ -59,6 +59,8 @@ "@hcengineering/core": "^0.6.32", "@hcengineering/panel": "^0.6.23", "@hcengineering/analytics": "^0.6.0", - "@hcengineering/templates": "^0.6.11" + "@hcengineering/templates": "^0.6.11", + "@hcengineering/integration-client": "^0.6.0", + "@hcengineering/account-client": "^0.6.0" } } diff --git a/plugins/gmail-resources/src/api.ts b/plugins/gmail-resources/src/api.ts new file mode 100644 index 0000000000..ed90d8e7b0 --- /dev/null +++ b/plugins/gmail-resources/src/api.ts @@ -0,0 +1,63 @@ +// +// Copyright © 2025 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 { getMetadata } from '@hcengineering/platform' +import presentation from '@hcengineering/presentation' +import login from '@hcengineering/login' +import { type GmailSyncState, gmailIntegrationKind } from '@hcengineering/gmail' +import { + getIntegrationClient as getIntegrationClientRaw, + type IntegrationClient +} from '@hcengineering/integration-client' + +import gmail from './plugin' +import { concatLink } from '@hcengineering/core' + +export async function getIntegrationClient (): Promise { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + const token = getMetadata(presentation.metadata.Token) + if (accountsUrl === undefined || token === undefined) { + throw new Error('Accounts URL or token is not defined') + } + return getIntegrationClientRaw(accountsUrl, token, gmailIntegrationKind, 'gmail') +} + +const url = getMetadata(gmail.metadata.GmailURL) ?? '' + +async function request (method: 'GET' | 'POST' | 'DELETE', path?: string, body?: any): Promise { + const response = await fetch(concatLink(url, path ?? ''), { + method, + headers: { + Authorization: 'Bearer ' + getMetadata(presentation.metadata.Token), + 'Content-Type': 'application/json' + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}) + }) + + if (response.status === 200) { + return await response.json() + } else if (response.status === 202) { + return undefined + } else { + throw new Error(`Unexpected response: ${response.status}`) + } +} + +export async function getState (socialId: string): Promise { + return await request('GET', `/state?socialId=${encodeURIComponent(socialId)}`) +} + +export async function signout (): Promise { + await request('GET', '/signout') +} diff --git a/plugins/gmail-resources/src/components/Configure.svelte b/plugins/gmail-resources/src/components/Configure.svelte index a7638adf74..aaec51386d 100644 --- a/plugins/gmail-resources/src/components/Configure.svelte +++ b/plugins/gmail-resources/src/components/Configure.svelte @@ -13,29 +13,62 @@ // limitations under the License. --> + +
+
+ {#if isLoading} +
+ +
+ {:else if integration.workspaceUuid == null} +
+ + +
+ {:else if errorLabel !== undefined} +
+ +
+ {:else if error != null} +
+ +
+ {:else if state != null} +
+
+ + {state.email ?? 'N/A'} +
+
+ +
+ {#if isRefreshing} + + {/if} + {state.totalMessages} +
+
+
+ {/if} +
+
+ + diff --git a/plugins/gmail-resources/src/components/icons/GmailColor.svelte b/plugins/gmail-resources/src/components/icons/GmailColor.svelte index c0cc4e78a1..6e14a59d2f 100644 --- a/plugins/gmail-resources/src/components/icons/GmailColor.svelte +++ b/plugins/gmail-resources/src/components/icons/GmailColor.svelte @@ -17,12 +17,12 @@ diff --git a/plugins/gmail-resources/src/index.ts b/plugins/gmail-resources/src/index.ts index 3f794dbd3e..84a9363e9b 100644 --- a/plugins/gmail-resources/src/index.ts +++ b/plugins/gmail-resources/src/index.ts @@ -14,7 +14,6 @@ // limitations under the License. // -import { concatLink } from '@hcengineering/core' import { getMetadata, type Resources } from '@hcengineering/platform' import presentation from '@hcengineering/presentation' import GmailWriteMessage from './components/activity/GmailWriteMessage.svelte' @@ -24,8 +23,11 @@ import Connect from './components/Connect.svelte' import IconGmail from './components/icons/GmailColor.svelte' import Main from './components/Main.svelte' import NewMessages from './components/NewMessages.svelte' +import IntegrationState from './components/IntegrationState.svelte' import gmail from '@hcengineering/gmail' import { checkHasEmail, MessageTitleProvider } from './utils' +import { getIntegrationClient, signout } from './api' +import type { Integration } from '@hcengineering/account-client' export default async (): Promise => ({ component: { @@ -33,7 +35,8 @@ export default async (): Promise => ({ Connect, IconGmail, NewMessages, - Configure + Configure, + IntegrationState }, activity: { GmailWriteMessage, @@ -44,17 +47,35 @@ export default async (): Promise => ({ MessageTitleProvider }, handler: { - DisconnectHandler: async () => { + DisconnectHandler: async (integration: Integration): Promise => { const url = getMetadata(gmail.metadata.GmailURL) const token = getMetadata(presentation.metadata.Token) - if (url === undefined || token === undefined) return - await fetch(concatLink(url, '/signout'), { - method: 'GET', - headers: { - Authorization: 'Bearer ' + token, - 'Content-Type': 'application/json' - } - }) + if (url === undefined || token === undefined) { + console.error('Url or token is not defined in DisconnectHandler') + } + if (integration == null) { + console.error('Missing required argument integration in DisconnectHandler') + return + } + const integrationClient = await getIntegrationClient() + const result = await integrationClient.removeIntegration(integration.socialId, integration.workspaceUuid) + if (result !== undefined && result.connectionRemoved) { + await signout() + } + }, + DisconnectAllHandler: async (integration: Integration): Promise => { + const url = getMetadata(gmail.metadata.GmailURL) + const token = getMetadata(presentation.metadata.Token) + if (url === undefined || token === undefined) { + console.error('Url or token is not defined in DisconnectHandler') + } + if (integration == null) { + console.error('Missing required argument integration in DisconnectHandler') + return + } + const integrationClient = await getIntegrationClient() + await integrationClient.removeConnection(integration) + await signout() } } }) diff --git a/plugins/gmail-resources/src/plugin.ts b/plugins/gmail-resources/src/plugin.ts index 25c151b383..c271f564f6 100644 --- a/plugins/gmail-resources/src/plugin.ts +++ b/plugins/gmail-resources/src/plugin.ts @@ -44,6 +44,9 @@ export default mergeIds(gmailId, gmail, { HaveWrittenEmail: '' as IntlString, NewIncomingMessage: '' as IntlString, GooglePrivacy: '' as IntlString, - FailedToConnect: '' as IntlString + FailedToConnect: '' as IntlString, + FailedToLoadState: '' as IntlString, + NotConnectedIntegration: '' as IntlString, + TotalMessages: '' as IntlString } }) diff --git a/plugins/gmail/src/index.ts b/plugins/gmail/src/index.ts index 6669c8f317..80bcd8e98f 100644 --- a/plugins/gmail/src/index.ts +++ b/plugins/gmail/src/index.ts @@ -14,7 +14,7 @@ // import { ChannelItem } from '@hcengineering/contact' -import type { PersonId, AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core' +import type { PersonId, AttachedDoc, Class, Doc, Ref, Timestamp, IntegrationKind } from '@hcengineering/core' import { NotificationProvider, NotificationType } from '@hcengineering/notification' import type { IntlString, Metadata, Plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' @@ -76,6 +76,22 @@ export interface SharedMessages extends AttachedDoc { messages: SharedMessage[] } +/** + * @public + */ +export const gmailIntegrationKind = 'gmail' as IntegrationKind + +/** + * @public + */ +export interface GmailSyncState { + status: string + email: string + totalMessages: number | null | undefined + lastSynchronization?: Date | null + error?: string +} + /** * @public */ @@ -87,7 +103,8 @@ export default plugin(gmailId, { Connect: '' as AnyComponent, IconGmail: '' as AnyComponent, NewMessages: '' as AnyComponent, - Configure: '' as AnyComponent + Configure: '' as AnyComponent, + IntegrationState: '' as AnyComponent }, string: { From: '' as IntlString, @@ -98,7 +115,8 @@ export default plugin(gmailId, { Gmail: '' as Ref }, handler: { - DisconnectHandler: '' as Handler + DisconnectHandler: '' as Handler, + DisconnectAllHandler: '' as Handler }, class: { Message: '' as Ref>, diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index c0adca42d8..6db637f5d3 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -13,6 +13,7 @@ "CurrentPassword": "Aktuální heslo", "NewPassword": "Nové heslo", "Disconnect": "Odpojit", + "DisconnectAll": "Odpojit všechny", "Saving": "Ukládání...", "Saved": "Uloženo", "Add": "Přidat", @@ -178,6 +179,18 @@ "ReleaseSocialId": "Uvolnit sociální ID", "ReleaseSocialIdConfirm": "Opravdu chcete uvolnit toto sociální ID: {socialId}? Tím ho odeberete ze svého účtu a nebudete ho již moci používat k přihlášení. Také budou odstraněny všechny související integrace.", "ReleasePrimarySocialId": "Uvolnit primární sociální ID", - "ReleasePrimarySocialIdConfirm": "Uvolnění vašeho současného primárního sociálního ID bude vyžadovat obnovení stránky. Opravdu chcete pokračovat?" + "ReleasePrimarySocialIdConfirm": "Uvolnění vašeho současného primárního sociálního ID bude vyžadovat obnovení stránky. Opravdu chcete pokračovat?", + "MyIntegrations": "Moje integrace", + "AllIntegrations": "Všechny", + "ConnectedIntegrations": "Připojené", + "AvailableIntegrations": "Dostupné", + "Connect": "Připojit", + "Integrate": "Integrace", + "FailedToLoadIntegrations": "Nepodařilo se načíst integrace", + "FailedToDisconnect": "Nepodařilo se odpojit integraci", + "ServiceIsUnavailable": "Služba není k dispozici", + "Integrated": "Integrované", + "Connected": "Připojené", + "Available": "Dostupné" } } diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index c0f6d57c48..7171f51fe0 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -13,6 +13,7 @@ "CurrentPassword": "Aktuelles Passwort", "NewPassword": "Neues Passwort", "Disconnect": "Trennen", + "DisconnectAll": "Alle trennen", "Saving": "Wird gespeichert...", "Saved": "Gespeichert", "Add": "Hinzufügen", @@ -180,6 +181,18 @@ "ReleasePrimarySocialId": "Primäre Social ID freigeben", "ReleasePrimarySocialIdConfirm": "Die Freigabe Ihrer aktuellen primären Social ID erfordert ein Neuladen der Seite. Sind Sie sicher, dass Sie fortfahren möchten?", "Login": "Anmelden", - "Primary": "Primär" + "Primary": "Primär", + "MyIntegrations": "Meine Integrationen", + "AllIntegrations": "Alle", + "ConnectedIntegrations": "Verbunden", + "AvailableIntegrations": "Verfügbar", + "Connect": "Verbinden", + "Integrate": "Integrieren", + "FailedToLoadIntegrations": "Fehler beim Laden der Integrationen", + "FailedToDisconnect": "Fehler beim Trennen der Integration", + "ServiceIsUnavailable": "Dienst ist nicht verfügbar", + "Integrated": "Integriert", + "Connected": "Verbunden", + "Available": "Verfügbar" } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 688df16d63..0944fd937b 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -13,6 +13,7 @@ "CurrentPassword": "Current password", "NewPassword": "New password", "Disconnect": "Disconnect", + "DisconnectAll": "Disconnect all", "Saving": "Saving...", "Saved": "Saved", "Add": "Add", @@ -180,6 +181,18 @@ "ReleasePrimarySocialId": "Release primary social ID", "ReleasePrimarySocialIdConfirm": "Releasing your current primary social id will require the page to be reloaded. Are you sure you want to proceed?", "Login": "Login", - "Primary": "Primary" + "Primary": "Primary", + "MyIntegrations": "My integrations", + "AllIntegrations": "All", + "ConnectedIntegrations": "Integrated", + "AvailableIntegrations": "Available", + "Connect": "Connect", + "Integrate": "Integrate", + "FailedToLoadIntegrations": "Failed to load integrations", + "FailedToDisconnect": "Failed to disconnect integration", + "ServiceIsUnavailable": "Service is unavailable", + "Integrated": "Integrated", + "Connected": "Connected", + "Available": "Available" } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index 5c46f3cecf..361e72bdc0 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -13,6 +13,7 @@ "CurrentPassword": "Contraseña actual", "NewPassword": "Nueva contraseña", "Disconnect": "Desconectar", + "DisconnectAll": "Desconectar todos", "Saving": "Guardando...", "Saved": "Guardado", "Add": "Añadir", @@ -171,6 +172,18 @@ "ReleasePrimarySocialId": "Liberar ID social primario", "ReleasePrimarySocialIdConfirm": "Liberar tu ID social primario actual requerirá que se recargue la página. ¿Estás seguro de que quieres continuar?", "Login": "Iniciar sesión", - "Primary": "Primario" + "Primary": "Primario", + "MyIntegrations": "Mis integraciones", + "AllIntegrations": "Todas", + "ConnectedIntegrations": "Conectadas", + "AvailableIntegrations": "Disponibles", + "Connect": "Conectar", + "Integrate": "Integrar", + "FailedToLoadIntegrations": "Error al cargar las integraciones", + "FailedToDisconnect": "Error al desconectar la integración", + "ServiceIsUnavailable": "El servicio no está disponible", + "Integrated": "Integrado", + "Connected": "Conectado", + "Available": "Disponible" } } diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 2a95d68ce4..ede49e36a6 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -13,6 +13,7 @@ "CurrentPassword": "Mot de passe actuel", "NewPassword": "Nouveau mot de passe", "Disconnect": "Déconnecter", + "DisconnectAll": "Déconnecter tous", "Saving": "Enregistrement...", "Saved": "Enregistré", "Add": "Ajouter", @@ -180,6 +181,18 @@ "ReleasePrimarySocialId": "Libérer l'ID social principal", "ReleasePrimarySocialIdConfirm": "Libérer votre ID social principal actuel nécessitera de recharger la page. Êtes-vous sûr de vouloir continuer ?", "Login": "Connexion", - "Primary": "Principal" + "Primary": "Principal", + "MyIntegrations": "Mes intégrations", + "AllIntegrations": "Toutes", + "ConnectedIntegrations": "Connectées", + "AvailableIntegrations": "Disponibles", + "Connect": "Connecter", + "Integrate": "Intégrer", + "FailedToLoadIntegrations": "Échec du chargement des intégrations", + "FailedToDisconnect": "Échec de la déconnexion de l'intégration", + "ServiceIsUnavailable": "Le service n'est pas disponible", + "Integrated": "Intégré", + "Connected": "Connecté", + "Available": "Disponible" } } diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index 6031464867..e4967496b7 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -13,6 +13,7 @@ "CurrentPassword": "Password attuale", "NewPassword": "Nuova password", "Disconnect": "Disconnetti", + "DisconnectAll": "Disconnetti tutti", "Saving": "Salvataggio...", "Saved": "Salvato", "Add": "Aggiungi", @@ -180,6 +181,18 @@ "ReleasePrimarySocialId": "Rilascia ID social primario", "ReleasePrimarySocialIdConfirm": "Rilasciare il tuo ID social primario attuale richiederà il ricaricamento della pagina. Sei sicuro di voler procedere?", "Login": "Accesso", - "Primary": "Primario" + "Primary": "Primario", + "MyIntegrations": "Le mie integrazioni", + "AllIntegrations": "Tutte", + "ConnectedIntegrations": "Connesse", + "AvailableIntegrations": "Disponibili", + "Connect": "Connetti", + "Integrate": "Integra", + "FailedToLoadIntegrations": "Impossibile caricare le integrazioni", + "FailedToDisconnect": "Impossibile disconnettere l'integrazione", + "ServiceIsUnavailable": "Il servizio non è disponibile", + "Integrated": "Integrato", + "Connected": "Connesso", + "Available": "Disponibile" } } diff --git a/plugins/setting-assets/lang/ja.json b/plugins/setting-assets/lang/ja.json index dc6c43522b..33043c748a 100644 --- a/plugins/setting-assets/lang/ja.json +++ b/plugins/setting-assets/lang/ja.json @@ -13,6 +13,7 @@ "CurrentPassword": "現在のパスワード", "NewPassword": "新しいパスワード", "Disconnect": "切断", + "DisconnectAll": "すべて切断", "Saving": "保存中...", "Saved": "保存済み", "Add": "追加", @@ -180,6 +181,18 @@ "ReleasePrimarySocialId": "プライマリソーシャルIDをリリース", "ReleasePrimarySocialIdConfirm": "現在のプライマリソーシャルIDをリリースするには、ページの再読み込みが必要です。続行してもよろしいですか?", "Login": "ログイン", - "Primary": "プライマリ" + "Primary": "プライマリ", + "MyIntegrations": "マイインテグレーション", + "AllIntegrations": "すべてのインテグレーション", + "ConnectedIntegrations": "接続されたインテグレーション", + "AvailableIntegrations": "利用可能なインテグレーション", + "Connect": "接続", + "Integrate": "統合", + "FailedToLoadIntegrations": "インテグレーションの読み込みに失敗しました", + "FailedToDisconnect": "インテグレーションの切断に失敗しました", + "ServiceIsUnavailable": "サービスは利用できません", + "Integrated": "統合済み", + "Connected": "接続済み", + "Available": "利用可能" } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 98d9ec8a64..f6f9a92394 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -13,6 +13,7 @@ "CurrentPassword": "Senha atual", "NewPassword": "Nova senha", "Disconnect": "Desconectar", + "DisconnectAll": "Desconectar todos", "Saving": "Salvando...", "Saved": "Salvo", "Add": "Adicionar", @@ -171,6 +172,18 @@ "ReleasePrimarySocialId": "Liberar ID social primário", "ReleasePrimarySocialIdConfirm": "Liberar seu ID social primário atual exigirá que a página seja recarregada. Tem certeza de que deseja prosseguir?", "Login": "Login", - "Primary": "Primário" + "Primary": "Primário", + "MyIntegrations": "Minhas integrações", + "AllIntegrations": "Todas", + "ConnectedIntegrations": "Conectadas", + "AvailableIntegrations": "Disponíveis", + "Connect": "Conectar", + "Integrate": "Integrar", + "FailedToLoadIntegrations": "Falha ao carregar integrações", + "FailedToDisconnect": "Falha ao desconectar a integração", + "ServiceIsUnavailable": "Serviço indisponível", + "Integrated": "Integrado", + "Connected": "Conectado", + "Available": "Disponível" } } diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index f6bce3bcf6..57d3e120ad 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -13,6 +13,7 @@ "CurrentPassword": "Текущий пароль", "NewPassword": "Новый пароль", "Disconnect": "Отключить", + "DisconnectAll": "Отключить все", "Saving": "Сохранение...", "Saved": "Сохранено", "Add": "Добавить", @@ -180,6 +181,18 @@ "ReleasePrimarySocialId": "Освободить основной социальный ID", "ReleasePrimarySocialIdConfirm": "Освобождение вашего текущего основного социального ID потребует перезагрузки страницы. Вы уверены, что хотите продолжить?", "Login": "Вход", - "Primary": "Основной" + "Primary": "Основной", + "MyIntegrations": "Мои интеграции", + "AllIntegrations": "Все", + "ConnectedIntegrations": "Подключенные", + "AvailableIntegrations": "Доступные", + "Connect": "Подключить", + "Integrate": "Интегрировать", + "FailedToLoadIntegrations": "Не удалось загрузить интеграции", + "FailedToDisconnect": "Не удалось отключить интеграцию", + "ServiceIsUnavailable": "Сервис недоступен", + "Integrated": "Интегрировано", + "Connected": "Подключено", + "Available": "Доступно" } } diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index 19463caa70..a64a239ef7 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -13,6 +13,7 @@ "CurrentPassword": "当前密码", "NewPassword": "新密码", "Disconnect": "断开连接", + "DisconnectAll": "断开所有连接", "Saving": "保存中...", "Saved": "已保存", "Add": "添加", @@ -180,6 +181,18 @@ "ReleasePrimarySocialId": "释放主要社交ID", "ReleasePrimarySocialIdConfirm": "释放您当前的主要社交ID需要重新加载页面。您确定要继续吗?", "Login": "登录", - "Primary": "主要" + "Primary": "主要", + "MyIntegrations": "我的集成", + "AllIntegrations": "所有集成", + "ConnectedIntegrations": "已连接的集成", + "AvailableIntegrations": "可用的集成 ", + "Connect": "连接", + "Integrate": "集成", + "FailedToLoadIntegrations": "加载集成失败", + "FailedToDisconnect": "断开集成失败", + "ServiceIsUnavailable": "服务不可用", + "Integrated": "已集成", + "Connected": "已连接", + "Available": "可用" } } diff --git a/plugins/setting-resources/package.json b/plugins/setting-resources/package.json index 4a5d8b7bb8..fcc1d490ec 100644 --- a/plugins/setting-resources/package.json +++ b/plugins/setting-resources/package.json @@ -61,6 +61,7 @@ "@hcengineering/workbench": "^0.6.16", "@hcengineering/workbench-resources": "^0.6.1", "@hcengineering/communication": "^0.6.0", - "@hcengineering/chat": "^0.6.0" + "@hcengineering/chat": "^0.6.0", + "@hcengineering/integration-client": "^0.6.0" } } diff --git a/plugins/setting-resources/src/components/IntegrationPanel.svelte b/plugins/setting-resources/src/components/IntegrationPanel.svelte deleted file mode 100644 index 6dbf19bb11..0000000000 --- a/plugins/setting-resources/src/components/IntegrationPanel.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - - -{#if integration} - -
- {#if type} - - {/if} -
- -{/if} diff --git a/plugins/setting-resources/src/components/Integrations.svelte b/plugins/setting-resources/src/components/Integrations.svelte deleted file mode 100644 index 9afd052375..0000000000 --- a/plugins/setting-resources/src/components/Integrations.svelte +++ /dev/null @@ -1,105 +0,0 @@ - - - -
-
- -
- -
- {#each integrationTypes as integrationType (integrationType._id)} - {#if integrationType.allowMultiple} - {#each getIntegrations(integrationType._id, integrations) as integration (integration._id)} - - {/each} - - {:else} - p.type === integrationType._id)} {integrationType} /> - {/if} - {/each} -
-
- - diff --git a/plugins/setting-resources/src/components/PluginCard.svelte b/plugins/setting-resources/src/components/PluginCard.svelte deleted file mode 100644 index 94bf087635..0000000000 --- a/plugins/setting-resources/src/components/PluginCard.svelte +++ /dev/null @@ -1,168 +0,0 @@ - - - -
-
-
-
-
-
-
-
- {#if integration && integration.value !== ''} - {integration.value} - {:else if integrationType.descriptionComponent} - - {:else} -
- -
- - diff --git a/plugins/setting-resources/src/components/integrations/IntegrationCard.svelte b/plugins/setting-resources/src/components/integrations/IntegrationCard.svelte new file mode 100644 index 0000000000..8d52793d03 --- /dev/null +++ b/plugins/setting-resources/src/components/integrations/IntegrationCard.svelte @@ -0,0 +1,255 @@ + + + +
+
+
+
+
+
+
+ +
+
+
+ {#if integration !== undefined && integrationType.stateComponent} + + {:else if integrationType.descriptionComponent} + + {:else} + + + {/if} + {#if integration?.disabled === true} +
+
+ {/if} +
+
+ +
+ + diff --git a/plugins/setting-resources/src/components/IntegrationErrorNotification.svelte b/plugins/setting-resources/src/components/integrations/IntegrationErrorNotification.svelte similarity index 93% rename from plugins/setting-resources/src/components/IntegrationErrorNotification.svelte rename to plugins/setting-resources/src/components/integrations/IntegrationErrorNotification.svelte index e99e0de367..eac9e6b21b 100644 --- a/plugins/setting-resources/src/components/IntegrationErrorNotification.svelte +++ b/plugins/setting-resources/src/components/integrations/IntegrationErrorNotification.svelte @@ -24,6 +24,8 @@ - {notification.subTitle} +
+ {notification.subTitle} +
diff --git a/plugins/setting-resources/src/components/integrations/IntegrationLabel.svelte b/plugins/setting-resources/src/components/integrations/IntegrationLabel.svelte new file mode 100644 index 0000000000..782032269d --- /dev/null +++ b/plugins/setting-resources/src/components/integrations/IntegrationLabel.svelte @@ -0,0 +1,72 @@ + + + + + + + diff --git a/plugins/setting-resources/src/components/integrations/IntegrationTag.svelte b/plugins/setting-resources/src/components/integrations/IntegrationTag.svelte new file mode 100644 index 0000000000..f616271244 --- /dev/null +++ b/plugins/setting-resources/src/components/integrations/IntegrationTag.svelte @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + +
+ + {#if labelIntl} + + + {#if removable} + dispatch('remove')} /> + {/if} +
+ + diff --git a/plugins/setting-resources/src/components/integrations/Integrations.svelte b/plugins/setting-resources/src/components/integrations/Integrations.svelte new file mode 100644 index 0000000000..bc55b5ac36 --- /dev/null +++ b/plugins/setting-resources/src/components/integrations/Integrations.svelte @@ -0,0 +1,305 @@ + + + +
+
+ + + { + if (result.detail !== undefined) mode = result.detail.id + }} + /> + +
+ {#if isLoading} +
+ +
+ {:else} +
+ {#each filteredIntegrationTypes as integrationInfo (getIntegrationKey(integrationInfo))} + {#if integrationInfo.integrationType !== undefined} + + {/if} + {/each} + {#if filteredIntegrationTypes.length === 1} +
+ + {/if} +
+ {/if} +
+ + diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index a049fcfa0b..f07f16c79e 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -22,7 +22,7 @@ import ClassSetting from './components/ClassSetting.svelte' import CreateMixin from './components/CreateMixin.svelte' import EditEnum from './components/EditEnum.svelte' import EnumSetting from './components/EnumSetting.svelte' -import Integrations from './components/Integrations.svelte' +import Integrations from './components/integrations/Integrations.svelte' import General from './components/General.svelte' import Backup from './components/Backup.svelte' import Owners from './components/Owners.svelte' @@ -35,7 +35,6 @@ import { Analytics } from '@hcengineering/analytics' import ClassAttributes from './components/ClassAttributes.svelte' import ClassAttributesList from './components/ClassAttributesList.svelte' import Configure from './components/Configure.svelte' -import IntegrationPanel from './components/IntegrationPanel.svelte' import InviteSetting from './components/InviteSetting.svelte' import PermissionPresenter from './components/presenters/PermissionPresenter.svelte' import SpaceTypeDescriptorPresenter from './components/presenters/SpaceTypeDescriptorPresenter.svelte' @@ -131,7 +130,6 @@ export default async (): Promise => ({ Owners, CreateMixin, InviteSetting, - IntegrationPanel, Configure, ManageSpaceTypes, ManageSpaceTypesTools, diff --git a/plugins/setting/package.json b/plugins/setting/package.json index 2d7578ba9f..7387dd265f 100644 --- a/plugins/setting/package.json +++ b/plugins/setting/package.json @@ -40,7 +40,8 @@ "@hcengineering/platform": "^0.6.11", "@hcengineering/core": "^0.6.32", "@hcengineering/templates": "^0.6.11", - "@hcengineering/ui": "^0.6.15" + "@hcengineering/ui": "^0.6.15", + "@hcengineering/account-client": "^0.6.0" }, "repository": "https://github.com/hcengineering/platform", "publishConfig": { diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 1e3ebf6a74..dfad064cd3 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -13,11 +13,23 @@ // limitations under the License. // -import type { AccountRole, Blob, Class, Configuration, Doc, Mixin, Ref, AccountUuid, Domain } from '@hcengineering/core' +import type { + AccountRole, + Blob, + Class, + Configuration, + Doc, + Mixin, + Ref, + AccountUuid, + Domain, + IntegrationKind +} from '@hcengineering/core' import type { Metadata, Plugin } from '@hcengineering/platform' import { Asset, IntlString, Resource, plugin } from '@hcengineering/platform' import { TemplateField, TemplateFieldCategory } from '@hcengineering/templates' -import { AnyComponent } from '@hcengineering/ui' +import { Action, AnyComponent } from '@hcengineering/ui' +import { type Integration as AccountIntegration } from '@hcengineering/account-client' import { SpaceTypeCreator, SpaceTypeEditor } from './spaceTypeEditor' @@ -30,7 +42,7 @@ export const DOMAIN_SETTING = 'setting' as Domain /** * @public */ -export type Handler = Resource<(value: string) => Promise> +export type Handler = Resource<(integration: AccountIntegration) => Promise> /** * @public @@ -39,14 +51,18 @@ export interface IntegrationType extends Doc { label: IntlString description: IntlString descriptionComponent?: AnyComponent + stateComponent?: AnyComponent icon: AnyComponent allowMultiple: boolean + kind: IntegrationKind createComponent?: AnyComponent onDisconnect?: Handler + onDisconnectAll?: Handler // Disconnect for all workspaces reconnectComponent?: AnyComponent - configureComponent?: AnyComponent + + getActions?: Resource<(integration?: AccountIntegration) => Promise> } /** @@ -196,6 +212,7 @@ export default plugin(settingId, { Categories: '' as IntlString, Delete: '' as IntlString, Disconnect: '' as IntlString, + DisconnectAll: '' as IntlString, Add: '' as IntlString, Proceed: '' as IntlString, SendConfirmation: '' as IntlString, @@ -257,7 +274,18 @@ export default plugin(settingId, { Customize: '' as IntlString, CodeSent: '' as IntlString, SendAgain: '' as IntlString, - SendAgainIn: '' as IntlString + SendAgainIn: '' as IntlString, + AllIntegrations: '' as IntlString, + ConnectedIntegrations: '' as IntlString, + AvailableIntegrations: '' as IntlString, + Connect: '' as IntlString, + Integrate: '' as IntlString, + FailedToLoadIntegrations: '' as IntlString, + FailedToDisconnect: '' as IntlString, + ServiceIsUnavailable: '' as IntlString, + Integrated: '' as IntlString, + Connected: '' as IntlString, + Available: '' as IntlString }, icon: { AccountSettings: '' as Asset, diff --git a/plugins/telegram-assets/assets/icons.svg b/plugins/telegram-assets/assets/icons.svg new file mode 100644 index 0000000000..b5ec3e2db6 --- /dev/null +++ b/plugins/telegram-assets/assets/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/telegram-assets/lang/cs.json b/plugins/telegram-assets/lang/cs.json index 355dd6a8b8..a987baaaad 100644 --- a/plugins/telegram-assets/lang/cs.json +++ b/plugins/telegram-assets/lang/cs.json @@ -59,6 +59,40 @@ "ConnectTelegramBot": "Připojit Telegram bota", "DisconnectMessage": "⚠️ Byli jste odpojeni od služby. Znovu připojte svůj účet na {app} pomocí /{command}.", "SyncAllChannels": "Synchronizovat všechny kanály", - "SyncStarredChannels": "Synchronizovat označené kanály" + "SyncStarredChannels": "Synchronizovat označené kanály", + "Public": "Veřejný", + "Private": "Soukromý", + "TotalChannels": "Celkem kanálů", + "SyncEnabled": "Synchronizace povolena", + "SyncDisabled": "Synchronizace zakázána", + "SelectAll": "Vybrat vše", + "Selected": "Vybráno", + "EnableSync": "Povolit synchronizaci", + "DisableSync": "Zakázat synchronizaci", + "SelectAccess": "Vybrat přístup", + "ConfigureIntegration": "Konfigurovat integraci", + "Apply": "Použít", + "SyncedChannels": "Synchronizované kanály", + "NotConnectedIntegration": "Účet {phone} není integrován s pracovním prostorem", + "FailedToLoadState": "Nepodařilo se načíst stav integrace", + "Type": "Typ", + "SyncMode": "Režim synchronizace", + "Access": "Přístup", + "User": "Uživatel", + "Group": "Skupina", + "Channel": "Kanál", + "SelectAllAction": "Vybrat vše", + "UnselectAllAction": "Zrušit výběr", + "EnableSynchronization": "Povolit synchronizaci", + "DisableSynchronization": "Zakázat synchronizaci", + "SetPublicAccess": "Nastavit veřejný přístup", + "SetPrivateAccess": "Nastavit soukromý přístup", + "NoChannelsFound": "Nenalezeny žádné kanály", + "TryAdjustingSearch": "Zkuste upravit vyhledávání nebo", + "ClearSearch": "vymazat vyhledávání", + "NoChannelsAvailable": "Nejsou dostupné žádné kanály", + "EnableSyncToConfigure": "Povolit synchronizaci pro konfiguraci přístupu kanálu", + "AccessCannotBeChanged": "Přístup nelze změnit pro existující kanál", + "ServiceIsUnavailable": "Služba Telegram není k dispozici" } } diff --git a/plugins/telegram-assets/lang/de.json b/plugins/telegram-assets/lang/de.json index e92e18de3e..7a3ccf571d 100644 --- a/plugins/telegram-assets/lang/de.json +++ b/plugins/telegram-assets/lang/de.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Telegram-Bot verbinden", "DisconnectMessage": "⚠️Sie wurden vom Dienst getrennt. Bitte verbinden Sie Ihr Konto in {app} erneut mit /{command}.", "SyncAllChannels": "Alle Kanäle synchronisieren", - "SyncStarredChannels": "Markierte Kanäle synchronisieren" + "SyncStarredChannels": "Markierte Kanäle synchronisieren", + "Public": "Öffentlich", + "Private": "Privat", + "TotalChannels": "Insgesamt Kanäle", + "SyncEnabled": "Synchronisierung aktiviert", + "SyncDisabled": "Synchronisierung deaktiviert", + "SelectAll": "Alle auswählen", + "Selected": "Ausgewählt", + "EnableSync": "Synchronisierung aktivieren", + "DisableSync": "Synchronisierung deaktivieren", + "SelectAccess": "Zugriff auswählen", + "ConfigureIntegration": "Integration konfigurieren", + "Apply": "Anwenden", + "SyncedChannels": "Synchronisierte Kanäle", + "NotConnectedIntegration": "Das Konto {phone} ist nicht mit dem Arbeitsbereich integriert", + "FailedToLoadState": "Fehler beim Laden des Integrationsstatus", + "Type": "Typ", + "SyncMode": "Synchronisationsmodus", + "Access": "Zugriff", + "User": "Benutzer", + "Group": "Gruppe", + "Channel": "Kanal", + "Actions": "Aktionen", + "SelectAllAction": "Alle auswählen", + "UnselectAllAction": "Alle abwählen", + "EnableSynchronization": "Synchronisierung aktivieren", + "DisableSynchronization": "Synchronisierung deaktivieren", + "SetPublicAccess": "Öffentlichen Zugriff festlegen", + "SetPrivateAccess": "Privaten Zugriff festlegen", + "NoChannelsFound": "Keine Kanäle gefunden", + "TryAdjustingSearch": "Versuchen Sie Ihre Suche anzupassen oder", + "ClearSearch": "Suche löschen", + "NoChannelsAvailable": "Keine Kanäle verfügbar", + "EnableSyncToConfigure": "Aktivieren Sie die Synchronisierung, um den Kanalzugriff zu konfigurieren", + "AccessCannotBeChanged": "Der Zugriff kann für vorhandene Kanäle nicht geändert werden", + "ServiceIsUnavailable": "Telegram-Dienst ist nicht verfügbar" } } diff --git a/plugins/telegram-assets/lang/en.json b/plugins/telegram-assets/lang/en.json index 1b9323b17a..f88b0d2ebe 100644 --- a/plugins/telegram-assets/lang/en.json +++ b/plugins/telegram-assets/lang/en.json @@ -25,9 +25,9 @@ "NewMessage": "New message", "NewIncomingMessage": "Sent you a new message", "Telegram": "Telegram", - "TelegramIntegrationDesc": "☠\uFE0F Experimental integration with Telegram ☠\uFE0F", - "TelegramIntegrationDesc2": "This feature is separate from regular Telegram notifications", - "ToSetupNotification": "To set up notifications:", + "TelegramIntegrationDesc": "Sync Telegram channels and messages", + "TelegramIntegrationDesc2": "This feature is independent of regular Telegram notifications", + "ToSetupNotification": "To set up regular Telegram notifications:", "TelegramNotificationPath": "Notifications > General > Telegram > Configure", "Status": "Status", "MessagesSelected": "messages selected", @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Connect Telegram Bot", "DisconnectMessage": "⚠️You have been disconnected from the service. Please reconnect your account on {app} using /{command}.", "SyncAllChannels": "Sync all channels", - "SyncStarredChannels": "Sync starred channels" + "SyncStarredChannels": "Sync starred channels", + "Public": "Public", + "Private": "Private", + "TotalChannels": "Total Channels", + "SyncEnabled": "Sync enabled", + "SyncDisabled": "Sync disabled", + "SelectAll": "Select all", + "Selected": "Selected", + "EnableSync": "Enable sync", + "DisableSync": "Disable sync", + "SelectAccess": "Select access", + "ConfigureIntegration": "Configure integration", + "Apply": "Apply", + "SyncedChannels": "Synchronized channels", + "NotConnectedIntegration": "The account {phone} is not integrated with the workspace", + "FailedToLoadState": "Failed to load integration state", + "Type": "Type", + "SyncMode": "Sync Mode", + "Access": "Access", + "User": "User", + "Group": "Group", + "Channel": "Channel", + "Actions": "Actions", + "SelectAllAction": "Select all", + "UnselectAllAction": "Unselect all", + "EnableSynchronization": "Enable synchronization", + "DisableSynchronization": "Disable synchronization", + "SetPublicAccess": "Set public access", + "SetPrivateAccess": "Set private access", + "NoChannelsFound": "No channels found", + "TryAdjustingSearch": "Try adjusting your search query or", + "ClearSearch": "clear search", + "NoChannelsAvailable": "No channels available", + "EnableSyncToConfigure": "Enable synchronization to configure channel access", + "AccessCannotBeChanged": "Access cannot be changed for existing channel", + "ServiceIsUnavailable": "Telegram service is not available" } } diff --git a/plugins/telegram-assets/lang/es.json b/plugins/telegram-assets/lang/es.json index 82e57bc16c..722fcbb5dc 100644 --- a/plugins/telegram-assets/lang/es.json +++ b/plugins/telegram-assets/lang/es.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Conectar bot de Telegram", "DisconnectMessage": "⚠️Te has desconectado del servicio. Por favor, vuelve a conectar tu cuenta en {app} usando /{command}.", "SyncAllChannels": "Sincronizar todos los canales", - "SyncStarredChannels": "Sincronizar canales marcados" + "SyncStarredChannels": "Sincronizar canales marcados", + "Public": "Público", + "Private": "Privado", + "TotalChannels": "Total de canales", + "SyncEnabled": "Sincronización habilitada", + "SyncDisabled": "Sincronización deshabilitada", + "SelectAll": "Seleccionar todo", + "Selected": "Seleccionado", + "EnableSync": "Habilitar sincronización", + "DisableSync": "Deshabilitar sincronización", + "SelectAccess": "Seleccionar acceso", + "ConfigureIntegration": "Configurar integración", + "Apply": "Aplicar", + "SyncedChannels": "Canales sincronizados", + "NotConnectedIntegration": "La cuenta {phone} no está integrada con el espacio de trabajo", + "FailedToLoadState": "Error al cargar el estado de la integración", + "Type": "Tipo", + "SyncMode": "Modo de Sincronización", + "Access": "Acceso", + "User": "Usuario", + "Group": "Grupo", + "Channel": "Canal", + "Actions": "Acciones", + "SelectAllAction": "Seleccionar todo", + "UnselectAllAction": "Deseleccionar todo", + "EnableSynchronization": "Habilitar sincronización", + "DisableSynchronization": "Deshabilitar sincronización", + "SetPublicAccess": "Establecer acceso público", + "SetPrivateAccess": "Establecer acceso privado", + "NoChannelsFound": "No se encontraron canales", + "TryAdjustingSearch": "Intenta ajustar tu búsqueda o", + "ClearSearch": "limpiar búsqueda", + "NoChannelsAvailable": "No hay canales disponibles", + "EnableSyncToConfigure": "Habilitar sincronización para configurar el acceso al canal", + "AccessCannotBeChanged": "El acceso no se puede cambiar para el canal existente", + "ServiceIsUnavailable": "El servicio de Telegram no está disponible" } } diff --git a/plugins/telegram-assets/lang/fr.json b/plugins/telegram-assets/lang/fr.json index d2492d3f65..a51b90259a 100644 --- a/plugins/telegram-assets/lang/fr.json +++ b/plugins/telegram-assets/lang/fr.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Connecter le bot Telegram", "DisconnectMessage": "⚠️Vous avez été déconnecté du service. Veuillez reconnecter votre compte sur {app} en utilisant /{command}.", "SyncAllChannels": "Synchroniser tous les canaux", - "SyncStarredChannels": "Synchroniser les chaînes marquées" + "SyncStarredChannels": "Synchroniser les chaînes marquées", + "Public": "Public", + "Private": "Privé", + "TotalChannels": "Total des canaux", + "SyncEnabled": "Synchronisation activée", + "SyncDisabled": "Synchronisation désactivée", + "SelectAll": "Sélectionner tout", + "Selected": "Sélectionné", + "EnableSync": "Activer la synchronisation", + "DisableSync": "Désactiver la synchronisation", + "SelectAccess": "Sélectionner l'accès", + "ConfigureIntegration": "Configurer l'intégration", + "Apply": "Appliquer", + "SyncedChannels": "Canaux synchronisés", + "NotConnectedIntegration": "Le compte {phone} n'est pas intégré avec l'espace de travail", + "FailedToLoadState": "Échec du chargement de l'état d'intégration", + "Type": "Type", + "SyncMode": "Mode de Synchronisation", + "Access": "Accès", + "User": "Utilisateur", + "Group": "Groupe", + "Channel": "Canal", + "Actions": "Actions", + "SelectAllAction": "Tout sélectionner", + "UnselectAllAction": "Tout désélectionner", + "EnableSynchronization": "Activer la synchronisation", + "DisableSynchronization": "Désactiver la synchronisation", + "SetPublicAccess": "Définir l'accès public", + "SetPrivateAccess": "Définir l'accès privé", + "NoChannelsFound": "Aucun canal trouvé", + "TryAdjustingSearch": "Essayez d'ajuster votre recherche ou", + "ClearSearch": "effacer la recherche", + "NoChannelsAvailable": "Aucun canal disponible", + "EnableSyncToConfigure": "Activer la synchronisation pour configurer l'accès au canal", + "AccessCannotBeChanged": "L'accès ne peut pas être modifié pour le canal existant", + "ServiceIsUnavailable": "Le service Telegram n'est pas disponible" } } diff --git a/plugins/telegram-assets/lang/it.json b/plugins/telegram-assets/lang/it.json index ecb5041c87..ab10399f3d 100644 --- a/plugins/telegram-assets/lang/it.json +++ b/plugins/telegram-assets/lang/it.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Collega il bot Telegram", "DisconnectMessage": "⚠️Sei stato disconnesso dal servizio. Per favore riconnetti il tuo account su {app} usando /{command}.", "SyncAllChannels": "Sincronizza tutti i canali", - "SyncStarredChannels": "Sincronizza canali preferiti" + "SyncStarredChannels": "Sincronizza canali preferiti", + "Public": "Pubblico", + "Private": "Privato", + "TotalChannels": "Totale canali", + "SyncEnabled": "Sincronizzazione attivata", + "SyncDisabled": "Sincronizzazione disattivata", + "SelectAll": "Seleziona tutto", + "Selected": "Selezionato", + "EnableSync": "Abilita sincronizzazione", + "DisableSync": "Disabilita sincronizzazione", + "SelectAccess": "Seleziona accesso", + "ConfigureIntegration": "Configura integrazione", + "Apply": "Applica", + "SyncedChannels": "Canali sincronizzati", + "NotConnectedIntegration": "L'account {phone} non è integrato con lo spazio di lavoro", + "FailedToLoadState": "Impossibile caricare lo stato dell'integrazione", + "Type": "Tipo", + "SyncMode": "Modalità di Sincronizzazione", + "Access": "Accesso", + "User": "Utente", + "Group": "Gruppo", + "Channel": "Canale", + "Actions": "Azioni", + "SelectAllAction": "Seleziona tutto", + "UnselectAllAction": "Deseleziona tutto", + "EnableSynchronization": "Attiva sincronizzazione", + "DisableSynchronization": "Disattiva sincronizzazione", + "SetPublicAccess": "Imposta accesso pubblico", + "SetPrivateAccess": "Imposta accesso privato", + "NoChannelsFound": "Nessun canale trovato", + "TryAdjustingSearch": "Prova ad aggiustare la ricerca o", + "ClearSearch": "cancella ricerca", + "NoChannelsAvailable": "Nessun canale disponibile", + "EnableSyncToConfigure": "Abilita sincronizzazione per configurare l'accesso al canale", + "AccessCannotBeChanged": "L'accesso non può essere modificato per il canale esistente", + "ServiceIsUnavailable": "Il servizio Telegram non è disponibile" } } diff --git a/plugins/telegram-assets/lang/ja.json b/plugins/telegram-assets/lang/ja.json index 997eb6214c..a9de9ce8cd 100644 --- a/plugins/telegram-assets/lang/ja.json +++ b/plugins/telegram-assets/lang/ja.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Telegramボットを接続", "DisconnectMessage": "⚠️サービスから切断されました。/{command} を使用して {app} でアカウントを再接続してください。", "SyncAllChannels": "すべてのチャンネルを同期", - "SyncStarredChannels": "スター付きチャンネルを同期" + "SyncStarredChannels": "スター付きチャンネルを同期", + "Public": "公開", + "Private": "非公開", + "TotalChannels": "総チャンネル数", + "SyncEnabled": "同期が有効", + "SyncDisabled": "同期が無効", + "SelectAll": "すべて選択", + "Selected": "選択済み", + "EnableSync": "同期を有効にする", + "DisableSync": "同期を無効にする", + "SelectAccess": "アクセスを選択", + "ConfigureIntegration": "統合を構成", + "Apply": "適用", + "SyncedChannels": "同期されたチャンネル", + "NotConnectedIntegration": "アカウント {phone} はワークスペースと統合されていません", + "FailedToLoadState": "統合の状態を読み込めませんでした", + "Type": "タイプ", + "SyncMode": "同期モード", + "Access": "アクセス", + "User": "ユーザー", + "Group": "グループ", + "Channel": "チャンネル", + "Actions": "アクション", + "SelectAllAction": "すべて選択", + "UnselectAllAction": "すべて選択解除", + "EnableSynchronization": "同期を有効にする", + "DisableSynchronization": "同期を無効にする", + "SetPublicAccess": "パブリックアクセスに設定", + "SetPrivateAccess": "プライベートアクセスに設定", + "NoChannelsFound": "チャンネルが見つかりません", + "TryAdjustingSearch": "検索条件を調整するか", + "ClearSearch": "検索をクリア", + "NoChannelsAvailable": "利用可能なチャンネルがありません", + "EnableSyncToConfigure": "チャンネルアクセスを構成するには同期を有効にしてください", + "AccessCannotBeChanged": "既存のチャンネルのアクセスは変更できません", + "ServiceIsUnavailable": "Telegramサービスは利用できません" } } diff --git a/plugins/telegram-assets/lang/pt.json b/plugins/telegram-assets/lang/pt.json index 240346314a..de679d9f5c 100644 --- a/plugins/telegram-assets/lang/pt.json +++ b/plugins/telegram-assets/lang/pt.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Ligar bot do Telegram", "DisconnectMessage": "⚠️Foi desconectado do serviço. Por favor, reconecte a sua conta em {app} usando /{command}.", "SyncAllChannels": "Sincronizar todos os canais", - "SyncStarredChannels": "Sincronizar canais marcados" + "SyncStarredChannels": "Sincronizar canais marcados", + "Public": "Público", + "Private": "Privado", + "TotalChannels": "Total de canais", + "SyncEnabled": "Sincronização ativada", + "SyncDisabled": "Sincronização desativada", + "SelectAll": "Selecionar tudo", + "Selected": "Selecionado", + "EnableSync": "Ativar sincronização", + "DisableSync": "Desativar sincronização", + "SelectAccess": "Selecionar acesso", + "ConfigureIntegration": "Configurar integração", + "Apply": "Aplicar", + "SyncedChannels": "Canais sincronizados", + "NotConnectedIntegration": "A conta {phone} não está integrada com o espaço de trabalho", + "FailedToLoadState": "Falha ao carregar o estado da integração", + "Type": "Tipo", + "SyncMode": "Modo de Sincronização", + "Access": "Acesso", + "User": "Utilizador", + "Group": "Grupo", + "Channel": "Canal", + "Actions": "Ações", + "SelectAllAction": "Selecionar tudo", + "UnselectAllAction": "Desselecionar tudo", + "EnableSynchronization": "Ativar sincronização", + "DisableSynchronization": "Desativar sincronização", + "SetPublicAccess": "Definir acesso público", + "SetPrivateAccess": "Definir acesso privado", + "NoChannelsFound": "Nenhum canal encontrado", + "TryAdjustingSearch": "Tente ajustar sua pesquisa ou", + "ClearSearch": "limpar pesquisa", + "NoChannelsAvailable": "Nenhum canal disponível", + "EnableSyncToConfigure": "Ativar sincronização para configurar o acesso ao canal", + "AccessCannotBeChanged": "O acesso não pode ser alterado para o canal existente", + "ServiceIsUnavailable": "O serviço Telegram não está disponível" } } diff --git a/plugins/telegram-assets/lang/ru.json b/plugins/telegram-assets/lang/ru.json index 401303cc57..53176a10f4 100644 --- a/plugins/telegram-assets/lang/ru.json +++ b/plugins/telegram-assets/lang/ru.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "Подключить Telegram бота", "DisconnectMessage": "⚠️Вы отключены от сервиса. Пожалуйста, подключите свой аккаунт на {app} с помощью /{command}.", "SyncAllChannels": "Синхронизировать все каналы", - "SyncStarredChannels": "Синхронизировать избранные каналы" + "SyncStarredChannels": "Синхронизировать избранные каналы", + "Public": "Публичный", + "Private": "Приватный", + "TotalChannels": "Всего каналов", + "SyncEnabled": "Синхронизация включена", + "SyncDisabled": "Синхронизация отключена", + "SelectAll": "Выбрать все", + "Selected": "Выбрано", + "EnableSync": "Включить синхронизацию", + "DisableSync": "Отключить синхронизацию", + "SelectAccess": "Выберите доступ", + "ConfigureIntegration": "Настроить интеграцию", + "Apply": "Применить", + "SyncedChannels": "Синхронизированные каналы", + "NotConnectedIntegration": "Аккаунт {phone} не интегрирован с пространством", + "FailedToLoadState": "Не удалось загрузить состояние интеграции", + "Type": "Тип", + "SyncMode": "Режим синхронизации", + "Access": "Доступ", + "User": "Пользователь", + "Group": "Группа", + "Channel": "Канал", + "Actions": "Действия", + "SelectAllAction": "Выбрать все", + "UnselectAllAction": "Снять выделение", + "EnableSynchronization": "Включить синхронизацию", + "DisableSynchronization": "Отключить синхронизацию", + "SetPublicAccess": "Установить публичный доступ", + "SetPrivateAccess": "Установить приватный доступ", + "NoChannelsFound": "Каналы не найдены", + "TryAdjustingSearch": "Попробуйте скорректировать поиск или", + "ClearSearch": "очистить поиск", + "NoChannelsAvailable": "Каналы недоступны", + "EnableSyncToConfigure": "Включите синхронизацию, чтобы настроить доступ к каналу", + "AccessCannotBeChanged": "Доступ не может быть изменен для существующего канала", + "ServiceIsUnavailable": "Telegram сервис недоступен" } } diff --git a/plugins/telegram-assets/lang/zh.json b/plugins/telegram-assets/lang/zh.json index a0df073bd9..68ae7a4546 100644 --- a/plugins/telegram-assets/lang/zh.json +++ b/plugins/telegram-assets/lang/zh.json @@ -59,6 +59,41 @@ "ConnectTelegramBot": "连接 Telegram 机器人", "DisconnectMessage": "⚠️您已从服务中断开连接。 请使用 /{command} 在 {app} 上重新连接您的帐户。", "SyncAllChannels": "同步所有频道", - "SyncStarredChannels": "同步标记的频道" + "SyncStarredChannels": "同步标记的频道", + "Public": "公开", + "Private": "私有", + "TotalChannels": "频道总数", + "SyncEnabled": "同步已启用", + "SyncDisabled": "同步已禁用", + "SelectAll": "全选", + "Selected": "已选择", + "EnableSync": "启用同步", + "DisableSync": "禁用同步", + "SelectAccess": "选择访问权限", + "ConfigureIntegration": "配置集成", + "Apply": "应用", + "SyncedChannels": "已同步的频道", + "NotConnectedIntegration": "帐户 {phone} 未与工作区集成", + "FailedToLoadState": "加载集成状态失败", + "Type": "类型", + "SyncMode": "同步模式", + "Access": "访问权限", + "User": "用户", + "Group": "群组", + "Channel": "频道", + "Actions": "操作", + "SelectAllAction": "选择全部", + "UnselectAllAction": "取消选择全部", + "EnableSynchronization": "启用同步", + "DisableSynchronization": "禁用同步", + "SetPublicAccess": "设置公开访问", + "SetPrivateAccess": "设置私有访问", + "NoChannelsFound": "未找到频道", + "TryAdjustingSearch": "请尝试调整搜索条件或", + "ClearSearch": "清除搜索", + "NoChannelsAvailable": "没有可用的频道", + "EnableSyncToConfigure": "启用同步以配置频道访问权限", + "AccessCannotBeChanged": "现有频道的访问权限无法更改", + "ServiceIsUnavailable": "Telegram 服务不可用" } } diff --git a/plugins/telegram-assets/src/index.ts b/plugins/telegram-assets/src/index.ts index dd71a1868f..c0ddd45e82 100644 --- a/plugins/telegram-assets/src/index.ts +++ b/plugins/telegram-assets/src/index.ts @@ -13,3 +13,15 @@ // See the License for the specific language governing permissions and // limitations under the License. // + +import { loadMetadata } from '@hcengineering/platform' +import telegram from '@hcengineering/telegram' + +const icons = require('../assets/icons.svg') as string // eslint-disable-line +loadMetadata(telegram.icon, { + Shared: `${icons}#shared`, + Locked: `${icons}#locked`, + User: `${icons}#user`, + Channel: `${icons}#channel`, + Group: `${icons}#group` +}) diff --git a/plugins/telegram-resources/package.json b/plugins/telegram-resources/package.json index 0519b89a17..f5e9370af1 100644 --- a/plugins/telegram-resources/package.json +++ b/plugins/telegram-resources/package.json @@ -8,10 +8,12 @@ "build": "compile ui", "build:docs": "api-extractor run --local", "format": "format src", + "svelte-check": "do-svelte-check", "build:watch": "compile ui", "_phase:build": "compile ui", "_phase:format": "format src", - "_phase:validate": "compile validate" + "_phase:validate": "compile validate", + "_phase:svelte-check": "do-svelte-check" }, "devDependencies": { "@hcengineering/platform-rig": "^0.6.0", @@ -36,6 +38,7 @@ "svelte-eslint-parser": "^0.33.1" }, "dependencies": { + "@hcengineering/account-client": "^0.6.0", "@hcengineering/attachment": "^0.6.14", "@hcengineering/attachment-resources": "^0.6.0", "@hcengineering/chunter": "^0.6.20", @@ -43,6 +46,7 @@ "@hcengineering/contact-resources": "^0.6.0", "@hcengineering/core": "^0.6.32", "@hcengineering/login": "^0.6.12", + "@hcengineering/integration-client": "^0.6.0", "@hcengineering/notification": "^0.6.23", "@hcengineering/notification-resources": "^0.6.0", "@hcengineering/panel": "^0.6.23", @@ -53,6 +57,7 @@ "@hcengineering/templates": "^0.6.11", "@hcengineering/text": "^0.6.5", "@hcengineering/text-editor-resources": "^0.6.0", + "@hcengineering/retry": "^0.6.0", "@hcengineering/ui": "^0.6.15", "@hcengineering/view-resources": "^0.6.0", "svelte": "^4.2.19" diff --git a/plugins/telegram-resources/src/api.ts b/plugins/telegram-resources/src/api.ts index f94d2efd36..e76ef16e9a 100644 --- a/plugins/telegram-resources/src/api.ts +++ b/plugins/telegram-resources/src/api.ts @@ -12,16 +12,40 @@ // limitations under the License. // -import { concatLink } from '@hcengineering/core' +import { concatLink, type PersonId } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' import telegram from './plugin' -import presentation from '@hcengineering/presentation' +import presentation, { getCurrentWorkspaceUuid } from '@hcengineering/presentation' +import login from '@hcengineering/login' +import { telegramIntegrationKind } from '@hcengineering/telegram' +import { + getIntegrationClient as getIntegrationClientRaw, + type IntegrationClient +} from '@hcengineering/integration-client' +import { withRetry } from '@hcengineering/retry' +import type { Integration } from '@hcengineering/account-client' -export type Integration = { status: 'authorized' | 'wantcode' | 'wantpassword', number: string } | 'Loading' | 'Missing' +export type IntegrationState = + | { status: 'authorized' | 'wantcode' | 'wantpassword', number: string, socialId?: PersonId } + | 'Loading' + | 'Missing' + +export interface TelegramChannel { + id: string + name: string + type: string + mode: string +} + +export interface TelegramChannelConfig extends TelegramChannel { + access: 'public' | 'private' + syncEnabled: boolean + readonlyAccess?: boolean // access update is not supported for existing channels +} const url = getMetadata(telegram.metadata.TelegramURL) ?? '' -async function request (method: 'GET' | 'POST' | 'DELETE', path?: string, body?: any): Promise { +async function _request (method: 'GET' | 'POST' | 'DELETE', path?: string, body?: any): Promise { const base = concatLink(url, 'api/integrations') const response = await fetch(concatLink(base, path ?? ''), { @@ -35,19 +59,50 @@ async function request (method: 'GET' | 'POST' | 'DELETE', path?: string, body?: if (response.status === 200) { return await response.json() + } else if (response.status === 202) { + return undefined } else { throw new Error(`Unexpected response: ${response.status}`) } } -export async function list (): Promise { - return await request('GET') +async function request (method: 'GET' | 'POST' | 'DELETE', path?: string, body?: any): Promise { + return await withRetry(async () => await _request(method, path, body)) } -export async function command ( - phone: string, - command: 'start' | 'next' | 'cancel', - input?: string -): Promise { +export async function getState (phone: string): Promise { + return await request('GET', phone) +} + +export async function restart (phone: string): Promise { + return await request('POST', `${phone}/restart`) +} + +export async function listChannels (phone: string): Promise { + return await request('GET', `${phone}/chats`) +} + +export async function disconnect (phone: string): Promise { + await request('DELETE', phone) +} + +export async function command (phone: string, command: 'start' | 'next', input?: string): Promise { return await request('POST', phone, { command, input }) } + +export async function getIntegrationClient (): Promise { + const accountsUrl = getMetadata(login.metadata.AccountsUrl) + const token = getMetadata(presentation.metadata.Token) + if (accountsUrl === undefined || token === undefined) { + throw new Error('Accounts URL or token is not defined') + } + return getIntegrationClientRaw(accountsUrl, token, telegramIntegrationKind, 'hulygram') +} + +export async function connect (phone: string, socialId: PersonId): Promise { + const client = await getIntegrationClient() + const connection = await client.connect(socialId, { + phone + }) + return await client.integrate(connection, getCurrentWorkspaceUuid()) +} diff --git a/plugins/telegram-resources/src/components/Chat.svelte b/plugins/telegram-resources/src/components/Chat.svelte index 53a80059e3..b57e04ad40 100644 --- a/plugins/telegram-resources/src/components/Chat.svelte +++ b/plugins/telegram-resources/src/components/Chat.svelte @@ -214,6 +214,7 @@ // return Array.from(res.values()) // } + let participants: Contact[] $: participants = [] // getParticipants(messages, $personAccountByIdStore, object, $personByIdStore) diff --git a/plugins/telegram-resources/src/components/Configure.svelte b/plugins/telegram-resources/src/components/Configure.svelte new file mode 100644 index 0000000000..0d27314871 --- /dev/null +++ b/plugins/telegram-resources/src/components/Configure.svelte @@ -0,0 +1,704 @@ + + + + + + + + + + + +
+ + +
+ {:else} + + + {/if} +
+ {:else} +
+ dispatch('changeContent')} + > + + {@const item = filteredChannels[itemId]} + {@const icon = getChannelIcon(item)} +
+ +
+ { + toggleChannelSelection(item.id) + }} + {readonly} + /> +
+ +
+
+ {#if icon !== undefined} + + {/if} + {item.name} +
+
+ +
+ { + updateChannelSync(item.id, e.detail) + }} + disabled={readonly} + /> +
+
+ { + updateChannelAccess(item.id, e.detail) + }} + /> +
+
+
+
+
+ {/if} +
+
+ + + + + + diff --git a/plugins/telegram-resources/src/components/Connect.svelte b/plugins/telegram-resources/src/components/Connect.svelte index e3c12cd9a4..e706cfd090 100644 --- a/plugins/telegram-resources/src/components/Connect.svelte +++ b/plugins/telegram-resources/src/components/Connect.svelte @@ -14,16 +14,19 @@ --> + +
+
+ {#if isLoading} +
+ +
+ {:else if error} +
+ +
+ {:else if integration.workspaceUuid == null} +
+ + +
+ {:else} +
+
+ + {connection?.data?.phone ?? 'N/A'} +
+
+ +
+ {#if isRefreshing} + + {/if} + {syncEnabledChannels} +
+
+
+ + {totalChannels} +
+
+ {/if} +
+
+ + diff --git a/plugins/telegram-resources/src/components/TelegramIntegrationDescription.svelte b/plugins/telegram-resources/src/components/TelegramIntegrationDescription.svelte index dccf7a9f74..5e594606b8 100644 --- a/plugins/telegram-resources/src/components/TelegramIntegrationDescription.svelte +++ b/plugins/telegram-resources/src/components/TelegramIntegrationDescription.svelte @@ -20,13 +20,13 @@
-
diff --git a/plugins/telegram-resources/src/components/config/ChannelsConfig.svelte b/plugins/telegram-resources/src/components/config/ChannelsConfig.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/telegram-resources/src/index.ts b/plugins/telegram-resources/src/index.ts index 91c1014a87..c93c6e8f56 100644 --- a/plugins/telegram-resources/src/index.ts +++ b/plugins/telegram-resources/src/index.ts @@ -14,9 +14,8 @@ // limitations under the License. // -import { getMetadata, type Resources } from '@hcengineering/platform' -import { concatLink } from '@hcengineering/core' -import presentation from '@hcengineering/presentation' +import { type Resources } from '@hcengineering/platform' +import { type Integration } from '@hcengineering/account-client' import Chat from './components/Chat.svelte' import Connect from './components/Connect.svelte' @@ -26,21 +25,25 @@ import TelegramMessageCreated from './components/activity/TelegramMessageCreated import MessagePresenter from './components/MessagePresenter.svelte' import NotificationProviderPresenter from './components/NotificationProviderPresenter.svelte' import TelegramIntegrationDescription from './components/TelegramIntegrationDescription.svelte' +import Configure from './components/Configure.svelte' +import StateComponent from './components/IntegrationState.svelte' -import telegram from './plugin' import { getCurrentEmployeeTG, getIntegrationOwnerTG, isTelegramNotificationsAvailable } from './utils' import SharedMessages from './components/SharedMessages.svelte' +import { getIntegrationClient, disconnect, restart } from './api' export default async (): Promise => ({ component: { Chat, Connect, Reconnect, + Configure, IconTelegram, SharedMessages, MessagePresenter, NotificationProviderPresenter, - TelegramIntegrationDescription + TelegramIntegrationDescription, + StateComponent }, activity: { TelegramMessageCreated @@ -51,15 +54,31 @@ export default async (): Promise => ({ IsTelegramNotificationsAvailable: isTelegramNotificationsAvailable }, handler: { - DisconnectHandler: async () => { - const url = getMetadata(telegram.metadata.TelegramURL) ?? '' - await fetch(concatLink(url, '/signout'), { - method: 'POST', - headers: { - Authorization: 'Bearer ' + (getMetadata(presentation.metadata.Token) ?? ''), - 'Content-Type': 'application/json' - } - }) + DisconnectHandler: async (integration: Integration): Promise => { + const integrationClient = await getIntegrationClient() + if (integration == null) { + console.warn('DisconnectHandler: No integration provided') + return + } + const connection = await integrationClient.getConnection(integration) + const phone = integration.data?.phone ?? connection?.data?.phone + const result = await integrationClient.removeIntegration(integration.socialId, integration.workspaceUuid) + if (result?.connectionRemoved === true) { + await disconnect(phone) + } else { + await restart(phone) + } + }, + DisconnectAllHandler: async (integration: Integration): Promise => { + const integrationClient = await getIntegrationClient() + const connection = await integrationClient.getConnection(integration) + if (connection == null) { + console.warn('DisconnectAllHandler: No connection found for integration', integration) + return + } + const phone = integration.data?.phone ?? connection?.data?.phone + await disconnect(phone) + await integrationClient.removeConnection(connection) } } }) diff --git a/plugins/telegram-resources/src/plugin.ts b/plugins/telegram-resources/src/plugin.ts index 80374d8c86..576d6e7ffd 100644 --- a/plugins/telegram-resources/src/plugin.ts +++ b/plugins/telegram-resources/src/plugin.ts @@ -44,7 +44,50 @@ export default mergeIds(telegramId, telegram, { ConnectBotInfoEnd: '' as IntlString, ConnectBotError: '' as IntlString, TestConnection: '' as IntlString, - Connected: '' as IntlString + Connected: '' as IntlString, + Public: '' as IntlString, + Private: '' as IntlString, + TotalChannels: '' as IntlString, + SyncedChannels: '' as IntlString, + SyncEnabled: '' as IntlString, + SyncDisabled: '' as IntlString, + SelectAll: '' as IntlString, + Selected: '' as IntlString, + EnableSync: '' as IntlString, + DisableSync: '' as IntlString, + SelectAccess: '' as IntlString, + ConfigureIntegration: '' as IntlString, + Apply: '' as IntlString, + NotConnectedIntegration: '' as IntlString, + FailedToLoadState: '' as IntlString, + + // Filter categories + Type: '' as IntlString, + SyncMode: '' as IntlString, + Access: '' as IntlString, + + // Filter options + User: '' as IntlString, + Group: '' as IntlString, + Channel: '' as IntlString, + + // Actions + Actions: '' as IntlString, + SelectAllAction: '' as IntlString, + UnselectAllAction: '' as IntlString, + EnableSynchronization: '' as IntlString, + DisableSynchronization: '' as IntlString, + SetPublicAccess: '' as IntlString, + SetPrivateAccess: '' as IntlString, + + // Search and empty state messages + NoChannelsFound: '' as IntlString, + TryAdjustingSearch: '' as IntlString, + ClearSearch: '' as IntlString, + NoChannelsAvailable: '' as IntlString, + EnableSyncToConfigure: '' as IntlString, + ServiceIsUnavailable: '' as IntlString, + AccessCannotBeChanged: '' as IntlString }, component: { MessagePresenter: '' as AnyComponent diff --git a/plugins/telegram/src/index.ts b/plugins/telegram/src/index.ts index 77baff626c..b34b92ab06 100644 --- a/plugins/telegram/src/index.ts +++ b/plugins/telegram/src/index.ts @@ -14,9 +14,9 @@ // import { ChannelItem } from '@hcengineering/contact' -import { AttachedDoc, Class, Doc, Ref, Timestamp } from '@hcengineering/core' +import { AttachedDoc, Class, Doc, IntegrationKind, Ref, Timestamp } from '@hcengineering/core' import { NotificationProvider, NotificationType } from '@hcengineering/notification' -import type { IntlString, Metadata, Plugin } from '@hcengineering/platform' +import type { Asset, IntlString, Metadata, Plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import type { Handler, IntegrationType } from '@hcengineering/setting' import { TemplateField } from '@hcengineering/templates' @@ -58,6 +58,11 @@ export interface SharedTelegramMessages extends AttachedDoc { messages: SharedTelegramMessage[] } +/** + * @public + */ +export const telegramIntegrationKind = 'hulygram' as IntegrationKind + /** * @public */ @@ -68,14 +73,17 @@ export default plugin(telegramId, { Chat: '' as AnyComponent, Connect: '' as AnyComponent, Reconnect: '' as AnyComponent, + Configure: '' as AnyComponent, IconTelegram: '' as AnyComponent, - SharedMessages: '' as AnyComponent + SharedMessages: '' as AnyComponent, + StateComponent: '' as AnyComponent }, integrationType: { Telegram: '' as Ref }, handler: { - DisconnectHandler: '' as Handler + DisconnectHandler: '' as Handler, + DisconnectAllHandler: '' as Handler }, ids: { NewMessageNotification: '' as Ref @@ -123,5 +131,12 @@ export default plugin(telegramId, { TelegramIntegrationDesc2: '' as IntlString, ToSetupNotification: '' as IntlString, TelegramNotificationPath: '' as IntlString + }, + icon: { + Shared: '' as Asset, + Locked: '' as Asset, + User: '' as Asset, + Channel: '' as Asset, + Group: '' as Asset } }) diff --git a/plugins/templates-resources/src/components/TemplatePopup.svelte b/plugins/templates-resources/src/components/TemplatePopup.svelte index 830d1e1090..aa22fa956b 100644 --- a/plugins/templates-resources/src/components/TemplatePopup.svelte +++ b/plugins/templates-resources/src/components/TemplatePopup.svelte @@ -60,6 +60,7 @@ title: item.title, message }) + closePopup() } export function onKeyDown (ev: KeyboardEvent) { diff --git a/rush.json b/rush.json index 460acabfdf..ec61d4dec0 100644 --- a/rush.json +++ b/rush.json @@ -2657,6 +2657,11 @@ "packageName": "@hcengineering/pod-process", "projectFolder": "services/process", "shouldPublish": false + }, + { + "packageName": "@hcengineering/integration-client", + "projectFolder": "packages/integration-client", + "shouldPublish": false } ] } diff --git a/server/account/src/__tests__/serviceOperations.test.ts b/server/account/src/__tests__/serviceOperations.test.ts index 551aa2f3e3..b1f28d8de4 100644 --- a/server/account/src/__tests__/serviceOperations.test.ts +++ b/server/account/src/__tests__/serviceOperations.test.ts @@ -14,6 +14,7 @@ // import { + type IntegrationKind, type MeasureContext, type PersonId, type PersonUuid, @@ -227,7 +228,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: {} } @@ -251,7 +252,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: {} } @@ -279,7 +280,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: {} } @@ -307,7 +308,7 @@ describe('integration methods', () => { const mockIntegration: Integration = { socialId: 'nonexistent-social-id' as PersonId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: {} } @@ -331,7 +332,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'nonexistent-workspace' as WorkspaceUuid, data: {} } @@ -356,7 +357,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: {} } @@ -379,7 +380,7 @@ describe('integration methods', () => { const mockIntegration: Integration = { socialId: 'test-social-id' as PersonId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: {} } @@ -397,7 +398,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: { someData: 'value' } } @@ -511,7 +512,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegrationKey: IntegrationKey = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid } @@ -608,7 +609,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockKey: IntegrationKey = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid } @@ -697,7 +698,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockIntegration: Integration = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, data: {} } @@ -739,7 +740,7 @@ describe('integration methods', () => { ;(mockDb.integration.find as jest.Mock).mockResolvedValue([mockIntegration]) const result = await listIntegrations(mockCtx, mockDb, mockBranding, mockToken, { - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid }) @@ -869,7 +870,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'test-secret' @@ -904,7 +905,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'test-secret' @@ -942,7 +943,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'test-secret' @@ -979,7 +980,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'test-secret' @@ -1004,7 +1005,7 @@ describe('integration methods', () => { const mockSecret: IntegrationSecret = { socialId: 'test-social-id' as PersonId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'test-secret' @@ -1034,7 +1035,7 @@ describe('integration methods', () => { const mockSecret: IntegrationSecret = { socialId: 'test-social-id' as PersonId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'test-secret' @@ -1053,7 +1054,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'new-secret' @@ -1162,7 +1163,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecretKey: IntegrationSecretKey = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key' } @@ -1262,7 +1263,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecretKey: IntegrationSecretKey = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key' } @@ -1312,7 +1313,7 @@ describe('integration methods', () => { const mockSocialId = 'test-social-id' as PersonId const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid, key: 'test-key', secret: 'test-secret' @@ -1359,7 +1360,7 @@ describe('integration methods', () => { ;(mockDb.integrationSecret.find as jest.Mock).mockResolvedValue([mockSecret]) const result = await listIntegrationsSecrets(mockCtx, mockDb, mockBranding, mockToken, { - kind: 'test-kind', + kind: 'test-kind' as IntegrationKind, workspaceUuid: 'test-workspace' as WorkspaceUuid }) diff --git a/server/account/src/types.ts b/server/account/src/types.ts index 7c1a9159f1..1751306112 100644 --- a/server/account/src/types.ts +++ b/server/account/src/types.ts @@ -29,7 +29,8 @@ import { type SocialId as SocialIdBase, type WorkspaceDataId, type WorkspaceUuid, - type WorkspaceInfo + type WorkspaceInfo, + type IntegrationKind } from '@hcengineering/core' import type { EndpointInfo } from './utils' @@ -160,7 +161,7 @@ export interface MailboxInfo { export interface Integration { socialId: PersonId - kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. workspaceUuid: WorkspaceUuid | null data?: Record } @@ -169,7 +170,7 @@ export type IntegrationKey = Omit export interface IntegrationSecret { socialId: PersonId - kind: string // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. + kind: IntegrationKind // Integration kind. E.g. 'github', 'mail', 'telegram-bot', 'telegram' etc. workspaceUuid: WorkspaceUuid | null key: string // Key for the secret in the integration. Different secrets for the same integration must have different keys. Can be any string. E.g. '', 'user_app_1' etc. secret: string diff --git a/services/calendar/pod-calendar/package.json b/services/calendar/pod-calendar/package.json index 20521658aa..cdfd03307b 100644 --- a/services/calendar/pod-calendar/package.json +++ b/services/calendar/pod-calendar/package.json @@ -68,6 +68,7 @@ "@hcengineering/analytics-service": "^0.6.0", "@hcengineering/kvs-client": "^0.6.0", "@hcengineering/server-token": "^0.6.11", + "@hcengineering/integration-client": "^0.6.0", "dotenv": "~16.0.0", "cors": "^2.8.5", "express": "^4.21.2", diff --git a/services/calendar/pod-calendar/src/auth.ts b/services/calendar/pod-calendar/src/auth.ts index 16d66b2992..6d38586708 100644 --- a/services/calendar/pod-calendar/src/auth.ts +++ b/services/calendar/pod-calendar/src/auth.ts @@ -14,7 +14,7 @@ // import { AccountClient, IntegrationSecret } from '@hcengineering/account-client' -import calendar from '@hcengineering/calendar' +import calendar, { calendarIntegrationKind } from '@hcengineering/calendar' import contact, { Employee, getPrimarySocialId, SocialIdentityRef } from '@hcengineering/contact' import core, { AccountUuid, @@ -29,12 +29,14 @@ import core, { import setting from '@hcengineering/setting' import { Credentials, OAuth2Client } from 'google-auth-library' import { calendar_v3, google } from 'googleapis' +import type { IntegrationClient } from '@hcengineering/integration-client' + import { encode64 } from './base64' import { getClient } from './client' import { setSyncHistory } from './kvsUtils' import { lock } from './mutex' import { IncomingSyncManager } from './sync' -import { CALENDAR_INTEGRATION, GoogleEmail, SCOPES, State, Token, User } from './types' +import { GoogleEmail, SCOPES, State, Token, User } from './types' import { addUserByEmail, getGoogleClient, removeIntegrationSecret, removeUserByEmail } from './utils' import { WatchController } from './watch' @@ -51,6 +53,7 @@ export class AuthController { private constructor ( private readonly ctx: MeasureContext, private readonly accountClient: AccountClient, + private readonly integrationClient: IntegrationClient, private readonly client: TxOperations, private readonly user: User ) { @@ -62,6 +65,7 @@ export class AuthController { static async createAndSync ( ctx: MeasureContext, accountClient: AccountClient, + integrationClient: IntegrationClient, state: State, code: string ): Promise { @@ -73,7 +77,7 @@ export class AuthController { try { const client = await getClient(state.workspace) const txOp = new TxOperations(client, state.userId) - const controller = new AuthController(ctx, accountClient, txOp, state) + const controller = new AuthController(ctx, accountClient, integrationClient, txOp, state) await controller.process(code) } finally { mutex() @@ -86,6 +90,7 @@ export class AuthController { static async signout ( ctx: MeasureContext, accountClient: AccountClient, + integrationClient: IntegrationClient, userId: PersonId, workspace: WorkspaceUuid, value: GoogleEmail @@ -98,7 +103,7 @@ export class AuthController { try { const client = await getClient(workspace) const txOp = new TxOperations(client, core.account.System) - const controller = new AuthController(ctx, accountClient, txOp, { + const controller = new AuthController(ctx, accountClient, integrationClient, txOp, { userId, workspace }) @@ -124,7 +129,7 @@ export class AuthController { } removeUserByEmail(this.user, value) const data = { - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: this.user.workspace, key: value, socialId: this.user.userId @@ -176,7 +181,7 @@ export class AuthController { } } const res = await this.oAuth2Client.refreshAccessToken() - await this.createAccIntegrationIfNotExists() + await this.createAccIntegrationIfNotExists(email) await this.updateToken(res.credentials, email) return { success: true, email } @@ -235,24 +240,14 @@ export class AuthController { ) } - private async createAccIntegrationIfNotExists (): Promise { + private async createAccIntegrationIfNotExists (email: string): Promise { await this.ctx.with( 'Create account integration if not exists', {}, async () => { - const integration = await this.accountClient.getIntegration({ - socialId: this.user.userId, - kind: CALENDAR_INTEGRATION, - workspaceUuid: this.user.workspace - }) - if (integration != null) { - return - } - await this.accountClient.createIntegration({ - socialId: this.user.userId, - kind: CALENDAR_INTEGRATION, - workspaceUuid: this.user.workspace - }) + const data = { email } + const connection = await this.integrationClient.connect(this.user.userId, data) + await this.integrationClient.integrate(connection, this.user.workspace, data) }, { user: this.user.userId } ) @@ -266,23 +261,13 @@ export class AuthController { } const data: IntegrationSecret = { socialId: this.user.userId, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: this.user.workspace, key: email, secret: JSON.stringify(_token) } try { - const currentIntegration = await this.accountClient.getIntegrationSecret({ - socialId: this.user.userId, - kind: CALENDAR_INTEGRATION, - workspaceUuid: this.user.workspace, - key: email - }) - if (currentIntegration != null) { - await this.accountClient.updateIntegrationSecret(data) - } else { - await this.accountClient.addIntegrationSecret(data) - } + await this.integrationClient.setSecret(data) addUserByEmail(_token, email) } catch (err) { this.ctx.error('update token error', { workspace: this.user.workspace, user: this.user.userId, err }) diff --git a/services/calendar/pod-calendar/src/calendar.ts b/services/calendar/pod-calendar/src/calendar.ts index ec0a1a5450..af7647b68c 100644 --- a/services/calendar/pod-calendar/src/calendar.ts +++ b/services/calendar/pod-calendar/src/calendar.ts @@ -14,14 +14,20 @@ // import { AccountClient } from '@hcengineering/account-client' -import calendar, { Event, ExternalCalendar, ReccuringEvent, ReccuringInstance } from '@hcengineering/calendar' +import calendar, { + Event, + ExternalCalendar, + ReccuringEvent, + ReccuringInstance, + calendarIntegrationKind +} from '@hcengineering/calendar' import { Client, MeasureContext, Ref, TxOperations } from '@hcengineering/core' import { areEqualMarkups, htmlToMarkup, isEmptyMarkup, jsonToHTML, markupToJSON } from '@hcengineering/text' import { deepEqual } from 'fast-equals' import { OAuth2Client } from 'google-auth-library' import { calendar_v3 } from 'googleapis' import { getRateLimitter, RateLimiter } from './rateLimiter' -import { CALENDAR_INTEGRATION, type Token } from './types' +import { type Token } from './types' import { convertDate, encodeReccuring, @@ -69,7 +75,7 @@ export class CalendarClient { removeUserByEmail(user, user.email) await removeIntegrationSecret(ctx, calendarClient.accountClient, { socialId: user.userId, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: user.workspace, key: user.email }) diff --git a/services/calendar/pod-calendar/src/integrations.ts b/services/calendar/pod-calendar/src/integrations.ts index 8512277959..d7e9b58f83 100644 --- a/services/calendar/pod-calendar/src/integrations.ts +++ b/services/calendar/pod-calendar/src/integrations.ts @@ -14,8 +14,8 @@ // import { AccountClient, Integration } from '@hcengineering/account-client' -import { CALENDAR_INTEGRATION } from './types' +import { calendarIntegrationKind } from '@hcengineering/calendar' export async function getIntegrations (client: AccountClient): Promise { - return (await client.listIntegrations({ kind: CALENDAR_INTEGRATION })) ?? [] + return (await client.listIntegrations({ kind: calendarIntegrationKind })) ?? [] } diff --git a/services/calendar/pod-calendar/src/kvsUtils.ts b/services/calendar/pod-calendar/src/kvsUtils.ts index b79155124f..e3364119d4 100644 --- a/services/calendar/pod-calendar/src/kvsUtils.ts +++ b/services/calendar/pod-calendar/src/kvsUtils.ts @@ -1,20 +1,22 @@ import { WorkspaceUuid } from '@hcengineering/core' import { KeyValueClient, getClient as getKeyValueClient } from '@hcengineering/kvs-client' +import { calendarIntegrationKind } from '@hcengineering/calendar' + import config from './config' -import { CALENDAR_INTEGRATION, GoogleEmail, User } from './types' +import { GoogleEmail, User } from './types' import { getServiceToken } from './utils' let keyValueClient: KeyValueClient | undefined export function getKvsClient (): KeyValueClient { if (keyValueClient !== undefined) return keyValueClient - keyValueClient = getKeyValueClient(CALENDAR_INTEGRATION, config.KvsUrl, getServiceToken()) + keyValueClient = getKeyValueClient(calendarIntegrationKind, config.KvsUrl, getServiceToken()) return keyValueClient } export async function getSyncHistory (workspace: WorkspaceUuid): Promise { const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:calendarSync:${workspace}` + const key = `${calendarIntegrationKind}:calendarSync:${workspace}` try { const res = await client.getValue(key) return res ?? undefined @@ -23,12 +25,12 @@ export async function getSyncHistory (workspace: WorkspaceUuid): Promise { const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:calendarSync:${workspace}` + const key = `${calendarIntegrationKind}:calendarSync:${workspace}` await client.setValue(key, value) } function calendarsHistoryKey (user: User, email: GoogleEmail): string { - return `${CALENDAR_INTEGRATION}:calendarsHistory:${user.workspace}:${user.userId}:${email}` + return `${calendarIntegrationKind}:calendarsHistory:${user.workspace}:${user.userId}:${email}` } export async function getCalendarsSyncHistory (user: User, email: GoogleEmail): Promise { @@ -42,7 +44,7 @@ export async function setCalendarsSyncHistory (user: User, email: GoogleEmail, h } function eventHistoryKey (user: User, email: GoogleEmail, calendarId: string): string { - return `${CALENDAR_INTEGRATION}:eventHistory:${user.workspace}:${user.userId}:${email}:${calendarId}` + return `${calendarIntegrationKind}:eventHistory:${user.workspace}:${user.userId}:${email}:${calendarId}` } export async function getEventHistory (user: User, email: GoogleEmail, calendarId: string): Promise { diff --git a/services/calendar/pod-calendar/src/main.ts b/services/calendar/pod-calendar/src/main.ts index 439f9625eb..b72551637a 100644 --- a/services/calendar/pod-calendar/src/main.ts +++ b/services/calendar/pod-calendar/src/main.ts @@ -20,8 +20,11 @@ import { setMetadata } from '@hcengineering/platform' import serverClient, { getAccountClient } from '@hcengineering/server-client' import { initStatisticsContext } from '@hcengineering/server-core' import serverToken, { decodeToken } from '@hcengineering/server-token' +import { calendarIntegrationKind } from '@hcengineering/calendar' import { type IncomingHttpHeaders } from 'http' import { join } from 'path' +import { getIntegrationClient } from '@hcengineering/integration-client' + import { AuthController } from './auth' import { decode64 } from './base64' import { CalendarController } from './calendarController' @@ -29,7 +32,7 @@ import config from './config' import { OutcomingClient } from './outcomingClient' import { PushHandler } from './pushHandler' import { createServer, listen } from './server' -import { CALENDAR_INTEGRATION, GoogleEmail, type Endpoint, type State } from './types' +import { GoogleEmail, type Endpoint, type State } from './types' import { getServiceToken } from './utils' import { WatchController } from './watch' @@ -42,14 +45,14 @@ const extractToken = (header: IncomingHttpHeaders): any => { } export const main = async (): Promise => { - const ctx = initStatisticsContext(CALENDAR_INTEGRATION, { + const ctx = initStatisticsContext(calendarIntegrationKind, { factory: () => createOpenTelemetryMetricsContext( 'calendar', {}, {}, newMetrics(), - new SplitLogger(CALENDAR_INTEGRATION, { + new SplitLogger(calendarIntegrationKind, { root: join(process.cwd(), 'logs'), enableConsole: (process.env.ENABLE_CONSOLE ?? 'true') === 'true' }) @@ -62,6 +65,12 @@ export const main = async (): Promise => { setMetadata(serverToken.metadata.Service, 'calendar') const accountClient = getAccountClient(getServiceToken()) + const integrationClient = getIntegrationClient( + config.AccountsURL, + getServiceToken(), + calendarIntegrationKind, + config.ServiceID + ) const pushHandler = new PushHandler(ctx, accountClient) const watchController = WatchController.get(ctx, accountClient) @@ -102,7 +111,7 @@ export const main = async (): Promise => { try { const state = JSON.parse(decode64(req.query.state as string)) as unknown as State try { - await AuthController.createAndSync(ctx, accountClient, state, code) + await AuthController.createAndSync(ctx, accountClient, integrationClient, state, code) res.redirect(state.redirectURL) } catch (err) { ctx.error('signin code error', { message: (err as any).message }) @@ -127,7 +136,7 @@ export const main = async (): Promise => { const value = req.query.value as GoogleEmail const { account, workspace } = decodeToken(token) const userId = await AuthController.getUserId(account, workspace, token) - await AuthController.signout(ctx, accountClient, userId, workspace, value) + await AuthController.signout(ctx, accountClient, integrationClient, userId, workspace, value) } catch (err) { ctx.error('signout', { message: (err as any).message }) } diff --git a/services/calendar/pod-calendar/src/outcomingClient.ts b/services/calendar/pod-calendar/src/outcomingClient.ts index 52bdd66136..c199662ac6 100644 --- a/services/calendar/pod-calendar/src/outcomingClient.ts +++ b/services/calendar/pod-calendar/src/outcomingClient.ts @@ -14,7 +14,13 @@ // import { AccountClient } from '@hcengineering/account-client' -import calendar, { Event, ExternalCalendar, ReccuringEvent, ReccuringInstance } from '@hcengineering/calendar' +import calendar, { + Event, + ExternalCalendar, + ReccuringEvent, + ReccuringInstance, + calendarIntegrationKind +} from '@hcengineering/calendar' import contact, { Contact } from '@hcengineering/contact' import core, { MeasureContext, Ref, SocialIdType, TxOperations, WorkspaceUuid } from '@hcengineering/core' import { areEqualMarkups, htmlToMarkup, isEmptyMarkup, jsonToHTML, markupToJSON } from '@hcengineering/text' @@ -25,7 +31,7 @@ import { getClient } from './client' import { setSyncHistory } from './kvsUtils' import { lock, synced } from './mutex' import { getRateLimitter, RateLimiter } from './rateLimiter' -import { CALENDAR_INTEGRATION, Token } from './types' +import { Token } from './types' import { convertDate, encodeReccuring, @@ -421,7 +427,7 @@ export class OutcomingClient { removeUserByEmail(user, user.email) await removeIntegrationSecret(ctx, accountClient, { socialId: user.userId, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: user.workspace, key: user.email }) @@ -448,7 +454,7 @@ async function getTokenByEvent ( if (_calendar === undefined) return const res = await accountClient.getIntegrationSecret({ socialId: event.user, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: workspace, key: _calendar.externalUser }) diff --git a/services/calendar/pod-calendar/src/pushHandler.ts b/services/calendar/pod-calendar/src/pushHandler.ts index 328b0e237f..c8150a9eed 100644 --- a/services/calendar/pod-calendar/src/pushHandler.ts +++ b/services/calendar/pod-calendar/src/pushHandler.ts @@ -14,10 +14,11 @@ // import { AccountClient } from '@hcengineering/account-client' +import { calendarIntegrationKind } from '@hcengineering/calendar' import { MeasureContext, TxOperations } from '@hcengineering/core' import { getClient } from './client' import { IncomingSyncManager } from './sync' -import { CALENDAR_INTEGRATION, GoogleEmail, Token } from './types' +import { GoogleEmail, Token } from './types' import { getGoogleClient, getUserByEmail, removeIntegrationSecret, removeUserByEmail, setCredentials } from './utils' export class PushHandler { @@ -38,7 +39,7 @@ export class PushHandler { if (!authSuccess) { removeUserByEmail(token, token.email) await removeIntegrationSecret(this.ctx, this.accountClient, { - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: token.workspace, socialId: token.userId, key: token.email diff --git a/services/calendar/pod-calendar/src/sync.ts b/services/calendar/pod-calendar/src/sync.ts index 02e791b539..b435a20f70 100644 --- a/services/calendar/pod-calendar/src/sync.ts +++ b/services/calendar/pod-calendar/src/sync.ts @@ -21,7 +21,8 @@ import calendar, { ExternalCalendar, ReccuringEvent, ReccuringInstance, - Visibility + Visibility, + calendarIntegrationKind } from '@hcengineering/calendar' import contact, { Contact, getPersonRefsBySocialIds, Person } from '@hcengineering/contact' import core, { @@ -46,7 +47,7 @@ import { getClient } from './client' import { getCalendarsSyncHistory, getEventHistory, setCalendarsSyncHistory, setEventHistory } from './kvsUtils' import { lock } from './mutex' import { getRateLimitter, RateLimiter } from './rateLimiter' -import { CALENDAR_INTEGRATION, GoogleEmail, Token, User } from './types' +import { GoogleEmail, Token, User } from './types' import { getGoogleClient, parseEventDate, @@ -102,7 +103,7 @@ export class IncomingSyncManager { removeUserByEmail(user, user.email) await removeIntegrationSecret(ctx, accountClient, { socialId: user.userId, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: user.workspace, key: user.email }) @@ -182,14 +183,14 @@ export class IncomingSyncManager { this.ctx.info('Sync calendar', { workspace: this.user.workspace, user: this.user.userId, - emai: this.email, + email: this.email, calendarId }) await this.syncEvents(calendarId) this.ctx.info('Sync calendar finished', { workspace: this.user.workspace, user: this.user.userId, - emai: this.email, + email: this.email, calendarId }) const watchController = WatchController.get(this.ctx, this.accountClient) @@ -205,7 +206,7 @@ export class IncomingSyncManager { this.ctx.info('Sync started for calendars', { workspace: this.user.workspace, user: this.user.userId, - emai: this.email, + email: this.email, count: this.calendars.length }) for (const calendar of this.calendars) { @@ -216,7 +217,7 @@ export class IncomingSyncManager { this.ctx.info('Incoming sync finished', { workspace: this.user.workspace, user: this.user.userId, - emai: this.email + email: this.email }) } catch (err) { this.ctx.error('Start sync error', { workspace: this.user.workspace, user: this.user.userId, err }) diff --git a/services/calendar/pod-calendar/src/tokens.ts b/services/calendar/pod-calendar/src/tokens.ts index f839e71572..e67326c795 100644 --- a/services/calendar/pod-calendar/src/tokens.ts +++ b/services/calendar/pod-calendar/src/tokens.ts @@ -15,14 +15,14 @@ import { AccountClient, IntegrationSecret } from '@hcengineering/account-client' import { WorkspaceUuid } from '@hcengineering/core' -import { CALENDAR_INTEGRATION } from './types' +import { calendarIntegrationKind } from '@hcengineering/calendar' export async function getWorkspaceTokens ( accountClient: AccountClient, workspace: WorkspaceUuid ): Promise { const secrets = await accountClient.listIntegrationsSecrets({ - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: workspace }) return secrets diff --git a/services/calendar/pod-calendar/src/types.ts b/services/calendar/pod-calendar/src/types.ts index 164f8b647e..dbaed069b8 100644 --- a/services/calendar/pod-calendar/src/types.ts +++ b/services/calendar/pod-calendar/src/types.ts @@ -18,8 +18,6 @@ import type { PersonId, Timestamp, WorkspaceUuid } from '@hcengineering/core' import type { NextFunction, Request, Response } from 'express' import type { Credentials } from 'google-auth-library' -export const CALENDAR_INTEGRATION = 'google-calendar' - export type GoogleEmail = string & { googleEmail: true } export interface WatchBase { diff --git a/services/calendar/pod-calendar/src/utils.ts b/services/calendar/pod-calendar/src/utils.ts index 868e5f945c..ffe4f6a092 100644 --- a/services/calendar/pod-calendar/src/utils.ts +++ b/services/calendar/pod-calendar/src/utils.ts @@ -14,7 +14,7 @@ // import { AccountClient, IntegrationSecretKey } from '@hcengineering/account-client' -import { Event, RecurringRule } from '@hcengineering/calendar' +import { Event, RecurringRule, calendarIntegrationKind } from '@hcengineering/calendar' import { Doc, Hierarchy, @@ -29,7 +29,7 @@ import { generateToken } from '@hcengineering/server-token' import { Credentials, OAuth2Client } from 'google-auth-library' import { calendar_v3, google } from 'googleapis' import config from './config' -import { CALENDAR_INTEGRATION, GoogleEmail, ReccuringData, State, type Token, type User } from './types' +import { GoogleEmail, ReccuringData, State, type Token, type User } from './types' export class DeferredPromise { public readonly promise: Promise @@ -273,11 +273,11 @@ export function getServiceToken (): string { } export function getWorkspaceToken (workspace: WorkspaceUuid): string { - return generateToken(systemAccountUuid, workspace, { service: CALENDAR_INTEGRATION }) + return generateToken(systemAccountUuid, workspace, { service: calendarIntegrationKind }) } function generateServiceToken (): string { - return generateToken(systemAccountUuid, undefined, { service: CALENDAR_INTEGRATION }) + return generateToken(systemAccountUuid, undefined, { service: calendarIntegrationKind }) } export function getGoogleClient (): { diff --git a/services/calendar/pod-calendar/src/watch.ts b/services/calendar/pod-calendar/src/watch.ts index a8d2effacd..aa19e2dbb0 100644 --- a/services/calendar/pod-calendar/src/watch.ts +++ b/services/calendar/pod-calendar/src/watch.ts @@ -2,10 +2,11 @@ import { AccountClient } from '@hcengineering/account-client' import { generateId, MeasureContext } from '@hcengineering/core' import { Credentials, OAuth2Client } from 'google-auth-library' import { calendar_v3 } from 'googleapis' +import { calendarIntegrationKind } from '@hcengineering/calendar' import config from './config' import { getKvsClient } from './kvsUtils' import { getRateLimitter, RateLimiter } from './rateLimiter' -import { CALENDAR_INTEGRATION, EventWatch, GoogleEmail, Token, User, Watch } from './types' +import { EventWatch, GoogleEmail, Token, User, Watch } from './types' import { getGoogleClient } from './utils' export class WatchClient { @@ -35,7 +36,7 @@ export class WatchClient { private async getWatches (): Promise { const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:watch:${this.user.email}` + const key = `${calendarIntegrationKind}:watch:${this.user.email}` const watches = await client.listKeys(key) return watches?.keys ?? [] } @@ -54,12 +55,12 @@ export class WatchClient { if (err?.response?.data?.error === 'invalid_grant') { await this.accountClient.deleteIntegrationSecret({ socialId: this.user.userId, - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, workspaceUuid: this.user.workspace, key: this.user.email }) const active = await this.accountClient.listIntegrationsSecrets({ - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, key: this.user.email }) if (active.length === 0) { @@ -128,7 +129,7 @@ async function watchCalendars (email: GoogleEmail, googleClient: calendar_v3.Cal const res = await googleClient.calendarList.watch({ requestBody: body }) if (res.data.expiration != null && res.data.resourceId !== null) { const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:watch:${email}:null` + const key = `${calendarIntegrationKind}:watch:${email}:null` await client.setValue(key, { email, calendarId: null, @@ -154,7 +155,7 @@ async function watchCalendar ( const res = await googleClient.events.watch({ calendarId, requestBody: body }) if (res.data.expiration != null && res.data.resourceId != null) { const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:watch:${email}:${calendarId}` + const key = `${calendarIntegrationKind}:watch:${email}:${calendarId}` await client.setValue(key, { email, calendarId, @@ -186,7 +187,7 @@ export class WatchController { private async getUserWatches (email: GoogleEmail): Promise> { const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:watch:${email}` + const key = `${calendarIntegrationKind}:watch:${email}` const keys = (await client.listKeys(key))?.keys ?? [] const res: Record = {} for (const key of keys) { @@ -200,7 +201,7 @@ export class WatchController { async unsubscribe (user: Token): Promise { const active = await this.accountClient.listIntegrationsSecrets({ - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, key: user.email }) if (active.length === 0) { @@ -234,7 +235,7 @@ export class WatchController { async checkAll (): Promise { const expired = Date.now() + 24 * 60 * 60 * 1000 const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:watch:` + const key = `${calendarIntegrationKind}:watch:` const watches = await client.listKeys(key) const toRefresh: Watch[] = [] for (const key of watches?.keys ?? []) { @@ -254,11 +255,11 @@ export class WatchController { } for (const group of groups) { const secrets = await this.accountClient.listIntegrationsSecrets({ - kind: CALENDAR_INTEGRATION, + kind: calendarIntegrationKind, key: group[0] }) for (const val of group[1]) { - await client.deleteKey(`${CALENDAR_INTEGRATION}:watch:${group[0]}:${val.calendarId ?? 'null'}`) + await client.deleteKey(`${calendarIntegrationKind}:watch:${group[0]}:${val.calendarId ?? 'null'}`) } for (const secret of secrets) { try { @@ -278,7 +279,7 @@ export class WatchController { googleClient: calendar_v3.Calendar ): Promise { const client = getKvsClient() - const key = `${CALENDAR_INTEGRATION}:watch:${email}:${calendarId ?? 'null'}` + const key = `${calendarIntegrationKind}:watch:${email}:${calendarId ?? 'null'}` const exists = await client.getValue(key) if (exists != null) { this.ctx.info('Watch already exists', { diff --git a/services/calendar/pod-calendar/src/workspaceClient.ts b/services/calendar/pod-calendar/src/workspaceClient.ts index 2ec17bdda3..d14155ffb3 100644 --- a/services/calendar/pod-calendar/src/workspaceClient.ts +++ b/services/calendar/pod-calendar/src/workspaceClient.ts @@ -24,6 +24,7 @@ import core, { WorkspaceUuid, type Ref } from '@hcengineering/core' + import { CalendarClient } from './calendar' import { getClient } from './client' import config from './config' diff --git a/services/github/github-assets/lang/en.json b/services/github/github-assets/lang/en.json index 67d1842b25..570d9b01a8 100644 --- a/services/github/github-assets/lang/en.json +++ b/services/github/github-assets/lang/en.json @@ -1,7 +1,7 @@ { "string": { "Github": "Github", - "GithubDesc": "Github integration", + "GithubDesc": "Connect your GitHub repositories to sync issues, pull requests, and project updates", "Settings": "Configure", "Connect": "Map repository to project", "Configure": "Configure {title} GitHub App", diff --git a/services/github/github-resources/package.json b/services/github/github-resources/package.json index fd0b11e580..c45b564984 100644 --- a/services/github/github-resources/package.json +++ b/services/github/github-resources/package.json @@ -66,6 +66,7 @@ "@hcengineering/preference": "^0.6.13", "@hcengineering/diffview": "^0.6.0", "@hcengineering/activity": "^0.6.0", - "@hcengineering/activity-resources": "^0.6.1" + "@hcengineering/activity-resources": "^0.6.1", + "@hcengineering/account-client": "^0.6.0" } } diff --git a/services/github/github-resources/src/components/IntegrationState.svelte b/services/github/github-resources/src/components/IntegrationState.svelte new file mode 100644 index 0000000000..2d0471377c --- /dev/null +++ b/services/github/github-resources/src/components/IntegrationState.svelte @@ -0,0 +1,98 @@ + + + +
+
+ {#if githubIntegration !== undefined} +
+
+ {githubIntegration.name} +
+
+ {#if githubIntegration.$lookup?.repositories != null} + {#each asRepos(githubIntegration.$lookup?.repositories) as repository} +
+ +
+ {/each} + {/if} +
+ {/if} +
+
+ + diff --git a/services/github/github-resources/src/components/RepositoryPresenterRef.svelte b/services/github/github-resources/src/components/RepositoryPresenterRef.svelte index aacea0ce10..eb7ff10dbe 100644 --- a/services/github/github-resources/src/components/RepositoryPresenterRef.svelte +++ b/services/github/github-resources/src/components/RepositoryPresenterRef.svelte @@ -21,7 +21,7 @@ => ({ PullRequestReviewDecisionValuePresenter, PullRequestStateValuePresenter, PullRequestMergeState, - GithubIssuePresenter + GithubIssuePresenter, + IntegrationState }, handler: { DisconnectHandler: async () => {} diff --git a/services/github/github-resources/src/plugin.ts b/services/github/github-resources/src/plugin.ts index c14e5ccefd..c6f170b9c7 100644 --- a/services/github/github-resources/src/plugin.ts +++ b/services/github/github-resources/src/plugin.ts @@ -92,7 +92,8 @@ export default mergeIds(githubId, github, { RepositoryPresenterRefEditor: '' as AnyComponent, GithubIssueInfo: '' as AnyComponent, GithubIssueInfoHeader: '' as AnyComponent, - GithubIssuePresenter: '' as AnyComponent + GithubIssuePresenter: '' as AnyComponent, + IntegrationState: '' as AnyComponent }, handler: { DisconnectHandler: '' as Handler diff --git a/services/github/github/src/index.ts b/services/github/github/src/index.ts index 0113db3945..ef3f48be4a 100644 --- a/services/github/github/src/index.ts +++ b/services/github/github/src/index.ts @@ -4,7 +4,19 @@ import { ActivityMessage, ActivityMessageViewlet } from '@hcengineering/activity' import { Attachment } from '@hcengineering/attachment' import { Person } from '@hcengineering/contact' -import { AttachedDoc, Class, Data, Doc, Hyperlink, Markup, Mixin, PersonId, Ref, Timestamp } from '@hcengineering/core' +import { + AttachedDoc, + Class, + Data, + Doc, + Hyperlink, + IntegrationKind, + Markup, + Mixin, + PersonId, + Ref, + Timestamp +} from '@hcengineering/core' import { Asset, IntlString, Metadata, Plugin, plugin } from '@hcengineering/platform' import { Preference } from '@hcengineering/preference' import task, { ProjectTypeDescriptor, TaskStatusFactory, TaskTypeDescriptor } from '@hcengineering/task' @@ -488,6 +500,16 @@ export function makeQuery (obj: Record 0) { await accountsClient.updateIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: integr.workspace, socialId: integr.accountId, data: { installationId: has } satisfies IntegrationDataValue }) } else { await accountsClient.deleteIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: integr.workspace, socialId: integr.accountId }) @@ -268,13 +274,13 @@ export class PlatformWorker { if (has.length > 0) { oldInstallation.installationId = has await accountsClient.updateIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: oldWorkspace, socialId: oldInstallation.accountId }) } else { await accountsClient.deleteIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: oldWorkspace, socialId: oldInstallation.accountId }) @@ -286,14 +292,14 @@ export class PlatformWorker { if (existingRecord !== undefined) { existingRecord.installationId.push(installationId) await accountsClient.updateIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: workspace, socialId: accountId, data: { installationId: existingRecord.installationId } satisfies IntegrationDataValue }) } else { await accountsClient.createIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: workspace, socialId: accountId, data: { installationId } satisfies IntegrationDataValue @@ -340,7 +346,7 @@ export class PlatformWorker { if (existing !== undefined) { existing.installationId.push(installationId) await accountsClient.updateIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: existing.workspace, socialId: existing.accountId, data: { installationId: existing.installationId } satisfies IntegrationDataValue @@ -352,7 +358,7 @@ export class PlatformWorker { accountId } await accountsClient.createIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: record.workspace, socialId: record.accountId, data: { installationId: record.installationId } @@ -909,7 +915,7 @@ export class PlatformWorker { const has = intgr.installationId.filter((it) => it !== installId) if (has.length > 0) { await sysAccountClient.updateIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: intgr.workspace, socialId: intgr.accountId, data: { installationId: has } satisfies IntegrationDataValue @@ -917,7 +923,7 @@ export class PlatformWorker { } else { intgr.installationId = [] await sysAccountClient.deleteIntegration({ - kind: 'github', + kind: githubIntegrationKind, workspaceUuid: intgr.workspace, socialId: intgr.accountId }) diff --git a/services/github/pod-github/src/users.ts b/services/github/pod-github/src/users.ts index c698d1cf34..4a6a562db8 100644 --- a/services/github/pod-github/src/users.ts +++ b/services/github/pod-github/src/users.ts @@ -2,6 +2,7 @@ import type { AccountClient, IntegrationSecret } from '@hcengineering/account-cl import core, { systemAccountUuid, type MeasureContext, type PersonId, type WorkspaceUuid } from '@hcengineering/core' import { getAccountClient } from '@hcengineering/server-client' import { generateToken } from '@hcengineering/server-token' +import { githubUserIntegrationKind } from '@hcengineering/github' import type { GithubUserRecord } from './types' export class UserManager { @@ -20,7 +21,7 @@ export class UserManager { if (res !== undefined) { return res } - const secrets = await this.accountClient.listIntegrationsSecrets({ kind: 'github-user', key: login }) + const secrets = await this.accountClient.listIntegrationsSecrets({ kind: githubUserIntegrationKind, key: login }) if (secrets.length === 0) { return } @@ -63,7 +64,10 @@ export class UserManager { // Ignore failed refs return } - const secrets = await this.accountClient.listIntegrationsSecrets({ kind: 'github-user', socialId: ref }) + const secrets = await this.accountClient.listIntegrationsSecrets({ + kind: githubUserIntegrationKind, + socialId: ref + }) if (secrets.length === 0) { return } @@ -90,14 +94,14 @@ export class UserManager { // Need to check if user integeration exists. const existing = await this.accountClient.getIntegration({ - kind: 'github-user', + kind: githubUserIntegrationKind, workspaceUuid: null, socialId: dta.account }) if (existing == null) { await this.accountClient.createIntegration({ - kind: 'github-user', + kind: githubUserIntegrationKind, workspaceUuid: null, socialId: dta.account, data: { @@ -108,7 +112,7 @@ export class UserManager { const exists = await this.accountClient.getIntegrationSecret({ key: dta._id, - kind: 'github-user', + kind: githubUserIntegrationKind, socialId: dta.account, workspaceUuid: null }) @@ -116,7 +120,7 @@ export class UserManager { if (exists !== null) { await this.accountClient.updateIntegrationSecret({ key: dta._id, - kind: 'github-user', + kind: githubUserIntegrationKind, socialId: dta.account, secret: JSON.stringify(dta), workspaceUuid: null @@ -124,7 +128,7 @@ export class UserManager { } else { await this.accountClient.addIntegrationSecret({ key: dta._id, - kind: 'github-user', + kind: githubUserIntegrationKind, socialId: dta.account, secret: JSON.stringify(dta), workspaceUuid: null @@ -140,7 +144,7 @@ export class UserManager { this.userCache.clear() this.refUserCache.clear() - const secerts = await this.accountClient.listIntegrationsSecrets({ kind: 'github-user', key: login }) + const secerts = await this.accountClient.listIntegrationsSecrets({ kind: githubUserIntegrationKind, key: login }) for (const s of secerts) { await this.accountClient.deleteIntegrationSecret(s) } diff --git a/services/gmail/pod-gmail/package.json b/services/gmail/pod-gmail/package.json index 299eaf9f81..dd1fd86fa6 100644 --- a/services/gmail/pod-gmail/package.json +++ b/services/gmail/pod-gmail/package.json @@ -76,6 +76,7 @@ "@hcengineering/server-client": "^0.6.0", "@hcengineering/server-storage": "^0.6.0", "@hcengineering/server-token": "^0.6.11", + "@hcengineering/integration-client": "^0.6.0", "cors": "^2.8.5", "dotenv": "~16.0.0", "express": "^4.21.2", diff --git a/services/gmail/pod-gmail/src/__tests__/gmailController.test.ts b/services/gmail/pod-gmail/src/__tests__/gmailController.test.ts index bfe9c892e7..e028e73eb9 100644 --- a/services/gmail/pod-gmail/src/__tests__/gmailController.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/gmailController.test.ts @@ -114,6 +114,20 @@ describe('GmailController', () => { // Mock WorkspaceClient.create jest.spyOn(WorkspaceClient, 'create').mockResolvedValue(mockWorkspaceClient) + // Mock getIntegrationClient + const mockIntegrationClient = { + getIntegrations: jest + .fn() + .mockResolvedValue([ + { workspaceUuid: workspaceAId, socialId: 'user1' } as any, + { workspaceUuid: workspaceAId, socialId: 'user2' } as any, + { workspaceUuid: workspaceBId, socialId: 'user3' } as any, + { workspaceUuid: workspaceBId, socialId: 'user4' } as any, + { workspaceUuid: workspaceBId, socialId: 'user5' } as any + ]) + } + jest.spyOn(integrations, 'getIntegrationClient').mockReturnValue(mockIntegrationClient as any) + // Mock getIntegrations jest .spyOn(integrations, 'getIntegrations') diff --git a/services/gmail/pod-gmail/src/__tests__/sync.test.ts b/services/gmail/pod-gmail/src/__tests__/sync.test.ts index 56ce150f8b..c1ad072530 100644 --- a/services/gmail/pod-gmail/src/__tests__/sync.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/sync.test.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { MeasureContext, PersonId } from '@hcengineering/core' +import { MeasureContext, PersonId, WorkspaceUuid } from '@hcengineering/core' import { SyncManager } from '../message/sync' import { MessageManagerV2 } from '../message/v2/message' import { RateLimiter } from '../rateLimiter' @@ -51,7 +51,7 @@ describe('SyncManager', () => { let mockKeyValueClient: MockKeyValueClient // Test constants - const workspace = 'test-workspace' + const workspace = 'test-workspace' as WorkspaceUuid const userId = 'user1' as PersonId const userEmail = 'user1@example.com' const historyId = '12345' diff --git a/services/gmail/pod-gmail/src/__tests__/tokens.test.ts b/services/gmail/pod-gmail/src/__tests__/tokens.test.ts index cd2357af96..62a1bc3c66 100644 --- a/services/gmail/pod-gmail/src/__tests__/tokens.test.ts +++ b/services/gmail/pod-gmail/src/__tests__/tokens.test.ts @@ -1,9 +1,10 @@ -import { AccountUuid, PersonId, WorkspaceUuid } from '@hcengineering/core' +import { AccountUuid, PersonId, WorkspaceUuid, type MeasureContext } from '@hcengineering/core' import { AccountClient, IntegrationSecret } from '@hcengineering/account-client' import { getAccountClient } from '@hcengineering/server-client' +import { gmailIntegrationKind } from '@hcengineering/gmail' + import { TokenStorage } from '../tokens' -import { GMAIL_INTEGRATION, SecretType, Token } from '../types' -import type { MeasureContext } from '@hcengineering/core' +import { SecretType, Token } from '../types' // Mock the getAccountClient function jest.mock('@hcengineering/server-client', () => ({ @@ -62,7 +63,7 @@ describe('TokenStorage', () => { expect(result).toBeNull() expect(mockAccountClient.getIntegrationSecret).toHaveBeenCalledWith({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId: mockSocialId, workspaceUuid: mockWorkspace }) @@ -71,7 +72,7 @@ describe('TokenStorage', () => { it('should return parsed token when token exists', async () => { const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, workspaceUuid: mockWorkspace, key: SecretType.TOKEN, secret: JSON.stringify(mockToken) @@ -87,7 +88,7 @@ describe('TokenStorage', () => { const existingToken = { existing: 'token' } const mockSecret: IntegrationSecret = { socialId: mockSocialId, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, workspaceUuid: mockWorkspace, key: SecretType.TOKEN, secret: JSON.stringify(existingToken) @@ -103,7 +104,7 @@ describe('TokenStorage', () => { expect(mockAccountClient.updateIntegrationSecret).toHaveBeenCalledWith({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId: mockSocialId, secret: JSON.stringify(expectedMergedToken), workspaceUuid: mockWorkspace @@ -116,7 +117,7 @@ describe('TokenStorage', () => { await tokenStorage.saveToken(mockSocialId, mockToken) expect(mockAccountClient.addIntegrationSecret).toHaveBeenCalledWith({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId: mockSocialId, secret: JSON.stringify(mockToken), workspaceUuid: mockWorkspace @@ -130,7 +131,7 @@ describe('TokenStorage', () => { await tokenStorage.deleteToken(mockSocialId) expect(mockAccountClient.deleteIntegrationSecret).toHaveBeenCalledWith({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId: mockSocialId, workspaceUuid: mockWorkspace }) diff --git a/services/gmail/pod-gmail/src/gmail.ts b/services/gmail/pod-gmail/src/gmail.ts index 672950bd6c..307934cee2 100644 --- a/services/gmail/pod-gmail/src/gmail.ts +++ b/services/gmail/pod-gmail/src/gmail.ts @@ -40,11 +40,11 @@ import { encode64 } from './base64' import config from './config' import { GmailController } from './gmailController' import { RateLimiter } from './rateLimiter' -import type { ProjectCredentials, Token, User } from './types' +import type { ProjectCredentials, Token, User, SyncState } from './types' import { addFooter, isToken, serviceToken, getKvsClient } from './utils' import type { WorkspaceClient } from './workspaceClient' import { getOrCreateSocialId } from './accounts' -import { createIntegrationIfNotEsixts, disableIntegration, removeIntegration } from './integrations' +import { createIntegrationIfNotExists, disableIntegration, removeIntegration } from './integrations' import { AttachmentHandler } from './message/attachments' import { TokenStorage } from './tokens' import { createMessageManager } from './message/adapter' @@ -290,7 +290,7 @@ export class GmailClient { async createIntregration (): Promise { try { - this.integration = await createIntegrationIfNotEsixts(this.socialId._id, this.user.workspace) + this.integration = await createIntegrationIfNotExists(this.socialId._id, this.user.workspace, this.email) } catch (err: any) { this.ctx.error('Failed to create integration', { socialdId: this.socialId, workspace: this.workspace }) } @@ -564,6 +564,7 @@ export class GmailClient { this.ctx.error('Watch error', { workspaceUuid: this.user.workspace, userId: this.user.userId, + email: this.email, message: (err as any).message }) } @@ -578,6 +579,29 @@ export class GmailClient { } } + async getStateSummary (): Promise { + let totalMessages: number | null | undefined + try { + const query: gmail_v1.Params$Resource$Users$Messages$List = { + userId: 'me', + maxResults: 1 + } + const result = await this.gmail.getProfile(query) + totalMessages = result.data.messagesTotal + } catch (err: any) { + this.ctx.error('Failed to get messages count', err.message) + } + + const syncStatus = this.syncManager.getSyncStatus() + + return { + status: this.syncStarted ? 'active' : 'inactive', + email: this.email, + totalMessages, + syncInfo: syncStatus + } + } + async close (): Promise { if (this.watchTimer !== undefined) clearInterval(this.watchTimer) if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer) diff --git a/services/gmail/pod-gmail/src/gmailController.ts b/services/gmail/pod-gmail/src/gmailController.ts index 9b6577e424..e1f6a7be04 100644 --- a/services/gmail/pod-gmail/src/gmailController.ts +++ b/services/gmail/pod-gmail/src/gmailController.ts @@ -26,16 +26,16 @@ import { } from '@hcengineering/core' import { normalizeEmail } from '@hcengineering/mail-common' import type { StorageAdapter } from '@hcengineering/server-core' - import { getAccountClient } from '@hcengineering/server-client' + import { decode64 } from './base64' import config from './config' import { type GmailClient } from './gmail' -import { getIntegrations } from './integrations' import { getWorkspaceTokens } from './tokens' -import { type ProjectCredentials, type Token, type User, type WorkspaceStateInfo } from './types' +import { SyncState, type ProjectCredentials, type Token, type User, type WorkspaceStateInfo } from './types' import { serviceToken } from './utils' import { WorkspaceClient } from './workspaceClient' +import { getIntegrationClient } from './integrations' import { AuthProvider } from './gmail/auth' import { AccountClient } from '@hcengineering/account-client' @@ -83,7 +83,7 @@ export class GmailController { try { const token = serviceToken() const sysClient = getAccountClient(token) - const integrations = await getIntegrations(sysClient, token) + const integrations = await getIntegrationClient().getIntegrations() this.ctx.info('Start integrations', { count: integrations.length }) const workspaceIds = new Set( @@ -311,4 +311,16 @@ export class GmailController { } return res } + + async getState (workspace: WorkspaceUuid, socialId: PersonId): Promise { + const workspaceClient = this.workspaces.get(workspace) + if (workspaceClient === undefined) { + return undefined + } + const mailClient = workspaceClient.getGmailClient(socialId) + if (mailClient === undefined) { + return undefined + } + return await mailClient.getStateSummary() + } } diff --git a/services/gmail/pod-gmail/src/integrations.ts b/services/gmail/pod-gmail/src/integrations.ts index 2578a079b0..4a7ec3ee6f 100644 --- a/services/gmail/pod-gmail/src/integrations.ts +++ b/services/gmail/pod-gmail/src/integrations.ts @@ -15,21 +15,29 @@ import { Integration, type AccountClient } from '@hcengineering/account-client' import { AccountUuid, MeasureContext, PersonId, TxOperations, WorkspaceUuid } from '@hcengineering/core' -import gmail from '@hcengineering/gmail' +import gmail, { gmailIntegrationKind } from '@hcengineering/gmail' import { getAccountClient } from '@hcengineering/server-client' import setting from '@hcengineering/setting' -import { getAccountSocialIds } from './accounts' -import { GMAIL_INTEGRATION } from './types' import { serviceToken } from './utils' +import { getAccountSocialIds } from './accounts' +import { IntegrationClient, IntegrationClientImpl } from '@hcengineering/integration-client' + +let integrationClient: IntegrationClient | undefined +export function getIntegrationClient (): IntegrationClient { + if (integrationClient === undefined) { + integrationClient = new IntegrationClientImpl(getAccountClient(serviceToken()), gmailIntegrationKind, 'gmail') + } + return integrationClient +} export async function getIntegration (socialId: PersonId, workspaceUuid?: WorkspaceUuid): Promise { const client = getAccountClient(serviceToken()) - return await client.getIntegration({ kind: GMAIL_INTEGRATION, socialId, workspaceUuid: workspaceUuid ?? null }) + return await client.getIntegration({ kind: gmailIntegrationKind, socialId, workspaceUuid: workspaceUuid ?? null }) } export async function getIntegrations (client: AccountClient, token: string): Promise { - return (await client.listIntegrations({ kind: GMAIL_INTEGRATION })) ?? [] + return (await client.listIntegrations({ kind: gmailIntegrationKind })) ?? [] } export async function getIntegrationByAccount ( @@ -38,7 +46,10 @@ export async function getIntegrationByAccount ( ): Promise { const token = serviceToken() const client = getAccountClient(token) - const integrations = await client.listIntegrations({ kind: GMAIL_INTEGRATION, workspaceUuid: workspaceUuid ?? null }) + const integrations = await client.listIntegrations({ + kind: gmailIntegrationKind, + workspaceUuid: workspaceUuid ?? null + }) if (integrations.length === 0) return null const socialIds = await getAccountSocialIds(account) @@ -59,11 +70,11 @@ export async function removeIntegration ( ): Promise { if (socialId == null) return const accountClient = getAccountClient(serviceToken()) - const integrations = await accountClient.listIntegrations({ kind: GMAIL_INTEGRATION, socialId, workspaceUuid }) + const integrations = await accountClient.listIntegrations({ kind: gmailIntegrationKind, socialId, workspaceUuid }) for (const integration of integrations) { await accountClient.deleteIntegration({ socialId, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, workspaceUuid: integration.workspaceUuid }) } @@ -74,18 +85,16 @@ export async function deleteIntegration (integration: Integration): Promise { - const accountClient = getAccountClient(serviceToken()) - const integration = { - socialId, - kind: GMAIL_INTEGRATION, - workspaceUuid: workspace - } - const existingIntegration = await accountClient.getIntegration(integration) - if (existingIntegration != null) return existingIntegration - - await accountClient.createIntegration(integration) - return integration +export async function createIntegrationIfNotExists ( + socialId: PersonId, + workspace: WorkspaceUuid, + email: string +): Promise { + const client = getIntegrationClient() + const connection = await client.connect(socialId, { + email + }) + return await client.integrate(connection, workspace) } export async function disableIntegration (integration: Integration): Promise { diff --git a/services/gmail/pod-gmail/src/main.ts b/services/gmail/pod-gmail/src/main.ts index 92f645f831..59db5e66b2 100644 --- a/services/gmail/pod-gmail/src/main.ts +++ b/services/gmail/pod-gmail/src/main.ts @@ -17,7 +17,7 @@ import { isWorkspaceLoginInfo } from '@hcengineering/account-client' import { createOpenTelemetryMetricsContext, SplitLogger } from '@hcengineering/analytics-service' -import { newMetrics } from '@hcengineering/core' +import { newMetrics, PersonId } from '@hcengineering/core' import { closeQueue, initQueue } from '@hcengineering/mail-common' import { setMetadata } from '@hcengineering/platform' import serverClient, { getAccountClient } from '@hcengineering/server-client' @@ -169,6 +169,36 @@ export const main = async (): Promise => { res.status(500).send() } } + }, + { + endpoint: '/state', + type: 'get', + handler: async (req, res) => { + try { + const token = extractToken(req.headers) + + if (token === undefined) { + res.status(401).send() + return + } + + const { workspace } = decodeToken(token) + const socialId = req.query.socialId as PersonId | undefined + if (socialId == null || socialId === '') { + res.status(400).send({ error: 'Missing socialId param' }) + return + } + const state = await gmailController.getState(workspace, socialId) + if (state === undefined) { + res.status(404).send({ error: 'No gmail clients found for social id' }) + return + } + res.send(state) + } catch (err: any) { + ctx.error('Failed to get integration state', { message: err.message }) + res.status(500).send({ error: err.message }) + } + } } ] diff --git a/services/gmail/pod-gmail/src/message/sync.ts b/services/gmail/pod-gmail/src/message/sync.ts index 4464a1074d..ede3e7cc8a 100644 --- a/services/gmail/pod-gmail/src/message/sync.ts +++ b/services/gmail/pod-gmail/src/message/sync.ts @@ -15,7 +15,7 @@ import { type GaxiosResponse } from 'gaxios' import { gmail_v1 } from 'googleapis' -import { type MeasureContext, PersonId } from '@hcengineering/core' +import { type MeasureContext, PersonId, WorkspaceUuid } from '@hcengineering/core' import { type KeyValueClient } from '@hcengineering/kvs-client' import { SyncMutex, type SyncOptions } from '@hcengineering/mail-common' @@ -28,18 +28,35 @@ export class SyncManager { private readonly syncMutex = new SyncMutex() private readonly stateManager: SyncStateManager private isClosing = false + private currentSyncType: 'full' | 'partial' | null = null + private processedMessages = 0 + private syncStartTime: Date | null = null constructor ( private readonly ctx: MeasureContext, private readonly messageManager: IMessageManager, private readonly gmail: gmail_v1.Resource$Users, - private readonly workspace: string, + private readonly workspace: WorkspaceUuid, keyValueClient: KeyValueClient, private readonly rateLimiter: RateLimiter ) { this.stateManager = new SyncStateManager(keyValueClient, workspace, config.Version) } + getSyncStatus (): { + type: 'full' | 'partial' | null + processedMessages: number + startTime: Date | null + isActive: boolean + } { + return { + type: this.currentSyncType, + processedMessages: this.processedMessages, + startTime: this.syncStartTime, + isActive: this.currentSyncType !== null + } + } + private async partSync ( userId: PersonId, userEmail: string | undefined, @@ -49,84 +66,97 @@ export class SyncManager { if (userEmail === undefined) { throw new Error('Cannot sync without user email') } - let pageToken: string | undefined - let histories: GaxiosResponse - let maxHistoryId: string | undefined - while (true) { - try { - await this.rateLimiter.take(2) - histories = await this.gmail.history.list({ - userId: 'me', - historyTypes: ['messageAdded'], - startHistoryId: historyId, - pageToken - }) - } catch (err: any) { - this.ctx.error('Part sync get history error', { - workspaceUuid: this.workspace, - userId, - error: err.message, - historyId - }) - void this.syncNewMessages(userId, userEmail) - return - } - const nextPageToken = histories.data.nextPageToken - const array = histories.data.history ?? [] - this.ctx.info('Messages to migrate', { count: array.length }) - try { - for (const history of array) { - for (const message of history.messagesAdded ?? []) { - if (this.isClosing) return - if (message.message?.id == null || (message.message.labelIds?.includes('DRAFT') ?? false)) { - continue - } - try { - const res = await this.getMessage(message.message.id) - await this.messageManager.saveMessage(res, userEmail) - } catch (err) { - this.ctx.error('Part sync message error', { - workspaceUuid: this.workspace, - userId, - messageId: message.message.id, - err - }) - } - } - if (history.id != null) { - if (maxHistoryId == null || BigInt(maxHistoryId) < BigInt(history.id)) { - maxHistoryId = history.id - } - } - } - if (nextPageToken == null) { - // Set the maximum historyId found during the sync, but only if it's greater than current - if (maxHistoryId != null) { - const currentHistory = await this.stateManager.getHistory(userId) - const currentHistoryId = currentHistory?.historyId - if (currentHistoryId == null || BigInt(maxHistoryId) > BigInt(currentHistoryId)) { - await this.stateManager.setHistoryId(userId, maxHistoryId) - this.ctx.info('Updated history ID', { - userId, - oldHistoryId: currentHistoryId, - newHistoryId: maxHistoryId - }) - } else { - this.ctx.info('Skipping history ID update', { - userId, - currentHistoryId, - maxHistoryId - }) - } - } + // Set sync tracking + this.currentSyncType = 'partial' + this.processedMessages = 0 + this.syncStartTime = new Date() + + try { + let pageToken: string | undefined + let histories: GaxiosResponse + let maxHistoryId: string | undefined + while (true) { + try { + await this.rateLimiter.take(2) + histories = await this.gmail.history.list({ + userId: 'me', + historyTypes: ['messageAdded'], + startHistoryId: historyId, + pageToken + }) + } catch (err: any) { + this.ctx.error('Part sync get history error', { + workspaceUuid: this.workspace, + userId, + error: err.message, + historyId + }) + void this.syncNewMessages(userId, userEmail) + return + } + const nextPageToken = histories.data.nextPageToken + const array = histories.data.history ?? [] + this.ctx.info('Messages to migrate', { count: array.length }) + try { + for (const history of array) { + for (const message of history.messagesAdded ?? []) { + if (this.isClosing) return + if (message.message?.id == null || (message.message.labelIds?.includes('DRAFT') ?? false)) { + continue + } + try { + const res = await this.getMessage(message.message.id) + await this.messageManager.saveMessage(res, userEmail) + this.processedMessages++ + } catch (err) { + this.ctx.error('Part sync message error', { + workspaceUuid: this.workspace, + userId, + messageId: message.message.id, + err + }) + } + } + if (history.id != null) { + if (maxHistoryId == null || BigInt(maxHistoryId) < BigInt(history.id)) { + maxHistoryId = history.id + } + } + } + if (nextPageToken == null) { + // Set the maximum historyId found during the sync, but only if it's greater than current + if (maxHistoryId != null) { + const currentHistory = await this.stateManager.getHistory(userId) + const currentHistoryId = currentHistory?.historyId + if (currentHistoryId == null || BigInt(maxHistoryId) > BigInt(currentHistoryId)) { + await this.stateManager.setHistoryId(userId, maxHistoryId) + this.ctx.info('Updated history ID', { + userId, + oldHistoryId: currentHistoryId, + newHistoryId: maxHistoryId + }) + } else { + this.ctx.info('Skipping history ID update', { + userId, + currentHistoryId, + maxHistoryId + }) + } + } + return + } else { + pageToken = nextPageToken + } + } catch (err) { + this.ctx.error('Part sync error', { workspaceUuid: this.workspace, userId, historyId, err }) return - } else { - pageToken = nextPageToken } - } catch (err) { - this.ctx.error('Part sync error', { workspaceUuid: this.workspace, userId, historyId, err }) - return } + } finally { + // Clear sync tracking + this.currentSyncType = null + this.processedMessages = 0 + this.syncStartTime = null } } @@ -136,20 +166,24 @@ export class SyncManager { throw new Error('Cannot sync without user email') } - // Get saved page token to continue from - let pageToken: string | undefined = (await this.stateManager.getPageToken(userId)) ?? undefined - - const query: gmail_v1.Params$Resource$Users$Messages$List = { - userId: 'me', - pageToken - } - if (q !== undefined) { - query.q = q - } - let currentHistoryId: string | undefined - let totalProcessedMessages = 0 + // Set sync tracking + this.currentSyncType = 'full' + this.processedMessages = 0 + this.syncStartTime = new Date() try { + // Get saved page token to continue from + let pageToken: string | undefined = (await this.stateManager.getPageToken(userId)) ?? undefined + + const query: gmail_v1.Params$Resource$Users$Messages$List = { + userId: 'me', + pageToken + } + if (q !== undefined) { + query.q = q + } + let currentHistoryId: string | undefined + // Process one page at a time while (true) { await this.rateLimiter.take(5) @@ -160,7 +194,7 @@ export class SyncManager { workspace: this.workspace, userId, messagesInPage: ids.length, - totalProcessed: totalProcessedMessages, + totalProcessed: this.processedMessages, currentHistoryId, pageToken: query.pageToken }) @@ -172,6 +206,7 @@ export class SyncManager { const message = await this.getMessage(id) const historyId = message.data.historyId await this.messageManager.saveMessage(message, userEmail) + this.processedMessages++ if (historyId != null && q === undefined) { if (currentHistoryId == null || BigInt(currentHistoryId) < BigInt(historyId)) { @@ -184,10 +219,8 @@ export class SyncManager { } } - totalProcessedMessages += ids.length - if (messages.data.nextPageToken == null) { - this.ctx.info('Completed sync', { workspace: this.workspace, userId, totalMessages: totalProcessedMessages }) + this.ctx.info('Completed sync', { workspace: this.workspace, userId, totalMessages: this.processedMessages }) break } @@ -204,6 +237,11 @@ export class SyncManager { } catch (err) { if (this.isClosing) return this.ctx.error('Full sync error', { workspace: this.workspace, userId, err }) + } finally { + // Clear sync tracking + this.currentSyncType = null + this.processedMessages = 0 + this.syncStartTime = null } } diff --git a/services/gmail/pod-gmail/src/tokens.ts b/services/gmail/pod-gmail/src/tokens.ts index 7b5faae98d..44f6a8eae8 100644 --- a/services/gmail/pod-gmail/src/tokens.ts +++ b/services/gmail/pod-gmail/src/tokens.ts @@ -16,7 +16,8 @@ import type { AccountClient } from '@hcengineering/account-client' import { groupByArray, MeasureContext, PersonId, WorkspaceUuid } from '@hcengineering/core' import { getAccountClient } from '@hcengineering/server-client' -import { GMAIL_INTEGRATION, SecretType, Token } from './types' +import { gmailIntegrationKind } from '@hcengineering/gmail' +import { SecretType, Token } from './types' export class TokenStorage { private readonly accountClient: AccountClient @@ -32,7 +33,7 @@ export class TokenStorage { async getToken (socialId: PersonId): Promise { const secret = await this.accountClient.getIntegrationSecret({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId, workspaceUuid: this.workspace }) @@ -49,7 +50,7 @@ export class TokenStorage { } await this.accountClient.updateIntegrationSecret({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId, secret: JSON.stringify(updatedToken), workspaceUuid: this.workspace @@ -58,7 +59,7 @@ export class TokenStorage { } else { await this.accountClient.addIntegrationSecret({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId, secret: JSON.stringify(token), workspaceUuid: this.workspace @@ -70,7 +71,7 @@ export class TokenStorage { async deleteToken (socialId: PersonId): Promise { await this.accountClient.deleteIntegrationSecret({ key: SecretType.TOKEN, - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, socialId, workspaceUuid: this.workspace }) @@ -85,10 +86,10 @@ export async function getWorkspaceTokens ( await accountClient.listIntegrationsSecrets( workspace !== undefined ? { - kind: GMAIL_INTEGRATION, + kind: gmailIntegrationKind, workspaceUuid: workspace } - : { kind: GMAIL_INTEGRATION } + : { kind: gmailIntegrationKind } ) ).filter((it) => it.workspaceUuid != null) diff --git a/services/gmail/pod-gmail/src/types.ts b/services/gmail/pod-gmail/src/types.ts index d03746cb7d..d73edf0e6e 100644 --- a/services/gmail/pod-gmail/src/types.ts +++ b/services/gmail/pod-gmail/src/types.ts @@ -62,7 +62,6 @@ export interface ProjectCredentialsData { client_secret: string redirect_uris: string[] } -export const GMAIL_INTEGRATION = 'gmail' export enum SecretType { TOKEN = 'token' @@ -79,3 +78,17 @@ export interface WorkspaceStateInfo { archived?: boolean inactive?: boolean } + +export interface SyncState { + status: string + email: string + totalMessages: number | null | undefined + lastSynchronization?: Date | null + error?: string + syncInfo?: { + type: 'full' | 'partial' | null + processedMessages: number + startTime: Date | null + isActive: boolean + } +} diff --git a/services/gmail/pod-gmail/src/workspaceClient.ts b/services/gmail/pod-gmail/src/workspaceClient.ts index cdd464228d..43c0b9be3e 100644 --- a/services/gmail/pod-gmail/src/workspaceClient.ts +++ b/services/gmail/pod-gmail/src/workspaceClient.ts @@ -129,7 +129,7 @@ export class WorkspaceClient { return this.clients.size } - private getGmailClient (userId: PersonId): GmailClient | undefined { + getGmailClient (userId: PersonId): GmailClient | undefined { return this.clients.get(userId) } @@ -246,6 +246,7 @@ export class WorkspaceClient { return [normalize(p.value), p] }) ) + this.ctx.info('Set channels', { workspaceUuid: this.workspace, channels }) this.channelsById = new Map( channels.map((p) => { return [p._id, p] diff --git a/services/telegram-bot/pod-telegram-bot/src/account.ts b/services/telegram-bot/pod-telegram-bot/src/account.ts index 22e1aec347..86f52a6dd7 100644 --- a/services/telegram-bot/pod-telegram-bot/src/account.ts +++ b/services/telegram-bot/pod-telegram-bot/src/account.ts @@ -24,6 +24,7 @@ import { import { getAccountClient } from '@hcengineering/server-client' import { generateToken } from '@hcengineering/server-token' import { Integration } from '@hcengineering/account-client' +import { telegramIntegrationKind } from '@hcengineering/telegram' import { serviceToken } from './utils' import { IntegrationInfo } from './types' @@ -51,7 +52,7 @@ export async function getAccountSocialIds (account: AccountUuid): Promise { const client = getAccountClient(generateToken(account, undefined, { service: 'telegram-bot' })) - const integrations = await client.listIntegrations({ kind: 'telegram-bot' }) + const integrations = await client.listIntegrations({ kind: telegramIntegrationKind }) if (integrations.length === 0) return [] const socialIds = await getAccountSocialIds(account) @@ -79,7 +80,7 @@ export async function listIntegrationsByTelegramId (telegramId: number): Promise ) if (socialId == null) return [] - const integrations = await client.listIntegrations({ kind: 'telegram-bot', socialId: socialId._id }) + const integrations = await client.listIntegrations({ kind: telegramIntegrationKind, socialId: socialId._id }) if (integrations == null) return [] return integrations.map((it) => ({ @@ -101,7 +102,7 @@ export async function getAnyIntegrationByTelegramId ( ) if (socialId == null) return undefined - const integrations = await client.listIntegrations({ kind: 'telegram-bot', socialId: socialId._id }) + const integrations = await client.listIntegrations({ kind: telegramIntegrationKind, socialId: socialId._id }) if (integrations.length === 0) return undefined const integration = workspace != null ? integrations.find((it) => it.workspaceUuid === workspace) : integrations[0] @@ -120,7 +121,7 @@ export async function getAnyIntegrationByAccount ( workspace?: WorkspaceUuid ): Promise { const client = getAccountClient(generateToken(account, undefined, { service: 'telegram-bot' })) - const integrations = await client.listIntegrations({ kind: 'telegram-bot', workspaceUuid: workspace }) + const integrations = await client.listIntegrations({ kind: telegramIntegrationKind, workspaceUuid: workspace }) if (integrations.length === 0) return undefined const integration = integrations[0] @@ -150,7 +151,7 @@ export async function createIntegration (socialId: PersonId, workspace: Workspac const accountClient = getAccountClient(serviceToken()) const integration = { socialId, - kind: 'telegram-bot', + kind: telegramIntegrationKind, workspaceUuid: workspace } await accountClient.createIntegration(integration) @@ -163,9 +164,13 @@ export async function removeIntegrationsByTg (telegramId: number): Promise buildSocialIdString({ type: SocialIdType.TELEGRAM, value: telegramId.toString() }) ) if (socialId == null) return - const integrations = await accountClient.listIntegrations({ kind: 'telegram-bot', socialId }) + const integrations = await accountClient.listIntegrations({ kind: telegramIntegrationKind, socialId }) for (const integration of integrations) { - await accountClient.deleteIntegration({ socialId, kind: 'telegram-bot', workspaceUuid: integration.workspaceUuid }) + await accountClient.deleteIntegration({ + socialId, + kind: telegramIntegrationKind, + workspaceUuid: integration.workspaceUuid + }) } } diff --git a/tests/sanity/tests/integrations.spec.ts b/tests/sanity/tests/integrations.spec.ts index faefca5fd8..ecd6096e05 100644 --- a/tests/sanity/tests/integrations.spec.ts +++ b/tests/sanity/tests/integrations.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test' import { faker } from '@faker-js/faker' import { Integration, IntegrationSecret } from '@hcengineering/account' -import { buildSocialIdString, SocialIdType } from '@hcengineering/core' +import { buildSocialIdString, IntegrationKind, SocialIdType } from '@hcengineering/core' import { PlatformUser } from './utils' import { getAdminAccountClient } from './API/AccountClient' @@ -39,7 +39,7 @@ test.describe('integrations in accounts tests', () => { // Test data setup const integration1: Integration = { socialId: personId1, - kind: 'github', + kind: 'github' as IntegrationKind, workspaceUuid: null, // Global integration data: { repo: 'repo1', @@ -50,7 +50,7 @@ test.describe('integrations in accounts tests', () => { const integration2: Integration = { socialId: personId2, - kind: 'telegram-bot', + kind: 'telegram-bot' as IntegrationKind, workspaceUuid, data: { chatId: '123', @@ -61,7 +61,7 @@ test.describe('integrations in accounts tests', () => { const integration3: Integration = { socialId: personId3, - kind: 'mailbox', + kind: 'mailbox' as IntegrationKind, workspaceUuid, data: { email: 'test@example.com', @@ -98,7 +98,7 @@ test.describe('integrations in accounts tests', () => { // Create secrets const secret1: IntegrationSecret = { socialId: personId1, - kind: 'github', + kind: 'github' as IntegrationKind, workspaceUuid: null, key: 'token', secret: 'github_pat_token_123' @@ -106,7 +106,7 @@ test.describe('integrations in accounts tests', () => { const secret2: IntegrationSecret = { socialId: personId2, - kind: 'telegram-bot', + kind: 'telegram-bot' as IntegrationKind, workspaceUuid, key: 'bot_token', secret: 'telegram_bot_token_123' @@ -114,7 +114,7 @@ test.describe('integrations in accounts tests', () => { const secret3: IntegrationSecret = { socialId: personId2, - kind: 'telegram-bot', + kind: 'telegram-bot' as IntegrationKind, workspaceUuid, key: 'api_key', secret: 'telegram_api_key_123' @@ -157,11 +157,11 @@ test.describe('integrations in accounts tests', () => { expect(workspaceIntegrations).toHaveLength(2) expect(workspaceIntegrations).toEqual(expect.arrayContaining([integration2, integration3])) - const githubIntegrations = await accountClient.listIntegrations({ kind: 'github' }) + const githubIntegrations = await accountClient.listIntegrations({ kind: 'github' as IntegrationKind }) expect(githubIntegrations).toHaveLength(1) expect(githubIntegrations[0]).toEqual(integration1) - const telegramIntegrations = await accountClient.listIntegrations({ kind: 'telegram-bot' }) + const telegramIntegrations = await accountClient.listIntegrations({ kind: 'telegram-bot' as IntegrationKind }) expect(telegramIntegrations).toHaveLength(1) expect(telegramIntegrations[0]).toEqual(integration2) @@ -176,7 +176,7 @@ test.describe('integrations in accounts tests', () => { expect(workspaceSecrets).toHaveLength(2) expect(workspaceSecrets).toEqual(expect.arrayContaining([secret2, secret3])) - const telegramSecrets = await accountClient.listIntegrationsSecrets({ kind: 'telegram-bot' }) + const telegramSecrets = await accountClient.listIntegrationsSecrets({ kind: 'telegram-bot' as IntegrationKind }) expect(telegramSecrets).toHaveLength(2) expect(telegramSecrets).toEqual(expect.arrayContaining([secret2, secret3]))